[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
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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}, '<t t-field="not.exist"/>\n');
|
||||
ace.edit(document.querySelector('#resource-editor div')).getSession().insert({row: 4, column: 1}, '<t t-field="not.exist"/>\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}, '<t t-field="not.exist"/>\n');
|
||||
ace.edit(document.querySelector('#resource-editor div')).getSession().insert({row: 4, column: 1}, '<t t-field="not.exist"/>\n');
|
||||
},
|
||||
},
|
||||
{
|
||||
content: "save the html editor",
|
||||
trigger: ".o_ace_view_editor button[data-action=save]",
|
||||
trigger: ".o_resource_editor button:contains(Save)",
|
||||
},
|
||||
BROKEN_STEP
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -426,149 +426,6 @@ a.o_underline {
|
||||
}
|
||||
}
|
||||
|
||||
// ACE EDITOR
|
||||
.o_ace_view_editor {
|
||||
background: $o-we-ace-color;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
opacity: 0.97;
|
||||
|
||||
.o_ace_view_editor_title {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: $grid-gutter-width/4;
|
||||
|
||||
>.o_ace_type_switcher>button::after {
|
||||
@include o-caret-down;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
>* {
|
||||
flex: 0 0 auto;
|
||||
margin: 0 $grid-gutter-width/4;
|
||||
|
||||
&.o_include_option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
|
||||
>.form-check {
|
||||
margin-right: $grid-gutter-width/4;
|
||||
}
|
||||
}
|
||||
|
||||
&.o_res_list {
|
||||
flex: 1 1 auto;
|
||||
min-width: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#ace-view-id {
|
||||
flex: 0 0 auto;
|
||||
padding: $grid-gutter-width/4 $grid-gutter-width/2;
|
||||
background-color: lighten($o-we-ace-color, 10%);
|
||||
|
||||
.o_ace_editor_resource_info {
|
||||
color: #ebecee;
|
||||
}
|
||||
}
|
||||
|
||||
#ace-view-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;
|
||||
}
|
||||
|
||||
height: 70%; // in case flex is not supported
|
||||
flex: 1 1 auto;
|
||||
|
||||
.ace_gutter {
|
||||
cursor: ew-resize;
|
||||
|
||||
.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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ace_resize_bar {
|
||||
@include o-position-absolute($right: 0);
|
||||
width: 25px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_ace_view_editor_resizing iframe {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.o_ace_error_popover {
|
||||
max-width: 40vw;
|
||||
|
||||
.popover-body {
|
||||
background-color: lighten($o-we-ace-color, 10%);
|
||||
color: #ebecee;
|
||||
}
|
||||
&.bs-popover-left .arrow::after {
|
||||
border-left-color: lighten($o-we-ace-color, 10%);
|
||||
}
|
||||
&.bs-popover-right .arrow::after {
|
||||
border-right-color: lighten($o-we-ace-color, 10%);
|
||||
}
|
||||
code {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
color: cyan;
|
||||
}
|
||||
}
|
||||
|
||||
.o_ace_select2_dropdown {
|
||||
width: auto !important;
|
||||
padding-top: 4px;
|
||||
font-family: monospace !important;
|
||||
|
||||
>.select2-results {
|
||||
max-height: none;
|
||||
max-height: 70vh;
|
||||
|
||||
.select2-result-label {
|
||||
padding-top: 1px;
|
||||
padding-bottom: 2px;
|
||||
|
||||
>.o_ace_select2_result {
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_nocontent_help {
|
||||
@include o-nocontent-empty;
|
||||
|
||||
|
||||
@@ -31,8 +31,6 @@ $o-we-accent: #01bad2 !default;
|
||||
$o-we-border-width: 1px !default;
|
||||
$o-we-border-color: $o-we-bg-light !default;
|
||||
|
||||
$o-we-ace-color: #2F3129 !default;
|
||||
|
||||
// Needed to be changed to be high enough to not overflow when a user
|
||||
// has a page with a lot of content (10000px was proven to be too small)
|
||||
$o-we-handles-offset-to-hide: 100000px !default;
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
|
||||
<t t-name="web_editor.ace_view_editor">
|
||||
<div class="o_ace_view_editor">
|
||||
<div class="o_ace_view_editor_title">
|
||||
<div class="btn-group o_ace_type_switcher">
|
||||
<button type="button" class="btn btn-info dropdown-toggle" data-bs-toggle="dropdown">XML (HTML)</button>
|
||||
<div class="dropdown-menu" role="menu">
|
||||
<a role="menuitem" href="#" class="dropdown-item o_ace_type_switcher_choice" data-type="xml">XML (HTML)</a>
|
||||
<a role="menuitem" href="#" class="dropdown-item o_ace_type_switcher_choice" data-type="scss">SCSS (CSS)</a>
|
||||
<a role="menuitem" href="#" class="dropdown-item o_ace_type_switcher_choice" data-type="js">JS</a>
|
||||
</div>
|
||||
</div>
|
||||
<select id="ace-view-list" class="o_res_list"/>
|
||||
<select id="ace-scss-list" class="o_res_list d-none"/>
|
||||
<select id="ace-js-list" class="o_res_list d-none"/>
|
||||
<label class="o_include_option oe_include_bundles">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-primary dropdown-toggle" type="button" id="o_dropdown_filter_views" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
Filter
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="o_dropdown_filter_views">
|
||||
<a class="dropdown-item o_ace_filter active" data-type="xml" data-value="default" href="#">Only Views</a>
|
||||
<a class="dropdown-item o_ace_filter" data-type="xml" data-value="all" href="#">Views and Assets bundles</a>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="o_include_option o_include_all_scss d-none">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-primary dropdown-toggle" type="button" id="o_dropdown_filter_assets" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
Filter
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="o_dropdown_filter_assets">
|
||||
<a class="dropdown-item o_ace_filter active" data-type="scss" data-value="custom" href="#">Only Custom SCSS Files</a>
|
||||
<a class="dropdown-item o_ace_filter" data-type="scss" data-value="restricted" href="#">Only Page SCSS Files</a>
|
||||
<a class="dropdown-item o_ace_filter" data-type="scss" data-value="all" href="#">All SCSS Files</a>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<div class="o_button_section">
|
||||
<button data-action="save" type="submit" class="btn btn-primary">Save</button>
|
||||
<button data-action="close" type="button" class="btn btn-secondary">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ace-view-id">
|
||||
<div class="float-end mb-2">
|
||||
<button data-action="reset" type="button" class="btn btn-sm btn-danger d-none"><i class="fa fa-undo"/> Reset</button>
|
||||
<button data-action="format" type="button" class="btn btn-sm btn-link">Format</button>
|
||||
</div>
|
||||
<span class="o_ace_editor_resource_info"/>
|
||||
<div class="alert alert-warning alert-dismissible mt-2 mb-0" role="alert">
|
||||
Editing a built-in file through this editor is not advised, as it will prevent it from being updated during future App upgrades.
|
||||
<button type="button" class="btn-close" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ace-view-editor"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
<WebsiteTranslator t-if="websiteContext.translation"
|
||||
reloadIframe.bind="this.reloadIframe"
|
||||
removeWelcomeMessage.bind="this.removeWelcomeMessage"/>
|
||||
<AceEditorWrapper t-if="websiteContext.showAceEditor"/>
|
||||
<ResizablePanel t-if="websiteContext.showResourceEditor" handleSide="'start'" class="'o_resource_editor_wrapper position-fixed'" onResize.bind="_onResourceEditorResize" initialWidth="aceEditorWidth">
|
||||
<ResourceEditor close="() => this.websiteContext.showResourceEditor = false"/>
|
||||
</ResizablePanel>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
|
||||
@@ -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`
|
||||
<div class="o_ace_view_editor_wrapper d-flex">
|
||||
<div class="d-flex position-relative">
|
||||
<div style="display: contents;" t-ref="wrapper"/>
|
||||
<AceEditorWarningOverlay/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates>
|
||||
|
||||
<t t-name="website.CheckableDropdownItem" t-inherit="web.DropdownItem" t-inherit-mode="primary">
|
||||
<xpath expr="//t[@role='menuitem']" position="attributes">
|
||||
<attribute name="role">menuitemcheckbox</attribute>
|
||||
<attribute name="t-att-aria-checked">props.checked ? 'true' : 'false'</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="website.ResourceEditor">
|
||||
<div class="o_resource_editor w-100 h-100">
|
||||
<div class="o_resource_editor_title">
|
||||
<Dropdown class="'btn-group o_resource_editor_type_switcher'" togglerClass="'btn btn-info'">
|
||||
<t t-set-slot="toggler"><t t-esc="types[state.type]"/></t>
|
||||
<t t-foreach="Object.keys(types)" t-as="type" t-key="type">
|
||||
<DropdownItem onSelected="() => this.onFileTypeChange(type)">
|
||||
<t t-esc="types[type]"/>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
<SelectMenu t-props="selectMenuProps" class="'o_resource_editor_resource_list'">
|
||||
<t t-call="website.ResourceEditor.ResourceLabel">
|
||||
<t t-set="resource" t-value="state.currentResource"/>
|
||||
<t t-set="label" t-value="state.type === 'xml' ? resource.name : resource.label"/>
|
||||
</t>
|
||||
<t t-set-slot="choice" t-slot-scope="scope">
|
||||
<t t-call="website.ResourceEditor.ResourceLabel">
|
||||
<t t-set="resource" t-value="state.resources[state.type][scope.data.value]"/>
|
||||
<t t-set="label" t-value="scope.data.label"/>
|
||||
</t>
|
||||
</t>
|
||||
</SelectMenu>
|
||||
<label t-if="state.type === 'xml' and debug" class="o_resource_editor_filter">
|
||||
<Dropdown class="'btn-group o_resource_editor_type_switcher'" togglerClass="'btn btn-primary'">
|
||||
<t t-set-slot="toggler">Filter</t>
|
||||
<t t-foreach="Object.keys(xmlFilters)" t-as="filter" t-key="filter">
|
||||
<CheckableDropdownItem class="{ selected: state.xmlFilter === filter }" checked="state.xmlFilter === filter" onSelected="() => this.onFilterChange('xml', filter)">
|
||||
<t t-esc="xmlFilters[filter]"/>
|
||||
</CheckableDropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</label>
|
||||
<label t-if="state.type === 'scss' and debug" class="o_resource_editor_filter">
|
||||
<Dropdown class="'btn-group o_resource_editor_type_switcher'" togglerClass="'btn btn-primary'">
|
||||
<t t-set-slot="toggler">Filter</t>
|
||||
<t t-foreach="Object.keys(scssFilters)" t-as="filter" t-key="filter">
|
||||
<CheckableDropdownItem class="{ selected: state.scssFilter === filter }" checked="state.scssFilter === filter" onSelected="() => this.onFilterChange('scss', filter)">
|
||||
<t t-esc="scssFilters[filter]"/>
|
||||
</CheckableDropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</label>
|
||||
<div>
|
||||
<button class="btn btn-primary" t-on-click="onSave"><i t-if="state.saving" class="fa fa-spin fa-spinner mr8"/>Save</button>
|
||||
<button class="btn btn-secondary" t-on-click="this.props.close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="state.currentResource" id="resource-editor-id">
|
||||
<div class="float-end">
|
||||
<button t-if="state.type !== 'xml' and isCustomResource(state.currentResource.url)" type="button" class="btn btn-sm btn-danger" t-on-click="onReset">
|
||||
<i class="fa fa-undo"/>
|
||||
Reset
|
||||
</button>
|
||||
<button t-if="state.type === 'xml'" type="button" class="btn btn-sm btn-link" t-on-click="onFormat">Format</button>
|
||||
</div>
|
||||
<span class="o_resource_editor_resource_info" t-esc="resourceInfo"/>
|
||||
</div>
|
||||
<div t-if="state.currentResource" id="resource-editor" t-ref="editor">
|
||||
<CodeEditor
|
||||
class="'h-100'"
|
||||
value="state.currentResource.arch"
|
||||
sessionId="state.currentResource.id"
|
||||
mode="state.type === 'xml' ? 'qweb' : state.type"
|
||||
theme="'monokai'"
|
||||
onChange.bind="onEditorChange"/>
|
||||
</div>
|
||||
</div>
|
||||
<ResourceEditorWarningOverlay/>
|
||||
</t>
|
||||
|
||||
<t t-name="website.ResourceEditor.ResourceLabel">
|
||||
<i t-if="resource.customized or resource.dirty" t-att-class="{ 'fa fa-floppy-o mr8': true, 'text-warning': resource.dirty, 'text-success': resource.customized }"/>
|
||||
<t t-esc="label"/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
+5
-6
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="website.AceEditorWarningOverlay">
|
||||
<t t-name="website.ResourceEditorWarningOverlay">
|
||||
<div t-if="state.visible" class="position-absolute top-0 start-0 bottom-0 end-0 d-flex align-items-center justify-content-center z-3 p-4 bg-light bg-opacity-75" role="alert">
|
||||
<div class="border shadow-sm p-4 bg-light text-dark rounded-2 bg-opacity-100">
|
||||
<div class="d-flex flex-row gap-4">
|
||||
@@ -0,0 +1,110 @@
|
||||
/** @odoo-module */
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
const MAPPING = {
|
||||
'{': '}', '}': '{',
|
||||
'(': ')', ')': '(',
|
||||
'[': ']', ']': '[',
|
||||
};
|
||||
const OPENINGS = ['{', '(', '['];
|
||||
const CLOSINGS = ['}', ')', ']'];
|
||||
|
||||
/**
|
||||
* Checks the syntax validity of some SCSS.
|
||||
*
|
||||
* @param {string} scss
|
||||
* @returns {Object} object with keys "isValid" and "error" if not valid
|
||||
*/
|
||||
export function checkSCSS(scss) {
|
||||
const stack = [];
|
||||
let line = 1;
|
||||
for (let 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 {
|
||||
isValid: false,
|
||||
error: {
|
||||
line,
|
||||
message: _t("Unexpected ") + scss[i],
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (scss[i] === '\n') {
|
||||
line++;
|
||||
}
|
||||
}
|
||||
if (stack.length > 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
line,
|
||||
message: _t("Expected ") + MAPPING[stack.pop()],
|
||||
},
|
||||
};
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the syntax validity of some XML.
|
||||
*
|
||||
* @param {string} xml
|
||||
* @returns {Object} object with keys "isValid" and "error" if not valid
|
||||
*/
|
||||
export function checkXML(xml) {
|
||||
const xmlDoc = (new window.DOMParser()).parseFromString(xml, 'text/xml');
|
||||
const errorEls = xmlDoc.getElementsByTagName('parsererror');
|
||||
if (errorEls.length > 0) {
|
||||
const errorEl = errorEls[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.querySelectorAll('.o_we_source_text_origin').forEach((el, i) => {
|
||||
el.after(codeEls[i]);
|
||||
});
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
line: parseInt(errorEl.innerHTML.match(/[Ll]ine[^\d]+(\d+)/)[1], 10),
|
||||
message: errorEl.textContent,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats some XML so that it has proper indentation and structure.
|
||||
*
|
||||
* @param {string} xml
|
||||
* @returns {string} formatted xml
|
||||
*/
|
||||
export function formatXML(xml) {
|
||||
// do nothing if an inline script is present to avoid breaking it
|
||||
if (/<script(?: [^>]*)?>[^<][\s\S]*<\/script>/i.test(xml)) {
|
||||
return xml;
|
||||
}
|
||||
return window.vkbeautify.xml(xml, 4);
|
||||
}
|
||||
@@ -90,7 +90,7 @@ registry.category('website_custom_menus').add('website.menu_optimize_seo', {
|
||||
&& !!env.services.website.currentWebsite.metadata.canOptimizeSeo,
|
||||
});
|
||||
registry.category('website_custom_menus').add('website.menu_ace_editor', {
|
||||
openWidget: (services) => services.website.context.showAceEditor = true,
|
||||
openWidget: (services) => services.website.context.showResourceEditor = true,
|
||||
isDisplayed: (env) => env.services.website.currentWebsite
|
||||
&& env.services.website.currentWebsite.metadata.viewXmlid
|
||||
&& !env.services.ui.isSmall,
|
||||
|
||||
@@ -46,7 +46,7 @@ export const websiteService = {
|
||||
|
||||
const context = reactive({
|
||||
showNewContentModal: false,
|
||||
showAceEditor: false,
|
||||
showResourceEditor: false,
|
||||
edition: false,
|
||||
isPublicRootReady: false,
|
||||
snippetsLoaded: false,
|
||||
|
||||
@@ -32,30 +32,35 @@ wTourUtils.registerWebsitePreviewTour('html_editor_multiple_templates', {
|
||||
content: "add something in the generic view",
|
||||
trigger: 'div.ace_line .ace_xml:contains("Generic")',
|
||||
run: function () {
|
||||
ace.edit('ace-view-editor').getSession().insert({row: 3, column: 1}, '<p>somenewcontent</p>\n');
|
||||
ace.edit(document.querySelector('#resource-editor div')).getSession().insert({row: 3, column: 1}, '<p>somenewcontent</p>\n');
|
||||
},
|
||||
},
|
||||
// 3. Edit oe_structure specific view
|
||||
{
|
||||
content: "select oe_structure specific view",
|
||||
trigger: 'div.ace_line .ace_xml:contains("somenewcontent")',
|
||||
run: function () {
|
||||
var viewId = $('#ace-view-list option:contains("oe_structure_test_ui")').val();
|
||||
$('#ace-view-list').val(viewId).trigger('change');
|
||||
},
|
||||
run: function () {},
|
||||
},
|
||||
{
|
||||
content: "open file selector menu",
|
||||
trigger: ".o_resource_editor .o_select_menu_toggler",
|
||||
},
|
||||
{
|
||||
content: "open oe_structure_test_ui view",
|
||||
trigger: ".o_resource_editor .o_select_menu_item:contains(oe_structure_test_ui)",
|
||||
},
|
||||
{
|
||||
content: "add something in the oe_structure specific view",
|
||||
extra_trigger: '#ace-view-id:contains("test.generic_view_oe_structure_test_ui")', // If no xml_id it should show key
|
||||
extra_trigger: '.o_resource_editor .o_select_menu_toggler:contains("oe_structure_test_ui")',
|
||||
trigger: 'div.ace_line .ace_xml:contains("s_cover")',
|
||||
run: function () {
|
||||
ace.edit('ace-view-editor').getSession().insert({row: 2, column: 1}, '<p>anothernewcontent</p>\n');
|
||||
ace.edit(document.querySelector('#resource-editor div')).getSession().insert({row: 2, column: 1}, '<p>anothernewcontent</p>\n');
|
||||
},
|
||||
},
|
||||
{
|
||||
content: "save the html editor",
|
||||
extra_trigger: 'div.ace_line .ace_xml:contains("anothernewcontent")',
|
||||
trigger: ".o_ace_view_editor button[data-action=save]",
|
||||
trigger: ".o_resource_editor button:contains(Save)",
|
||||
},
|
||||
{
|
||||
content: "check that the page has both modification",
|
||||
@@ -83,32 +88,29 @@ wTourUtils.registerWebsitePreviewTour('test_html_editor_scss', {
|
||||
},
|
||||
{
|
||||
content: "open type switcher",
|
||||
trigger: '.o_ace_type_switcher button',
|
||||
trigger: '.o_resource_editor_type_switcher button',
|
||||
},
|
||||
{
|
||||
content: "select scss files",
|
||||
trigger: '.o_ace_type_switcher_choice[data-type="scss"]',
|
||||
trigger: '.o_resource_editor_type_switcher .dropdown-item:contains("SCSS")',
|
||||
},
|
||||
{
|
||||
content: "select 'user_custom_rules'",
|
||||
trigger: 'body:has(#ace-scss-list option:contains("user_custom_rules"))',
|
||||
run: function () {
|
||||
var scssId = $('#ace-scss-list option:contains("user_custom_rules")').val();
|
||||
$('#ace-scss-list').val(scssId).trigger('change');
|
||||
},
|
||||
trigger: '.o_resource_editor .o_select_menu_toggler:contains("user_custom_rules")',
|
||||
run: () => {},
|
||||
},
|
||||
// 2. Edit that file and ensure it was saved then reset it
|
||||
{
|
||||
content: "add some scss content in the file",
|
||||
trigger: 'div.ace_line .ace_comment:contains("footer {")',
|
||||
run: function () {
|
||||
ace.edit('ace-view-editor').getSession().insert({row: 2, column: 0}, `${adminCssModif}\n`);
|
||||
ace.edit(document.querySelector('#resource-editor div')).getSession().insert({row: 2, column: 0}, `${adminCssModif}\n`);
|
||||
},
|
||||
},
|
||||
{
|
||||
content: "save the html editor",
|
||||
extra_trigger: `div.ace_line:contains("${adminCssModif}")`,
|
||||
trigger: ".o_ace_view_editor button[data-action=save]",
|
||||
trigger: ".o_resource_editor_title button:contains(Save)",
|
||||
},
|
||||
{
|
||||
content: "check that the scss modification got applied",
|
||||
@@ -118,7 +120,7 @@ wTourUtils.registerWebsitePreviewTour('test_html_editor_scss', {
|
||||
},
|
||||
{
|
||||
content: "reset view (after reload, html editor should have been reopened where it was)",
|
||||
trigger: '#ace-view-id button[data-action="reset"]:not([disabled])',
|
||||
trigger: '#resource-editor-id button:contains(Reset)',
|
||||
},
|
||||
{
|
||||
content: "confirm reset warning",
|
||||
@@ -136,13 +138,13 @@ wTourUtils.registerWebsitePreviewTour('test_html_editor_scss', {
|
||||
content: "add some scss content in the file",
|
||||
trigger: 'div.ace_line .ace_comment:contains("footer {")',
|
||||
run: function () {
|
||||
ace.edit('ace-view-editor').getSession().insert({row: 2, column: 0}, `${adminCssModif}\n`);
|
||||
ace.edit(document.querySelector('#resource-editor div')).getSession().insert({row: 2, column: 0}, `${adminCssModif}\n`);
|
||||
},
|
||||
},
|
||||
{
|
||||
content: "save the html editor",
|
||||
extra_trigger: `div.ace_line:contains("${adminCssModif}")`,
|
||||
trigger: '.o_ace_view_editor button[data-action=save]',
|
||||
trigger: ".o_resource_editor_title button:contains(Save)",
|
||||
},
|
||||
{
|
||||
content: "check that the scss modification got applied",
|
||||
@@ -171,36 +173,33 @@ wTourUtils.registerWebsitePreviewTour('test_html_editor_scss_2', {
|
||||
},
|
||||
{
|
||||
content: "open type switcher",
|
||||
trigger: '.o_ace_type_switcher button',
|
||||
trigger: '.o_resource_editor_type_switcher button',
|
||||
},
|
||||
{
|
||||
content: "select scss files",
|
||||
trigger: '.o_ace_type_switcher_choice[data-type="scss"]',
|
||||
trigger: '.o_resource_editor_type_switcher .dropdown-item:contains("SCSS")',
|
||||
},
|
||||
{
|
||||
content: "select 'user_custom_rules'",
|
||||
trigger: 'body:has(#ace-scss-list option:contains("user_custom_rules"))',
|
||||
run: function () {
|
||||
var scssId = $('#ace-scss-list option:contains("user_custom_rules")').val();
|
||||
$('#ace-scss-list').val(scssId).trigger('change');
|
||||
},
|
||||
trigger: '.o_resource_editor .o_select_menu_toggler:contains("user_custom_rules")',
|
||||
run: () => {},
|
||||
},
|
||||
// 5. Edit that file and ensure it was saved then reset it
|
||||
{
|
||||
content: "add some scss content in the file",
|
||||
trigger: `div.ace_line:contains("${adminCssModif}")`, // ensure the admin modification is here
|
||||
run: function () {
|
||||
ace.edit('ace-view-editor').getSession().insert({row: 2, column: 0}, `${demoCssModif}\n`);
|
||||
ace.edit(document.querySelector('#resource-editor div')).getSession().insert({row: 2, column: 0}, `${demoCssModif}\n`);
|
||||
},
|
||||
},
|
||||
{
|
||||
content: "save the html editor",
|
||||
extra_trigger: `div.ace_line:contains("${demoCssModif}")`,
|
||||
trigger: ".o_ace_view_editor button[data-action=save]",
|
||||
trigger: ".o_resource_editor button:contains(Save)",
|
||||
},
|
||||
{
|
||||
content: "reset view (after reload, html editor should have been reopened where it was)",
|
||||
trigger: '#ace-view-id button[data-action="reset"]:not([disabled])',
|
||||
trigger: '#resource-editor-id button:contains(Reset)',
|
||||
timeout: 30000, // SCSS compilation might take some time
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user