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 @@ - +