From 5a030db3eb77b6d80187f8ae1a64303b2f50391d Mon Sep 17 00:00:00 2001 From: qsm-odoo Date: Wed, 19 Oct 2016 17:41:10 +0200 Subject: [PATCH] [REF] website, web_editor: refactor ace editor Stop using ugly code for ace editor. * Really close the ace editor by changing its display property thanks to the dedicated do_show and do_hide method. * Make the common part of ace editors (website, web_studio) consistent so that the extensions are really small. * Do not do RPC to retrieve data already retrieved (now, there is only one unique RPC when the editor starts an one RPC per view changed on save). * Fix born-dead code so that the website editor starts automatically if the URL contains its associated hash and loads the correct view * Better handle errors (previous behavior only worked partially and only on chrome): - Jump to the error line and make it red. - Do not open a Dialog with mostly incomprehensible message but allow to open it on click of the error marker. - Do not save any changed file if one contains an error. - Do not allow formatting a file if it contains an error (show the error instead). * Do not use different routes to get the same content (remove the /website/customize_template_get route as it returns the same content as /web_editor/customize_template_get route). Note: in a further commit this route will be refactored. * In a general way, use consistent method names and behaviors / comment the code / lint files / ... * Open the code to extensions (so that it may later load other data than XML). Note: this refactoring allowed to fix bugs in previous odoo versions but this commit message will not detail them. --- addons/web_editor/static/src/js/ace.js | 629 ++++++++++-------- .../src/less/web_editor.ui.components.less | 31 +- addons/web_editor/static/src/xml/ace.xml | 6 +- addons/website/controllers/main.py | 9 - addons/website/static/src/js/website.ace.js | 106 ++- .../website/static/src/js/website.editor.js | 2 +- 6 files changed, 427 insertions(+), 356 deletions(-) diff --git a/addons/web_editor/static/src/js/ace.js b/addons/web_editor/static/src/js/ace.js index 7bd6ec36ba2..8b4ee8d7b43 100644 --- a/addons/web_editor/static/src/js/ace.js +++ b/addons/web_editor/static/src/js/ace.js @@ -2,8 +2,8 @@ odoo.define('web_editor.ace', function (require) { 'use strict'; var ajax = require('web.ajax'); -var Class = require('web.Class'); var core = require('web.core'); +var Dialog = require("web.Dialog"); var Widget = require('web.Widget'); var base = require('web_editor.base'); var local_storage = require('web.local_storage'); @@ -13,329 +13,414 @@ var _t = core._t; ajax.loadXML('/web_editor/static/src/xml/ace.xml', qweb); -var XmlDocument = Class.extend({ - init: function (text) { - this.xml = text; - }, - isWellFormed: function () { - var error; - if (document.implementation.createDocument) { - // use try catch for ie - try { - var dom = new DOMParser().parseFromString(this.xml, "text/xml"); - error = dom.getElementsByTagName("parsererror"); - return error.length === 0 || $(error).text(); - } catch (e) {} +function checkXML(xml) { + if (typeof window.DOMParser != "undefined") { + var xmlDoc = (new window.DOMParser()).parseFromString(xml, "text/xml"); + var error = xmlDoc.getElementsByTagName("parsererror"); + if (error.length > 0) { + return { + isValid: false, + error: { + line: parseInt(error[0].innerHTML.match(/[Ll]ine[^\d]+(\d+)/)[1], 10), + message: error[0].innerHTML + } + }; } - if (window.ActiveXObject) { - // IE - var msDom = new ActiveXObject("Microsoft.XMLDOM"); - msDom.async = false; - msDom.loadXML(this.xml); - return !msDom.parseError.errorCode || msDom.parseError.reason + "\nline " + msDom.parseError.line; + } else if (typeof window.ActiveXObject != "undefined" && new window.ActiveXObject("Microsoft.XMLDOM")) { + var xmlDocIE = new window.ActiveXObject("Microsoft.XMLDOM"); + xmlDocIE.async = "false"; + xmlDocIE.loadXML(xml); + if (xmlDocIE.parseError.line > 0) { + return { + isValid: false, + error: { + line: xmlDocIE.parseError.line, + message: xmlDocIE.parseError.reason + } + }; } - return true; - }, - format: function () { - return vkbeautify.xml(this.xml, 4); - }, -}); + } -var ViewOption = Widget.extend({ - template: 'web_editor.ace_view_option', - init: function (parent, options) { - this.view_id = options.id; - this.view_name = options.name; + return { + isValid: true + }; +} - var indent = _.str.repeat("- ", options.level); - this.view_name = _.str.sprintf("%s%s", indent, options.name); - this._super(parent); - }, -}); +function formatXML(xml) { + return window.vkbeautify.xml(xml, 4); +} +/** + * The ViewEditor Widget allow to visualize resources (by default, XML views) and edit them. + */ var ViewEditor = Widget.extend({ - resizing: false, - refX: 0, - minWidth: 40, template: 'web_editor.ace_view_editor', events: { - 'change #ace-view-list': 'displaySelectedView', - 'click .js_include_bundles': 'loadTemplates', - 'click button[data-action=save]': 'saveViews', - 'click button[data-action=format]': 'formatXml', - 'click button[data-action=close]': 'close', + 'change #ace-view-list': function () { + this.displayView(this.selectedViewId()); + }, + 'click .js_include_bundles': function (e) { + this.options.includeBundles = $(e.target).prop("checked"); + this.loadResources().then(this._updateViewSelectDOM.bind(this)); + }, + 'click button[data-action=save]': 'saveResources', + 'click button[data-action=format]': 'formatResource', + 'click button[data-action=close]': 'do_hide', }, - init: function (parent) { - this.buffers = {}; - this.views = {}; - this._super(parent); - }, - start: function () { - var self = this; - self.aceEditor = ace.edit(self.$('#ace-view-editor')[0]); - self.aceEditor.setTheme("ace/theme/monokai"); - self.loadTemplates(); + /** + * The init method should initialize the parameters of which information the ace editor will + * have to load. + * @param parent: the parent element of the editor widget. + * @param viewKey: xml_id of the view whose linked resources are to be loaded. + Also allow to receive the id directly. + * @param options: an object containing some options + * - initialViewID: a specific view ID to load on start (otherwise the main view ID + * associated with the specified viewKey will be used). + * - includeBundles: whether or not the assets bundles templates needs to be loaded. + */ + init: function (parent, viewKey, options) { + this._super.apply(this, arguments); - var $editor = self.$('.ace_editor'); - function resizeEditor (target) { - var width = Math.min(document.body.clientWidth, Math.max(parseInt(target, 10), self.minWidth)); - $editor.width(width); - self.aceEditor.resize(); - self.$el.width(width); + this.viewKey = viewKey; + this.options = options || {}; + + this.views = {}; + this.buffers = {}; + }, + /** + * The willStart method is in charge of loading everything the ace library needs to work. + * It also loads the resources to visualize. See @loadResources. + */ + willStart: function () { + var js_def = ajax.loadJS('/web/static/lib/ace/ace.odoo-custom.js').then(function () { + return $.when( + ajax.loadJS('/web/static/lib/ace/mode-xml.js'), + ajax.loadJS('/web/static/lib/ace/theme-monokai.js') + ); + }); + return $.when(this._super.apply(this, arguments), js_def, this.loadResources()); + }, + /** + * The start method is in charge of initializing the library and initial view once the DOM is + * ready. It also initializes the resize feature of the ace editor. + * @return a deferred which is resolved when the widget DOM content is fully loaded. + */ + start: function () { + this.$viewEditor = this.$('#ace-view-editor'); + this.$editor = this.$(".ace_editor"); + this.$viewList = this.$('#ace-view-list'); + this.$viewID = this.$('#ace-view-id'); + + this.aceEditor = window.ace.edit(this.$viewEditor[0]); + this.aceEditor.setTheme("ace/theme/monokai"); + + var refX = 0; + var resizing = false; + var minWidth = 400; + + this._updateViewSelectDOM(); + this.displayView( + this.options.initialViewID + || (typeof this.viewKey === "number" ? this.viewKey : _.findWhere(this.views, {xml_id: this.viewKey}).id) + ); + + $(document).on("mouseup.ViewEditor", stopResizing.bind(this)).on("mousemove.ViewEditor", updateWidth.bind(this)); + this.$el.on("mousedown.ViewEditor", ".ace_gutter", startResizing.bind(this)); + + resizeEditor.call(this, readEditorWidth.call(this)); + + return this._super.apply(this, arguments); + + function resizeEditor(target) { + var width = Math.min(document.body.clientWidth, Math.max(parseInt(target, 10), minWidth)); + this.$editor.width(width); + this.aceEditor.resize(); + this.$el.width(width); } function storeEditorWidth() { - local_storage.setItem('ace_editor_width', self.$el.width()); + local_storage.setItem('ace_editor_width', this.$el.width()); } function readEditorWidth() { var width = local_storage.getItem('ace_editor_width'); return parseInt(width || 720, 10); } - function startResizing (e) { - self.refX = e.pageX; - self.resizing = true; + function startResizing(e) { + refX = e.pageX; + resizing = true; } - function stopResizing () { - self.resizing = false; + function stopResizing() { + resizing = false; } - function updateWidth (e) { - if (self.resizing) { - var offset = e.pageX - self.refX; - var width = self.$el.width() - offset; - self.refX = e.pageX; - resizeEditor(width); - storeEditorWidth(); - } - } - self.$('.ace_gutter').mouseup(stopResizing).mousedown(startResizing).click(stopResizing); - $(document).on("mousemove.ViewEditor", updateWidth).on("mouseup.ViewEditor", stopResizing); - $('button[data-action=edit]').click(function () { - self.close(); - }); - resizeEditor(readEditorWidth()); + function updateWidth(e) { + if (!resizing) return; - return this._super.apply(this, arguments); + var offset = e.pageX - refX; + var width = this.$el.width() - offset; + refX = e.pageX; + resizeEditor.call(this, width); + storeEditorWidth.call(this); + } }, + /** + * The destroy method unbinds custom events binded to the document element. + */ destroy: function () { this._super.apply(this, arguments); + this.$el.off(".ViewEditor"); $(document).off(".ViewEditor"); }, + /** + * The loadResources method is in charge of loading data the ace editor will vizualize + * and of processing them. + * Default behavior is loading the activate views, index them and build their hierarchy. + * @return a deferred which is resolved once everything is loaded and processed. + */ + loadResources: function () { + return ajax.jsonRpc('/web_editor/customize_template_get', 'call', { + key: this.viewKey, + full: true, + bundles: this.options.includeBundles + }).then((function (views) { + // Only keep the active views and index them by ID. + this.views = _.indexBy(_.filter(views, function (view) { + return view.active; + }), "id"); - // The 4 following methods are meant to be extended depending on the context - open: function () { - this.$el.removeClass('oe_ace_closed').addClass('oe_ace_open'); - }, - close: function () { - this.$el.removeClass('oe_ace_open').addClass('oe_ace_closed'); - }, - reloadPage: function () {}, - loadTemplates: function () {}, - - loadViews: function (views) { - var $viewList = this.$('#ace-view-list').empty(); - var viewGraph = this.buildViewGraph(views); - _(viewGraph).each(function (view) { - if (!view.id) { return; } - - this.views[view.id] = view; - new ViewOption(this, view).appendTo($viewList); - }.bind(this)); - return this.loadView(viewGraph[0].id); - }, - buildViewGraph: function (views) { - var activeViews = _.uniq(_.filter(views, function (view) { - return view.active; - }), false, function (view) { - return view.id; - }); - var index = {}; - var roots = []; - _.each(activeViews, function (view) { - index[view.id] = view; - view.children = []; - }); - _.each(index, function (view) { - var parentId = view.inherit_id[0]; - if (parentId && index[parentId]) { - index[parentId].children.push(view); - } else { - roots.push(view); - } - }); - var result = []; - function visit (node, level) { - node.level = level; - result.push(node); - _.each(node.children, function (child) { - visit(child, level + 1); + // Initialize a 0 level for each view and assign them an array containing their children. + var self = this; + var roots = []; + _.each(this.views, function (view) { + view.level = 0; + view.children = []; }); + _.each(this.views, function (view) { + var parentId = view.inherit_id[0]; + var parent = parentId && self.views[parentId]; + if (parent) { + parent.children.push(view); + } else { + roots.push(view); + } + }); + + // Assign the correct level based on children key and save a sorted array where + // each view is followed by their children. + this.sorted_views = []; + function visit(view, level) { + view.level = level; + self.sorted_views.push(view); + _.each(view.children, function (child) { + visit(child, level + 1); + }); + } + _.each(roots, function (root) { + visit(root, 0); + }); + }).bind(this)); + }, + /** + * The private _updateViewSelectDOM method purpose is to render the content of the view + * select DOM element according to current widget data. + */ + _updateViewSelectDOM: function () { + var currentId = this.selectedViewId(); + + var self = this; + this.$viewList.empty(); + _.each(this.sorted_views, function (view) { + self.$viewList.append($("