From 3f3175d4ca62b93c2213972779f03b14cad4774b Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Thu, 19 Oct 2023 09:32:18 +0200 Subject: [PATCH] [REF] website,web,web_editor: convert AceEditor to owl This commit refactors the website AceEditor to owl. The wrapper around the lib was defined in web_editor, and extended in website, where it was used (single usecase). This commit thus introduces an owl Component to replace it, directly in website, and specialized to the website usecase. This thus allows to remove the legacy implementation. This also removes the last usecase of Widget and select2 library in the backend bundle, which will allow to trim it down. Part of task~3439226 Part-of: odoo/odoo#139154 --- addons/project/__manifest__.py | 1 - .../static/tests/tours/reset_views.js | 18 +- addons/web_editor/__manifest__.py | 1 - .../web_editor/static/src/js/backend/ace.js | 1067 ----------------- .../static/src/scss/web_editor.common.scss | 143 --- .../static/src/scss/web_editor.variables.scss | 2 - addons/web_editor/static/src/xml/ace.xml | 61 - addons/website/__manifest__.py | 5 +- .../website_preview/website_preview.js | 21 +- .../website_preview/website_preview.scss | 9 + .../website_preview/website_preview.xml | 4 +- .../src/components/ace_editor/ace_editor.js | 141 --- .../src/components/ace_editor/ace_editor.scss | 9 - .../resource_editor/resource_editor.js | 501 ++++++++ .../resource_editor/resource_editor.scss | 80 ++ .../resource_editor/resource_editor.xml | 87 ++ .../resource_editor_warning.js} | 11 +- .../resource_editor_warning.xml} | 2 +- .../src/components/resource_editor/utils.js | 110 ++ .../src/services/website_custom_menus.js | 2 +- .../static/src/services/website_service.js | 2 +- .../website/static/tests/tours/html_editor.js | 59 +- 22 files changed, 853 insertions(+), 1483 deletions(-) delete mode 100644 addons/web_editor/static/src/js/backend/ace.js delete mode 100644 addons/web_editor/static/src/xml/ace.xml delete mode 100644 addons/website/static/src/components/ace_editor/ace_editor.js delete mode 100644 addons/website/static/src/components/ace_editor/ace_editor.scss create mode 100644 addons/website/static/src/components/resource_editor/resource_editor.js create mode 100644 addons/website/static/src/components/resource_editor/resource_editor.scss create mode 100644 addons/website/static/src/components/resource_editor/resource_editor.xml rename addons/website/static/src/components/{ace_warning/ace_warning.js => resource_editor/resource_editor_warning.js} (83%) rename addons/website/static/src/components/{ace_warning/ace_warning.xml => resource_editor/resource_editor_warning.xml} (96%) create mode 100644 addons/website/static/src/components/resource_editor/utils.js diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 223913431fb..b0353bf3ddf 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -191,7 +191,6 @@ 'web_editor/static/src/js/common/**/*', 'web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js', 'web_editor/static/src/js/wysiwyg/fonts.js', - 'web_editor/static/src/xml/ace.xml', 'web_editor/static/src/components/**/*', 'web_editor/static/src/scss/web_editor.common.scss', diff --git a/addons/test_website/static/tests/tours/reset_views.js b/addons/test_website/static/tests/tours/reset_views.js index 8adb5f8fa39..9a1f59156fa 100644 --- a/addons/test_website/static/tests/tours/reset_views.js +++ b/addons/test_website/static/tests/tours/reset_views.js @@ -39,13 +39,13 @@ wTourUtils.registerWebsitePreviewTour('test_reset_page_view_complete_flow_part1' content: "add a broken t-field in page DOM", trigger: 'div.ace_line .ace_xml:contains("placeholder")', run: function () { - ace.edit('ace-view-editor').getSession().insert({row: 4, column: 1}, '\n'); + ace.edit(document.querySelector('#resource-editor div')).getSession().insert({row: 4, column: 1}, '\n'); }, }, { content: "save the html editor", extra_trigger: '.ace_content:contains("not.exist")', - trigger: ".o_ace_view_editor button[data-action=save]", + trigger: ".o_resource_editor button:contains(Save)", }, BROKEN_STEP ] @@ -77,22 +77,22 @@ wTourUtils.registerWebsitePreviewTour('test_reset_page_view_complete_flow_part2' }, { content: "select oe_structure view", - trigger: '#s2id_ace-view-list', // use select2 version - run: function () { - var viewId = $('#ace-view-list option:contains("oe_structure_test_website_page")').val(); - $('#ace-view-list').val(viewId).trigger('change'); - }, + trigger: '.o_resource_editor_title .o_select_menu_toggler', + }, + { + content: "select oe_structure view", + trigger: '.o_resource_editor_title .o_select_menu_item:contains(Test Page View)', }, { content: "add a broken t-field in page DOM", trigger: 'div.ace_line .ace_xml:contains("oe_structure_test_website_page")', run: function () { - ace.edit('ace-view-editor').getSession().insert({row: 4, column: 1}, '\n'); + ace.edit(document.querySelector('#resource-editor div')).getSession().insert({row: 4, column: 1}, '\n'); }, }, { content: "save the html editor", - trigger: ".o_ace_view_editor button[data-action=save]", + trigger: ".o_resource_editor button:contains(Save)", }, BROKEN_STEP ] diff --git a/addons/web_editor/__manifest__.py b/addons/web_editor/__manifest__.py index 2cd8663fa1e..11e247195b9 100644 --- a/addons/web_editor/__manifest__.py +++ b/addons/web_editor/__manifest__.py @@ -175,7 +175,6 @@ Odoo Web Editor widget. 'web_editor/static/src/js/common/**/*', 'web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js', 'web_editor/static/src/js/wysiwyg/fonts.js', - 'web_editor/static/src/xml/ace.xml', ('include', 'web_editor.assets_media_dialog'), diff --git a/addons/web_editor/static/src/js/backend/ace.js b/addons/web_editor/static/src/js/backend/ace.js deleted file mode 100644 index 725407e673c..00000000000 --- a/addons/web_editor/static/src/js/backend/ace.js +++ /dev/null @@ -1,1067 +0,0 @@ -/** @odoo-module **/ - -import { Mutex } from "@web/core/utils/concurrency"; -import { _t } from "@web/core/l10n/translation"; -import dom from "@web/legacy/js/core/dom"; -import Widget from "@web/legacy/js/core/widget"; -import { browser } from "@web/core/browser/browser"; -import { debounce } from "@web/core/utils/timing"; -import { sortBy } from "@web/core/utils/arrays"; -import { omit, pick } from "@web/core/utils/objects"; -import { AlertDialog, ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; -import { markup } from "@odoo/owl"; - -/** - * Formats a content-check result (@see checkXML, checkSCSS). - * - * @param {boolean} isValid - * @param {integer} [errorLine] needed if isValid is false - * @param {string} [errorMessage] needed if isValid is false - * @returns {Object} - */ -function _getCheckReturn(isValid, errorLine, errorMessage) { - return { - isValid: isValid, - error: isValid ? null : { - line: errorLine, - message: errorMessage, - }, - }; -} -/** - * Checks the syntax validity of some XML. - * - * @param {string} xml - * @returns {Object} @see _getCheckReturn - */ -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) { - const errorEl = error[0]; - const sourceTextEls = errorEl.querySelectorAll('sourcetext'); - let codeEls = null; - if (sourceTextEls.length) { - codeEls = [...sourceTextEls].map(el => { - const codeEl = document.createElement('code'); - codeEl.textContent = el.textContent; - const brEl = document.createElement('br'); - brEl.classList.add('o_we_source_text_origin'); - el.parentElement.insertBefore(brEl, el); - return codeEl; - }); - for (const el of sourceTextEls) { - el.remove(); - } - } - for (const el of [...errorEl.querySelectorAll(':not(code):not(pre):not(br)')]) { - const pEl = document.createElement('p'); - for (const cEl of [...el.childNodes]) { - pEl.appendChild(cEl); - } - el.parentElement.insertBefore(pEl, el); - el.remove(); - } - errorEl.innerHTML = errorEl.innerHTML.replace(/\r?\n/g, '
'); - errorEl.querySelectorAll('.o_we_source_text_origin').forEach((el, i) => { - el.after(codeEls[i]); - }); - return _getCheckReturn(false, parseInt(error[0].innerHTML.match(/[Ll]ine[^\d]+(\d+)/)[1], 10), errorEl.innerHTML); - } - } 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 _getCheckReturn(false, xmlDocIE.parseError.line, xmlDocIE.parseError.reason); - } - } - return _getCheckReturn(true); -} -/** - * Formats some XML so that it has proper indentation and structure. - * - * @param {string} xml - * @returns {string} formatted xml - */ -function formatXML(xml) { - // do nothing if an inline script is present to avoid breaking it - if (/]*)?>[^<][\s\S]*<\/script>/i.test(xml)) { - return xml; - } - return window.vkbeautify.xml(xml, 4); -} -/** - * Checks the syntax validity of some SCSS. - * - * @param {string} scss - * @returns {Object} @see _getCheckReturn - */ -var checkSCSS = (function () { - var mapping = { - '{': '}', '}': '{', - '(': ')', ')': '(', - '[': ']', ']': '[', - }; - var openings = ['{', '(', '[']; - var closings = ['}', ')', ']']; - - return function (scss) { - var stack = []; - var line = 1; - for (var i = 0; i < scss.length; i++) { - if (openings.includes(scss[i])) { - stack.push(scss[i]); - } else if (closings.includes(scss[i])) { - if (stack.pop() !== mapping[scss[i]]) { - return _getCheckReturn(false, line, _t("Unexpected ") + scss[i]); - } - } else if (scss[i] === '\n') { - line++; - } - } - if (stack.length > 0) { - return _getCheckReturn(false, line, _t("Expected ") + mapping[stack.pop()]); - } - return _getCheckReturn(true); - }; -})(); -/** - * Formats some SCSS so that it has proper indentation and structure. - * - * @todo Right now, this does return the given SCSS content, untouched. - * @param {string} scss - * @returns {string} formatted scss - */ -function formatSCSS(scss) { - return scss; -} - -/** - * Allows to visualize resources (by default, XML views) and edit them. - */ -var ViewEditor = Widget.extend({ - template: 'web_editor.ace_view_editor', - 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-qweb.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 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 .btn-close': '_onCloseWarningClick' - }, - - /** - * Initializes the parameters so that the ace editor knows which information - * it has to load. - * - * @constructor - * @param {Widget} parent - * @param {string|integer} viewKey - * xml_id or id of the view whose linked resources have to be loaded. - * @param {Object} [options] - * @param {string|integer} [options.initialResID] - * a specific view ID / SCSS URL to load on start (otherwise the main - * view ID associated with the specified viewKey will be used) - * @param {string} [options.position=right] - * @param {boolean} [options.doNotLoadViews=false] - * @param {boolean} [options.doNotLoadSCSS=false] - * @param {boolean} [options.doNotLoadJS=false] - * @param {boolean} [options.noWarning=false] - * @param {boolean} [options.includeBundles=false] - * @param {string} [options.filesFilter=custom] - * @param {string[]} [options.defaultBundlesRestriction] - */ - init: function (parent, viewKey, options) { - this._super.apply(this, arguments); - - this.context = options.context; - - this.viewKey = viewKey; - this.options = Object.assign({ - position: 'right', - doNotLoadViews: false, - doNotLoadSCSS: false, - doNotLoadJS: false, - // TODO review if this warning is still useful since this option - // is never kept to false and replaced by the website blocking - // warning. Maybe once the blocking warning is validated, this - // small warning should still be shown. To be discussed later. - noWarning: false, - includeBundles: false, - filesFilter: 'custom', - defaultBundlesRestriction: [], - }, options); - - this.resources = {xml: {}, scss: {}, js: {}}; - this.editingSessions = {xml: {}, scss: {}, js: {}}; - this.currentType = 'xml'; - - // Alias - this.views = this.resources.xml; - this.scss = this.resources.scss; - this.js = this.resources.js; - - this.rpc = this.bindService("rpc"); - this.orm = this.bindService("orm"); - }, - /** - * Loads everything the ace library needs to work. - * It also loads the resources to visualize (@see _loadResources). - * - * @override - */ - willStart: function () { - return Promise.all([ - this._super.apply(this, arguments), - this._loadResources() - ]); - }, - /** - * Initializes the library and initial view once the DOM is ready. It also - * initializes the resize feature of the ace editor. - * - * @override - */ - start: function () { - this.$viewEditor = this.$('#ace-view-editor'); - - this.$typeSwitcherChoices = this.$('.o_ace_type_switcher_choice'); - this.$typeSwitcherBtn = this.$('.o_ace_type_switcher > .dropdown-toggle'); - - this.$lists = { - xml: this.$('#ace-view-list'), - scss: this.$('#ace-scss-list'), - js: this.$('#ace-js-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]'); - - this.aceEditor = window.ace.edit(this.$viewEditor[0]); - this.aceEditor.setTheme('ace/theme/monokai'); - this.$editor = this.$('.ace_editor'); - - var refX = 0; - var resizing = false; - var minWidth = 400; - var debounceStoreEditorWidth = debounce(storeEditorWidth, 500); - - this._updateViewSelectDOM(); - - var initResID; - var initType; - if (this.options.initialResID) { - initResID = this.options.initialResID; - if (typeof initResID === "string" && initResID[0] === '/') { - if (initResID.endsWith(".scss")) { - initType = 'scss'; - } else { - initType = 'js'; - } - } else { - initType = '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; - } else { - var view = Object.values(this.views).find(view => view.xml_id === this.viewKey); - if (!view) { - view = Object.values(this.views).find(view => view.key === this.viewKey); - } - initResID = view.id; - } - initType = 'xml'; - } - } - if (initResID) { - this._displayResource(initResID, initType); - } - - if (!this.sortedViews.length || !this.sortedSCSS.length) { - setTimeout((function () { - this._switchType(this.sortedViews.length ? 'xml' : 'scss'); - this.$typeSwitcherBtn.parent('.btn-group').addClass('d-none'); - }).bind(this)); - } - - $(document).on('mouseup.ViewEditor', stopResizing.bind(this)).on('mousemove.ViewEditor', updateWidth.bind(this)); - if (this.options.position === 'left') { - this.$('.ace_scroller').after($('
').addClass('ace_resize_bar')); - this.$('.ace_gutter').css({'cursor': 'default'}); - this.$el.on('mousedown.ViewEditor', '.ace_resize_bar', startResizing.bind(this)); - } else { - 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); - - if (this.$errorLine) { - this.$errorLine.popover('update'); - } - } - function storeEditorWidth() { - browser.localStorage.setItem('ace_editor_width', this.$el.width()); - } - function readEditorWidth() { - var width = browser.localStorage.getItem('ace_editor_width'); - return parseInt(width || 720, 10); - } - function startResizing(e) { - refX = e.pageX; - resizing = true; - document.body.classList.add("o_ace_view_editor_resizing"); - } - function stopResizing() { - if (resizing) { - resizing = false; - document.body.classList.remove("o_ace_view_editor_resizing"); - - if (this.errorSession) { - // To trigger an update of the error display - this.errorSession.setScrollTop(this.errorSession.getScrollTop() + 1); - } - } - } - function updateWidth(e) { - if (!resizing) return; - - var offset = e.pageX - refX; - if (this.options.position === 'left') { - offset = - offset; - } - var width = this.$el.width() - offset; - refX = e.pageX; - resizeEditor.call(this, width); - debounceStoreEditorWidth.call(this); - } - }, - /** - * @override - */ - destroy: function () { - this._super.apply(this, arguments); - this.$el.off('.ViewEditor'); - $(document).off('.ViewEditor'); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Initializes a text editor for the specified resource. - * - * @private - * @param {integer|string} resID - the ID/URL of the view/scss/js file - * @param {string} [type] (default to the currently selected one) - * @returns {ace.EditSession} - */ - _buildEditingSession: function (resID, type) { - var self = this; - type = type || this.currentType; - var editingSession = new window.ace.EditSession(this.resources[type][resID].arch); - editingSession.setUseWorker(false); - let mode = (type || this.currentType); - editingSession.setMode('ace/mode/' + (mode === 'xml' ? 'qweb' : mode)); - editingSession.setUndoManager(new window.ace.UndoManager()); - editingSession.on('change', function () { - setTimeout(function () { - self._toggleDirtyInfo(resID); - self._showErrorLine(); - }); - }); - return editingSession; - }, - /** - * Forces the view/scss/js 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') - */ - _displayResource: function (resID, type) { - if (type) { - this._switchType(type); - } - - if (!this.resources[this.currentType].hasOwnProperty(resID)) { - // This could happen if trying to switch to a file which is not - // visible with the default filters. In that case, we prefer the - // user to have to switch explicitely to the right filters again. - return; - } - - var editingSession = this.editingSessions[this.currentType][resID]; - if (!editingSession) { - editingSession = this.editingSessions[this.currentType][resID] = this._buildEditingSession(resID); - } - this.aceEditor.setSession(editingSession); - - if (this.currentType === 'xml') { - this.$viewID.text(_t("Template ID: %s", this.views[resID].key)); - } else if (this.currentType === 'scss') { - this.$viewID.text(_t("SCSS file: %s", resID)); - } else { - this.$viewID.text(_t("JS file: %s", resID)); - } - const isCustomized = this._isCustomResource(resID); - this.$lists[this.currentType].select2('val', resID); - - this.$resetButton.toggleClass('d-none', this.currentType === 'xml' || !isCustomized); - - this.$warningMessage.toggleClass('d-none', - this.currentType !== 'xml' && (resID.indexOf('/user_custom_') >= 0) || isCustomized || this.options.noWarning); - - this.aceEditor.resize(true); - }, - /** - * Formats the current resource being vizualized. - * (@see formatXML, formatSCSS) - * - * @private - */ - _formatResource: function () { - var res = this.aceEditor.getValue(); - var check = (this.currentType === 'xml' ? checkXML : checkSCSS)(res); - if (check.isValid) { - this.aceEditor.setValue((this.currentType === 'xml' ? formatXML : formatSCSS)(res)); - } else { - this._showErrorLine(check.error.line, check.error.message, this._getSelectedResource()); - } - }, - _getContext() { - return {}; - }, - /** - * Returns the currently selected resource data. - * - * @private - * @returns {integer|string} view ID or scss file URL - */ - _getSelectedResource: function () { - var value = this.$lists[this.currentType].select2('val'); - return parseInt(value, 10) || value; - }, - /** - * Checks resource is customized or not. - * - * @private - * @param {integer|string} resID - */ - _isCustomResource(resID) { - // TODO we should be able to detect if the XML template is customized - // to not show the warning in that case - let isCustomized = false; - if (this.currentType === 'scss') { - isCustomized = this.scss[resID].customized; - } else if (this.currentType === 'js') { - isCustomized = this.js[resID].customized; - } - return isCustomized; - }, - /** - * Loads data the ace editor will vizualize and process it. Default behavior - * is loading the activate views, index them and build their hierarchy. - * - * @private - * @returns {Promise} - */ - _loadResources: function () { - // Reset resources - this.resources = {xml: {}, scss: {}, js: {}}; - this.editingSessions = {xml: {}, scss: {}, js: {}}; - this.views = this.resources.xml; - this.scss = this.resources.scss; - this.js = this.resources.js; - - // Load resources - return this.rpc('/web_editor/get_assets_editor_resources', { - 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', - }).then((function (resources) { - _processViews.call(this, resources.views || []); - _processJSorSCSS.call(this, resources.scss || [], 'scss'); - _processJSorSCSS.call(this, resources.js || [], 'js'); - }).bind(this)); - - function _processViews(views) { - // Only keep the active views and index them by ID. - const indexedById = {}; - views - .filter((view) => view.active) - .forEach((view) => { - indexedById[view.id] = view; - }); - Object.assign(this.views, indexedById); - - // Initialize a 0 level for each view and assign them an array containing their children. - var self = this; - var roots = []; - Object.values(this.views).forEach((view) => { - view.level = 0; - view.children = []; - }); - Object.values(this.views).forEach((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.sortedViews = []; - function visit(view, level) { - view.level = level; - self.sortedViews.push(view); - view.children.forEach((child) => { - visit(child, level + 1); - }); - } - roots.forEach((root) => { - visit(root, 0); - }); - } - - 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; - } - - // Store the URL ungrouped by bundle and use the URL as key (resource ID) - var resources = type === 'scss' ? this.scss : this.js; - data.forEach((bundleInfos) => { - const indexedByUrl = {}; - bundleInfos[1].forEach((info) => { - info.bundle = bundleInfos[0]; - indexedByUrl[info.url] = info; - }); - Object.assign(resources, indexedByUrl); - }); - } - }, - /** - * Forces the view/scss/js 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 - * - * @private - * @param {integer|string} [resID] (default to the currently selected one) - * @param {string} [type] (default to the currently selected one) - * @returns {Promise} - */ - _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")); - } else { - var resource = type === 'scss' ? this.scss[resID] : this.js[resID]; - return this.orm.call("web_editor.assets", "reset_asset", [resID, resource.bundle], { - context: this._getContext(), - }); - } - }, - /** - * Saves a unique SCSS or JS 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 - * error occured. - */ - _saveSCSSorJS: function (session) { - var self = this; - var sessionIdEndsWithJS = String(session.id).endsWith(".js"); - var bundle = sessionIdEndsWithJS ? this.js[session.id].bundle : this.scss[session.id].bundle; - var fileType = sessionIdEndsWithJS ? 'js' : 'scss'; - return this.orm.call( - "web_editor.assets", - "save_asset", - [ - session.id, - bundle, - session.text, - fileType, - ], - { context: this._getContext() } - ).then(function () { - self._toggleDirtyInfo(session.id, fileType, false); - }); - }, - /** - * 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 - * error occured. - */ - _saveResources: function () { - var self = this; - var toSave = {}; - var errorFound = false; - Object.entries(this.editingSessions || {}).forEach( - (([type, editingSessions]) => { - if (errorFound) return; - const dirtySessionsKeys = Object.entries(editingSessions) - .map(([key, session]) => { - return session.getUndoManager().hasUndo() ? key : false; - }) - .filter((x) => !!x); - const dirtySessions = pick(editingSessions, ...dirtySessionsKeys); - toSave[type] = Object.entries(dirtySessions).map(([resID, session]) => { - return { - id: parseInt(resID, 10) || resID, - text: session.getValue(), - }; - }); - - this._showErrorLine(); - for (var i = 0 ; i < toSave[type].length && !errorFound ; i++) { - var check = (type === 'xml' ? checkXML : checkSCSS)(toSave[type][i].text); - if (!check.isValid) { - this._showErrorLine(check.error.line, check.error.message, toSave[type][i].id, type); - errorFound = toSave[type][i]; - } - } - }).bind(this) - ); - if (errorFound) return Promise.reject(errorFound); - - var defs = []; - var mutex = new Mutex(); - Object.entries(toSave || {}).forEach( - (([type, _toSave]) => { - // Child views first as COW on a parent would delete them - _toSave = sortBy(_toSave, "id").reverse(); - _toSave.forEach((session) => { - defs.push(mutex.exec(function () { - return (type === 'xml' ? self._saveView(session) : self._saveSCSSorJS(session)); - })); - }); - }).bind(this) - ); - - return Promise.all(defs).guardedCatch(function (results) { - // some overrides handle errors themselves - if (results === undefined) { - return; - } - var error = results[1]; - const content = _t( - "A server error occured. Please check you correctly signed in and that the file you are saving is correctly formatted." - ); - const body = markup(` -
${content}
-
- ${error} - `); - this.call("dialog", "add", AlertDialog, { - title: _t("Server error"), - body, - }); - }); - }, - /** - * 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 - * error occured. - */ - _saveView: function (session) { - var self = this; - return new Promise(function (resolve, reject) { - self.orm.write( - "ir.ui.view", - [session.id], - { arch: session.text }, - { context: omit(self._getContext(), "lang") } - ).then( - function () { - self._toggleDirtyInfo(session.id, 'xml', false); - resolve(); - }, function (source, error) { - reject(session, error); - }); - }); - }, - /** - * Shows a line which produced an error. Red color is added to the editor, - * the cursor move to the line and a message is opened on click on the line - * number. If called without argument, the effects are removed. - * - * @private - * @param {integer} [line] - the line number to highlight - * @param {string} [message] - to show on click on the line number - * @param {integer|string} [resID] - * @param {string} [type] - */ - _showErrorLine: function (line, message, resID, type) { - if (line === undefined || line <= 0) { - __restore.call(this); - return; - } - - if (type) { - this._switchType(type); - } - - if (this._getSelectedResource() === resID) { - __showErrorLine.call(this, line); - } else { - var onChangeSession = (function () { - this.aceEditor.off('changeSession', onChangeSession); - setTimeout(__showErrorLine.bind(this, line), 400); - }).bind(this); - this.aceEditor.on('changeSession', onChangeSession); - this._displayResource(resID, this.currentType); - } - - function __restore() { - if (this.errorSession) { - this.errorSession.off('change', this.errorSessionChangeCallback); - this.errorSession.off('changeScrollTop', this.errorSessionScrollCallback); - this.errorSession = undefined; - } - __restoreErrorLine.call(this); - - if (this.$errorContent) { // TODO remove in master - this.$errorContent.removeClass('o_error'); - this.$errorContent = undefined; - } - } - - function __restoreErrorLine() { - if (this.$errorLine) { - this.$errorLine.removeClass('o_error'); - this.$errorLine.popover('hide'); - this.$errorLine.popover('dispose'); - this.$errorLine = undefined; - } - } - - function __updateErrorLineDisplay(line) { - __restoreErrorLine.call(this); - - const $lines = this.$viewEditor.find('.ace_gutter-cell'); - this.$errorLine = $lines.filter(function (i, el) { - return parseInt($(el).text()) === line; - }); - if (!this.$errorLine.length) { - const $firstLine = $lines.first(); - const firstLineNumber = parseInt($firstLine.text()); - this.$errorLine = line < firstLineNumber ? $lines.eq(1) : $lines.eq($lines.length - 2); - } - this.$errorLine.addClass('o_error'); - this.$errorLine.popover({ - animation: false, - html: true, - content: message, - placement: 'left', - container: 'body', - trigger: 'manual', - template: '' - }); - this.$errorLine.popover('show'); - } - - function __showErrorLine(line) { - this.$errorContent = this.$viewEditor.find('.ace_scroller').addClass('o_error'); // TODO remove in master - - this.errorSession = this.aceEditor.getSession(); - this.errorSessionChangeCallback = __restore.bind(this); - this.errorSession.on('change', this.errorSessionChangeCallback); - this.errorSessionScrollCallback = debounce(__updateErrorLineDisplay.bind(this, line), 10); - this.errorSession.on('changeScrollTop', this.errorSessionScrollCallback); - - __updateErrorLineDisplay.call(this, line); - - setTimeout(() => this.aceEditor.gotoLine(line), 100); - } - }, - /** - * Switches to the SCSS, XML or JS edition. Calling this method will adapt all - * DOM elements to keep the editor consistent. - * - * @private - * @param {string} type - either 'xml', 'scss' or 'js' - */ - _switchType: function (type) { - this.currentType = type; - this.$typeSwitcherBtn.html(this.$typeSwitcherChoices.filter('[data-type=' + type + ']').html()); - Object.entries(this.$lists).forEach(([_type, $list]) => { $list.toggleClass('d-none', type !== _type); }); - this.$lists[type].change(); - - this.$includeBundlesArea.toggleClass('d-none', this.currentType !== 'xml' || !odoo.debug); - this.$includeAllSCSSArea.toggleClass('d-none', this.currentType !== 'scss' || !odoo.debug); - this.$includeAllSCSSArea.find('[data-value="restricted"]').toggleClass('d-none', this.options.defaultBundlesRestriction.length === 0); - this.$formatButton.toggleClass('d-none', this.currentType !== 'xml'); - }, - /** - * Updates the select option DOM element associated with a particular resID - * to indicate if the option is dirty or not. - * - * @private - * @param {integer|string} resID - * @param {string} [type] (default to the currently selected one) - * @param {boolean} [isDirty] true if the view is dirty, default to content - * of UndoManager - */ - _toggleDirtyInfo: function (resID, type, isDirty) { - type = type || this.currentType; - - if (!resID || !this.editingSessions[type][resID]) return; - - var $option = this.$lists[type].find('[value="' + resID + '"]'); - if (isDirty === undefined) { - isDirty = this.editingSessions[type][resID].getUndoManager().hasUndo(); - } - $option.data('dirty', isDirty); - }, - /** - * Renders the content of the view/file - - - -
- - -
-
-
-
- - -
- - -
-
-
-
- - diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index 9693071e58c..d55883a9d2c 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -275,10 +275,7 @@ ('include', 'web._assets_helpers'), 'web/static/src/scss/pre_variables.scss', 'web/static/lib/bootstrap/scss/_variables.scss', - 'website/static/src/components/ace_editor/ace_editor.js', - 'website/static/src/components/ace_editor/ace_editor.scss', - 'website/static/src/components/ace_warning/ace_warning.js', - 'website/static/src/components/ace_warning/ace_warning.xml', + 'website/static/src/components/resource_editor/**/*', 'website/static/src/components/edit_head_body_dialog/edit_head_body_dialog.js', 'website/static/src/components/edit_head_body_dialog/edit_head_body_dialog.scss', 'website/static/src/components/edit_head_body_dialog/edit_head_body_dialog.xml', diff --git a/addons/website/static/src/client_actions/website_preview/website_preview.js b/addons/website/static/src/client_actions/website_preview/website_preview.js index ec9916d9048..c5dbbce69e7 100644 --- a/addons/website/static/src/client_actions/website_preview/website_preview.js +++ b/addons/website/static/src/client_actions/website_preview/website_preview.js @@ -3,8 +3,9 @@ import { _t } from "@web/core/l10n/translation"; import { browser } from '@web/core/browser/browser'; import { registry } from '@web/core/registry'; +import { ResizablePanel } from '@web/core/resizable_panel/resizable_panel'; import { useService, useBus } from '@web/core/utils/hooks'; -import { AceEditorWrapper } from '../../components/ace_editor/ace_editor'; +import { ResourceEditor } from '../../components/resource_editor/resource_editor'; import { WebsiteEditorComponent } from '../../components/editor/editor'; import { WebsiteTranslator } from '../../components/translator/translator'; import { unslugHtmlDataObject } from '../../services/website_service'; @@ -119,7 +120,7 @@ export class WebsitePreview extends Component { }, () => [this.props.action.context.params]); useEffect(() => { - this.websiteContext.showAceEditor = false; + this.websiteContext.showResourceEditor = false; }, () => [ this.websiteContext.showNewContentModal, this.websiteContext.edition, @@ -136,7 +137,7 @@ export class WebsitePreview extends Component { }); onWillUnmount(() => { - this.websiteService.context.showAceEditor = false; + this.websiteService.context.showResourceEditor = false; const { pathname, search, hash } = this.iframe.el.contentWindow.location; this.websiteService.lastUrl = `${pathname}${search}${hash}`; this.websiteService.currentWebsiteId = null; @@ -262,6 +263,11 @@ export class WebsitePreview extends Component { return false; } + get aceEditorWidth() { + const storedWidth = browser.localStorage.getItem("ace_editor_width"); + return storedWidth ? parseInt(storedWidth) : 720; + } + reloadIframe(url) { return new Promise((resolve, reject) => { this.websiteService.websiteRootInstance = undefined; @@ -350,7 +356,7 @@ export class WebsitePreview extends Component { _onPageLoaded() { if (this.lastHiddenPageURL !== this.iframe.el.contentWindow.location.href) { // Hide Ace Editor when moving to another page. - this.websiteService.context.showAceEditor = false; + this.websiteService.context.showResourceEditor = false; this.lastHiddenPageURL = undefined; } if (this.props.action.context.params?.with_loader) { @@ -470,6 +476,10 @@ export class WebsitePreview extends Component { } } + _onResourceEditorResize(width) { + browser.localStorage.setItem("ace_editor_width", width); + } + _onPageUnload() { this.iframe.el.setAttribute('is-ready', 'false'); // Before leaving the iframe, its content is replicated on an @@ -519,7 +529,8 @@ WebsitePreview.components = { WebsiteEditorComponent, BlockPreview, WebsiteTranslator, - AceEditorWrapper, + ResourceEditor, + ResizablePanel, }; registry.category('actions').add('website_preview', WebsitePreview); diff --git a/addons/website/static/src/client_actions/website_preview/website_preview.scss b/addons/website/static/src/client_actions/website_preview/website_preview.scss index f0389e212c4..a82acbaecf5 100644 --- a/addons/website/static/src/client_actions/website_preview/website_preview.scss +++ b/addons/website/static/src/client_actions/website_preview/website_preview.scss @@ -98,3 +98,12 @@ header { .o_block_preview { z-index: $zindex-dropdown - 1 !important; } + +.o_resource_editor_wrapper { + @include o-position-absolute($o-navbar-height, 0, 0); + z-index: $zindex-modal; + + .o_website_fullscreen & { + top: 0; + } +} diff --git a/addons/website/static/src/client_actions/website_preview/website_preview.xml b/addons/website/static/src/client_actions/website_preview/website_preview.xml index 639bcbfb284..2226bfbefa9 100644 --- a/addons/website/static/src/client_actions/website_preview/website_preview.xml +++ b/addons/website/static/src/client_actions/website_preview/website_preview.xml @@ -37,7 +37,9 @@ - + + +
diff --git a/addons/website/static/src/components/ace_editor/ace_editor.js b/addons/website/static/src/components/ace_editor/ace_editor.js deleted file mode 100644 index c48cfdd2767..00000000000 --- a/addons/website/static/src/components/ace_editor/ace_editor.js +++ /dev/null @@ -1,141 +0,0 @@ -/** @odoo-module */ - -import { useWidget } from "@web/legacy/utils"; -import {useService} from '@web/core/utils/hooks'; -import AceEditor from '@web_editor/js/backend/ace'; - -import { Component, xml } from "@odoo/owl"; -import { AceEditorWarningOverlay } from "../ace_warning/ace_warning"; - -export const WebsiteAceEditor = AceEditor.extend({ - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - do_hide() { - this.options.toggleAceEditor(false); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @override - */ - _getContext() { - return this.options.getContext(); - }, - /** - * @override - */ - _saveResources() { - return this._super.apply(this, arguments).then(() => { - const defs = []; - if (this.currentType === 'xml') { - // When saving a view, the view ID might change. Thus, the - // active ID in the URL will be incorrect. After the save - // reload, that URL ID won't be found and JS will crash. - // We need to find the new ID (either because the view became - // specific or because its parent was edited too and the view - // got copy/unlink). - const selectedView = Object.values(this.views).find(view => view.id === this._getSelectedResource()); - const context = this.options.getContext(); - defs.push( - this.orm.searchRead( - "ir.ui.view", - [ - ["key", "=", selectedView.key], - ["website_id", "=", context.website_id], - ], - ["id"], - { context: this.options.getContext() } - ) - ); - } - return Promise.all(defs).then((async () => { - await this._updateEditor(); - this.options.reload(); - })); - }); - }, - /** - * @override - */ - _switchType(type) { - const ret = this._super(...arguments); - - if (type === 'scss') { - // By default show the user_custom_rules.scss one as some people - // would write rules in user_custom_bootstrap_overridden.scss - // otherwise, not reading the comment inside explaining how that - // file should be used. - this._displayResource('/website/static/src/scss/user_custom_rules.scss'); - } - - return ret; - }, - /** - * @override - */ - _resetResource() { - return this._super.apply(this, arguments).then((() => { - this._updateEditor(); - this.options.reload(); - }).bind(this)); - }, - /** - * Used to update UI (resource display, toggle resetButton visibility,...) - * on save/reset since we reload the iframe only after an update. - * - * @private - */ - async _updateEditor() { - if (['js', 'scss'].includes(this.currentType)) { - await this._loadResources(); - return this._displayResource(this._getSelectedResource()); - } - }, -}); - -export class AceEditorWrapper extends Component { - static components = { AceEditorWarningOverlay }; - static template = xml` -
-
-
- -
-
- `; - - setup() { - this.website = useService("website"); - this.user = useService("user"); - this.env = Component.env; - - useWidget("wrapper", WebsiteAceEditor, [ - this.website.pageDocument && this.website.pageDocument.documentElement.dataset.viewXmlid, - { - toggleAceEditor: () => this.website.context.showAceEditor = false, - defaultBundlesRestriction: [ - 'web.assets_frontend', - 'web.assets_frontend_minimal', - 'web.assets_frontend_lazy', - ], - reload: () => { - this.website.contentWindow.location.reload(); - }, - getContext: () => ({ - ...this.user.context, - website_id: this.website.currentWebsite.id, - }), - noWarning: true, - }, - ]); - } -} diff --git a/addons/website/static/src/components/ace_editor/ace_editor.scss b/addons/website/static/src/components/ace_editor/ace_editor.scss deleted file mode 100644 index d33d31588c8..00000000000 --- a/addons/website/static/src/components/ace_editor/ace_editor.scss +++ /dev/null @@ -1,9 +0,0 @@ -.o_ace_view_editor_wrapper:not(.oe_form_field) { - @include o-position-absolute($o-navbar-height, 0, 0); - position: fixed; - z-index: $zindex-modal; - - .o_website_fullscreen & { - top: 0; - } -} diff --git a/addons/website/static/src/components/resource_editor/resource_editor.js b/addons/website/static/src/components/resource_editor/resource_editor.js new file mode 100644 index 00000000000..f7caa1b689c --- /dev/null +++ b/addons/website/static/src/components/resource_editor/resource_editor.js @@ -0,0 +1,501 @@ +/** @odoo-module */ + +import { CodeEditor } from "@web/core/code_editor/code_editor"; +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { _t } from "@web/core/l10n/translation"; +import { SelectMenu } from "@web/core/select_menu/select_menu"; +import { sortBy } from "@web/core/utils/arrays"; +import { KeepLast } from "@web/core/utils/concurrency"; +import { useService } from "@web/core/utils/hooks"; + +import { ResourceEditorWarningOverlay } from "./resource_editor_warning"; +import { checkSCSS, checkXML, formatXML } from "./utils"; + +import { Component, onWillUnmount, onWillStart, reactive, useRef, useState } from "@odoo/owl"; + +const BUNDLES_RESTRICTION = [ + "web.assets_frontend", + "web.assets_frontend_minimal", + "web.assets_frontend_lazy", +]; + +// TODO move in web? +class CheckableDropdownItem extends DropdownItem {} +CheckableDropdownItem.template = "website.CheckableDropdownItem"; +CheckableDropdownItem.props = { + ...DropdownItem.props, + checked: { + type: Boolean, + optional: false, + }, +}; + +export class ResourceEditor extends Component { + static components = { + ResourceEditorWarningOverlay, + CodeEditor, + Dropdown, + DropdownItem, + CheckableDropdownItem, + SelectMenu, + }; + static template = "website.ResourceEditor"; + static props = { + close: { type: Function, optional: true }, + }; + static defaultProps = { + close: () => {}, + }; + + setup() { + this.website = useService("website"); + this.user = useService("user"); + this.orm = useService("orm"); + this.rpc = useService("rpc"); + this.dialog = useService("dialog"); + + this.keepLast = new KeepLast(); + + this.editorRef = useRef("editor"); + + this.debug = this.env.debug; + this.viewKey = + this.website.pageDocument && + this.website.pageDocument.documentElement.dataset.viewXmlid; + + this.types = { + xml: "XML (HTML)", + scss: "SCSS (CSS)", + js: "JS", + }; + this.xmlFilters = { + views: _t("Only Views"), + all: _t("Views and Assets bundles"), + }; + this.scssFilters = { + custom: _t("Only Custom SCSS Files"), + restricted: _t("Only Page SCSS Files"), + all: _t("All SCSS Files"), + }; + this.state = useState({ + type: "xml", + xmlFilter: "views", + scssFilter: "custom", + currentResource: false, + showEditWarning: true, + resources: { + xml: {}, + js: {}, + scss: {}, + }, + sortedXML: [], + sortedSCSS: [], + sortedJS: [], + saving: false, + }); + + let showErrorInterval; + this.errors = reactive([], () => { + clearInterval(showErrorInterval); + if (this.errors.length) { + this.showErrorLine(); + // The ace library updates its content asynchronously, and sometimes + // at unexpected moments, so we consistently re-apply the error indicators + // when they are errors. This is kind of a hack, but it works. + showErrorInterval = setInterval(() => this.showErrorLine(), 500); + } else { + this.clearErrorLine(); + } + }); + onWillUnmount(() => clearInterval(showErrorInterval)); + + onWillStart(async () => this.loadResources()); + } + + // ------------------------------------------------------------------------- + // Getters + // ------------------------------------------------------------------------- + + get context() { + return { + ...this.user.context, + website_id: this.website.currentWebsite.id, + }; + } + + get resourceInfo() { + if (!this.state.currentResource) { + return ""; + } + if (this.state.type === "xml") { + return _t("Template ID: %s", this.state.currentResource.key); + } else if (this.state.type === "scss") { + return _t("SCSS file: %s", this.state.currentResource.url); + } else { + return _t("JS file: %s", this.state.currentResource.url); + } + } + + get selectMenuProps() { + const props = { + onSelect: (value) => { + this.state.currentResource = this.state.resources[this.state.type][value]; + }, + autoSort: false, + required: true, + }; + if (this.state.type === "xml") { + const choices = this.state.sortedXML.map((view) => { + return { value: view.id, label: view.label }; + }); + const value = this.state.currentResource?.id; + return { ...props, choices, value }; + } else { + const { type, sortedSCSS, sortedJS } = this.state; + const bundles = type === "scss" ? sortedSCSS : sortedJS; + const groups = bundles.map(([name, files]) => { + const choices = files.map((file) => ({ value: file.url, label: file.label })); + return { label: name, choices }; + }); + const value = this.state.currentResource?.url; + return { ...props, groups, value }; + } + } + + // ------------------------------------------------------------------------- + // Methods + // ------------------------------------------------------------------------- + + /** + * Checks resource is customized or not. + * + * @param {string} url + * @returns {boolean} + */ + isCustomResource(url) { + // TODO we should be able to detect if the XML template is customized + // to not show the warning in that case + if (this.state.type === "scss") { + return this.state.resources.scss[url].customized; + } else if (this.state.type === "js") { + return this.state.resources.js[url].customized; + } + return false; + } + + async loadResources() { + const resources = await this.keepLast.add( + this.rpc("/web_editor/get_assets_editor_resources", { + key: this.viewKey, + bundles: this.state.xmlFilter === "all", + bundles_restriction: BUNDLES_RESTRICTION, + only_user_custom_files: this.state.scssFilter === "custom", + }) + ); + this.state.resources = { xml: {}, js: {}, scss: {} }; + this.processResources(resources.views || [], "xml"); + this.processResources(resources.scss || [], "scss"); + this.processResources(resources.js || [], "js"); + const type = this.state.type; + if (this.state.currentResource) { + this.state.currentResource = this.state.resources[type][this.state.currentResource.id]; + } + if (!this.state.currentResource) { + this.setDefaultFile(); + } + this.errors.length = 0; + } + + processResources(resources, type) { + if (type === "xml") { + // Only keep the active views and index them by ID. + const indexedById = {}; + resources + .filter((view) => view.active) + .forEach((view) => { + view.type = "xml"; + indexedById[view.id] = view; + }); + Object.assign(this.state.resources.xml, indexedById); + + // Initialize a 0 level for each view and assign them an array containing their children. + const roots = []; + Object.values(this.state.resources.xml).forEach((view) => { + view.level = 0; + view.children = []; + }); + Object.values(this.state.resources.xml).forEach((view) => { + const parentId = view.inherit_id[0]; + const parent = parentId && this.state.resources.xml[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. + const sortedXML = []; + const visit = (view, level) => { + view.level = level; + sortedXML.push(view); + view.children.forEach((child) => { + visit(child, level + 1); + }); + }; + roots.forEach((root) => { + visit(root, 0); + }); + this.state.sortedXML = sortedXML; + + // Compute labels + Object.values(this.state.resources.xml).forEach((view) => { + view.label = `${"-".repeat(view.level)} ${view.name}`; + if (this.debug && view.xml_id) { + view.label += ` (${view.xml_id})`; + } + }); + } else if (type === "scss" || type === "js") { + // The received scss or js data is already sorted by bundle and DOM order + if (type === "scss") { + this.state.sortedSCSS = resources; + } else { + this.state.sortedJS = resources; + } + + // Store the URL ungrouped by bundle and use the URL as key (resource ID) + resources.forEach(([bundle, files]) => { + const indexedByUrl = {}; + files.forEach((file) => { + // Compute labels + file.label = file.url.split("/").at(-1).split(".")[0]; + if (this.debug) { + file.label += ` (${file.url})`; + } + + file.bundle = bundle; + file.id = file.url; // for consistency with xml resources + file.type = type; + indexedByUrl[file.url] = file; + }); + if (type === "scss") { + Object.assign(this.state.resources.scss, indexedByUrl); + } else { + Object.assign(this.state.resources.js, indexedByUrl); + } + }); + } + } + + /** + * Forces the current scss/js file identified by its url to be reset to the way + * it was before the user started editing it. + * + * @todo views (xml) reset is not supported yet + * + * @returns {Promise} + */ + async resetResource() { + if (this.state.type === "xml") { + throw new Error(_t("Reseting views is not supported yet")); + } + const resource = this.state.currentResource; + await this.orm.call("web_editor.assets", "reset_asset", [resource.url, resource.bundle], { + context: this.context, + }); + await this.loadResources(); + this.website.contentWindow.location.reload(); + } + + async saveResources() { + const { js, scss, xml } = this.state.resources; + const toSave = { + js: Object.values(js).filter((r) => r.dirty), + scss: Object.values(scss).filter((r) => r.dirty), + // child views first as COW on a parent would delete them + xml: sortBy( + Object.values(xml).filter((r) => r.dirty), + "id" + ).reverse(), + }; + + for (const [type, resources] of Object.entries(toSave)) { + for (let i = 0; i < resources.length; i++) { + const arch = resources[i].arch; + const { isValid, error } = type === "xml" ? checkXML(arch) : checkSCSS(arch); + if (!isValid) { + this.errors.push({ error, resource: resources[i] }); + } + } + } + if (this.errors.length) { + // switch to the first resource in error if the current has no error + if ( + !this.errors + .map(({ resource }) => resource.id) + .includes(this.state.currentResource.id) + ) { + this.state.currentResource = this.errors[0].resource; + this.state.type = this.errors[0].resource.type; + } + return; + } + + // sequentially save all resources + for (const [type, resources] of Object.entries(toSave)) { + for (const resource of resources) { + if (type === "xml") { + await this.saveXML(resource); + } else { + await this.saveSCSSorJS(resource); + } + } + } + await this.loadResources(); + this.website.contentWindow.location.reload(); + } + + /** + * Saves a unique SCSS or JS file. + * + * @private + * @param {Object} resource a SCSS or JS file to save + * @return {Promise} indicates if the save is finished or if an error occured. + */ + async saveSCSSorJS(resource) { + const { url, arch } = resource; + const isJSFile = String(url).endsWith(".js"); + const bundle = isJSFile + ? this.state.resources.js[url].bundle + : this.state.resources.scss[url].bundle; + const fileType = isJSFile ? "js" : "scss"; + const params = [url, bundle, arch, fileType]; + await this.orm.call("web_editor.assets", "save_asset", params, { context: this.context }); + delete resource.dirty; + } + + /** + * Saves a unique XML view. + * + * @param {Object} resource an xml view to save + * @returns {Promise} indicates if the save is finished or if an error occured. + */ + async saveXML(resource) { + const { id, arch } = resource; + const context = { ...this.context, lang: false }; + await this.orm.write("ir.ui.view", [id], { arch }, { context }); + delete resource.dirty; + } + + setDefaultFile() { + if (this.state.type === "xml") { + const views = Object.values(this.state.resources.xml); + let view = views.find((view) => [view.id, view.xml_id].includes(this.viewKey)); + if (!view) { + view = views.find((view) => view.key === this.viewKey); + } + this.state.currentResource = view || this.state.sortedXML[0] || false; + } else if (this.state.type === "scss") { + // By default show the user_custom_rules.scss one as some people + // would write rules in user_custom_bootstrap_overridden.scss + // otherwise, not reading the comment inside explaining how that + // file should be used. + this.state.currentResource = + this.state.resources.scss["/website/static/src/scss/user_custom_rules.scss"]; + } else { + this.state.currentResource = + this.state.sortedJS.map(([_, files]) => files).flat()[0] || false; + } + } + + showErrorLine() { + const resourceId = this.state.currentResource.id; + const error = this.errors.find(({ resource }) => resource.id === resourceId)?.error; + if (error) { + const { line, message } = error; + const gutterCell = this.editorRef.el.querySelectorAll(".ace_gutter-cell")[line - 1]; + if (gutterCell && !gutterCell.classList.contains("o_error")) { + gutterCell.classList.add("o_error"); + gutterCell.setAttribute("data-tooltip", message); + gutterCell.setAttribute("data-tooltip-position", "left"); + } + } + } + + clearErrorLine() { + const allGutterCells = this.editorRef.el.querySelectorAll(".ace_gutter-cell"); + for (const gutterCell of allGutterCells) { + gutterCell.classList.remove("o_error"); + gutterCell.removeAttribute("data-tooltip"); + gutterCell.removeAttribute("data-tooltip-position"); + } + } + + // ------------------------------------------------------------------------- + // Handlers + // ------------------------------------------------------------------------- + + onEditorChange(value) { + const currentResource = this.state.currentResource; + currentResource.arch = value; + currentResource.dirty = true; + this.errors.length = 0; + } + + /** + * @param {"xml"|"scss"|"js"} type + */ + onFileTypeChange(type) { + if (type !== this.state.type) { + this.state.type = type; + this.setDefaultFile(); + } + } + + /** + * @param {"xml"|"scss"} type + * @param {string} filter + */ + onFilterChange(type, filter) { + if (type === "scss") { + this.state.scssFilter = filter; + } else if (type === "xml") { + this.state.xmlFilter = filter; + } + this.loadResources(); + } + + onFormat() { + if (this.state.type === "xml") { + const { isValid, error } = checkXML(this.state.currentResource.arch); + if (isValid) { + this.state.currentResource.arch = formatXML(this.state.currentResource.arch); + } else { + this.errors.push({ error, resource: this.state.currentResource }); + } + } + } + + onReset() { + this.dialog.add(ConfirmationDialog, { + title: _t("Careful"), + body: _t( + "If you reset this file, all your customizations will be lost as it will be reverted to the default file." + ), + confirm: () => this.resetResource(), + cancel: () => {}, + }); + } + + async onSave() { + this.state.saving = true; + try { + await this.saveResources(); + } finally { + this.state.saving = false; + } + } +} diff --git a/addons/website/static/src/components/resource_editor/resource_editor.scss b/addons/website/static/src/components/resource_editor/resource_editor.scss new file mode 100644 index 00000000000..5fb1aaadd03 --- /dev/null +++ b/addons/website/static/src/components/resource_editor/resource_editor.scss @@ -0,0 +1,80 @@ +$o-we-ace-color: #2F3129 !default; + +.o_resource_editor { + background: $o-we-ace-color; + color: white; + display: flex; + flex-flow: column nowrap; + opacity: 0.97; + + .o_resource_editor_title { + flex: 0 0 auto; + display: flex; + align-items: center; + padding: $grid-gutter-width/4; + + > .o_resource_editor_type_switcher > button::after { + @include o-caret-down; + margin-left: 4px; + } + + > * { + flex: 0 0 auto; + margin: 0 $grid-gutter-width/4; + + &.o_resource_editor_filter { + display: flex; + align-items: center; + font-size: 11px; + } + + &.o_resource_editor_resource_list { + flex: 1 1 auto; + min-width: 60px; + } + } + } + + #resource-editor-id { + flex: 0 0 auto; + padding: $grid-gutter-width/4 $grid-gutter-width/2; + background-color: lighten($o-we-ace-color, 10%); + + .o_resource_editor_resource_info { + color: #ebecee; + } + } + + #resource-editor { + @mixin ace-line-error-mixin { + content: ""; + z-index: 1000; + display: flex; + background-color: $o-we-color-danger; + color: white; + font-size: 1.2em; + align-items: center; + justify-content: center; + cursor: help; + } + + flex: 1 1 auto; + .ace_gutter { + .ace_gutter-cell.o_error { + position: relative; + + &::before { + @include o-position-absolute(-100vh, 0, -100vh); + @include ace-line-error-mixin; + width: 2px; + } + &::after { + @include o-position-absolute(-100%, 0, -100%, 0); + @include ace-line-error-mixin; + font-family: FontAwesome; + content: "\f071"; + } + } + } + } +} diff --git a/addons/website/static/src/components/resource_editor/resource_editor.xml b/addons/website/static/src/components/resource_editor/resource_editor.xml new file mode 100644 index 00000000000..fcd1ca697d6 --- /dev/null +++ b/addons/website/static/src/components/resource_editor/resource_editor.xml @@ -0,0 +1,87 @@ + + + + + + menuitemcheckbox + props.checked ? 'true' : 'false' + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+ + +
+ +
+
+ +
+
+ +
+ + + + + + +
diff --git a/addons/website/static/src/components/ace_warning/ace_warning.js b/addons/website/static/src/components/resource_editor/resource_editor_warning.js similarity index 83% rename from addons/website/static/src/components/ace_warning/ace_warning.js rename to addons/website/static/src/components/resource_editor/resource_editor_warning.js index dc45ffdeb0b..5b7816fd425 100644 --- a/addons/website/static/src/components/ace_warning/ace_warning.js +++ b/addons/website/static/src/components/resource_editor/resource_editor_warning.js @@ -6,12 +6,11 @@ import { browser } from "@web/core/browser/browser"; import { useService } from "@web/core/utils/hooks"; /** - * Represents the warning overlay that appears when the user opens the Ace - * editor. It provides options to hide the warning, inject code, and not show - * the warning again. + * Represents the warning overlay that appears when the user opens the ResourceEditor + * It provides options to hide the warning, inject code, and not show the warning again. */ -export class AceEditorWarningOverlay extends Component { - static template = "website.AceEditorWarningOverlay"; +export class ResourceEditorWarningOverlay extends Component { + static template = "website.ResourceEditorWarningOverlay"; /** * Initializes the component by setting up the necessary services and state. @@ -30,7 +29,7 @@ export class AceEditorWarningOverlay extends Component { * Closes the Ace editor and updates the website context to hide it. */ onCloseEditor() { - this.website.context.showAceEditor = false; + this.website.context.showResourceEditor = false; } /** diff --git a/addons/website/static/src/components/ace_warning/ace_warning.xml b/addons/website/static/src/components/resource_editor/resource_editor_warning.xml similarity index 96% rename from addons/website/static/src/components/ace_warning/ace_warning.xml rename to addons/website/static/src/components/resource_editor/resource_editor_warning.xml index 7b050f76c20..5d278a7ea13 100644 --- a/addons/website/static/src/components/ace_warning/ace_warning.xml +++ b/addons/website/static/src/components/resource_editor/resource_editor_warning.xml @@ -1,7 +1,7 @@ - +