diff --git a/addons/web_editor/static/src/js/base.js b/addons/web_editor/static/src/js/base.js new file mode 100644 index 00000000000..d69cace3100 --- /dev/null +++ b/addons/web_editor/static/src/js/base.js @@ -0,0 +1,157 @@ +odoo.define('web_editor.base', function (require) { +'use strict'; + +var ajax = require('web.ajax'); +var session = require('web.session'); + +var domReady = $.Deferred(); +$(domReady.resolve.bind(domReady)); + +return { + /** + * Retrieves all the CSS rules which match the given parser (Regex). + * + * @param {Regex} filter + * @returns {Object[]} Array of CSS rules descriptions (objects). A rule is + * defined by 3 values: 'selector', 'css' and 'names'. 'selector' + * is a string which contains the whole selector, 'css' is a string + * which contains the css properties and 'names' is an array of the + * first captured groups for each selector part. E.g.: if the + * filter is set to match .fa-* rules and capture the icon names, + * the rule: + * '.fa-alias1::before, .fa-alias2::before { hello: world; }' + * will be retrieved as + * { + * selector: '.fa-alias1::before, .fa-alias2::before', + * css: 'hello: world;', + * names: ['.fa-alias1', '.fa-alias2'], + * } + */ + cacheCssSelectors: {}, + getCssSelectors: function (filter) { + if (this.cacheCssSelectors[filter]) { + return this.cacheCssSelectors[filter]; + } + this.cacheCssSelectors[filter] = []; + var sheets = document.styleSheets; + for (var i = 0; i < sheets.length; i++) { + var rules; + try { + // try...catch because Firefox not able to enumerate + // document.styleSheets[].cssRules[] for cross-domain + // stylesheets. + rules = sheets[i].rules || sheets[i].cssRules; + } catch (e) { + console.warn("Can't read the css rules of: " + sheets[i].href, e); + continue; + } + if (!rules) { + continue; + } + + for (var r = 0 ; r < rules.length ; r++) { + var selectorText = rules[r].selectorText; + if (!selectorText) { + continue; + } + var selectors = selectorText.split(/\s*,\s*/); + var data = null; + for (var s = 0; s < selectors.length; s++) { + var match = selectors[s].trim().match(filter); + if (!match) { + continue; + } + if (!data) { + data = { + selector: match[0], + css: rules[r].cssText.replace(/(^.*\{\s*)|(\s*\}\s*$)/g, ''), + names: [match[1]] + }; + } else { + data.selector += (', ' + match[0]); + data.names.push(match[1]); + } + } + if (data) { + this.cacheCssSelectors[filter].push(data); + } + } + } + return this.cacheCssSelectors[filter]; + }, + /** + * List of font icons to load by editor. The icons are displayed in the media + * editor and identified like font and image (can be colored, spinned, resized + * with fa classes). + * To add font, push a new object {base, parser} + * + * - base: class who appear on all fonts + * - parser: regular expression used to select all font in css stylesheets + * + * @type Array + */ + fontIcons: [{base: 'fa', parser: /\.(fa-(?:\w|-)+)::?before/i}], + /** + * Searches the fonts described by the @see fontIcons variable. + */ + computeFonts: _.once(function () { + var self = this; + _.each(this.fontIcons, function (data) { + data.cssData = self.getCssSelectors(data.parser); + data.alias = _.flatten(_.map(data.cssData, _.property('names'))); + }); + }), + /** + * If a widget needs to be instantiated on page loading, it needs to wait + * for appropriate resources to be loaded. This function returns a Deferred + * which is resolved when the dom is ready, the session is bound + * (translations loaded) and the XML is loaded. This should however not be + * necessary anymore as widgets should not be parentless and should then be + * instantiated (directly or not) by the page main component (webclient, + * website root, editor bar, ...). The DOM will be ready then, the main + * component is in charge of waiting for the session and the XML can be + * lazy loaded thanks to the @see Widget.xmlDependencies key. + * + * @returns {Deferred} + */ + ready: function () { + return $.when(domReady, session.is_bound, ajax.loadXML()); + }, +}; +}); + +//============================================================================== + +odoo.define('web_editor.context', function (require) { +'use strict'; + +function getContext(context) { + var html = document.documentElement; + return _.extend({ + lang: (html.getAttribute('lang') || 'en_US').replace('-', '_'), + }, context || {}); +} +function getExtraContext(context) { + var html = document.documentElement; + return _.extend(getContext(), { + editable: !!(html.dataset.editable || $('[data-oe-model]').length), // temporary hack, this should be done in python + translatable: !!html.dataset.translatable, + edit_translations: !!html.dataset.edit_translations, + }, context || {}); +} + +return { + get: getContext, + getExtra: getExtraContext, +}; +}); + +//============================================================================== + +odoo.define('web_editor.ready', function (require) { +'use strict'; + +var base = require('web_editor.base'); + +return base.ready(); +}); diff --git a/addons/web_editor/static/src/js/common/ace.js b/addons/web_editor/static/src/js/common/ace.js index dc2d78a53c7..37c21f2aa36 100644 --- a/addons/web_editor/static/src/js/common/ace.js +++ b/addons/web_editor/static/src/js/common/ace.js @@ -2,12 +2,13 @@ odoo.define('web_editor.ace', function (require) { 'use strict'; var ajax = require('web.ajax'); -var config = require('web.config'); var concurrency = require('web.concurrency'); var core = require('web.core'); var Dialog = require('web.Dialog'); var Widget = require('web.Widget'); +var weContext = require('web_editor.context'); var localStorage = require('web.local_storage'); +var session = require('web.session'); var _t = core._t; @@ -119,22 +120,20 @@ var ViewEditor = Widget.extend({ jsLibs: [ '/web/static/lib/ace/ace.js', [ - '/web/static/lib/ace/javascript_highlight_rules.js', '/web/static/lib/ace/mode-xml.js', '/web/static/lib/ace/mode-scss.js', - '/web/static/lib/ace/mode-js.js', '/web/static/lib/ace/theme-monokai.js' ] ], events: { 'click .o_ace_type_switcher_choice': '_onTypeChoice', 'change .o_res_list': '_onResChange', - 'click .o_ace_filter': '_onFilterChange', + 'click .js_include_bundles': '_onIncludeBundlesChange', + 'click .js_include_all_scss': '_onIncludeAllSCSSChange', 'click button[data-action=save]': '_onSaveClick', 'click button[data-action=reset]': '_onResetClick', 'click button[data-action=format]': '_onFormatClick', 'click button[data-action=close]': '_onCloseClick', - 'click #ace-view-id > .alert-warning .close': '_onCloseWarningClick' }, /** @@ -152,35 +151,30 @@ var ViewEditor = Widget.extend({ * @param {string} [options.position=right] * @param {boolean} [options.doNotLoadViews=false] * @param {boolean} [options.doNotLoadSCSS=false] - * @param {boolean} [options.doNotLoadJS=false] * @param {boolean} [options.includeBundles=false] - * @param {string} [options.filesFilter=custom] + * @param {boolean} [options.includeAllSCSS=false] * @param {string[]} [options.defaultBundlesRestriction] */ init: function (parent, viewKey, options) { this._super.apply(this, arguments); - this.context = options.context; - this.viewKey = viewKey; this.options = _.defaults({}, options, { position: 'right', doNotLoadViews: false, doNotLoadSCSS: false, - doNotLoadJS: false, includeBundles: false, - filesFilter: 'custom', + includeAllSCSS: false, defaultBundlesRestriction: [], }); - this.resources = {xml: {}, scss: {}, js: {}}; - this.editingSessions = {xml: {}, scss: {}, js: {}}; + this.resources = {xml: {}, scss: {}}; + this.editingSessions = {xml: {}, scss: {}}; this.currentType = 'xml'; // Alias this.views = this.resources.xml; this.scss = this.resources.scss; - this.js = this.resources.js; }, /** * Loads everything the ace library needs to work. @@ -189,10 +183,11 @@ var ViewEditor = Widget.extend({ * @override */ willStart: function () { - return Promise.all([ + return $.when( this._super.apply(this, arguments), + ajax.loadLibs(this), this._loadResources() - ]); + ); }, /** * Initializes the library and initial view once the DOM is ready. It also @@ -208,13 +203,11 @@ var ViewEditor = Widget.extend({ this.$lists = { xml: this.$('#ace-view-list'), - scss: this.$('#ace-scss-list'), - js: this.$('#ace-js-list'), + scss: this.$('#ace-scss-list') }; this.$includeBundlesArea = this.$('.oe_include_bundles'); this.$includeAllSCSSArea = this.$('.o_include_all_scss'); this.$viewID = this.$('#ace-view-id > span'); - this.$warningMessage = this.$('#ace-view-id > .alert-warning'); this.$formatButton = this.$('button[data-action=format]'); this.$resetButton = this.$('button[data-action=reset]'); @@ -234,24 +227,12 @@ var ViewEditor = Widget.extend({ var initType; if (this.options.initialResID) { initResID = this.options.initialResID; - if (_.isString(initResID) && initResID[0] === '/') { - if (_.str.endsWith(initResID, '.scss')) { - initType = 'scss'; - } else { - initType = 'js'; - } - } else { - initType = 'xml'; - } + initType = (_.isString(initResID) && initResID[0] === '/') ? 'scss' : 'xml'; } else { if (!this.options.doNotLoadSCSS) { initResID = this.sortedSCSS[0][1][0].url; // first bundle, scss files, first one initType = 'scss'; } - if (!this.options.doNotLoadJS) { - initResID = this.sortedJS[0][1][0].url; // first bundle, js files, first one - initType = 'js'; - } if (!this.options.doNotLoadViews) { if (typeof this.viewKey === "number") { initResID = this.viewKey; @@ -339,7 +320,7 @@ var ViewEditor = Widget.extend({ * Initializes a text editor for the specified resource. * * @private - * @param {integer|string} resID - the ID/URL of the view/scss/js file + * @param {integer|string} resID - the ID/URL of the view/scss file * @param {string} [type] (default to the currently selected one) * @returns {ace.EditSession} */ @@ -359,13 +340,13 @@ var ViewEditor = Widget.extend({ return editingSession; }, /** - * Forces the view/scss/js file identified by its ID/URL to be displayed in the + * Forces the view/scss file identified by its ID/URL to be displayed in the * editor. The method will update the resource select DOM element as well if * necessary. * * @private * @param {integer|string} resID - * @param {string} [type] - the type of resource (either 'xml', 'scss' or 'js') + * @param {string} [type] - the type of resource (either 'xml' or 'scss') */ _displayResource: function (resID, type) { if (type) { @@ -378,29 +359,14 @@ var ViewEditor = Widget.extend({ } this.aceEditor.setSession(editingSession); - var isCustomized = false; if (this.currentType === 'xml') { this.$viewID.text(_.str.sprintf(_t("Template ID: %s"), this.views[resID].key)); - } else if (this.currentType === 'scss') { - isCustomized = this.scss[resID].customized; - this.$viewID.text(_.str.sprintf(_t("SCSS file: %s"), resID)); } else { - isCustomized = this.js[resID].customized; - this.$viewID.text(_.str.sprintf(_t("JS file: %s"), resID)); + this.$viewID.text(_.str.sprintf(_t("SCSS file: %s"), resID)); } this.$lists[this.currentType].select2('val', resID); - this.$resetButton.toggleClass('d-none', this.currentType === 'xml' || !isCustomized); - - // TODO the warning message is always shown for XML templates but: - // 1) We have to implement a way to be able to reset XML templates - // otherwise the warning message is not accurate - // 2) We should be able to detect if the XML template is customized to - // not show the warning in that case - this.$warningMessage.toggleClass('d-none', - this.currentType !== 'xml' && (resID.indexOf('/user_custom_') >= 0 || isCustomized)); - - this.aceEditor.resize(true); + this.$resetButton.toggleClass('d-none', this.currentType === 'xml' || !this.scss[resID].customized); }, /** * Formats the current resource being vizualized. @@ -432,15 +398,14 @@ var ViewEditor = Widget.extend({ * is loading the activate views, index them and build their hierarchy. * * @private - * @returns {Promise} + * @returns {Deferred} */ _loadResources: function () { // Reset resources - this.resources = {xml: {}, scss: {}, js: {}}; - this.editingSessions = {xml: {}, scss: {}, js: {}}; + this.resources = {xml: {}, scss: {}}; + this.editingSessions = {xml: {}, scss: {}}; this.views = this.resources.xml; this.scss = this.resources.scss; - this.js = this.resources.js; // Load resources return this._rpc({ @@ -449,15 +414,12 @@ var ViewEditor = Widget.extend({ key: this.viewKey, get_views: !this.options.doNotLoadViews, get_scss: !this.options.doNotLoadSCSS, - get_js: !this.options.doNotLoadJS, bundles: this.options.includeBundles, - bundles_restriction: this.options.filesFilter === 'all' ? [] : this.options.defaultBundlesRestriction, - only_user_custom_files: this.options.filesFilter === 'custom', + bundles_restriction: this.options.includeAllSCSS ? [] : this.options.defaultBundlesRestriction, }, }).then((function (resources) { _processViews.call(this, resources.views || []); - _processJSorSCSS.call(this, resources.scss || [], 'scss'); - _processJSorSCSS.call(this, resources.js || [], 'js'); + _processSCSS.call(this, resources.scss || []); }).bind(this)); function _processViews(views) { @@ -498,24 +460,20 @@ var ViewEditor = Widget.extend({ }); } - function _processJSorSCSS(data, type) { - // The received scss or js data is already sorted by bundle and DOM order - if (type === 'scss') { - this.sortedSCSS = data; - } else { - this.sortedJS = data; - } + function _processSCSS(scss) { + // The received scss data is already sorted by bundle and DOM order + this.sortedSCSS = scss; // Store the URL ungrouped by bundle and use the URL as key (resource ID) - var resources = type === 'scss' ? this.scss : this.js; - _.each(data, function (bundleInfos) { + var self = this; + _.each(scss, function (bundleInfos) { _.each(bundleInfos[1], function (info) { info.bundle_xmlid = bundleInfos[0].xmlid; }); - _.extend(resources, _.indexBy(bundleInfos[1], 'url')); + _.extend(self.scss, _.indexBy(bundleInfos[1], 'url')); }); } }, /** - * Forces the view/scss/js file identified by its ID/URL to be reset to the way + * Forces the view/scss file identified by its ID/URL to be reset to the way * it was before the user started editing it. * * @todo views reset is not supported yet @@ -523,57 +481,59 @@ var ViewEditor = Widget.extend({ * @private * @param {integer|string} [resID] (default to the currently selected one) * @param {string} [type] (default to the currently selected one) - * @returns {Promise} + * @returns {Deferred} */ _resetResource: function (resID, type) { resID = resID || this._getSelectedResource(); type = type || this.currentType; if (this.currentType === 'xml') { - return Promise.reject(_t("Reseting views is not supported yet")); + return $.Defered().reject(_t("Reseting views is not supported yet")); } else { - var resource = type === 'scss' ? this.scss[resID] : this.js[resID]; return this._rpc({ - route: '/web_editor/reset_asset', + route: '/web_editor/reset_scss', params: { url: resID, - bundle_xmlid: resource.bundle_xmlid, + bundle_xmlid: this.scss[resID].bundle_xmlid, }, }); } }, /** - * Saves a unique SCSS or JS file. + * Saves an unique SCSS file. * * @private * @param {Object} session - contains the 'id' (url) and the 'text' of the - * SCSS or JS file to save. - * @return {Promise} status indicates if the save is finished or if an + * SCSS file to save. + * @return {Deferred} status indicates if the save is finished or if an * error occured. */ - _saveSCSSorJS: function (session) { + _saveSCSS: function (session) { + var def = $.Deferred(); + var self = this; - var sessionIdEndsWithJS = _.string.endsWith(session.id, '.js'); - var bundleXmlID = sessionIdEndsWithJS ? this.js[session.id].bundle_xmlid : this.scss[session.id].bundle_xmlid; - var fileType = sessionIdEndsWithJS ? 'js' : 'scss'; - return self._rpc({ - route: '/web_editor/save_asset', + this._rpc({ + route: '/web_editor/save_scss', params: { url: session.id, - bundle_xmlid: bundleXmlID, + bundle_xmlid: this.scss[session.id].bundle_xmlid, content: session.text, - file_type: fileType, }, }).then(function () { - self._toggleDirtyInfo(session.id, fileType, false); + self._toggleDirtyInfo(session.id, 'scss', false); + def.resolve(); + }, function (source, error) { + def.reject(session, error); }); + + return def; }, /** * Saves every resource that has been modified. If one cannot be saved, none * is saved and an error message is displayed. * * @private - * @return {Promise} status indicates if the save is finished or if an + * @return {Deferred} status indicates if the save is finished or if an * error occured. */ _saveResources: function () { @@ -602,7 +562,7 @@ var ViewEditor = Widget.extend({ } } }).bind(this)); - if (errorFound) return Promise.reject(errorFound); + if (errorFound) return $.Deferred().reject(errorFound); var defs = []; var mutex = new concurrency.Mutex(); @@ -611,15 +571,13 @@ var ViewEditor = Widget.extend({ _toSave = _.sortBy(_toSave, 'id').reverse(); _.each(_toSave, function (session) { defs.push(mutex.exec(function () { - return (type === 'xml' ? self._saveView(session) : self._saveSCSSorJS(session)); + return (type === 'xml' ? self._saveView(session) : self._saveSCSS(session)); })); }); }).bind(this)); - var self = this; - return Promise.all(defs).guardedCatch(function (results) { - var error = results[1]; - Dialog.alert(self, '', { + return $.when.apply($, defs).fail((function (session, error) { + Dialog.alert(this, '', { title: _t("Server error"), $content: $('
').html( _t("A server error occured. Please check you correctly signed in and that the file you are saving is correctly formatted.") @@ -627,32 +585,34 @@ var ViewEditor = Widget.extend({ + error ) }); - }); + }).bind(this)); }, /** * Saves an unique XML view. * * @private * @param {Object} session - the 'id' and the 'text' of the view to save. - * @returns {Promise} status indicates if the save is finished or if an + * @returns {Deferred} status indicates if the save is finished or if an * error occured. */ _saveView: function (session) { + var def = $.Deferred(); + var self = this; - return new Promise(function (resolve, reject) { - self._rpc({ - model: 'ir.ui.view', - method: 'write', - args: [[session.id], {arch: session.text}], - }, { - noContextKeys: 'lang', - }).then(function () { - self._toggleDirtyInfo(session.id, 'xml', false); - resolve(); - }, function (source, error) { - reject(session, error); - }); + this._rpc({ + model: 'ir.ui.view', + method: 'write', + args: [[session.id], {arch: session.text}], + }, { + noContextKeys: 'lang', + }).then(function () { + self._toggleDirtyInfo(session.id, 'xml', false); + def.resolve(); + }, function (source, error) { + def.reject(session, error); }); + + return def; }, /** * Shows a line which produced an error. Red color is added to the editor, @@ -704,11 +664,11 @@ var ViewEditor = Widget.extend({ } }, /** - * Switches to the SCSS, XML or JS edition. Calling this method will adapt all + * Switches to the SCSS or XML edition. Calling this method will adapt all * DOM elements to keep the editor consistent. * * @private - * @param {string} type - either 'xml', 'scss' or 'js' + * @param {string} type - either 'xml' or 'scss' */ _switchType: function (type) { this.currentType = type; @@ -716,10 +676,9 @@ var ViewEditor = Widget.extend({ _.each(this.$lists, function ($list, _type) { $list.toggleClass('d-none', type !== _type); }); this.$lists[type].change(); - this.$includeBundlesArea.toggleClass('d-none', this.currentType !== 'xml' || !config.isDebug()); - this.$includeAllSCSSArea.toggleClass('d-none', this.currentType !== 'scss' || !config.isDebug()); - this.$includeAllSCSSArea.find('[data-value="restricted"]').toggleClass('d-none', this.options.defaultBundlesRestriction.length === 0); - this.$formatButton.toggleClass('d-none', this.currentType !== 'xml'); + this.$includeBundlesArea.toggleClass('d-none', this.currentType === 'scss' || !session.debug); + this.$includeAllSCSSArea.toggleClass('d-none', this.currentType === 'xml' || !session.debug || this.options.defaultBundlesRestriction.length === 0); + this.$formatButton.toggleClass('d-none', this.currentType === 'scss'); }, /** * Updates the select option DOM element associated with a particular resID @@ -764,10 +723,21 @@ var ViewEditor = Widget.extend({ }); this.$lists.scss.empty(); - _populateList(this.sortedSCSS, this.$lists.scss, 5); - - this.$lists.js.empty(); - _populateList(this.sortedJS, this.$lists.js, 3); + _.each(this.sortedSCSS, function (bundleInfos) { + var $optgroup = $('', { + label: bundleInfos[0].name, + }).appendTo(self.$lists.scss); + _.each(bundleInfos[1], function (scssInfo) { + var name = scssInfo.url.substring(_.lastIndexOf(scssInfo.url, '/') + 1, scssInfo.url.length - 5); + $optgroup.append($('', { - label: bundleInfos[0].name, - }).appendTo($list); - _.each(bundleInfos[1], function (dataInfo) { - var name = dataInfo.url.substring(_.lastIndexOf(dataInfo.url, '/') + 1, dataInfo.url.length - lettersToRemove); - $optgroup.append($('