diff --git a/addons/website/models/ir_qweb.py b/addons/website/models/ir_qweb.py index 64a374119fa..32e6a8d33e8 100644 --- a/addons/website/models/ir_qweb.py +++ b/addons/website/models/ir_qweb.py @@ -204,12 +204,29 @@ class ManyToOne(orm.AbstractModel): _inherit = ['website.qweb.field', 'ir.qweb.field.many2one'] def from_html(self, cr, uid, model, column, element, context=None): - # FIXME: this behavior is really weird, what if the user wanted to edit the name of the related thingy? Should m2os really be editable without a widget? - matches = self.pool[column._obj].name_search( - cr, uid, name=element.text_content().strip(), context=context) - # FIXME: no match? More than 1 match? - assert len(matches) == 1 - return matches[0][0] + # FIXME: layering violations all the things + Model = self.pool[element.get('data-oe-model')] + M2O = self.pool[column._obj] + field = element.get('data-oe-field') + id = int(element.get('data-oe-id')) + value = element.text_content().strip() + + # if anything blows up, just ignore it and bail + try: + # get parent record + [obj] = Model.read(cr, uid, [id], [field]) + # get m2o record id + (m2o_id, _) = obj[field] + # assume _rec_name and write directly to it + M2O.write(cr, uid, [m2o_id], { + M2O._rec_name: value + }, context=context) + except: + logger.exception("Could not save %r to m2o field %s of model %s", + value, field, Model._name) + + # not necessary, but might as well be explicit about it + return None class HTML(orm.AbstractModel): _name = 'website.qweb.field.html' @@ -281,7 +298,7 @@ class Image(orm.AbstractModel): match.group('module'), 'static', *(rest.split('/'))) if not path: - return False + return None try: with open(path, 'rb') as f: @@ -292,7 +309,7 @@ class Image(orm.AbstractModel): return f.read().encode('base64') except Exception: logger.exception("Failed to load local image %r", url) - return False + return None def load_remote_url(self, url): try: @@ -310,7 +327,7 @@ class Image(orm.AbstractModel): image.load() except Exception: logger.exception("Failed to load remote image %r", url) - return False + return None # don't use original data in case weird stuff was smuggled in, with # luck PIL will remove some of it? diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index b935d6e3465..02fa87e10aa 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -70,9 +70,11 @@ class view(osv.osv): el.get('data-oe-type')) value = converter.from_html(cr, uid, Model, column, el) - Model.write(cr, uid, [int(el.get('data-oe-id'))], { - field: value - }, context=context) + if value is not None: + # TODO: batch writes? + Model.write(cr, uid, [int(el.get('data-oe-id'))], { + field: value + }, context=context) def to_field_ref(self, cr, uid, el, context=None): # filter out meta-information inserted in the document diff --git a/addons/website/static/src/css/editor.css b/addons/website/static/src/css/editor.css index e21bfc1fbe8..b784177fd1c 100644 --- a/addons/website/static/src/css/editor.css +++ b/addons/website/static/src/css/editor.css @@ -1,4 +1,4 @@ -/* ---- CKEditor Minimal Reset ---- */ +/* ---- CKEditor Minimal Reset ---- {{{ */ .navbar.navbar-inverse .cke_chrome { border: none; } @@ -28,7 +28,7 @@ box-shadow: none; } -/* ---- OpenERP Style ---- */ +/* ---- OpenERP Style ---- {{{ */ .oe_website_editorbar { position: fixed; top: 0; @@ -103,7 +103,7 @@ top: 2px; } -/* ---- EDITOR BAR ---- */ +/* ---- EDITOR BAR ---- {{{ */ table.editorbar-panel { cursor: pointer; width: 100%; @@ -115,6 +115,7 @@ table.editorbar-panel td.selected { background-color: #b1c9d9; } +/* ---- TRANSLATIONS ---- {{{ */ .oe_translate_or { color: white; padding: 0 0 0 1em; @@ -145,6 +146,7 @@ table.editorbar-panel td.selected { background: #ffffb6; } +/* ---- MENU ---- {{{ */ div.oe_menu_buttons { top: -8px; right: -8px; @@ -171,11 +173,12 @@ ul.oe_menu_editor .disclose { display: none; } -/* ---- RTE ---- */ +/* ---- RTE ---- {{{ */ .oe_editable .btn, .btn.oe_editable { -webkit-user-select: auto; -moz-user-select: auto; user-select: auto; + cursor: text !important; } .modal-dialog.select-image { @@ -233,12 +236,11 @@ ul.oe_menu_editor .disclose { z-index: 1000; } -/* ---- SNIPPET EDITOR ---- */ +/* ---- SNIPPET EDITOR ---- {{{ */ #oe_snippets { position: fixed; left: 0px; right: 0px; - top: 51px; background: #282828; -webkit-box-shadow: 0px 10px 10px -10px black inset; -moz-box-shadow: 0px 10px 10px -10px black inset; @@ -368,7 +370,7 @@ ul.oe_menu_editor .disclose { background: #747474, radial-gradient(rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.4)); } -/* ---- SNIPPETS DROP ZONES ---- */ +/* ---- SNIPPETS DROP ZONES ---- {{{ */ .oe_drop_zone.oe_insert { display: block; height: 48px; @@ -429,7 +431,7 @@ ul.oe_menu_editor .disclose { padding-top: 12px; } -/* ---- SNIPPET MANIPULATOR ---- */ +/* ---- SNIPPET MANIPULATOR ---- {{{ */ .oe_overlay { display: none; position: absolute; @@ -599,7 +601,7 @@ ul.oe_menu_editor .disclose { cursor: move !important; } -/* ---- MOBILE PREVIEW ---- */ +/* ---- MOBILE PREVIEW ---- {{{ */ .oe_mobile_preview.modal .modal-content { height: 660px; background-color: black; @@ -655,7 +657,7 @@ ul.oe_menu_editor .disclose { background-color: black; } -/* ---- SEO TOOLS ---- */ +/* ---- SEO TOOLS ---- {{{ */ .oe_seo_configuration .modal-dialog { width: 80%; } @@ -674,10 +676,9 @@ ul.oe_menu_editor .disclose { border-radius: 0.4em; } -/* ---- ACE EDITOR ---- */ +/* ---- ACE EDITOR ---- {{{ */ .oe_ace_view_editor { position: fixed; - top: 51px; right: 0; z-index: 1000; height: 100%; @@ -707,7 +708,6 @@ ul.oe_menu_editor .disclose { position: absolute; top: 30px; right: 0; - bottom: 51px; left: 0; } .oe_ace_view_editor .ace_editor .ace_gutter { @@ -723,7 +723,7 @@ ul.oe_menu_editor .disclose { opacity: 0; } -/* ---- EDITOR TOUR ---- */ +/* ---- EDITOR TOUR ---- {{{ */ div.tour-backdrop { z-index: 2009; } diff --git a/addons/website/static/src/css/editor.sass b/addons/website/static/src/css/editor.sass index ff62f416d8d..6cc590bf6b2 100644 --- a/addons/website/static/src/css/editor.sass +++ b/addons/website/static/src/css/editor.sass @@ -1,8 +1,6 @@ -@charset "utf-8" - @import "common" -/* ---- CKEditor Minimal Reset ---- */ +/* ---- CKEditor Minimal Reset ---- {{{ */ .navbar.navbar-inverse .cke_chrome border: none @@ -26,7 +24,9 @@ border: none +box-shadow(none) -/* ---- OpenERP Style ---- */ +// }}} + +/* ---- OpenERP Style ---- {{{ */ .oe_website_editorbar position: fixed @@ -85,7 +85,9 @@ position: relative !important top: +2px -/* ---- EDITOR BAR ---- */ +// }}} + +/* ---- EDITOR BAR ---- {{{ */ table.editorbar-panel cursor: pointer @@ -95,7 +97,10 @@ table.editorbar-panel td.selected background-color: #b1c9d9 -// ---- TRANSLATIONS ---- {{{ +// }}} + +/* ---- TRANSLATIONS ---- {{{ */ + .oe_translate_or color: white padding: 0 0 0 1em @@ -112,9 +117,11 @@ table.editorbar-panel padding: 0 10px .oe_translatable_todo background: rgb(255, 255, 182) + // }}} -// -------- MENU -------- {{{ +/* ---- MENU ---- {{{ */ + div.oe_menu_buttons top: -8px right: -8px @@ -137,12 +144,13 @@ ul.oe_menu_editor // }}} -/* ---- RTE ---- */ +/* ---- RTE ---- {{{ */ // bootstrap makes .btn elements unselectable -> RTE double-click can't know // about them either .oe_editable .btn, .btn.oe_editable +user-select(auto) + cursor: text !important .modal-dialog.select-image width: 80% @@ -197,13 +205,15 @@ ul.oe_menu_editor // This z-index is due to .navbar of bootstrap z-index: 1000 -/* ---- SNIPPET EDITOR ---- */ +// }}} + +/* ---- SNIPPET EDITOR ---- {{{ */ #oe_snippets position: fixed left: 0px right: 0px - top: 51px + // top property is set programmatically background: rgb(40,40,40) +box-shadow(0px 10px 10px -10px black inset) z-index: 1010 @@ -286,7 +296,9 @@ ul.oe_menu_editor #oe_snippets .oe_snippet_thumbnail @include background(#747474, radial-gradient(rgba(0,0,0,0.25),rgba(0,0,0,0.4))) -/* ---- SNIPPETS DROP ZONES ---- */ +// }}}} + +/* ---- SNIPPETS DROP ZONES ---- {{{ */ .oe_drop_zone.oe_insert display: block @@ -333,7 +345,9 @@ ul.oe_menu_editor margin-bottom: 32px padding-top: 12px -/* ---- SNIPPET MANIPULATOR ---- */ +// }}} + +/* ---- SNIPPET MANIPULATOR ---- {{{ */ .oe_overlay display: none @@ -454,8 +468,9 @@ ul.oe_menu_editor .move-important, .move-important * cursor: move !important +// }}} -/* ---- MOBILE PREVIEW ---- */ +/* ---- MOBILE PREVIEW ---- {{{ */ $mobile_preview_background: #000000 $mobile_preview_border: #1C1F1F @@ -498,7 +513,9 @@ $icon_close: #E00101 .modal-footer background-color: $mobile_preview_background -/* ---- SEO TOOLS ---- */ +// }}} + +/* ---- SEO TOOLS ---- {{{ */ $remove_color: $icon_close $in_title_color: #5cb85c @@ -516,15 +533,16 @@ $highlighted_text_color: #ffffff padding: .2em .4em .2em .5em +border-radius(.4em) -/* ---- ACE EDITOR ---- */ +// }}} + +/* ---- ACE EDITOR ---- {{{ */ $editorbar_height: 30px // TODO Fix => might break with themes -$navbar_height: 51px .oe_ace_view_editor position: fixed - top: $navbar_height + // top property is set programmatically right: 0 z-index: 1000 height: 100% @@ -546,7 +564,7 @@ $navbar_height: 51px position: absolute top: $editorbar_height right: 0 - bottom: $navbar_height + // bottom property is set programmatically left: 0 .ace_gutter cursor: ew-resize @@ -556,9 +574,15 @@ $navbar_height: 51px z-index: -1000 +opacity(0) -/* ---- EDITOR TOUR ---- */ +// }}} + +/* ---- EDITOR TOUR ---- {{{ */ + div.tour-backdrop z-index: 2009 .popover.tour z-index: 2010 + +// }}} + // vim:tabstop=4:shiftwidth=4:softtabstop=4:fdm=marker: diff --git a/addons/website/static/src/js/website.ace.js b/addons/website/static/src/js/website.ace.js index 7108fd9ded7..f3b8a5c5885 100644 --- a/addons/website/static/src/js/website.ace.js +++ b/addons/website/static/src/js/website.ace.js @@ -1,35 +1,34 @@ (function () { 'use strict'; - var globalEditor; - var hash = "#advanced-view-editor"; var website = openerp.website; website.add_template_file('/website/static/src/xml/website.ace.xml'); - website.ready().then(function () { - if (window.location.hash.indexOf(hash) >= 0) { - launch(); - } - }); - - function launch () { - if (globalEditor) { - globalEditor.open(); - } else { - globalEditor = new website.ace.ViewEditor(); - globalEditor.appendTo($(document.body)); - } - } - website.EditorBar.include({ events: _.extend({}, website.EditorBar.prototype.events, { 'click a[data-action=ace]': 'launchAce', }), + start: function () { + var self = this; + this.globalEditor = null; + return this._super.apply(this, arguments).then(function () { + if (window.location.hash.indexOf(hash) >= 0) { + self.launchAce(); + } + }); + }, launchAce: function (e) { - e.preventDefault(); - launch(); + if (e) { + e.preventDefault(); + } + if (this.globalEditor) { + this.globalEditor.open(); + } else { + this.globalEditor = new website.ace.ViewEditor(this); + this.globalEditor.appendTo($(document.body)); + } }, }); @@ -104,6 +103,10 @@ self.aceEditor.resize(); self.$el.width(width); } + function resizeEditorHeight(height) { + self.$el.css('top', height); + self.$('.ace_editor').css('bottom', height); + } function storeEditorWidth() { window.localStorage.setItem('ace_editor_width', self.$el.width()); } @@ -133,7 +136,11 @@ $('button[data-action=edit]').click(function () { self.close(); }); + this.getParent().on('change:height', this, function (editor) { + resizeEditorHeight(editor.get('height')); + }); resizeEditor(readEditorWidth()); + resizeEditorHeight(this.getParent().get('height')); }, loadViews: function (views) { var self = this; diff --git a/addons/website/static/src/js/website.editor.js b/addons/website/static/src/js/website.editor.js index c7c5620c9a4..51f14a0f6d5 100644 --- a/addons/website/static/src/js/website.editor.js +++ b/addons/website/static/src/js/website.editor.js @@ -374,7 +374,6 @@ editor.edit(); } }); - $body.css('padding-top', '50px'); // Not working properly: editor.$el.outerHeight()); }; /* ----- TOP EDITOR BAR FOR ADMIN ---- */ @@ -423,7 +422,6 @@ }, start: function() { var self = this; - this.saving_mutex = new openerp.Mutex(); this.$('#website-top-edit').hide(); @@ -443,12 +441,25 @@ this.rte.on('rte:ready', this, function () { self.setup_hover_buttons(); self.trigger('rte:ready'); + self.check_height(); }); + $(window).on('resize', _.debounce(this.check_height.bind(this), 50)); + this.check_height(); + return $.when( this._super.apply(this, arguments), this.rte.appendTo(this.$('#website-top-edit .nav.pull-right')) - ); + ).then(function () { + self.check_height(); + }); + }, + check_height: function () { + var editor_height = this.$el.outerHeight(); + if (this.get('height') != editor_height) { + $(document.body).css('padding-top', editor_height); + this.set('height', editor_height); + } }, edit: function () { this.$buttons.edit.prop('disabled', true); @@ -456,7 +467,7 @@ this.$('#website-top-edit').show(); $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden"); - this.rte.start_edition(); + this.rte.start_edition().then(this.check_height.bind(this)); }, rte_changed: function () { this.$buttons.save.prop('disabled', false); diff --git a/addons/website/static/src/js/website.snippets.editor.js b/addons/website/static/src/js/website.snippets.editor.js index 4cf49bb962b..d4f8920663a 100644 --- a/addons/website/static/src/js/website.snippets.editor.js +++ b/addons/website/static/src/js/website.snippets.editor.js @@ -139,6 +139,10 @@ $(document).on('click', '.dropdown-submenu a[tabindex]', function (e) { e.preventDefault(); }); + + this.getParent().on('change:height', this, function (editor) { + self.$el.css('top', editor.get('height')); + }); }, fetch_snippet_templates: function () { var self = this; diff --git a/addons/website/tests/__init__.py b/addons/website/tests/__init__.py index 24de4d7ddad..665bbc9f535 100644 --- a/addons/website/tests/__init__.py +++ b/addons/website/tests/__init__.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- -import test_views, test_converter, test_requests +import test_views, test_converter, test_requests, test_ui checks = [ test_views, test_converter, ] diff --git a/addons/website/tests/test_converter.py b/addons/website/tests/test_converter.py index 1086ec8e8d3..4b24e8f0b9f 100644 --- a/addons/website/tests/test_converter.py +++ b/addons/website/tests/test_converter.py @@ -63,20 +63,6 @@ class TestConvertBack(common.TransactionCase): self.field_roundtrip('char', "ⒸⓄⓇⒼⒺ") - def test_m2o(self): - Sub = self.registry('website.converter.test.sub') - sub = partial(Sub.create, self.cr, self.uid) - ids = [ - sub({'name': "Foo"}), - sub({'name': "Bar"}), - sub({'name': "Baz"}), - ] - - self.field_rountrip_result( - 'many2one', - ids[2], - ids[2]) - def test_selection(self): self.field_roundtrip('selection', 3) @@ -104,3 +90,47 @@ class TestConvertBack(common.TransactionCase): You never know You never know until you go """) + + def test_m2o(self): + """ the M2O field conversion (from html) is markedly different from + others as it directly writes into the m2o and returns nothing at all. + """ + model = 'website.converter.test' + field = 'many2one' + + Sub = self.registry('website.converter.test.sub') + sub_id = Sub.create(self.cr, self.uid, {'name': "Foo"}) + + Model = self.registry(model) + id = Model.create(self.cr, self.uid, {field: sub_id}) + [record] = Model.browse(self.cr, self.uid, [id]) + + e = document.createElement('span') + field_value = 'record.%s' % field + e.setAttribute('t-field', field_value) + + rendered = self.registry('website.qweb').render_tag_field( + e, {'field': field_value}, '', ir_qweb.QWebContext(self.cr, self.uid, { + 'record': record, + })) + + element = html.fromstring(rendered, parser=html.HTMLParser(encoding='utf-8')) + # emulate edition + element.text = "New content" + + column = Model._all_columns[field].column + converter = self.registry('website.qweb').get_converter_for( + element.get('data-oe-type')) + + value_back = converter.from_html( + self.cr, self.uid, model, column, element) + + self.assertIsNone( + value_back, "the m2o converter should return None to avoid spurious" + " or useless writes on the parent record") + + self.assertEqual( + Sub.browse(self.cr, self.uid, sub_id).name, + "New content", + "element edition should have been written directly to the m2o record" + ) diff --git a/addons/website/tests/test_ui.py b/addons/website/tests/test_ui.py new file mode 100644 index 00000000000..927d407af70 --- /dev/null +++ b/addons/website/tests/test_ui.py @@ -0,0 +1,98 @@ +import json +import subprocess +import unittest +import os +import time + +ROOT = os.path.join(os.path.dirname(__file__), 'ui_suite') + +__all__ = ['load_tests', 'WebsiteUiSuite'] + +def _exc_info_to_string(err, test): + return err + +class WebsiteUiTest(unittest.TestCase): + def __init__(self, name): + self.name = name + def shortDescription(self): + return None + def __str__(self): + return self.name + +class WebsiteUiSuite(unittest.TestSuite): + def __init__(self, testfile, timeout=5000): + self.testfile = testfile + self.timeout = timeout + self._test = None + + def __iter__(self): + return iter([self]) + + def run(self, result): + # Test if phantom is correctly installed + try: + subprocess.call(['phantomjs', '-v'], + stdout=open(os.devnull, 'w'), + stderr=subprocess.STDOUT) + except OSError: + test = WebsiteUiTest('UI Tests') + result.startTest(test) + result.addSkip(test, "phantomjs command not found") + result.stopTest(test) + return + + result._exc_info_to_string = _exc_info_to_string + try: + self._run(result) + finally: + del result._exc_info_to_string + + def _run(self, result): + phantom = subprocess.Popen([ + 'phantomjs', + os.path.join(ROOT, self.testfile), + json.dumps({ 'timeout': self.timeout }) + ], stdout=subprocess.PIPE) + + self._test = WebsiteUiTest(self.testfile) + + try: + while True: + line = phantom.stdout.readline() + if line: + # the runner expects only one result per test + self.process(line, result) + result.stopTest(self._test) + break + else: + time.sleep(0.1) + finally: + # kill phantomjs if phantom.exit() wasn't called in the test + if phantom.poll() is None: + phantom.terminate() + + def process(self, line, result): + # Test protocol + # ------------- + # use console.log in phantomjs to output test results using the following format: + # - for a success: { "event": "success" } + # - for a failure: { "event": "failure", "message": "Failure description" } + # any other message is treated as an error + result.startTest(self._test) + try: + args = json.loads(line) + event = args.get('event') + message = args.get('message', "") + + if event == 'success': + result.addSuccess(self._test) + elif event == 'failure': + result.addFailure(self._test, message) + else: + result.addError(self._test, "Unexpected message: %s" % line) + except ValueError: + result.addError(self._test, "Unexpected message: %s" % line) + +def load_tests(loader, base, _): + base.addTest(WebsiteUiSuite('sample_test.js')) + return base \ No newline at end of file diff --git a/addons/website/tests/ui_suite/sample_test.js b/addons/website/tests/ui_suite/sample_test.js new file mode 100644 index 00000000000..7646e4ec76a --- /dev/null +++ b/addons/website/tests/ui_suite/sample_test.js @@ -0,0 +1,4 @@ +console.log('{ "event": "success" }'); +// For a failure: +// console.log('{ "event": "failure", "message": "The test failed" }'); +phantom.exit(); \ No newline at end of file diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index fa65b909c9a..4272bdcf1bd 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -597,7 +597,7 @@ Sitemap: sitemap.xml