+
+
diff --git a/addons/web_editor/static/src/components/upload_progress_toast/upload_service.js b/addons/web_editor/static/src/components/upload_progress_toast/upload_service.js
new file mode 100644
index 00000000000..659f0e8284e
--- /dev/null
+++ b/addons/web_editor/static/src/components/upload_progress_toast/upload_service.js
@@ -0,0 +1,117 @@
+/** @odoo-module **/
+
+import { registry } from '@web/core/registry';
+import { UploadProgressToast } from './upload_progress_toast';
+import { getDataURLFromFile } from 'web.utils';
+
+const { reactive } = owl;
+
+export const AUTOCLOSE_DELAY = 3000;
+
+export const uploadService = {
+ dependencies: ['rpc'],
+ start(env, { rpc }) {
+ let fileId = 0;
+ const filesToUpload = reactive({});
+
+ registry.category('main_components').add('UploadProgressToast', {
+ Component: UploadProgressToast,
+ props: { files: filesToUpload },
+ });
+
+ return {
+ get fileId() {
+ return fileId;
+ },
+
+ addFile(file) {
+ filesToUpload[file.id] = file;
+ return filesToUpload[file.id];
+ },
+
+ deleteFile(fileId) {
+ delete filesToUpload[fileId];
+ },
+
+ incrementId() {
+ fileId++;
+ },
+
+ uploadUrl: async (url, { resModel, resId }, onUploaded) => {
+ const attachment = await rpc('/web_editor/attachment/add_url', {
+ url,
+ 'res_model': resModel,
+ 'res_id': resId,
+ });
+ await onUploaded(attachment);
+ },
+
+ uploadFiles: async (files, {resModel, resId, isImage}, onUploaded) => {
+ // Upload the smallest file first to block the user the least possible.
+ for (const [index, file] of Array.from(files).sort((a, b) => a.size - b.size).entries()) {
+ let fileSize = file.size;
+ if (!fileSize) {
+ fileSize = null;
+ } else if (fileSize < 1024) {
+ fileSize = fileSize.toFixed(2) + " bytes";
+ } else if (fileSize < 1048576) {
+ fileSize = (fileSize / 1024).toFixed(2) + " KB";
+ } else {
+ fileSize = (fileSize / 1048576).toFixed(2) + " MB";
+ }
+
+ const id = ++fileId;
+ filesToUpload[id] = {
+ id,
+ index,
+ name: file.name,
+ size: fileSize,
+ progress: 0,
+ hasError: false,
+ uploaded: false,
+ errorMessage: '',
+ };
+ }
+
+ await Promise.all(Object.keys(filesToUpload).map(async (fileId, index) => {
+ const file = filesToUpload[fileId];
+ const dataURL = await getDataURLFromFile(files[index]);
+ try {
+ const xhr = new XMLHttpRequest();
+ xhr.upload.addEventListener('progress', ev => {
+ const rpcComplete = ev.loaded / ev.total * 100;
+ file.progress = rpcComplete;
+ });
+ xhr.upload.addEventListener('load', function () {
+ // Don't show yet success as backend code only starts now
+ file.progress = 100;
+ });
+ const attachment = await rpc('/web_editor/attachment/add_data', {
+ 'name': file.name,
+ 'data': dataURL.split(',')[1],
+ 'res_id': resId,
+ 'res_model': resModel,
+ 'is_image': !!isImage,
+ 'width': 0,
+ 'quality': 0,
+ }, {xhr});
+ if (attachment.error) {
+ file.hasError = true;
+ file.errorMessage = attachment.error;
+ } else {
+ file.uploaded = true;
+ await onUploaded(attachment);
+ }
+ setTimeout(() => delete filesToUpload[file.id], AUTOCLOSE_DELAY);
+ } catch (error) {
+ file.hasError = true;
+ setTimeout(() => delete filesToUpload[file.id], AUTOCLOSE_DELAY);
+ throw error;
+ }
+ }));
+ }
+ };
+ },
+};
+
+registry.category('services').add('upload', uploadService);
diff --git a/addons/web_editor/static/src/js/base.js b/addons/web_editor/static/src/js/base.js
index 7d807114e6e..0bbda3bfcea 100644
--- a/addons/web_editor/static/src/js/base.js
+++ b/addons/web_editor/static/src/js/base.js
@@ -125,43 +125,6 @@ return {
//==============================================================================
-odoo.define('web_editor.context', function (require) {
-'use strict';
-
-// TODO this should be re-removed as soon as possible.
-
-function getContext(context) {
- var html = document.documentElement;
- return _.extend({
- lang: (html.getAttribute('lang') || 'en_US').replace('-', '_'),
-
- // Unfortunately this is a mention of 'website' in 'web_editor' as there
- // was no other way to do it as this was restored in a stable version.
- // Indeed, the editor is currently using this context at the root of JS
- // module, so there is no way for website to hook itself before
- // web_editor uses it (without a risky refactoring of web_editor in
- // stable). As mentioned above, the editor should not use this context
- // anymore anyway (this was restored by the saas-12.2 editor revert).
- 'website_id': html.getAttribute('data-website-id') | 0,
- }, context || {});
-}
-function getExtraContext(context) {
- var html = document.documentElement;
- return _.extend(getContext(), {
- editable: !!(html.dataset.editable || $('[data-oe-model]').length), // temporary hack, this should be done in python
- translatable: !!html.dataset.translatable,
- edit_translations: !!html.dataset.edit_translations,
- }, context || {});
-}
-
-return {
- get: getContext,
- getExtra: getExtraContext,
-};
-});
-
-//==============================================================================
-
odoo.define('web_editor.ready', function (require) {
'use strict';
diff --git a/addons/web_editor/static/src/js/common/ace.js b/addons/web_editor/static/src/js/common/ace.js
index c3ffafae00e..2b744f79c45 100644
--- a/addons/web_editor/static/src/js/common/ace.js
+++ b/addons/web_editor/static/src/js/common/ace.js
@@ -1004,7 +1004,12 @@ var ViewEditor = Widget.extend({
* @private
*/
_onSaveClick: function (ev) {
- const restore = dom.addButtonLoadingEffect(ev.currentTarget);
+ const restoreSave = dom.addButtonLoadingEffect(ev.currentTarget);
+ const restore = () => {
+ restoreSave();
+ this.$resetButton[0].disabled = false;
+ };
+ this.$resetButton[0].disabled = true;
this._saveResources().then(restore).guardedCatch(restore);
},
/**
diff --git a/addons/web_editor/static/src/js/common/utils.js b/addons/web_editor/static/src/js/common/utils.js
index ad89222ee38..6c31abc8f81 100644
--- a/addons/web_editor/static/src/js/common/utils.js
+++ b/addons/web_editor/static/src/js/common/utils.js
@@ -3,6 +3,9 @@ odoo.define('web_editor.utils', function (require) {
const {ColorpickerWidget} = require('web.Colorpicker');
+let editableWindow = window;
+const _setEditableWindow = (ew) => editableWindow = ew;
+
/**
* window.getComputedStyle cannot work properly with CSS shortcuts (like
* 'border-width' which is a shortcut for the top + right + bottom + left border
@@ -58,7 +61,7 @@ const DEFAULT_PALETTE = {
*/
function _computePxByRem(toRem) {
if (_computePxByRem.PX_BY_REM === undefined) {
- const htmlStyle = window.getComputedStyle(document.documentElement);
+ const htmlStyle = editableWindow.getComputedStyle(editableWindow.document.documentElement);
_computePxByRem.PX_BY_REM = parseFloat(htmlStyle['font-size']);
}
return toRem ? (1 / _computePxByRem.PX_BY_REM) : _computePxByRem.PX_BY_REM;
@@ -249,7 +252,7 @@ function _computeColorClasses(colorNames, prefix = 'bg-') {
*/
function _getCSSVariableValue(key, htmlStyle) {
if (htmlStyle === undefined) {
- htmlStyle = window.getComputedStyle(document.documentElement);
+ htmlStyle = editableWindow.getComputedStyle(editableWindow.document.documentElement);
}
// Get trimmed value from the HTML element
let value = htmlStyle.getPropertyValue(`--${key}`).trim();
@@ -379,5 +382,6 @@ return {
backgroundImagePartsToCss: _backgroundImagePartsToCss,
generateHTMLId: _generateHTMLId,
getColorClass: _getColorClass,
+ setEditableWindow: _setEditableWindow,
};
});
diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js
index 189e027d82b..a3c5af85845 100644
--- a/addons/web_editor/static/src/js/editor/snippets.editor.js
+++ b/addons/web_editor/static/src/js/editor/snippets.editor.js
@@ -15,6 +15,8 @@ const QWeb = core.qweb;
var _t = core._t;
+let cacheSnippetTemplate = {};
+
// jQuery extensions
$.extend($.expr[':'], {
o_editable: function (node, i, m) {
@@ -57,7 +59,7 @@ function nodeLength(node) {
$.fn.extend({
focusIn: function () {
if (this.length) {
- const selection = document.getSelection();
+ const selection = this[0].ownerDocument.getSelection();
selection.removeAllRanges();
const range = new Range();
@@ -70,7 +72,7 @@ $.fn.extend({
},
focusInEnd: function () {
if (this.length) {
- const selection = document.getSelection();
+ const selection = this[0].ownerDocument.getSelection();
selection.removeAllRanges();
const range = new Range();
@@ -88,7 +90,7 @@ $.fn.extend({
return this.selectElement();
}
if (this.length) {
- const selection = document.getSelection();
+ const selection = this[0].ownerDocument.getSelection();
selection.removeAllRanges();
const range = new Range();
@@ -100,7 +102,7 @@ $.fn.extend({
},
selectElement: function () {
if (this.length) {
- const selection = document.getSelection();
+ const selection = this[0].ownerDocument.getSelection();
selection.removeAllRanges();
const element = this[0];
@@ -161,7 +163,7 @@ var SnippetEditor = Widget.extend({
this.templateOptions = templateOptions;
this.isTargetParentEditable = false;
this.isTargetMovable = false;
- this.$scrollingElement = $().getScrollingElement(this.ownerDocument);
+ this.$scrollingElement = $().getScrollingElement(this.$editable[0].ownerDocument);
if (!this.$scrollingElement[0]) {
this.$scrollingElement = $(this.ownerDocument).find('.o_editable');
}
@@ -199,7 +201,7 @@ var SnippetEditor = Widget.extend({
handle: '.o_move_handle',
helper: () => {
var $clone = this.$el.clone().css({width: '24px', height: '24px', border: 0});
- $clone.appendTo(this.$body).removeClass('d-none');
+ $clone.appendTo(this.$el[0].ownerDocument.body).removeClass('d-none');
return $clone;
},
start: this._onDragAndDropStart.bind(this),
@@ -366,6 +368,16 @@ var SnippetEditor = Widget.extend({
// Now cover the element
const offset = $target.offset();
+
+ // If the target is in an iframe, we need the iframe offset.
+ const targetWindow = $target[0].ownerDocument.defaultView;
+ const editorWindow = this.$el[0].ownerDocument.defaultView;
+ if (targetWindow.frameElement && targetWindow !== editorWindow) {
+ const { x, y } = targetWindow.frameElement.getBoundingClientRect();
+ offset.left += x;
+ offset.top += y;
+ }
+
var manipulatorOffset = this.$el.parent().offset();
offset.top -= manipulatorOffset.top;
offset.left -= manipulatorOffset.left;
@@ -405,6 +417,9 @@ var SnippetEditor = Widget.extend({
if (this.$target.parent('.row').length) {
return _t("Column");
}
+ if (this.$target.is('#wrapwrap > main')) {
+ return _t("Page Options");
+ }
return _t("Block");
},
/**
@@ -759,6 +774,9 @@ var SnippetEditor = Widget.extend({
if (!val.selector.is(this.$target)) {
return;
}
+ if (val.data.string) {
+ $optionsSection[0].querySelector('we-title > span').textContent = val.data.string;
+ }
if (val['drop-near']) {
this.selectorSiblings.push(val['drop-near']);
}
@@ -803,7 +821,7 @@ var SnippetEditor = Widget.extend({
this.$el.find('[data-toggle="dropdown"]').dropdown();
- return Promise.all(defs).then(() => {
+ return Promise.all(defs).then(async () => {
const options = _.sortBy(this.styles, '__order');
const firstOptions = [];
options.forEach(option => {
@@ -1218,6 +1236,7 @@ var SnippetsMenu = Widget.extend({
this.loadingTimers = {};
this.loadingElements = {};
this._loadingEffectDisabled = false;
+ this._onClick = this._onClick.bind(this);
},
/**
* @override
@@ -1279,7 +1298,7 @@ var SnippetsMenu = Widget.extend({
// Fetch snippet templates and compute it
defs.push((async () => {
- await this._loadSnippetsTemplates();
+ await this._loadSnippetsTemplates(this.options.invalidateSnippetCache);
await this._updateInvisibleDOM();
})());
@@ -1289,46 +1308,9 @@ var SnippetsMenu = Widget.extend({
}).insertAfter(this.$el);
// Active snippet editor on click in the page
- var lastElement;
- const onClick = ev => {
- var srcElement = ev.target || (ev.originalEvent && (ev.originalEvent.target || ev.originalEvent.originalTarget)) || ev.srcElement;
- if (!srcElement || lastElement === srcElement) {
- return;
- }
- var $target = $(srcElement);
- // Keep popover open if clicked inside it, but not on a button
- if ($target.parents('.o_edit_menu_popover').length && !$target.parent('a').addBack('a').length) {
- return;
- }
- lastElement = srcElement;
- _.defer(function () {
- lastElement = false;
- });
-
- if (!$target.closest('we-button, we-toggler, we-select, .o_we_color_preview').length) {
- this._closeWidgets();
- }
- if (!$target.closest('body > *').length || $target.is('#iframe_target')) {
- return;
- }
- if ($target.closest(this._notActivableElementsSelector).length) {
- return;
- }
- const $oeStructure = $target.closest('.oe_structure');
- if ($oeStructure.length && !$oeStructure.children().length && this.$snippets) {
- // If empty oe_structure, encourage using snippets in there by
- // making them "wizz" in the panel.
- this._activateSnippet(false).then(() => {
- this.$snippets.odooBounce();
- });
- return;
- }
- this._activateSnippet($target);
- };
-
- this.$document.on('click.snippets_menu', '*', onClick);
+ this.$document.on('click.snippets_menu', '*', this._onClick);
// Needed as bootstrap stop the propagation of click events for dropdowns
- this.$document.on('mouseup.snippets_menu', '.dropdown-toggle', onClick);
+ this.$document.on('mouseup.snippets_menu', '.dropdown-toggle', this._onClick);
core.bus.on('deactivate_snippet', this, this._onDeactivateSnippet);
@@ -1337,17 +1319,18 @@ var SnippetsMenu = Widget.extend({
this.updateCurrentSnippetEditorOverlay();
}, 50);
this.$window.on('resize.snippets_menu', debouncedCoverUpdate);
- this.$window.on('content_changed.snippets_menu', debouncedCoverUpdate);
+ this.$body.on('content_changed.snippets_menu', debouncedCoverUpdate);
+ $(this.$body[0].ownerDocument.defaultView).on('resize.snippets_menu', debouncedCoverUpdate);
// On keydown add a class on the active overlay to hide it and show it
// again when the mouse moves
- this.$document.on('keydown.snippets_menu', () => {
+ this.$body.on('keydown.snippets_menu', () => {
this.__overlayKeyWasDown = true;
this.snippetEditors.forEach(editor => {
editor.toggleOverlayVisibility(false);
});
});
- this.$document.on('mousemove.snippets_menu, mousedown.snippets_menu', _.throttle(() => {
+ this.$body.on('mousemove.snippets_menu, mousedown.snippets_menu', _.throttle(() => {
if (!this.__overlayKeyWasDown) {
return;
}
@@ -1360,7 +1343,7 @@ var SnippetsMenu = Widget.extend({
// Hide the active overlay when scrolling.
// Show it again and recompute all the overlays after the scroll.
- this.$scrollingElement = $().getScrollingElement(this.ownerDocument);
+ this.$scrollingElement = $().getScrollingElement(this.$body[0].ownerDocument);
if (!this.$scrollingElement[0]) {
this.$scrollingElement = $(this.ownerDocument).find('.o_editable');
}
@@ -1385,13 +1368,13 @@ var SnippetsMenu = Widget.extend({
// Auto-selects text elements with a specific class and remove this
// on text changes
- this.$document.on('click.snippets_menu', '.o_default_snippet_text', function (ev) {
+ this.$body.on('click.snippets_menu', '.o_default_snippet_text', function (ev) {
$(ev.target).closest('.o_default_snippet_text').removeClass('o_default_snippet_text');
$(ev.target).selectContent();
$(ev.target).removeClass('o_default_snippet_text');
});
- this.$document.on('keyup.snippets_menu', function () {
- const selection = document.getSelection();
+ this.$body.on('keyup.snippets_menu', function () {
+ const selection = this.ownerDocument.getSelection();
if (!Selection.rangeCount) {
return;
}
@@ -1402,7 +1385,7 @@ var SnippetsMenu = Widget.extend({
for (const snippetEditor of this.snippetEditors) {
this._mutex.exec(() => snippetEditor.destroy());
}
- const selection = this.$document[0].getSelection();
+ const selection = this.$body[0].ownerDocument.getSelection();
if (selection.rangeCount) {
const target = selection.getRangeAt(0).startContainer.parentElement;
this._activateSnippet($(target));
@@ -1479,7 +1462,6 @@ var SnippetsMenu = Widget.extend({
}
core.bus.off('deactivate_snippet', this, this._onDeactivateSnippet);
$(document.body).off('click', this._checkEditorToolbarVisibilityCallback);
- delete this.cacheSnippetTemplate[this.options.snippets];
},
//--------------------------------------------------------------------------
@@ -1502,6 +1484,10 @@ var SnippetsMenu = Widget.extend({
// Next, notify that we want the DOM to be cleaned (e.g. in website this
// may be the moment where the public widgets need to be destroyed).
this.trigger_up('ready_to_clean_for_save');
+ // Wait for the mutex a second time as some options do editor actions when
+ // their snippets are destroyed. (E.g. s_popup triggers visibility updates
+ // when hidden, destroying the widget hides it.)
+ await this._mutex.getUnlockedDef();
// Then destroy all snippet editors, making them call their own
// "clean for save" methods (and options ones).
@@ -1519,8 +1505,8 @@ var SnippetsMenu = Widget.extend({
* @param {boolean} invalidateCache
*/
loadSnippets: function (invalidateCache) {
- if (!invalidateCache && this.cacheSnippetTemplate[this.options.snippets]) {
- this._defLoadSnippets = this.cacheSnippetTemplate[this.options.snippets];
+ if (!invalidateCache && cacheSnippetTemplate[this.options.snippets]) {
+ this._defLoadSnippets = cacheSnippetTemplate[this.options.snippets];
return this._defLoadSnippets;
}
this._defLoadSnippets = this._rpc({
@@ -1530,8 +1516,8 @@ var SnippetsMenu = Widget.extend({
kwargs: {
context: this.options.context,
},
- });
- this.cacheSnippetTemplate[this.options.snippets] = this._defLoadSnippets;
+ }, { shadow: true });
+ cacheSnippetTemplate[this.options.snippets] = this._defLoadSnippets;
return this._defLoadSnippets;
},
/**
@@ -1687,7 +1673,7 @@ var SnippetsMenu = Widget.extend({
}
// Firstly, add a dropzone after the clone
- var $clone = $('.oe_drop_clone');
+ var $clone = this.$body.find('.oe_drop_clone');
if ($clone.length) {
var $neighbor = $clone.prev();
if (!$neighbor.length) {
@@ -1766,6 +1752,13 @@ var SnippetsMenu = Widget.extend({
// Cleaning consecutive zone and up zones placed between floating or
// inline elements. We do not like these kind of zones.
$zones = this.getEditableArea().find('.oe_drop_zone:not(.oe_vertical)');
+
+ let iframeOffset;
+ const bodyWindow = this.$body[0].ownerDocument.defaultView;
+ if (bodyWindow.frameElement && bodyWindow !== this.ownerDocument.defaultView) {
+ iframeOffset = bodyWindow.frameElement.getBoundingClientRect();
+ }
+
$zones.each(function () {
var zone = $(this);
var prev = zone.prev();
@@ -1786,6 +1779,20 @@ var SnippetsMenu = Widget.extend({
&& dispPrev.indexOf('inline') >= 0 && dispNext.indexOf('inline') >= 0) {
zone.remove();
}
+
+ // In the case of the SnippetsMenu being instanciated in the global
+ // document, with its editable content in an iframe, we want to
+ // take the iframe's offset into account to compute the dropzones.
+ if (iframeOffset) {
+ this.oldGetBoundingClientRect = this.getBoundingClientRect;
+ this.getBoundingClientRect = () => {
+ const rect = this.oldGetBoundingClientRect();
+ const { x, y } = iframeOffset;
+ rect.x += x;
+ rect.y += y;
+ return rect;
+ };
+ }
});
},
/**
@@ -2060,7 +2067,7 @@ var SnippetsMenu = Widget.extend({
return $from.closest(selector, parentNode).filter(filterFunc);
};
functions.all = function ($from) {
- return ($from ? dom.cssFind($from, selector) : $(selector)).filter(filterFunc);
+ return ($from ? dom.cssFind($from, selector) : self.$body.find(selector)).filter(filterFunc);
};
} else {
functions.is = function ($from) {
@@ -2173,7 +2180,7 @@ var SnippetsMenu = Widget.extend({
if (snippetClasses && snippetClasses.length) {
snippetClasses = '.' + snippetClasses.join('.');
}
- const $els = $(snippetClasses).not('[data-name]').add($sbody);
+ const $els = self.$body.find(snippetClasses).not('[data-name]').add($(snippetClasses)).add($sbody);
$els.attr('data-name', name).data('name', name);
// Create the thumbnail
@@ -2408,7 +2415,7 @@ var SnippetsMenu = Widget.extend({
var $toInsert, dropped, $snippet;
let dragAndDropResolve;
- let $scrollingElement = $().getScrollingElement(this.ownerDocument);
+ let $scrollingElement = $().getScrollingElement(this.$body[0].ownerDocument);
if (!$scrollingElement[0] || $scrollingElement.find('body.o_in_iframe').length) {
$scrollingElement = $(this.ownerDocument).find('.o_editable');
}
@@ -2421,6 +2428,7 @@ var SnippetsMenu = Widget.extend({
dragSnip.querySelectorAll('.o_delete_btn, .o_rename_btn').forEach(
el => el.remove()
);
+ self.$el[0].ownerDocument.body.append(dragSnip);
return dragSnip;
},
start: function () {
@@ -2672,21 +2680,15 @@ var SnippetsMenu = Widget.extend({
*/
_createLoadingElement() {
const loaderContainer = document.createElement('div');
- const loader = document.createElement('i');
+ const loader = document.createElement('img');
const loaderContainerClassList = [
'o_we_ui_loading',
'd-flex',
'justify-content-center',
'align-items-center',
];
- const loaderClassList = [
- 'fa',
- 'fa-circle-o-notch',
- 'fa-spin',
- 'fa-4x',
- ];
loaderContainer.classList.add(...loaderContainerClassList);
- loader.classList.add(...loaderClassList);
+ loader.setAttribute('src', '/web/static/img/spin.svg');
loaderContainer.appendChild(loader);
return loaderContainer;
},
@@ -2760,6 +2762,46 @@ var SnippetsMenu = Widget.extend({
// Handlers
//--------------------------------------------------------------------------
+ /**
+ * Activates the right snippet and initializes its SnippetEditor.
+ *
+ * @private
+ */
+ _onClick(ev) {
+ var srcElement = ev.target || (ev.originalEvent && (ev.originalEvent.target || ev.originalEvent.originalTarget)) || ev.srcElement;
+ if (!srcElement || this.lastElement === srcElement) {
+ return;
+ }
+ var $target = $(srcElement);
+ // Keep popover open if clicked inside it, but not on a button
+ if ($target.parents('.o_edit_menu_popover').length && !$target.parent('a').addBack('a').length) {
+ return;
+ }
+ this.lastElement = srcElement;
+ _.defer(() => {
+ this.lastElement = false;
+ });
+
+ if (!$target.closest('we-button, we-toggler, we-select, .o_we_color_preview').length) {
+ this._closeWidgets();
+ }
+ if (!$target.closest('body > *').length || $target.is('#iframe_target')) {
+ return;
+ }
+ if ($target.closest(this._notActivableElementsSelector).length) {
+ return;
+ }
+ const $oeStructure = $target.closest('.oe_structure');
+ if ($oeStructure.length && !$oeStructure.children().length && this.$snippets) {
+ // If empty oe_structure, encourage using snippets in there by
+ // making them "wizz" in the panel.
+ this._activateSnippet(false).then(() => {
+ this.$snippets.odooBounce();
+ });
+ return;
+ }
+ this._activateSnippet($target);
+ },
/**
* Called when a child editor asks for insertion zones to be enabled.
*
@@ -2892,8 +2934,9 @@ var SnippetsMenu = Widget.extend({
args: [[moduleID]],
}).then(() => {
self.trigger_up('request_save', {
- reloadEditor: true,
+ invalidateSnippetCache: true,
_toMutex: true,
+ reloadWebClient: true,
});
}).guardedCatch(reason => {
reason.event.preventDefault();
@@ -3142,19 +3185,9 @@ var SnippetsMenu = Widget.extend({
}
delete data._toMutex;
ev.stopPropagation();
- this._execWithLoadingEffect(() => {
- if (data.reloadEditor) {
- data.reload = false;
- const oldOnSuccess = data.onSuccess;
- data.onSuccess = async function () {
- if (oldOnSuccess) {
- await oldOnSuccess.call(this, ...arguments);
- }
- window.location.href = window.location.origin + window.location.pathname + '?enable_editor=1';
- };
- }
+ this._buttonClick(() => this._execWithLoadingEffect(() => {
this.trigger_up('request_save', data);
- }, true);
+ }, true), this.$el[0].querySelector('button[data-action=save]'));
},
/**
* @private
@@ -3366,13 +3399,18 @@ var SnippetsMenu = Widget.extend({
* On click on discard button.
*/
_onDiscardClick: function () {
- this.trigger_up('request_cancel');
+ this._buttonClick(after => {
+ this.snippetEditors.forEach(editor => {
+ editor.toggleOverlay(false);
+ });
+ this.trigger_up('request_cancel', {onReject: after});
+ }, this.$el[0].querySelector('button[data-action=cancel]'), false);
},
/**
* Preview on mobile.
*/
- _onMobilePreviewClick: async function () {
- throw new Error('implement me');
+ _onMobilePreviewClick: function () {
+ this.trigger_up('request_mobile_preview');
},
/**
* Undo..
@@ -3415,6 +3453,41 @@ var SnippetsMenu = Widget.extend({
}
});
},
+ /***
+ * Display a loading effect on the clicked button, and disables the other
+ * buttons. Passes an argument to restore the buttons to their normal
+ * state to the function to execute.
+ *
+ * @param action {Function} The action to execute
+ * @param button {HTMLElement} The button element
+ * @param addLoadingEffect {boolean} whether or not to add a loading effect.
+ * @returns {Promise}
+ * @private
+ */
+ async _buttonClick(action, button, addLoadingEffect = true) {
+ if (this._buttonAction) {
+ return;
+ }
+ this._buttonAction = true;
+ let removeLoadingEffect;
+ if (addLoadingEffect) {
+ removeLoadingEffect = dom.addButtonLoadingEffect(button);
+ }
+ const actionButtons = this.$el[0].querySelectorAll('[data-action]');
+ for (const actionButton of actionButtons) {
+ actionButton.disabled = true;
+ }
+ const after = () => {
+ if (removeLoadingEffect) {
+ removeLoadingEffect();
+ }
+ for (const actionButton of actionButtons) {
+ actionButton.disabled = false;
+ }
+ };
+ await action(after);
+ this._buttonAction = false;
+ },
});
return {
diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js
index f74bfe1da65..a7e6e6123a5 100644
--- a/addons/web_editor/static/src/js/editor/snippets.options.js
+++ b/addons/web_editor/static/src/js/editor/snippets.options.js
@@ -1,6 +1,8 @@
odoo.define('web_editor.snippets.options', function (require) {
'use strict';
+const { ComponentWrapper } = require('web.OwlCompatibility');
+const { MediaDialogWrapper } = require('@web_editor/components/media_dialog/media_dialog');
var core = require('web.core');
const {ColorpickerWidget} = require('web.Colorpicker');
const Dialog = require('web.Dialog');
@@ -1480,18 +1482,17 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({
await this._colorPaletteRenderPromise;
- const classes = weUtils.computeColorClasses(this.colorPalette.getColorNames());
- this.colorPreviewEl.classList.remove(...classes);
this.colorPreviewEl.style.removeProperty('background-color');
this.colorPreviewEl.style.removeProperty('background-image');
+ const prefix = this.options.dataAttributes.colorPrefix || 'bg';
if (this._ccValue) {
- this.colorPreviewEl.classList.add('o_cc', `o_cc${this._ccValue}`);
+ this.colorPreviewEl.style.backgroundColor = `var(--we-cp-o-cc${this._ccValue}-${prefix.replace(/-/, '')})`;
}
if (this._value) {
if (ColorpickerWidget.isCSSColor(this._value)) {
this.colorPreviewEl.style.backgroundColor = this._value;
} else if (!weUtils.isColorGradient(this._value)) {
- this.colorPreviewEl.classList.add(`bg-${this._value}`);
+ this.colorPreviewEl.style.backgroundColor = `var(--we-cp-${this._value}`;
} else {
this.colorPreviewEl.style.backgroundImage = this._value;
}
@@ -1535,6 +1536,11 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({
if (this.options.dataAttributes.selectedTab) {
options.selectedTab = this.options.dataAttributes.selectedTab;
}
+ const wysiwyg = this.getParent().options.wysiwyg;
+ if (wysiwyg) {
+ options.ownerDocument = wysiwyg.el.ownerDocument;
+ options.editable = wysiwyg.$editable[0];
+ }
const oldColorPalette = this.colorPalette;
this.colorPalette = new ColorPaletteWidget(this, options);
if (oldColorPalette) {
@@ -1646,10 +1652,10 @@ const MediapickerUserValueWidget = UserValueWidget.extend({
* @param {boolean} [videos] whether videos should be available
* default: false
*/
- _openDialog(el, {images = false, videos = false}) {
+ _openDialog(el, {images = false, videos = false, save}) {
el.src = this._value;
const $editable = this.$target.closest('.o_editable');
- const mediaDialog = new weWidgets.MediaDialog(this, {
+ const mediaDialogWrapper = new ComponentWrapper(this, MediaDialogWrapper, {
noImages: !images,
noVideos: !videos,
noIcons: true,
@@ -1659,8 +1665,10 @@ const MediapickerUserValueWidget = UserValueWidget.extend({
'312451183', '415226028', '367762632', '340475898', '374265101', '370467553'],
'res_model': $editable.data('oe-model'),
'res_id': $editable.data('oe-id'),
- }, el).open();
- return mediaDialog;
+ save,
+ media: el,
+ });
+ return mediaDialogWrapper.mount(this.el);
},
//--------------------------------------------------------------------------
@@ -1699,13 +1707,15 @@ const ImagepickerUserValueWidget = MediapickerUserValueWidget.extend({
_onEditMedia(ev) {
// Need a dummy element for the media dialog to modify.
const dummyEl = document.createElement('img');
- const dialog = this._openDialog(dummyEl, {images: true});
- dialog.on('save', this, data => {
- // Accessing the value directly through dummyEl.src converts the url to absolute,
- // using getAttribute allows us to keep the url as it was inserted in the DOM
- // which can be useful to compare it to values stored in db.
- this._value = dummyEl.getAttribute('src');
- this._onUserValueChange();
+ this._openDialog(dummyEl, {
+ images: true,
+ save: (media) => {
+ // Accessing the value directly through dummyEl.src converts the url to absolute,
+ // using getAttribute allows us to keep the url as it was inserted in the DOM
+ // which can be useful to compare it to values stored in db.
+ this._value = media.getAttribute('src');
+ this._onUserValueChange();
+ }
});
},
});
@@ -1721,11 +1731,12 @@ const VideopickerUserValueWidget = MediapickerUserValueWidget.extend({
_onEditMedia(ev) {
// Need a dummy element for the media dialog to modify.
const dummyEl = document.createElement('iframe');
- const dialog = this._openDialog(dummyEl, {videos: true});
- dialog.on('save', this, data => {
- this._value = data.bgVideoSrc;
- this._onUserValueChange();
- });
+ this._openDialog(dummyEl, {
+ videos: true,
+ save: (media) => {
+ this._value = media.querySelector('iframe').src;
+ this._onUserValueChange();
+ }});
},
});
@@ -4025,28 +4036,7 @@ const SnippetOptionWidget = Widget.extend({
}
}
- const reloadMessage = await this._checkIfWidgetsUpdateNeedReload(widgets);
- requiresReload = !!reloadMessage;
- if (requiresReload) {
- const save = await new Promise(resolve => {
- Dialog.confirm(this, _t("To apply this change, we need to save all your previous modifications and reload the page.") + ' '
- + (typeof reloadMessage === 'string' ? reloadMessage : ''), {
- buttons: [{
- text: _t('Save and Reload'),
- classes: 'btn-primary',
- close: true,
- click: () => resolve(true),
- }, {
- text: _t("Cancel"),
- close: true,
- click: () => resolve(false)
- }],
- });
- });
- if (!save) {
- return;
- }
- }
+ requiresReload = !!await this._checkIfWidgetsUpdateNeedReload(widgets);
}
// Queue action so that we can later skip useless actions.
@@ -4159,6 +4149,8 @@ const SnippetOptionWidget = Widget.extend({
if (requiresReload) {
this.trigger_up('request_save', {
reloadEditor: true,
+ optionSelector: this.data.selector,
+ url: this.data.reload,
});
}
},
@@ -5295,7 +5287,7 @@ registry.ImageTools = ImageHandlerOption.extend({
this.trigger_up('hide_overlay');
this.trigger_up('disable_loading_effect');
const img = this._getImg();
- new weWidgets.ImageCropWidget(this, img, {mimetype: this._getImageMimetype(img)}).appendTo(this.options.wysiwyg.odooEditor.document.body);
+ new weWidgets.ImageCropWidget(this, img, {mimetype: this._getImageMimetype(img)}).appendTo(this.$el[0].ownerDocument.body);
await new Promise(resolve => {
this.$target.one('image_cropper_destroyed', async () => {
@@ -5340,7 +5332,7 @@ registry.ImageTools = ImageHandlerOption.extend({
async resetCrop() {
const img = this._getImg();
const cropper = new weWidgets.ImageCropWidget(this, img, {mimetype: this._getImageMimetype(img)});
- await cropper.appendTo(this.options.wysiwyg.odooEditor.document.body);
+ await cropper.appendTo(this.$el[0].ownerDocument.body);
await cropper.reset();
await this._reapplyCurrentShape();
},
@@ -6464,7 +6456,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({
.clone()
.addClass('d-none')
// Needs to be in document for bg-image class to take effect
- .appendTo(document.body);
+ .appendTo(this.$target[0].ownerDocument.body);
const shapeContainer = $shapeContainer[0];
$shapeContainer.css('background-image', '');
const shapeSrc = shapeContainer && getBgImageURL(shapeContainer);
@@ -6690,7 +6682,7 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({
return;
}
// TODO: change #wrapwrap after web_editor rework.
- const $wrapwrap = $('#wrapwrap');
+ const $wrapwrap = $(this.ownerDocument.body).find("#wrapwrap");
const targetOffset = this.$target.offset();
this.$backgroundOverlay.css({
@@ -6809,7 +6801,7 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({
_onDragBackgroundStart: function (ev) {
ev.preventDefault();
this.$bgDragger.addClass('o_we_grabbing');
- const $document = $(this.ownerDocument);
+ const $document = $(document);
$document.on('mousemove.bgposition', this._onDragBackgroundMove.bind(this));
$document.one('mouseup', () => {
this.$bgDragger.removeClass('o_we_grabbing');
@@ -7116,6 +7108,7 @@ registry.SnippetSave = SnippetOptionWidget.extend({
});
this.trigger_up('request_save', {
reloadEditor: true,
+ invalidateSnippetCache: true,
onSuccess: async () => {
const defaultSnippetName = _.str.sprintf(_t("Custom %s"), this.data.snippetName);
const targetCopyEl = this.$target[0].cloneNode(true);
diff --git a/addons/web_editor/static/src/js/frontend/loader.js b/addons/web_editor/static/src/js/frontend/loader.js
index 0434715ddd9..419495aaeab 100644
--- a/addons/web_editor/static/src/js/frontend/loader.js
+++ b/addons/web_editor/static/src/js/frontend/loader.js
@@ -3,7 +3,7 @@ odoo.define('web_editor.loader', function (require) {
var ajax = require('web.ajax');
-let wysiwygPromise;
+let wysiwygPromises = {};
const exports = {};
@@ -19,9 +19,17 @@ exports.loadWysiwyg = loadWysiwyg;
* @param {object} options The wysiwyg options
*/
exports.createWysiwyg = async (parent, options, additionnalAssets = []) => {
+ const Wysiwyg = await getWysiwygClass(options, additionnalAssets);
+ return new Wysiwyg(parent, options);
+};
+
+async function getWysiwygClass(options, additionnalAssets = []) {
const wysiwygAlias = options.wysiwygAlias || 'web_editor.wysiwyg';
- if (!wysiwygPromise) {
- wysiwygPromise = new Promise(async (resolve) => {
+ if (!wysiwygPromises[wysiwygAlias]) {
+ wysiwygPromises[wysiwygAlias] = new Promise(async (resolve) => {
+ if (odoo.__DEBUG__.services[wysiwygAlias]) {
+ return resolve();
+ }
await loadWysiwyg(additionnalAssets);
// Wait the loading of the service and his dependencies (use string to
// avoid parsing of require function).
@@ -35,10 +43,10 @@ exports.createWysiwyg = async (parent, options, additionnalAssets = []) => {
resolve();
});
}
- await wysiwygPromise;
- const Wysiwyg = odoo.__DEBUG__.services[wysiwygAlias];
- return new Wysiwyg(parent, options);
-};
+ await wysiwygPromises[wysiwygAlias];
+ return odoo.__DEBUG__.services[wysiwygAlias];
+}
+exports.getWysiwygClass = getWysiwygClass;
exports.loadFromTextarea = async (parent, textarea, options) => {
var loading = textarea.nextElementSibling;
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js b/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js
index a395ab67e10..c6e8356399f 100644
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js
@@ -8,6 +8,7 @@ const {ColorpickerWidget} = require('web.Colorpicker');
const Widget = require('web.Widget');
const customColors = require('web_editor.custom_colors');
const weUtils = require('web_editor.utils');
+const compatibilityColorNames = ['primary', 'secondary', 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'success', 'info', 'warning', 'danger'];
const qweb = core.qweb;
@@ -53,7 +54,8 @@ const ColorPaletteWidget = Widget.extend({
*/
init: function (parent, options) {
this._super.apply(this, arguments);
- this.style = window.getComputedStyle(document.documentElement);
+ const editableDocument = options.editable ? options.editable.ownerDocument : document;
+ this.style = editableDocument.defaultView.getComputedStyle(editableDocument.documentElement);
this.options = _.extend({
selectedColor: false,
resetButton: true,
@@ -245,7 +247,7 @@ const ColorPaletteWidget = Widget.extend({
}
// Compute class colors
- const compatibilityColorNames = ['primary', 'secondary', 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'success', 'info', 'warning', 'danger'];
+
this.colorNames = [...compatibilityColorNames];
this.colorToColorNames = {};
this.el.querySelectorAll('button[data-color]:not(.o_custom_gradient_btn)').forEach(elem => {
@@ -258,7 +260,7 @@ const ColorPaletteWidget = Widget.extend({
if (isCCName) {
$color.find('.o_we_cc_preview_wrapper').addClass(`o_cc o_cc${colorName}`);
} else {
- $color.addClass(`bg-${colorName}`);
+ elem.style.backgroundColor = `var(--we-cp-${colorName})`;
}
this.colorNames.push(colorName);
if (!isCCName && !elem.classList.contains('d-none')) {
@@ -300,6 +302,15 @@ const ColorPaletteWidget = Widget.extend({
});
await this.colorPicker.appendTo(this.sections['custom-colors']);
}
+ // When the color palette is created outside its editing
+ // element, the theme preview might not reflect the styles
+ // of the editable. The parent widgets will update the styles
+ // accordingly.
+ if (!this.options.excluded.includes('theme')) {
+ this.trigger_up('update_color_previews', {
+ ccPreviewEls: this.el.querySelectorAll('.o_we_cc_preview_wrapper'),
+ });
+ }
return res;
},
/**
@@ -1017,5 +1028,6 @@ ColorPaletteWidget.loadDependencies = async function (rpcCapableObj) {
return {
ColorPaletteWidget: ColorPaletteWidget,
+ compatibilityColorNames: compatibilityColorNames,
};
});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/link.js b/addons/web_editor/static/src/js/wysiwyg/widgets/link.js
index 4f3895341d1..0e41c44de9d 100644
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/link.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/link.js
@@ -509,6 +509,9 @@ Link.getOrCreateLink = ({ containerNode, startNode } = {}) => {
let link = getInSelection(doc, 'a');
const $link = $(link);
const range = getDeepRange(containerNode, {splitText: true, select: true, correctTripleClick: true});
+ if (!range) {
+ return {};
+ }
const isContained = containerNode.contains(range.startContainer) && containerNode.contains(range.endContainer);
if (link && (!$link.has(range.startContainer).length || !$link.has(range.endContainer).length)) {
// Expand the current link to include the whole selection.
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/link_popover_widget.js b/addons/web_editor/static/src/js/wysiwyg/widgets/link_popover_widget.js
index 5311833e268..7622047821a 100644
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/link_popover_widget.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/link_popover_widget.js
@@ -49,12 +49,14 @@ const LinkPopoverWidget = Widget.extend({
message: _t("Link copied to clipboard."),
});
});
+ const targetWindow = this.target.ownerDocument.defaultView;
+ const popoverContainer = targetWindow.frameElement ? targetWindow.frameElement.parentElement : targetWindow.document.body;
// init tooltips & popovers
this.$('[data-toggle="tooltip"]').tooltip({
delay: 0,
placement: 'bottom',
- container: this.options.wysiwyg.odooEditor.document.body,
+ container: popoverContainer,
});
const tooltips = [];
for (const el of this.$('[data-toggle="tooltip"]').toArray()) {
@@ -73,7 +75,7 @@ const LinkPopoverWidget = Widget.extend({
// 5. Close when the user click somewhere on the page (not being the link or the popover content)
trigger: 'manual',
boundary: 'viewport',
- container: this.options.wysiwyg.odooEditor.document.body,
+ container: popoverContainer,
})
.on('show.bs.popover.link_popover', () => {
this.options.wysiwyg.odooEditor.observerUnactive('show.bs.popover');
@@ -267,7 +269,7 @@ LinkPopoverWidget.createFor = async function (parent, targetEl, options) {
return null;
}
const popoverWidget = new this(parent, targetEl, options);
- const wysiwyg = $('#wrapwrap').data('wysiwyg');
+ const wysiwyg = options.wysiwyg;
if (wysiwyg) {
wysiwyg.odooEditor.observerUnactive('LinkPopoverWidget');
}
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/link_tools.js b/addons/web_editor/static/src/js/wysiwyg/widgets/link_tools.js
index ce15875ddda..1680e1cd6ce 100644
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/link_tools.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/link_tools.js
@@ -321,8 +321,7 @@ const LinkTools = Link.extend({
const previewClasses = computeColorClasses(colorNames, 'bg-');
$colorPreview[0].classList.remove(...previewClasses);
if (isColorClass) {
- $colorPreview.addClass(`bg-${color.replace(prefix, '')}`);
- $colorPreview.css('background-color', '');
+ $colorPreview.css('background-color', `var(--we-cp-${color.replace(prefix, '')}`);
$colorPreview.css('background-image', '');
} else {
$colorPreview.css('background-color', isColorGradient(color) ? 'rgba(0, 0, 0, 0)' : color);
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
deleted file mode 100644
index 3cac0191254..00000000000
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
+++ /dev/null
@@ -1,1522 +0,0 @@
-odoo.define('wysiwyg.widgets.media', function (require) {
-'use strict';
-
-var concurrency = require('web.concurrency');
-var core = require('web.core');
-var Dialog = require('web.Dialog');
-var dom = require('web.dom');
-var fonts = require('wysiwyg.fonts');
-var utils = require('web.utils');
-var Widget = require('web.Widget');
-var session = require('web.session');
-const {removeOnImageChangeAttrs} = require('web_editor.image_processing');
-const {getCSSVariableValue, DEFAULT_PALETTE} = require('web_editor.utils');
-const { UploadProgressToast } = require('@web_editor/js/wysiwyg/widgets/upload_progress_toast');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-var MediaWidget = Widget.extend({
- xmlDependencies: ['/web_editor/static/src/xml/wysiwyg.xml'],
-
- /**
- * @constructor
- * @param {Element} media: the target Element for which we select a media
- * @param {Object} options: useful parameters such as res_id, res_model,
- * context, user_id, ...
- */
- init: function (parent, media, options) {
- this._super.apply(this, arguments);
- this.media = media;
- this.$media = $(media);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @todo comment
- */
- clear: function () {
- if (!this.media) {
- return;
- }
- this._clear();
- },
- /**
- * Saves the currently configured media on the target media.
- *
- * @abstract
- * @returns {Promise}
- */
- save: function () {},
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @abstract
- */
- _clear: function () {},
-});
-
-var SearchableMediaWidget = MediaWidget.extend({
- events: _.extend({}, MediaWidget.prototype.events || {}, {
- 'keydown .o_we_search': '_onSearchKeydown',
- 'input .o_we_search': '_onSearchInput',
- }),
-
- /**
- * @constructor
- */
- init: function () {
- this._super.apply(this, arguments);
- this._onSearchInput = _.debounce(this._onSearchInput, 500);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Finds and displays existing attachments related to the target media.
- *
- * @abstract
- * @param {string} needle: only return attachments matching this parameter
- * @returns {Promise}
- */
- search: function (needle) {},
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Renders thumbnails for the attachments.
- *
- * @abstract
- * @returns {Promise}
- */
- _renderThumbnails: function () {},
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _onSearchKeydown: function (ev) {
- // If the template contains a form that has only one input, the enter
- // will reload the page as the html 2.0 specify this behavior.
- if (ev.originalEvent && (ev.originalEvent.code === "Enter" || ev.originalEvent.key === "Enter")) {
- ev.preventDefault();
- }
- },
- /**
- * @private
- */
- _onSearchInput: async function (ev) {
- this.attachments = [];
- // Disable user interactions with attachments while updating results.
- this.$('.o_we_existing_attachments').css('pointer-events', 'none');
- await this.search($(ev.currentTarget).val() || '');
- await this._renderThumbnails();
- // Re-enable user interactions with attachments after updating results.
- this.$('.o_we_existing_attachments').css('pointer-events', '');
- },
-});
-
-/**
- * Let users choose a file, including uploading a new file in odoo.
- */
-var FileWidget = SearchableMediaWidget.extend({
- events: _.extend({}, SearchableMediaWidget.prototype.events || {}, {
- 'click .o_upload_media_button': '_onUploadButtonClick',
- 'change .o_file_input': '_onFileInputChange',
- 'click .o_upload_media_url_button': '_onUploadURLButtonClick',
- 'input .o_we_url_input': '_onURLInputChange',
- 'click .o_existing_attachment_cell': '_onAttachmentClick',
- 'click .o_existing_attachment_remove': '_onRemoveClick',
- 'click .o_load_more': '_onLoadMoreClick',
- }),
- existingAttachmentsTemplate: undefined,
-
- IMAGE_MIMETYPES: ['image/jpg', 'image/jpeg', 'image/jpe', 'image/png', 'image/svg+xml', 'image/gif'],
- IMAGE_EXTENSIONS: ['.jpg', '.jpeg', '.jpe', '.png', '.svg', '.gif'],
- NUMBER_OF_ATTACHMENTS_TO_DISPLAY: 30,
- MAX_DB_ATTACHMENTS: 5,
-
- /**
- * @constructor
- */
- init: function (parent, media, options) {
- this._super.apply(this, arguments);
- this._mutex = new concurrency.Mutex();
-
- this.numberOfAttachmentsToDisplay = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY;
-
- this.options = _.extend({
- mediaWidth: media && media.parentElement && $(media.parentElement).width(),
- useMediaLibrary: true,
- }, options || {});
-
- this.attachments = [];
- this.selectedAttachments = [];
- this.libraryMedia = [];
- this.selectedMedia = [];
-
- this._onUploadURLButtonClick = dom.makeAsyncHandler(this._onUploadURLButtonClick);
- },
- /**
- * @override
- */
- start: function () {
- var def = this._super.apply(this, arguments);
- var self = this;
- this.$urlInput = this.$('.o_we_url_input');
- this.$form = this.$('form');
- this.$fileInput = this.$('.o_file_input');
- this.$uploadButton = this.$('.o_upload_media_button');
- this.$addUrlButton = this.$('.o_upload_media_url_button');
- this.$urlSuccess = this.$('.o_we_url_success');
- this.$urlWarning = this.$('.o_we_url_warning');
- this.$urlError = this.$('.o_we_url_error');
- this.$errorText = this.$('.o_we_error_text');
-
- // If there is already an attachment on the target, select by default
- // that attachment if it is among the loaded images.
- var o = {
- url: null,
- alt: null,
- };
- if (this.$media.is('img')) {
- o.url = this.$media.attr('src');
- } else if (this.$media.is('a.o_image')) {
- o.url = this.$media.attr('href').replace(/[?].*/, '');
- o.id = +o.url.match(/\/web\/content\/(\d+)/, '')[1];
- }
-
- return this.search('').then(async () => {
- await this._renderThumbnails();
- if (o.url) {
- self._selectAttachement(_.find(self.attachments, function (attachment) {
- return o.url === attachment.image_src;
- }) || o);
- }
- return def;
- });
- },
- /**
- * @override
- */
- destroy() {
- if (this.uploader) {
- // Prevent uploader from being destroyed with call to super so it can linger
- this.uploader.setParent(null);
- this.uploader.close(2000);
- }
- this._super(...arguments);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Saves the currently selected image on the target media. If new files are
- * currently being added, delays the save until all files have been added.
- *
- * @override
- */
- save: function () {
- return this._mutex.exec(this._save.bind(this));
- },
- /**
- * @override
- */
- search: function (needle) {
- this.needle = needle;
- return this.fetchAttachments(this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY, 0);
- },
- /**
- * @param {Number} number - the number of attachments to fetch
- * @param {Number} offset - from which result to start fetching
- */
- fetchAttachments: function (number, offset) {
- return this._rpc({
- model: 'ir.attachment',
- method: 'search_read',
- args: [],
- kwargs: {
- domain: this._getAttachmentsDomain(this.needle),
- fields: ['name', 'mimetype', 'description', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height', 'original_id'],
- order: [{name: 'id', asc: false}],
- context: this.options.context,
- // Try to fetch first record of next page just to know whether there is a next page.
- limit: number + 1,
- offset: offset,
- },
- }).then(attachments => {
- this.attachments = this.attachments.slice();
- Array.prototype.splice.apply(this.attachments, [offset, attachments.length].concat(attachments));
- });
- },
- /**
- * Computes whether there is content to display in the template.
- */
- hasContent() {
- return this.attachments.length;
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- _clear: function () {
- this.media.className = this.media.className && this.media.className.replace(/(^|\s+)(o_image)(?=\s|$)/g, ' ');
- },
- /**
- * Returns the domain for attachments used in media dialog.
- * We look for attachments related to the current document. If there is a value for the model
- * field, it is used to search attachments, and the attachments from the current document are
- * filtered to display only user-created documents.
- * In the case of a wizard such as mail, we have the documents uploaded and those of the model
- *
- * @private
- * @params {string} needle
- * @returns {Array} "ir.attachment" odoo domain.
- */
- _getAttachmentsDomain: function (needle) {
- var domain = this.options.attachmentIDs && this.options.attachmentIDs.length ? ['|', ['id', 'in', this.options.attachmentIDs]] : [];
-
- var attachedDocumentDomain = [
- '&',
- ['res_model', '=', this.options.res_model],
- ['res_id', '=', this.options.res_id|0]
- ];
- // if the document is not yet created, do not see the documents of other users
- if (!this.options.res_id) {
- attachedDocumentDomain.unshift('&');
- attachedDocumentDomain.push(['create_uid', '=', this.options.user_id]);
- }
- if (this.options.data_res_model) {
- var relatedDomain = ['&',
- ['res_model', '=', this.options.data_res_model],
- ['res_id', '=', this.options.data_res_id|0]];
- if (!this.options.data_res_id) {
- relatedDomain.unshift('&');
- relatedDomain.push(['create_uid', '=', session.uid]);
- }
- domain = domain.concat(['|'], attachedDocumentDomain, relatedDomain);
- } else {
- domain = domain.concat(attachedDocumentDomain);
- }
- domain = ['|', ['public', '=', true]].concat(domain);
- domain = domain.concat(this.options.mimetypeDomain);
- if (needle && needle.length) {
- domain.push(['name', 'ilike', needle]);
- }
- if (!this.options.useMediaLibrary) {
- domain.push('|', ['url', '=', false], '!', ['url', '=ilike', '/web_editor/shape/%']);
- }
- domain.push('!', ['name', '=like', '%.crop']);
- domain.push('|', ['type', '=', 'binary'], '!', ['url', '=like', '/%/static/%']);
- return domain;
- },
- /**
- * @private
- */
- _highlightSelected: function () {
- var self = this;
- this.$('.o_existing_attachment_cell.o_we_attachment_selected').removeClass("o_we_attachment_selected");
- _.each(this.selectedAttachments, function (attachment) {
- self.$('.o_existing_attachment_cell[data-id=' + attachment.id + ']')
- .addClass("o_we_attachment_selected").css('display', '');
- });
- },
- /**
- * @private
- * @param {object} attachment
- */
- _handleNewAttachment: function (attachment) {
- this.attachments = this.attachments.filter(att => att.id !== attachment.id);
- this.attachments.unshift(attachment);
- this._renderThumbnails();
- this._selectAttachement(attachment);
- },
- /**
- * @private
- * @returns {Promise}
- */
- _loadMoreImages: function (forceSearch) {
- return this.fetchAttachments(10, this.numberOfAttachmentsToDisplay).then(() => {
- this.numberOfAttachmentsToDisplay += 10;
- if (!forceSearch) {
- this._renderThumbnails();
- return Promise.resolve();
- } else {
- return this.search(this.$('.o_we_search').val() || '');
- }
- });
- },
- /**
- * Renders the existing attachments and returns the result as a string.
- *
- * @param {Object[]} attachments
- * @returns {string}
- */
- _renderExisting: function (attachments) {
- return QWeb.render(this.existingAttachmentsTemplate, {
- attachments: attachments,
- widget: this,
- });
- },
- /**
- * @private
- */
- _renderThumbnails: function () {
- var attachments = this.attachments.slice(0, this.numberOfAttachmentsToDisplay);
-
- // Render menu & content
- this.$('.o_we_existing_attachments').replaceWith(
- this._renderExisting(attachments)
- );
-
- this._highlightSelected();
-
- // adapt load more
- this.$('.o_we_load_more').toggleClass('d-none', !this.hasContent());
- var noLoadMoreButton = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY >= this.attachments.length;
- var noMoreImgToLoad = this.numberOfAttachmentsToDisplay >= this.attachments.length;
- this.$('.o_load_done_msg').toggleClass('d-none', noLoadMoreButton || !noMoreImgToLoad);
- this.$('.o_load_more').toggleClass('d-none', noMoreImgToLoad);
- },
- /**
- * @private
- * @returns {Promise}
- */
- _save: async function () {
- // Create all media-library attachments.
- const toSave = Object.fromEntries(this.selectedMedia.map(media => [
- media.id, {
- query: media.query || '',
- is_dynamic_svg: !!media.isDynamicSVG,
- dynamic_colors: media.dynamicColors,
- }
- ]));
- let mediaAttachments = [];
- if (Object.keys(toSave).length !== 0) {
- mediaAttachments = await this._rpc({
- route: '/web_editor/save_library_media',
- params: {
- media: toSave,
- },
- });
- }
- const selected = this.selectedAttachments.concat(mediaAttachments).map(attachment => {
- // Color-customize dynamic SVGs with the theme colors
- if (attachment.image_src && attachment.image_src.startsWith('/web_editor/shape/')) {
- const colorCustomizedURL = new URL(attachment.image_src, window.location.origin);
- colorCustomizedURL.searchParams.forEach((value, key) => {
- const match = key.match(/^c([1-5])$/);
- if (match) {
- colorCustomizedURL.searchParams.set(key, getCSSVariableValue(`o-color-${match[1]}`))
- }
- })
- attachment.image_src = colorCustomizedURL.pathname + colorCustomizedURL.search;
- }
- return attachment;
- });
- if (this.options.multiImages) {
- return selected;
- }
-
- const img = selected[0];
- if (!img || !img.id || this.$media.attr('src') === img.image_src) {
- return this.media;
- }
-
- if (!img.public && !img.access_token) {
- await this._rpc({
- model: 'ir.attachment',
- method: 'generate_access_token',
- args: [[img.id]]
- }).then(function (access_token) {
- img.access_token = access_token[0];
- });
- }
- return this._renderMedia(img);
- },
- /**
- * @param {Object} img
- * @returns
- */
- _renderMedia: function (img) {
- if (img.image_src) {
- var src = img.image_src;
- if (!img.public && img.access_token) {
- src += _.str.sprintf('?access_token=%s', img.access_token);
- }
- if (!this.$media.is('img')) {
-
- // Note: by default the images receive the bootstrap opt-in
- // img-fluid class. We cannot make them all responsive
- // by design because of libraries and client databases img.
- this.$media = $('', {class: 'img-fluid o_we_custom_image'});
- this.media = this.$media[0];
- }
- this.$media.attr('src', src);
- } else {
- if (!this.$media.is('a')) {
- $('.note-control-selection').hide();
- this.$media = $('');
- this.media = this.$media[0];
- }
- var href = '/web/content/' + img.id + '?';
- if (!img.public && img.access_token) {
- href += _.str.sprintf('access_token=%s&', img.access_token);
- }
- href += 'unique=' + img.checksum + '&download=true';
- this.$media.attr('href', href);
- this.$media.addClass('o_image').attr('title', img.name);
- }
-
- this.$media.attr('alt', img.alt || img.description || '');
- var style = this.style;
- if (style) {
- this.$media.css(style);
- }
-
- // Remove image modification attributes
- removeOnImageChangeAttrs.forEach(attr => {
- delete this.media.dataset[attr];
- });
- // Add mimetype for documents
- if (!img.image_src) {
- this.media.dataset.mimetype = img.mimetype;
- }
- this.media.classList.remove('o_modified_image_to_save');
- this.$media.trigger('image_changed');
- return this.media;
- },
- /**
- * @param {object} attachment
- * @param {boolean} [save=true] to save the given attachment in the DOM and
- * and to close the media dialog
- * @private
- */
- _selectAttachement: function (attachment, save, {type = 'attachment'} = {}) {
- const possibleProps = {
- 'attachment': 'selectedAttachments',
- 'media': 'selectedMedia'
- };
- const prop = possibleProps[type];
- if (this.options.multiImages) {
- // if the clicked attachment is already selected then unselect it
- // unless it was a save request (then keep the current selection)
- const index = this[prop].indexOf(attachment);
- if (index !== -1) {
- if (!save) {
- this[prop].splice(index, 1);
- }
- } else {
- // if the clicked attachment is not selected, add it to selected
- this[prop].push(attachment);
- }
- } else {
- Object.values(possibleProps).forEach(prop => {
- this[prop] = [];
- });
- // select the clicked attachment
- this[prop] = [attachment];
- }
- this._highlightSelected();
- if (save) {
- this.trigger_up('save_request');
- }
- },
- /**
- * Updates the add by URL UI.
- *
- * @private
- * @param {boolean} emptyValue
- * @param {boolean} isURL
- * @param {boolean} isImage
- */
- _updateAddUrlUi: function (emptyValue, isURL, isImage) {
- this.$addUrlButton.toggleClass('btn-secondary', emptyValue)
- .toggleClass('btn-primary', !emptyValue)
- .prop('disabled', !isURL);
- this.$urlSuccess.toggleClass('d-none', !isURL);
- this.$urlError.toggleClass('d-none', emptyValue || isURL);
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _onAttachmentClick: function (ev) {
- const attachment = ev.currentTarget;
- const {id: attachmentID, mediaId} = attachment.dataset;
- if (attachmentID) {
- const attachment = this.attachments.find(attachment => attachment.id === parseInt(attachmentID));
- this._selectAttachement(attachment, !this.options.multiImages);
- } else if (mediaId) {
- const media = this.libraryMedia.find(media => media.id === parseInt(mediaId));
- this._selectAttachement(media, !this.options.multiImages, {type: 'media'});
- }
- },
- /**
- * Handles change of the file input: create attachments with the new files
- * and open the Preview dialog for each of them. Locks the save button until
- * all new files have been processed.
- *
- * @private
- * @returns {Promise}
- */
- _onFileInputChange: function () {
- return this._mutex.exec(this._addData.bind(this));
- },
- /**
- * Uploads the files that are currently selected on the file input, which
- * creates new attachments. Then inserts them on the media dialog and
- * selects them. If multiImages is not set, also triggers up the
- * save_request event to insert the attachment in the DOM.
- *
- * @private
- * @returns {Promise}
- */
- async _addData() {
- let files = this.$fileInput[0].files;
- if (!files.length) {
- // Case if the input is emptied, return resolved promise
- return;
- }
-
- const uploadMutex = new concurrency.Mutex();
-
- // Upload the smallest file first to block the user the least possible.
- files = _.sortBy(files, 'size');
- await this._setUpProgressToast(files);
- _.each(files, (file, index) => {
- // Upload one file at a time: no need to parallel as upload is
- // limited by bandwidth.
- uploadMutex.exec(async () => {
- const dataURL = await utils.getDataURLFromFile(file);
- const attachment = await this.uploader.rpcShowProgress({
- route: '/web_editor/attachment/add_data',
- params: {
- 'name': file.name,
- 'data': dataURL.split(',')[1],
- 'res_id': this.options.res_id,
- 'res_model': this.options.res_model,
- 'is_image': this.widgetType === 'image',
- 'width': 0,
- 'quality': 0,
- }
- }, index);
- if (!attachment.error) {
- this._handleNewAttachment(attachment);
- }
- });
- });
-
- return uploadMutex.getUnlockedDef().then(() => {
- if (!this.uploader.hasError) {
- this.uploader.close(3000);
- }
- if (!this.options.multiImages && !this.noSave) {
- this.trigger_up('save_request');
- }
- this.noSave = false;
- });
- },
- /**
- * @private
- */
- _onRemoveClick: function (ev) {
- var self = this;
- ev.stopPropagation();
- Dialog.confirm(this, _t("Are you sure you want to delete this file ?"), {
- confirm_callback: function () {
- var $a = $(ev.currentTarget).closest('.o_existing_attachment_cell');
- var id = parseInt($a.data('id'), 10);
- var attachment = _.findWhere(self.attachments, {id: id});
- return self._rpc({
- route: '/web_editor/attachment/remove',
- params: {
- ids: [id],
- },
- }).then(function (prevented) {
- if (_.isEmpty(prevented)) {
- self.attachments = _.without(self.attachments, attachment);
- self.attachments.filter(at => at.original_id[0] === attachment.id).forEach(at => delete at.original_id);
- if (!self.attachments.length) {
- self._renderThumbnails(); //render the message and image if empty
- } else {
- $a.closest('.o_existing_attachment_cell').remove();
- }
- return;
- }
- self.$errorText.replaceWith(QWeb.render('wysiwyg.widgets.image.existing.error', {
- views: prevented[id],
- widget: self,
- }));
- });
- }
- });
- },
- /**
- * @private
- */
- _onURLInputChange: function () {
- const inputValue = this.$urlInput.val().split('?')[0];
- var emptyValue = (inputValue === '');
-
- var isURL = /^.+\..+$/.test(inputValue); // TODO improve
- var isImage = _.any(this.IMAGE_EXTENSIONS, function (format) {
- return inputValue.endsWith(format);
- });
-
- this._updateAddUrlUi(emptyValue, isURL, isImage);
- },
- /**
- * @private
- */
- _onUploadButtonClick: function () {
- this.$fileInput.click();
- },
- /**
- * @private
- */
- _onUploadURLButtonClick: function () {
- if (this.$urlInput.is('.o_we_horizontal_collapse')) {
- this.$urlInput.removeClass('o_we_horizontal_collapse');
- this.$addUrlButton.attr('disabled', 'disabled');
- return;
- }
- return this._mutex.exec(this._addUrl.bind(this));
- },
- /**
- * @private
- * @returns {Promise}
- */
- _addUrl: function () {
- var self = this;
- return this._rpc({
- route: '/web_editor/attachment/add_url',
- params: {
- 'url': this.$urlInput.val(),
- 'res_id': this.options.res_id,
- 'res_model': this.options.res_model,
- },
- }).then(function (attachment) {
- self.$urlInput.val('');
- self._onURLInputChange();
- self._handleNewAttachment(attachment);
- if (!self.options.multiImages) {
- self.trigger_up('save_request');
- }
- });
- },
- /**
- * @private
- */
- _onLoadMoreClick: function () {
- this._loadMoreImages();
- },
- /**
- * @override
- */
- _onSearchInput: function () {
- this.attachments = [];
- this.numberOfAttachmentsToDisplay = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY;
- this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Sets up a progress bar for every file being uploaded in a toast.
- *
- * @private
- * @param {Object[]} files
- */
- _setUpProgressToast: async function (files) {
- this.uploader = new UploadProgressToast(this, files);
- await this.uploader.appendTo(document.body);
- },
-});
-
-/**
- * Let users choose an image, including uploading a new image in odoo.
- */
-var ImageWidget = FileWidget.extend({
- template: 'wysiwyg.widgets.image',
- existingAttachmentsTemplate: 'wysiwyg.widgets.image.existing.attachments',
- events: Object.assign({}, FileWidget.prototype.events, {
- 'change input.o_we_show_optimized': '_onShowOptimizedChange',
- 'change .o_we_search_select': '_onSearchSelect',
- }),
- MIN_ROW_HEIGHT: 128,
-
- /**
- * @constructor
- */
- init: function (parent, media, options) {
- this.searchService = 'all';
- this.widgetType = 'image';
- options = _.extend({
- accept: 'image/*',
- mimetypeDomain: [['mimetype', 'in', this.IMAGE_MIMETYPES]],
- }, options || {});
- // Binding so we can add/remove it as an addEventListener
- this._onAttachmentImageLoad = this._onAttachmentImageLoad.bind(this);
- this._super(parent, media, options);
- },
- /**
- * @override
- */
- start: async function () {
- await this._super(...arguments);
- this.el.addEventListener('load', this._onAttachmentImageLoad, true);
- },
- /**
- * @override
- */
- destroy: function () {
- this.el.removeEventListener('load', this._onAttachmentImageLoad, true);
- return this._super(...arguments);
- },
- /**
- * @override
- */
- async fetchAttachments(number, offset) {
- if (this.needle && this.searchService !== 'database') {
- number = this.MAX_DB_ATTACHMENTS;
- offset = 0;
- }
- const result = await this._super(number, offset);
- // Color-substitution for dynamic SVG attachment
- const primaryColors = {};
- for (let color = 1; color <= 5; color++) {
- primaryColors[color] = getCSSVariableValue('o-color-' + color);
- }
- this.attachments.forEach(attachment => {
- if (attachment.image_src.startsWith('/')) {
- const newURL = new URL(attachment.image_src, window.location.origin);
- // Set the main colors of dynamic SVGs to o-color-1~5
- if (attachment.image_src.startsWith('/web_editor/shape/')) {
- newURL.searchParams.forEach((value, key) => {
- const match = key.match(/^c([1-5])$/);
- if (match) {
- newURL.searchParams.set(key, primaryColors[match[1]]);
- }
- })
- } else {
- // Set height so that db images load faster
- newURL.searchParams.set('height', 2 * this.MIN_ROW_HEIGHT);
- }
- attachment.thumbnail_src = newURL.pathname + newURL.search;
- }
- });
- if (this.needle && this.options.useMediaLibrary) {
- try {
- const response = await this._rpc({
- route: '/web_editor/media_library_search',
- params: {
- 'query': this.needle,
- 'offset': this.libraryMedia.length,
- },
- });
- const newMedia = response.media;
- this.nbMediaResults = response.results;
- this.libraryMedia.push(...newMedia);
- } catch (_e) {
- // Either API endpoint doesn't exist or is misconfigured.
- console.error(`Couldn't reach API endpoint.`);
- }
- }
- return result;
- },
- /**
- * @override
- */
- hasContent() {
- if (this.searchService === 'all') {
- return this._super(...arguments) || this.libraryMedia.length;
- } else if (this.searchService === 'media-library') {
- return !!this.libraryMedia.length;
- }
- return this._super(...arguments);
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- _updateAddUrlUi: function (emptyValue, isURL, isImage) {
- this._super.apply(this, arguments);
- const warning = isURL && !isImage;
- this.$urlWarning.toggleClass('d-none', !warning);
- this.$addUrlButton.prop('disabled', warning || !isURL);
- this.$urlSuccess.toggleClass('d-none', warning || !isURL);
- },
- /**
- * @override
- */
- _renderThumbnails: function () {
- const alreadyLoaded = this.$('.o_existing_attachment_cell[data-loaded="true"]');
- this._super(...arguments);
- // Hide images until they're loaded
- this.$('.o_existing_attachment_cell').addClass('d-none');
- // Replace images that had been previously loaded if any to prevent scroll resetting to top
- alreadyLoaded.each((index, el) => {
- const toReplace = this.$(`.o_existing_attachment_cell[data-id="${el.dataset.id}"], .o_existing_attachment_cell[data-media-id="${el.dataset.mediaId}"]`);
- if (toReplace.length) {
- toReplace.replaceWith(el);
- }
- });
- this._toggleOptimized(this.$('input.o_we_show_optimized')[0].checked);
- // Placeholders have a 3:2 aspect ratio like most photos.
- const placeholderWidth = 3 / 2 * this.MIN_ROW_HEIGHT;
- this.$('.o_we_attachment_placeholder').css({
- flexGrow: placeholderWidth,
- flexBasis: placeholderWidth,
- });
- if (this.needle && ['media-library', 'all'].includes(this.searchService)) {
- const noMoreImgToLoad = this.libraryMedia.length === this.nbMediaResults;
- const noLoadMoreButton = noMoreImgToLoad && this.libraryMedia.length <= 15;
- this.$('.o_load_done_msg').toggleClass('d-none', noLoadMoreButton || !noMoreImgToLoad);
- this.$('.o_load_more').toggleClass('d-none', noMoreImgToLoad);
- }
- },
- /**
- * @override
- */
- _renderExisting: function (attachments) {
- if (this.needle && this.searchService !== 'database') {
- attachments = attachments.slice(0, this.MAX_DB_ATTACHMENTS);
- }
- return QWeb.render(this.existingAttachmentsTemplate, {
- attachments: attachments,
- libraryMedia: this.libraryMedia,
- widget: this,
- });
- },
- /**
- * @private
- *
- * @param {boolean} value whether to toggle optimized attachments on or off
- */
- _toggleOptimized: function (value) {
- this.$('.o_we_attachment_optimized').each((i, cell) => cell.style.setProperty('display', value ? null : 'none', 'important'));
- },
- /**
- * @override
- */
- _highlightSelected: function () {
- this._super(...arguments);
- this.selectedMedia.forEach(media => {
- this.$(`.o_existing_attachment_cell[data-media-id=${media.id}]`)
- .addClass("o_we_attachment_selected");
- });
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- _onAttachmentImageLoad: async function (ev) {
- const img = ev.target;
- const cell = img.closest('.o_existing_attachment_cell');
- if (!cell) {
- return;
- }
- if (cell.dataset.mediaId && !img.src.startsWith('blob')) {
- const mediaUrl = img.src;
- try {
- const response = await fetch(mediaUrl);
- if (response.headers.get('content-type') === 'image/svg+xml') {
- let svg = await response.text();
- const fileName = mediaUrl.split('/').pop();
- const dynamicColors = {};
- const combinedColorsRegex = new RegExp(Object.values(DEFAULT_PALETTE).join('|'), 'gi');
- svg = svg.replace(combinedColorsRegex, match => {
- const colorId = Object.keys(DEFAULT_PALETTE).find(key => DEFAULT_PALETTE[key] === match.toUpperCase());
- const colorKey = 'c' + colorId
- dynamicColors[colorKey] = getCSSVariableValue('o-color-' + colorId);
- return dynamicColors[colorKey];
- });
- if (Object.keys(dynamicColors).length) {
- const file = new File([svg], fileName, {
- type: "image/svg+xml",
- });
- img.src = URL.createObjectURL(file);
- const media = this.libraryMedia.find(media => media.id === parseInt(cell.dataset.mediaId));
- if (media) {
- media.isDynamicSVG = true;
- media.dynamicColors = dynamicColors;
- }
- // We changed the src: wait for the next load event to do the styling
- return;
- }
- }
- } catch (_e) {
- console.error('CORS is misconfigured on the API server, image will be treated as non-dynamic.');
- }
- }
- let aspectRatio = img.naturalWidth / img.naturalHeight;
- // Special case for SVGs with no instrinsic sizes on firefox
- // See https://github.com/whatwg/html/issues/3510#issuecomment-369982529
- if (img.naturalHeight === 0) {
- img.width = 1000;
- // Position fixed so that the image doesn't affect layout while rendering
- img.style.position = 'fixed';
- // Make invisible so the image doesn't briefly appear on the screen
- img.style.opacity = '0';
- // Image needs to be in the DOM for dimensions to be correct after render
- const originalParent = img.parentElement;
- document.body.appendChild(img);
-
- aspectRatio = img.width / img.height;
- originalParent.appendChild(img);
- img.removeAttribute('width');
- img.style.removeProperty('position');
- img.style.removeProperty('opacity');
- }
- const width = aspectRatio * this.MIN_ROW_HEIGHT;
- cell.style.flexGrow = width;
- cell.style.flexBasis = `${width}px`;
- cell.classList.remove('d-none');
- cell.classList.add('d-flex');
- cell.dataset.loaded = 'true';
- },
- /**
- * @override
- */
- _onShowOptimizedChange: function (ev) {
- this._toggleOptimized(ev.target.checked);
- },
- /**
- * @override
- */
- _onSearchSelect: function (ev) {
- const {value} = ev.target;
- this.searchService = value;
- this.$('.o_we_search').trigger('input');
- },
- /**
- * @private
- */
- _onSearchInput: function (ev) {
- this.libraryMedia = [];
- this._super(...arguments);
- this.$('.o_we_search_select').removeClass('d-none');
- },
- /**
- * @override
- */
- _clear: function (type) {
- // Not calling _super: we don't want to call the document widget's _clear method on images
- var allImgClasses = /(^|\s+)(img|img-\S*|o_we_custom_image|rounded-circle|rounded|thumbnail|shadow)(?=\s|$)/g;
- this.media.className = this.media.className && this.media.className.replace(allImgClasses, ' ');
- },
-});
-
-
-/**
- * Let users choose a document, including uploading a new document in odoo.
- */
-var DocumentWidget = FileWidget.extend({
- template: 'wysiwyg.widgets.document',
- existingAttachmentsTemplate: 'wysiwyg.widgets.document.existing.attachments',
-
- /**
- * @constructor
- */
- init: function (parent, media, options) {
- options = _.extend({
- accept: '*/*',
- mimetypeDomain: [['mimetype', 'not in', this.IMAGE_MIMETYPES]],
- }, options || {});
- this._super(parent, media, options);
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- _getAttachmentsDomain: function (needle) {
- var domain = this._super.apply(this, arguments);
- // The assets should not be part of the documents.
- // All assets begin with '/web/assets/', see _get_asset_template_url().
- return ['&', '|', ['url', '=', null], '!', ['url', '=like', '/web/assets/%']].concat(domain);
- },
-});
-
-/**
- * Let users choose a font awesome icon, support all font awesome loaded in the
- * css files.
- */
-var IconWidget = SearchableMediaWidget.extend({
- template: 'wysiwyg.widgets.font-icons',
- events: _.extend({}, SearchableMediaWidget.prototype.events || {}, {
- 'click .font-icons-icon': '_onIconClick',
- }),
-
- /**
- * @constructor
- */
- init: function (parent, media) {
- this._super.apply(this, arguments);
-
- fonts.computeFonts();
- this.iconsParser = fonts.fontIcons;
- this.alias = _.flatten(_.map(this.iconsParser, function (data) {
- return data.alias;
- }));
- },
- /**
- * @override
- */
- start: function () {
- this.$icons = this.$('.font-icons-icon');
- var classes = (this.media && this.media.className || '').split(/\s+/);
- for (var i = 0; i < classes.length; i++) {
- var cls = classes[i];
- if (_.contains(this.alias, cls)) {
- this.selectedIcon = cls;
- this.initialIcon = cls;
- this._highlightSelectedIcon();
- }
- }
-
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- save: function () {
- var style = this.$media.attr('style') || '';
- var iconFont = this._getFont(this.selectedIcon) || {base: 'fa', font: ''};
- if (!this.$media.is('span, i')) {
- var $span = $('');
- $span.data(this.$media.data());
- this.$media = $span;
- this.media = this.$media[0];
- style = style.replace(/\s*width:[^;]+/, '');
- }
- this.$media.removeClass(this.initialIcon).addClass([iconFont.base, iconFont.font]);
- this.$media.attr('style', style || null);
- return Promise.resolve(this.media);
- },
- /**
- * @override
- */
- search: function (needle) {
- var iconsParser = this.iconsParser;
- if (needle && needle.length) {
- iconsParser = [];
- _.filter(this.iconsParser, function (data) {
- var cssData = _.filter(data.cssData, function (cssData) {
- return _.find(cssData.names, function (alias) {
- return alias.indexOf(needle) >= 0;
- });
- });
- if (cssData.length) {
- iconsParser.push({
- base: data.base,
- cssData: cssData,
- });
- }
- });
- }
- this.$('div.font-icons-icons').html(
- QWeb.render('wysiwyg.widgets.font-icons.icons', {iconsParser: iconsParser, widget: this})
- );
- return Promise.resolve();
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- _clear: function () {
- var allFaClasses = /(^|\s)(fa|(text-|bg-|fa-)\S*|rounded-circle|rounded|thumbnail|shadow)(?=\s|$)/g;
- this.media.className = this.media.className && this.media.className.replace(allFaClasses, ' ');
- },
- /**
- * @private
- */
- _getFont: function (classNames) {
- if (!(classNames instanceof Array)) {
- classNames = (classNames || "").split(/\s+/);
- }
- var fontIcon, cssData;
- for (var k = 0; k < this.iconsParser.length; k++) {
- fontIcon = this.iconsParser[k];
- for (var s = 0; s < fontIcon.cssData.length; s++) {
- cssData = fontIcon.cssData[s];
- if (_.intersection(classNames, cssData.names).length) {
- return {
- base: fontIcon.base,
- parser: fontIcon.parser,
- font: cssData.names[0],
- };
- }
- }
- }
- return null;
- },
- /**
- * @private
- */
- _highlightSelectedIcon: function () {
- var self = this;
- this.$icons.removeClass('o_we_attachment_selected');
- this.$icons.filter(function (i, el) {
- return _.contains($(el).data('alias').split(','), self.selectedIcon);
- }).addClass('o_we_attachment_selected');
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _onIconClick: function (ev) {
- ev.preventDefault();
- ev.stopPropagation();
-
- this.selectedIcon = $(ev.currentTarget).data('id');
- this._highlightSelectedIcon();
- this.trigger_up('save_request');
- },
-});
-
-/**
- * Let users choose a video, support embed iframe.
- */
-var VideoWidget = MediaWidget.extend({
- template: 'wysiwyg.widgets.video',
- events: _.extend({}, MediaWidget.prototype.events || {}, {
- 'change .o_video_dialog_options input': '_onUpdateVideoOption',
- 'input textarea#o_video_text': '_onVideoCodeInput',
- 'change textarea#o_video_text': '_onVideoCodeChange',
- 'click .o_sample_video': '_onSampleVideoClick',
- }),
-
- /**
- * @constructor
- */
- init: function (parent, media, options) {
- this._super.apply(this, arguments);
- this.isForBgVideo = !!options.isForBgVideo;
- this._onVideoCodeInput = _.debounce(this._onVideoCodeInput, 1000);
- // list of videoIds from vimeo.
- this._vimeoPreviewIds = options.vimeoPreviewIds;
- },
- /**
- * @override
- */
- start: async function () {
- const _super = this._super.bind(this);
- this.$content = this.$('.o_video_dialog_iframe');
-
- if (this.media) {
- var $media = $(this.media);
- var src = $media.data('oe-expression') || $media.data('src') || ($media.is('iframe') ? $media.attr('src') : '') || '';
- this.$('textarea#o_video_text').val(src);
-
- this.$('input#o_video_autoplay').prop('checked', src.indexOf('autoplay=1') >= 0);
- this.$('input#o_video_hide_controls').prop('checked', src.indexOf('controls=0') >= 0);
- this.$('input#o_video_loop').prop('checked', src.indexOf('loop=1') >= 0);
- this.$('input#o_video_hide_fullscreen').prop('checked', src.indexOf('fs=0') >= 0);
- this.$('input#o_video_hide_yt_logo').prop('checked', src.indexOf('modestbranding=1') >= 0);
- this.$('input#o_video_hide_dm_logo').prop('checked', src.indexOf('ui-logo=0') >= 0);
- this.$('input#o_video_hide_dm_share').prop('checked', src.indexOf('sharing-enable=0') >= 0);
-
- await this._updateVideo();
- }
-
- // loads the thumbnail of vimeo video previews.
- this.$('.o_sample_video').each((index, node) => {
- const $node = $(node);
- const videoId = $node.attr('data-vimeo');
- if (!videoId) {
- return;
- }
- fetch(`https://vimeo.com/api/oembed.json?url=http%3A//vimeo.com/${videoId}`)
- .then(response=>response.json())
- .then((response) => {
- $node.append($('', {
- src: response.thumbnail_url,
- class: 'mw-100 mh-100 p-1',
- }));
- });
- });
-
- return _super(...arguments);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- save: async function () {
- await this._updateVideo();
- const videoSrc = this.$content.attr('src');
- if (this.isForBgVideo) {
- return Promise.resolve({bgVideoSrc: videoSrc});
- }
- if (this.$('.o_video_dialog_iframe').is('iframe') && videoSrc) {
- this.$media = this.getWrappedIframe(videoSrc);
- this.media = this.$media[0];
- }
- return Promise.resolve(this.media);
- },
-
- /**
- * Get an iframe wrapped for the website builder.
- *
- * @param {string} src The video url.
- */
- getWrappedIframe: function (src) {
- return $(
- '
' +
- '
' +
- '
' +
- '' +
- '
'
- );
- },
- /**
- * Creates a video node according to the given URL and options. If not
- * possible, returns an error code.
- *
- * @param {string} url
- * @param {Object} options
- * @returns {Object}
- * $video -> the created video jQuery node
- * type -> the type of the created video
- * errorCode -> if defined, either '0' for invalid URL or '1' for
- * unsupported video provider
- */
- createVideoNode: async function (url, options) {
- options = options || {};
- const videoData = await this._getVideoURLData(url, options);
- if (videoData.error) {
- return {errorCode: 0};
- }
- if (!videoData.platform) {
- return {errorCode: 1};
- }
- const $video = $('
-
-
-
- Setup Unsplash to access royalty free photos.
-
-
-
-
-
- Search is temporarily unavailable
-
-
- The max number of searches is exceeded. Please retry in an hour or extend to a better account.
-
-
-
-
- Unauthorized Key
-
-
- Please check your Unsplash access key and application ID.
-
-
-
-
-
- Something went wrong
-
-
- Please check your internet connection or contact administrator.
-
-
-
-
-
-
-
diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py
index f512f404f5a..4e3a07ab426 100644
--- a/addons/website/__manifest__.py
+++ b/addons/website/__manifest__.py
@@ -33,8 +33,6 @@
'data/digest_data.xml',
'views/assets.xml',
'views/website_templates.xml',
- 'views/website_navbar_templates.xml',
- 'views/website_configurator_templates.xml',
'views/snippets/snippets.xml',
'views/snippets/s_title.xml',
'views/snippets/s_cover.xml',
@@ -120,16 +118,12 @@
('replace', 'web/static/src/legacy/js/public/public_root_instance.js', 'website/static/src/js/content/website_root_instance.js'),
'website/static/src/scss/website.scss',
'website/static/src/scss/website.ui.scss',
- 'website/static/src/scss/website.navbar.scss',
- 'website/static/src/scss/website.navbar.mobile.scss',
'website/static/src/js/utils.js',
'website/static/src/js/content/website_root.js',
'website/static/src/js/widgets/dialog.js',
- 'website/static/src/js/widgets/fullscreen_indication.js',
'website/static/src/js/content/compatibility.js',
'website/static/src/js/content/menu.js',
'website/static/src/js/content/snippets.animation.js',
- 'website/static/src/js/menu/navbar.js',
'website/static/src/js/show_password.js',
'website/static/src/js/post_link.js',
'website/static/src/js/plausible.js',
@@ -138,11 +132,13 @@
'web.assets_frontend_minimal': [
'website/static/src/js/content/inject_dom.js',
'website/static/src/js/content/auto_hide_menu.js',
+ 'website/static/src/js/content/redirect.js',
],
'web.assets_frontend_lazy': [
# Remove assets_frontend_minimal
('remove', 'website/static/src/js/content/inject_dom.js'),
('remove', 'website/static/src/js/content/auto_hide_menu.js'),
+ ('remove', 'website/static/src/js/content/redirect.js'),
],
'web._assets_primary_variables': [
'website/static/src/scss/primary_variables.scss',
@@ -158,6 +154,8 @@
'website/static/tests/tours/**/*',
],
'web.assets_backend': [
+ ('include', 'website.assets_editor'),
+ 'website/static/src/scss/color_palettes.scss',
'website/static/src/scss/view_hierarchy.scss',
'website/static/src/scss/website.backend.scss',
'website/static/src/scss/website_visitor_views.scss',
@@ -169,11 +167,25 @@
'website/static/src/js/widget_iframe.js',
'website/static/src/js/theme_preview_kanban.js',
'website/static/src/js/theme_preview_form.js',
+ 'website/static/src/client_actions/*/*.js',
+ 'website/static/src/client_actions/*/*.scss',
+ 'website/static/src/components/fields/fields.js',
+ 'website/static/src/components/fullscreen_indication/fullscreen_indication.js',
+ 'website/static/src/components/fullscreen_indication/fullscreen_indication.scss',
+ 'website/static/src/services/website_service.js',
+ 'website/static/src/js/utils.js',
+ 'website/static/src/js/website_page_list.js',
+ ],
+ 'web.assets_common': [
+ 'website/static/src/js/tours/tour_utils.js',
],
'web.qunit_suite_tests': [
'website/static/tests/dashboard_tests.js',
'website/static/tests/website_tests.js',
],
+ 'web.tests_assets': [
+ 'website/static/tests/website_service_mock.js',
+ ],
'web._assets_frontend_helpers': [
('prepend', 'website/static/src/scss/bootstrap_overridden.scss'),
],
@@ -212,34 +224,44 @@
'website/static/src/snippets/s_process_steps/options.js',
'website/static/src/js/editor/wysiwyg.js',
'website/static/src/js/editor/widget_link.js',
- 'website/static/src/js/widgets/media.js',
'website/static/src/js/widgets/link_popover_widget.js',
],
+ 'web_editor.assets_media_dialog': [
+ 'website/static/src/components/media_dialog/image_selector.js',
+ ],
'website.assets_editor': [
('include', 'web._assets_helpers'),
'web/static/lib/bootstrap/scss/_variables.scss',
- 'web/static/src/legacy/scss/ace.scss',
- 'website/static/src/scss/website.editor.ui.scss',
- 'website/static/src/scss/website.theme_install.scss',
+ 'website/static/src/components/ace_editor/ace_editor.js',
+ 'website/static/src/components/ace_editor/ace_editor.scss',
+ 'website/static/src/components/dialog/*.js',
+ 'website/static/src/components/dialog/*.scss',
+ 'website/static/src/components/editor/editor.js',
+ 'website/static/src/components/editor/editor.scss',
+ 'website/static/src/components/navbar/navbar.js',
+ 'website/static/src/components/switch/switch.js',
+ 'website/static/src/components/switch/switch.scss',
+ 'website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.js',
+ 'website/static/src/components/wysiwyg_adapter/page_options.js',
+ 'website/static/src/components/translator/translator.js',
+ 'website/static/src/components/translator/translator.scss',
'website/static/src/js/form_editor_registry.js',
- 'website/static/src/js/menu/content.js',
- 'website/static/src/js/menu/customize.js',
- 'website/static/src/js/menu/debug_menu.js',
- 'website/static/src/js/menu/edit.js',
- 'website/static/src/js/menu/mobile_view.js',
- 'website/static/src/js/menu/new_content.js',
- 'website/static/src/js/menu/seo.js',
- 'website/static/src/js/menu/translate.js',
- 'website/static/src/js/set_view_track.js',
+ 'website/static/src/js/new_content_form.js',
'website/static/src/js/tours/homepage.js',
- 'website/static/src/js/tours/tour_utils.js',
- 'website/static/src/js/widgets/ace.js',
+ 'website/static/src/systray_items/*.js',
+ 'website/static/src/systray_items/*.scss',
],
'web.assets_qweb': [
+ 'website/static/src/client_actions/*/*.xml',
+ 'website/static/src/components/dialog/*.xml',
+ 'website/static/src/components/editor/editor.xml',
+ 'website/static/src/components/fields/fields.xml',
+ 'website/static/src/components/navbar/navbar.xml',
+ 'website/static/src/components/translator/*.xml',
+ 'website/static/src/systray_items/*.xml',
'website/static/src/xml/website.backend.xml',
'website/static/src/xml/website_widget.xml',
'website/static/src/xml/theme_preview.xml',
- 'website/static/src/components/configurator/configurator.xml',
],
'website.test_bundle': [
'/web/static/lib/qweb/qweb2.js',
@@ -250,15 +272,6 @@
'http://test.external.link/javascript2.js',
'http://test.external.link/style2.css',
],
- 'website.website_configurator_assets_js': [
- '/website/static/src/components/configurator/configurator.js',
- ],
- 'website.website_configurator_assets_scss': [
- ('include', 'web.assets_frontend'),
- '/website/static/src/scss/configurator.scss',
- '/website/static/src/scss/website.theme_install.scss',
- '/website/static/src/scss/website.wysiwyg.scss'
- ]
},
'license': 'LGPL-3',
}
diff --git a/addons/website/controllers/backend.py b/addons/website/controllers/backend.py
index 2022acc97a7..c5cf4822c06 100644
--- a/addons/website/controllers/backend.py
+++ b/addons/website/controllers/backend.py
@@ -41,6 +41,10 @@ class WebsiteBackend(http.Controller):
dashboard_data['dashboards']['plausible_share_url'] = current_website._get_plausible_share_url()
return dashboard_data
+ @http.route('/website/iframefallback', type="http", auth='user', website=True)
+ def get_iframe_fallback(self):
+ return request.render('website.iframefallback')
+
@http.route('/website/dashboard/set_ga_data', type='json', auth='user')
def website_set_ga_data(self, website_id, ga_client_id, ga_analytics_key):
if not request.env.user.has_group('base.group_system'):
diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py
index 0a76e721883..b807760f43e 100644
--- a/addons/website/controllers/main.py
+++ b/addons/website/controllers/main.py
@@ -255,7 +255,10 @@ class Website(Home):
return request.redirect('/')
if request.env.lang != website_id.default_lang_id.code:
return request.redirect('/%s%s' % (website_id.default_lang_id.url_code, request.httprequest.path))
- return request.render('website.website_configurator')
+ action_url = '/web#action=website.website_configurator&menu_id=%s' % request.env.ref('website.menu_website_configuration').id
+ if step > 1:
+ action_url += '&step=' + str(step)
+ return request.redirect(action_url)
@http.route(['/website/social/'], type='http', auth="public", website=True, sitemap=False)
def social(self, social, **kwargs):
@@ -303,6 +306,16 @@ class Website(Home):
]
}
+ @http.route('/website/get_modules_info', type='json', auth="user")
+ def get_modules_info(self, xml_ids):
+ def get_module_info(xml_id):
+ module_info = request.env.ref(xml_id)
+ return {'id': module_info.id, 'name': module_info.shortdesc}
+ modules_info = {}
+ for xml_id in xml_ids:
+ modules_info[xml_id] = get_module_info(xml_id)
+ return modules_info
+
@http.route('/website/snippet/filters', type='json', auth='public', website=True)
def get_dynamic_filter(self, filter_id, template_key, limit=None, search_domain=None, with_sample=False):
dynamic_filter = request.env['website.snippet.filter'].sudo().search(
@@ -527,50 +540,6 @@ class Website(Home):
# Edit
# ------------------------------------------------------
- @http.route(['/website/pages', '/website/pages/page/'], type='http', auth="user", website=True)
- def pages_management(self, page=1, sortby='url', search='', **kw):
- # only website_designer should access the page Management
- if not request.env.user.has_group('website.group_website_designer'):
- raise werkzeug.exceptions.NotFound()
-
- Page = request.env['website.page']
- searchbar_sortings = {
- 'url': {'label': _('Sort by Url'), 'order': 'url'},
- 'name': {'label': _('Sort by Name'), 'order': 'name'},
- }
- # default sortby order
- sort_order = searchbar_sortings.get(sortby, 'url')['order'] + ', website_id desc, id'
-
- domain = request.website.website_domain()
- if search:
- domain += ['|', ('name', 'ilike', search), ('url', 'ilike', search)]
-
- pages = Page.search(domain, order=sort_order)
- if sortby != 'url' or not request.session.debug:
- pages = pages._get_most_specific_pages()
- pages_count = len(pages)
-
- step = 50
- pager = portal_pager(
- url="/website/pages",
- url_args={'sortby': sortby},
- total=pages_count,
- page=page,
- step=step
- )
-
- pages = pages[(page - 1) * step:page * step]
-
- values = {
- 'pager': pager,
- 'pages': pages,
- 'search': search,
- 'sortby': sortby,
- 'searchbar_sortings': searchbar_sortings,
- 'search_count': pages_count,
- }
- return request.render("website.list_website_pages", values)
-
@http.route(['/website/add', '/website/add/'], type='http', auth="user", website=True, methods=['POST'])
def pagenew(self, path="", add_menu=False, template=False, **kwargs):
# for supported mimetype, get correct default template
@@ -588,7 +557,7 @@ class Website(Home):
if ext_special_case: # redirect non html pages to backend to edit
return werkzeug.utils.redirect('/web#id=' + str(page.get('view_id')) + '&view_type=form&model=ir.ui.view')
- return werkzeug.utils.redirect(url + "?enable_editor=1")
+ return werkzeug.utils.redirect(request.env['website'].get_client_action_url(url, True))
@http.route("/website/get_switchable_related_views", type="json", auth="user", website=True)
def get_switchable_related_views(self, key):
@@ -596,13 +565,6 @@ class Website(Home):
views = views.sorted(key=lambda v: (v.inherit_id.id, v.name))
return views.with_context(display_website=False).read(['name', 'id', 'key', 'xml_id', 'active', 'inherit_id'])
- @http.route('/website/toggle_switchable_view', type='json', auth='user', website=True)
- def toggle_switchable_view(self, view_key):
- if request.website.user_has_groups('website.group_website_designer'):
- request.website.viewref(view_key).toggle_active()
- else:
- return werkzeug.exceptions.Forbidden()
-
@http.route('/website/reset_template', type='http', auth='user', methods=['POST'], website=True, csrf=False)
def reset_template(self, view_id, mode='soft', redirect='/', **kwargs):
""" This method will try to reset a broken view.
@@ -723,9 +685,8 @@ class Website(Home):
Reloads asset bundles and returns their unique URLs.
"""
return {
- 'web.assets_common': request.env['ir.qweb']._get_asset_link_urls('web.assets_common'),
- 'web.assets_frontend': request.env['ir.qweb']._get_asset_link_urls('web.assets_frontend'),
- 'website.assets_editor': request.env['ir.qweb']._get_asset_link_urls('website.assets_editor'),
+ 'web.assets_common': request.env['ir.qweb']._get_asset_link_urls('web.assets_common', request.session.debug),
+ 'web.assets_frontend': request.env['ir.qweb']._get_asset_link_urls('web.assets_frontend', request.session.debug),
}
# ------------------------------------------------------
diff --git a/addons/website/models/ir_module_module.py b/addons/website/models/ir_module_module.py
index 1073b384d81..4423ef5bf92 100644
--- a/addons/website/models/ir_module_module.py
+++ b/addons/website/models/ir_module_module.py
@@ -382,8 +382,6 @@ class IrModuleModule(models.Model):
result = active_todo.action_launch()
else:
result = website.button_go_website(mode_edit=True)
- if result.get('url') and 'enable_editor' in result['url']:
- result['url'] = result['url'].replace('enable_editor', 'with_loader=1&enable_editor')
return result
def button_remove_theme(self):
diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py
index 5463e6651a6..5f3a7664319 100644
--- a/addons/website/models/ir_ui_view.py
+++ b/addons/website/models/ir_ui_view.py
@@ -426,11 +426,7 @@ class View(models.Model):
return super(View, self).get_default_lang_code()
def redirect_to_page_manager(self):
- return {
- 'type': 'ir.actions.act_url',
- 'url': '/website/pages',
- 'target': 'self',
- }
+ return self.env["ir.actions.server"]._for_xml_id('website.action_website_pages_list')
def _read_template_keys(self):
return super(View, self)._read_template_keys() + ['website_id']
diff --git a/addons/website/models/mixins.py b/addons/website/models/mixins.py
index 623721dc900..7a195652b25 100644
--- a/addons/website/models/mixins.py
+++ b/addons/website/models/mixins.py
@@ -200,9 +200,10 @@ class WebsitePublishedMixin(models.AbstractModel):
return self.write({'website_published': not self.website_published})
def open_website_url(self):
+ url = self.env['website'].get_client_action_url(self.website_url)
return {
'type': 'ir.actions.act_url',
- 'url': self.website_url,
+ 'url': url,
'target': 'self',
}
@@ -282,9 +283,12 @@ class WebsitePublishedMultiMixin(WebsitePublishedMixin):
return is_published
def open_website_url(self):
+ client_action_url = self.env['website'].get_client_action_url(self.website_url)
+ if self.website_id.domain:
+ client_action_url = url_join(self.website_id.domain, f'{client_action_url}&website_id={self.website_id.id}')
return {
'type': 'ir.actions.act_url',
- 'url': url_join(self.website_id.domain, self.website_url) if self.website_id.domain else self.website_url,
+ 'url': client_action_url,
'target': 'self',
}
diff --git a/addons/website/models/website.py b/addons/website/models/website.py
index 1327c6af2a6..eff0160eeb6 100644
--- a/addons/website/models/website.py
+++ b/addons/website/models/website.py
@@ -13,6 +13,7 @@ from psycopg2 import sql
from werkzeug import urls
from werkzeug.datastructures import OrderedMultiDict
from werkzeug.exceptions import NotFound
+from markupsafe import Markup
from odoo import api, fields, models, tools, http, release, registry
from odoo.addons.http_routing.models.ir_http import RequestUID, slugify, _guess_mimetype, url_for
@@ -319,7 +320,7 @@ class Website(models.Model):
def configurator_init(self):
r = dict()
company = self.get_current_website().company_id
- configurator_features = self.env['website.configurator.feature'].with_context(lang=self.get_current_website().default_lang_id.code).search([])
+ configurator_features = self.env['website.configurator.feature'].search([])
r['features'] = [{
'id': feature.id,
'name': feature.name,
@@ -333,7 +334,7 @@ class Website(models.Model):
if company.logo and company.logo != company._get_logo():
r['logo'] = company.logo.decode('utf-8')
try:
- result = self._website_api_rpc('/api/website/1/configurator/industries', {'lang': self.get_current_website().default_lang_id.code})
+ result = self._website_api_rpc('/api/website/1/configurator/industries', {'lang': self.env.context.get('lang')})
r['industries'] = result['industries']
except AccessError as e:
logger.warning(e.args[0])
@@ -591,7 +592,7 @@ class Website(models.Model):
images = custom_resources.get('images', {})
set_images(images)
- return url
+ return {'url': url, 'website_id': website.id}
# ----------------------------------------------------------
# Page Management
@@ -722,7 +723,8 @@ class Website(models.Model):
# we only want a unique_path for website specific.
# we need to be able to have /url for website=False, and /url for website=1
# in case of duplicate, page manager will allow you to manage this case
- domain_static = [('website_id', '=', self.get_current_website().id)] # .website_domain()
+ website_id = self.env.context.get('website_id', False) or self.get_current_website().id
+ domain_static = [('website_id', '=', website_id)] # .website_domain()
page_temp = page_url
while self.env['website.page'].with_context(active_test=False).sudo().search([('url', '=', page_temp)] + domain_static):
inc += 1
@@ -761,6 +763,9 @@ class Website(models.Model):
key_copy = string
inc = 0
domain_static = self.get_current_website().website_domain()
+ website_id = self.env.context.get('website_id', False)
+ if website_id:
+ domain_static = [('website_id', 'in', (False, website_id))]
while self.env['website.page'].with_context(active_test=False).sudo().search([('key', '=', key_copy)] + domain_static):
inc += 1
key_copy = string + (inc and "-%s" % inc or "")
@@ -779,7 +784,7 @@ class Website(models.Model):
return dependencies
page = self.env['website.page'].browse(int(page_id))
- website = self.env['website'].browse(self._context.get('website_id'))
+ website = page.website_id or self.get_current_website()
url = page.url
# search for website_page with link
@@ -792,7 +797,7 @@ class Website(models.Model):
for page in pages:
dependencies.setdefault(page_key, [])
dependencies[page_key].append({
- 'text': _('Page %s contains a link to this page', page.url),
+ 'content': Markup(_("Page %s contains a link to this page")) % page.url,
'item': page.name,
'link': page.url,
})
@@ -807,7 +812,7 @@ class Website(models.Model):
for view in views:
dependencies.setdefault(view_key, [])
dependencies[view_key].append({
- 'text': _('Template %s (id:%s) contains a link to this page') % (view.key or view.name, view.id),
+ 'content': Markup(_('Template %s (id:%s) contains a link to this page')) % (view.key or view.name, view.id),
'link': '/web#id=%s&view_type=form&model=ir.ui.view' % view.id,
'item': _('%s (id:%s)') % (view.key or view.name, view.id),
})
@@ -820,7 +825,7 @@ class Website(models.Model):
menu_key = _('Menus')
for menu in menus:
dependencies.setdefault(menu_key, []).append({
- 'text': _('This page is in the menu %s', menu.name),
+ 'content': Markup(_('This page is in the menu %s')) % menu.name,
'link': '/web#id=%s&view_type=form&model=website.menu' % menu.id,
'item': menu.name,
})
@@ -840,7 +845,7 @@ class Website(models.Model):
return dependencies
page = self.env['website.page'].browse(int(page_id))
- website = self.env['website'].browse(self._context.get('website_id'))
+ website = page.website_id or self.get_current_website()
key = page.key
# search for website_page with link
@@ -856,7 +861,7 @@ class Website(models.Model):
for p in pages:
dependencies.setdefault(page_key, [])
dependencies[page_key].append({
- 'text': _('Page %s is calling this file', p.url),
+ 'content': Markup(_('Page %s is calling this file')) % p.url,
'item': p.name,
'link': p.url,
})
@@ -874,7 +879,7 @@ class Website(models.Model):
for view in views:
dependencies.setdefault(view_key, [])
dependencies[view_key].append({
- 'text': _('Template %s (id:%s) is calling this file') % (view.key or view.name, view.id),
+ 'content': Markup(_('Template %s (id:%s) is calling this file')) % (view.key or view.name, view.id),
'item': _('%s (id:%s)') % (view.key or view.name, view.id),
'link': '/web#id=%s&view_type=form&model=ir.ui.view' % view.id,
})
@@ -1083,22 +1088,13 @@ class Website(models.Model):
raise ValueError('No record found for unique ID %s. It may have been deleted.' % (view_id))
return view
- @tools.ormcache_context(keys=('website_id',))
- def _cache_customize_show_views(self):
- views = self.env['ir.ui.view'].with_context(active_test=False).sudo().search([('customize_show', '=', True)])
- views = views.filter_duplicate()
- return {v.key: v.active for v in views}
-
@tools.ormcache_context('key', keys=('website_id',))
- def is_view_active(self, key, raise_if_not_found=False):
+ def is_view_active(self, key):
"""
- Return True if active, False if not active, None if not found or not a customize_show view
+ Return True if active, False if not active, None if not found
"""
- views = self._cache_customize_show_views()
- view = key in views and views[key]
- if view is None and raise_if_not_found:
- raise ValueError('No view of type customize_show found for key %s' % key)
- return view
+ view = self.viewref(key, raise_if_not_found=False)
+ return view.active if view else None
@api.model
def get_template(self, template):
@@ -1292,13 +1288,22 @@ class Website(models.Model):
return self.env["ir.actions.actions"]._for_xml_id("website.backend_dashboard")
return self.env["ir.actions.actions"]._for_xml_id("website.action_website")
+ def get_client_action_url(self, url, mode_edit=False):
+ action_params = {
+ "action": "website.website_preview",
+ "path": url,
+ }
+ if mode_edit:
+ action_params["enable_editor"] = 1
+ return "/web#" + urls.url_encode(action_params)
+
def button_go_website(self, path='/', mode_edit=False):
self._force()
if mode_edit:
# If the user gets on a translated page (e.g /fr) the editor will
# never start. Forcing the default language fixes this issue.
path = url_for(path, self.default_lang_id.url_code)
- path += '?enable_editor=1'
+ path = self.get_client_action_url(path, True)
return {
'type': 'ir.actions.act_url',
'url': path,
diff --git a/addons/website/models/website_page.py b/addons/website/models/website_page.py
index 539022dfca7..ca43613105b 100644
--- a/addons/website/models/website_page.py
+++ b/addons/website/models/website_page.py
@@ -10,6 +10,7 @@ from odoo import api, fields, models
from odoo.osv import expression
from odoo.tools import escape_psql
from odoo.tools.safe_eval import safe_eval
+from odoo.tools.translate import _
class Page(models.Model):
@@ -26,8 +27,10 @@ class Page(models.Model):
view_id = fields.Many2one('ir.ui.view', string='View', required=True, ondelete="cascade")
website_indexed = fields.Boolean('Is Indexed', default=True)
date_publish = fields.Datetime('Publishing Date')
- # This is needed to be able to display if page is a menu in /website/pages
menu_ids = fields.One2many('website.menu', 'page_id', 'Related Menus')
+ # This is needed to be able to control if page is a menu in page properties.
+ # TODO this should be reviewed entirely so that we use a transient model.
+ is_in_menu = fields.Boolean(compute='_compute_website_menu', inverse='_inverse_website_menu')
is_homepage = fields.Boolean(compute='_compute_homepage', inverse='_set_homepage', string='Homepage')
is_visible = fields.Boolean(compute='_compute_visible', string='Is Visible')
@@ -61,6 +64,26 @@ class Page(models.Model):
not page.date_publish or page.date_publish < fields.Datetime.now()
)
+ @api.depends('menu_ids')
+ def _compute_website_menu(self):
+ for page in self:
+ page.is_in_menu = bool(page.menu_ids)
+
+ def _inverse_website_menu(self):
+ for page in self:
+ if page.is_in_menu:
+ if not page.menu_ids:
+ self.env['website.menu'].create({
+ 'name': page.name,
+ 'url': page.url,
+ 'page_id': page.id,
+ 'parent_id': page.website_id.menu_id.id,
+ 'website_id': page.website_id.id,
+ })
+ elif page.menu_ids:
+ # If the page is no longer in menu, we should remove its website_menu
+ page.menu_ids.unlink()
+
def _get_most_specific_pages(self):
''' Returns the most specific pages in self. '''
ids = []
@@ -72,93 +95,6 @@ class Page(models.Model):
previous_page = page
return self.filtered(lambda page: page.id in ids)
- def get_page_properties(self):
- self.ensure_one()
- res = self.read([
- 'id', 'view_id', 'name', 'url', 'website_published', 'website_indexed', 'date_publish',
- 'menu_ids', 'is_homepage', 'website_id', 'visibility', 'groups_id'
- ])[0]
- if not res['groups_id']:
- res['group_id'] = self.env.ref('base.group_user').name_get()[0]
- elif len(res['groups_id']) == 1:
- res['group_id'] = self.env['res.groups'].browse(res['groups_id']).name_get()[0]
- del res['groups_id']
-
- res['visibility_password'] = res['visibility'] == 'password' and self.visibility_password_display or ''
- return res
-
- @api.model
- def save_page_info(self, website_id, data):
- website = self.env['website'].browse(website_id)
- page = self.browse(int(data['id']))
-
- # If URL has been edited, slug it
- original_url = page.url
- url = data['url']
- if not url.startswith('/'):
- url = '/' + url
- if page.url != url:
- url = '/' + slugify(url, max_length=1024, path=True)
- url = self.env['website'].get_unique_path(url)
-
- # If name has changed, check for key uniqueness
- if page.name != data['name']:
- page_key = self.env['website'].get_unique_key(slugify(data['name']))
- else:
- page_key = page.key
-
- menu = self.env['website.menu'].search([('page_id', '=', int(data['id']))])
- if not data['is_menu']:
- # If the page is no longer in menu, we should remove its website_menu
- if menu:
- menu.unlink()
- else:
- # The page is now a menu, check if has already one
- if menu:
- menu.write({'url': url})
- else:
- self.env['website.menu'].create({
- 'name': data['name'],
- 'url': url,
- 'page_id': data['id'],
- 'parent_id': website.menu_id.id,
- 'website_id': website.id,
- })
-
- # Edits via the page manager shouldn't trigger the COW
- # mechanism and generate new pages. The user manages page
- # visibility manually with is_published here.
- w_vals = {
- 'key': page_key,
- 'name': data['name'],
- 'url': url,
- 'is_published': data['website_published'],
- 'website_indexed': data['website_indexed'],
- 'date_publish': data['date_publish'] or None,
- 'is_homepage': data['is_homepage'],
- 'visibility': data['visibility'],
- }
- if page.visibility == 'restricted_group' and data['visibility'] != "restricted_group":
- w_vals['groups_id'] = False
- elif 'group_id' in data:
- w_vals['groups_id'] = [data['group_id']]
- if 'visibility_pwd' in data:
- w_vals['visibility_password_display'] = data['visibility_pwd'] or ''
-
- page.with_context(no_cow=True).write(w_vals)
-
- # Create redirect if needed
- if data['create_redirect']:
- self.env['website.rewrite'].create({
- 'name': data['name'],
- 'redirect_type': data['redirect_type'],
- 'url_from': original_url,
- 'url_to': url,
- 'website_id': website.id,
- })
-
- return url
-
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
if default:
@@ -178,8 +114,7 @@ class Page(models.Model):
page = self.browse(int(page_id))
copy_param = dict(name=page_name or page.name, website_id=self.env['website'].get_current_website().id)
if page_name:
- page_url = '/' + slugify(page_name, max_length=1024, path=True)
- copy_param['url'] = self.env['website'].get_unique_path(page_url)
+ copy_param['url'] = self.get_valid_page_url(page_name)
new_page = page.copy(copy_param)
# Should not clone menu if the page was cloned from one website to another
@@ -190,7 +125,7 @@ class Page(models.Model):
# If the page being cloned has a menu, clone it too
menu.copy({'url': new_page.url, 'name': new_page.name, 'page_id': new_page.id})
- return new_page.url + '?enable_editor=1'
+ return new_page.url
def unlink(self):
# When a website_page is deleted, the ORM does not delete its
@@ -209,8 +144,41 @@ class Page(models.Model):
return super(Page, self).unlink()
def write(self, vals):
- if 'url' in vals and not vals['url'].startswith('/'):
- vals['url'] = '/' + vals['url']
+ for page in self:
+ website_id = False
+ if vals.get('website_id') or page.website_id:
+ website_id = vals.get('website_id') or page.website_id.id
+
+ # If URL has been edited, slug it
+ if 'url' in vals:
+ url = vals['url']
+ redirect_old_url = redirect_type = None
+ # TODO This should be done another way after the backend/frontend merge
+ if isinstance(url, dict):
+ redirect_old_url = url.get('redirect_old_url')
+ redirect_type = url.get('redirect_type')
+ url = url.get('url')
+ if not url.startswith('/'):
+ url = '/' + url
+ if page.url != url:
+ url = self.get_valid_page_url(url, website_id)
+ page.menu_ids.write({'url': url})
+ if redirect_old_url:
+ self.env['website.rewrite'].create({
+ 'name': vals.get('name') or page.name,
+ 'redirect_type': redirect_type,
+ 'url_from': page.url,
+ 'url_to': url,
+ 'website_id': website_id,
+ })
+ vals['url'] = url
+
+ # If name has changed, check for key uniqueness
+ if 'name' in vals and page.name != vals['name']:
+ vals['key'] = self.env['website'].with_context(website_id=website_id).get_unique_key(slugify(vals['name']))
+ if 'visibility' in vals:
+ if vals['visibility'] != 'restricted_group':
+ vals['groups_id'] = False
self.clear_caches() # write on page == write on view that invalid cache
return super(Page, self).write(vals)
@@ -306,6 +274,18 @@ class Page(models.Model):
results = results.filtered(lambda result: filter_page(search, result, results))
return results, count
+ def get_valid_page_url(self, page_url, website_id=False):
+ url = '/' + slugify(page_url, max_length=1024, path=True)
+ return self.env['website'].with_context(website_id=website_id).get_unique_path(url)
+
+ def action_manage_website_pages(self):
+ return {
+ 'name': _('Website Pages'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'website.page',
+ 'view_mode': 'tree',
+ 'view_id': self.env.ref('website.website_pages_tree_view').id,
+ }
# this is just a dummy function to be used as ormcache key
def _cached_response():
diff --git a/addons/website/static/src/components/configurator/configurator.js b/addons/website/static/src/client_actions/configurator/configurator.js
similarity index 64%
rename from addons/website/static/src/components/configurator/configurator.js
rename to addons/website/static/src/client_actions/configurator/configurator.js
index 6e128184ecd..c273626cd47 100644
--- a/addons/website/static/src/components/configurator/configurator.js
+++ b/addons/website/static/src/client_actions/configurator/configurator.js
@@ -2,14 +2,13 @@
import utils from 'web.utils';
import weUtils from 'web_editor.utils';
-import session from 'web.session';
import {ColorpickerWidget} from 'web.Colorpicker';
-import {_t, _lt} from 'web.core';
+import {_t, _lt, qweb} from 'web.core';
import {svgToPNG} from 'website.utils';
-import {useService} from "@web/core/utils/hooks";
-import {renderToString} from "@web/core/utils/render";
+import { useService } from "@web/core/utils/hooks";
+import { registry } from "@web/core/registry";
-const { App, Component, onMounted, reactive, useEnv, useRef, useState, whenReady } = owl;
+const { Component, onMounted, reactive, useEnv, useRef, useState, useSubEnv, onWillStart } = owl;
const ROUTES = {
descriptionScreen: 2,
@@ -61,28 +60,20 @@ const PALETTE_NAMES = [
// from CSS and added in each palette.
const CUSTOM_BG_COLOR_ATTRS = ['menu', 'footer'];
-const SESSION_STORAGE_ITEM_NAME = 'websiteConfigurator' + session.website_id;
-
//------------------------------------------------------------------------------
// Components
//------------------------------------------------------------------------------
-class SkipButton extends Component {
- async skip() {
- await skipConfigurator(Component.env.services);
- }
-}
-
+class SkipButton extends Component {}
SkipButton.template = 'website.Configurator.SkipButton';
class WelcomeScreen extends Component {
setup() {
this.state = useStore();
- this.router = useRouter();
}
goToDescription() {
- this.router.navigate(ROUTES.descriptionScreen);
+ this.props.navigate(ROUTES.descriptionScreen);
}
}
@@ -95,7 +86,6 @@ class DescriptionScreen extends Component {
setup() {
this.industrySelection = useRef('industrySelection');
this.state = useStore();
- this.router = useRouter();
this.labelToId = {};
this.autocompleteHasResults = true;
@@ -278,7 +268,7 @@ class DescriptionScreen extends Component {
checkDescriptionCompletion() {
const {selectedType, selectedPurpose, selectedIndustry} = this.state;
if (selectedType && selectedPurpose && selectedIndustry) {
- this.router.navigate(ROUTES.paletteSelectionScreen);
+ this.props.navigate(ROUTES.paletteSelectionScreen);
}
}
}
@@ -291,10 +281,10 @@ Object.assign(DescriptionScreen, {
class PaletteSelectionScreen extends Component {
setup() {
this.state = useStore();
- this.router = useRouter();
this.logoInputRef = useRef('logoSelectionInput');
this.notification = useService("notification");
this.rpc = useService("rpc");
+ this.orm = useService('orm');
onMounted(() => {
if (this.state.logo) {
@@ -312,13 +302,10 @@ class PaletteSelectionScreen extends Component {
if (logoSelectInput.files.length === 1) {
const file = logoSelectInput.files[0];
const data = await utils.getDataURLFromFile(file);
- const attachment = await this.rpc({
- route: '/web_editor/attachment/add_data',
- params: {
- 'name': 'logo',
- 'data': data.split(',')[1],
- 'is_image': true,
- },
+ const attachment = await this.rpc('/web_editor/attachment/add_data', {
+ 'name': 'logo',
+ 'data': data.split(',')[1],
+ 'is_image': true,
});
if (!attachment.error) {
this.state.changeLogo(data, attachment.id);
@@ -338,18 +325,17 @@ class PaletteSelectionScreen extends Component {
img = await svgToPNG(img);
}
img = img.split(',')[1];
- const [color1, color2] = await this.rpc({
- model: 'base.document.layout',
- method: 'extract_image_primary_secondary_colors',
- args: [img],
- kwargs: {mitigate: 255},
- });
+ const [color1, color2] = await this.orm.call('base.document.layout',
+ 'extract_image_primary_secondary_colors',
+ [img],
+ {mitigate: 255},
+ );
this.state.setRecommendedPalette(color1, color2);
}
selectPalette(paletteName) {
this.state.selectPalette(paletteName);
- this.router.navigate(ROUTES.featuresSelectionScreen);
+ this.props.navigate(ROUTES.featuresSelectionScreen);
}
}
@@ -358,32 +344,78 @@ Object.assign(PaletteSelectionScreen, {
template: 'website.Configurator.PaletteSelectionScreen',
});
-class FeaturesSelectionScreen extends Component {
+class ApplyConfiguratorScreen extends Component {
+ async applyConfigurator(themeName) {
+ if (!this.state.selectedIndustry) {
+ return this.props.navigate(ROUTES.descriptionScreen);
+ }
+ if (!this.state.selectedPalette) {
+ return this.props.navigate(ROUTES.paletteSelectionScreen);
+ }
+ if (themeName !== undefined) {
+ $('body').append(qweb.render('website.ThemePreview.Loader', {showTips: true}));
+ const selectedFeatures = Object.values(this.state.features).filter((feature) => feature.selected).map((feature) => feature.id);
+ let selectedPalette = this.state.selectedPalette.name;
+ if (!selectedPalette) {
+ selectedPalette = [
+ this.state.selectedPalette.color1,
+ this.state.selectedPalette.color2,
+ this.state.selectedPalette.color3,
+ this.state.selectedPalette.color4,
+ this.state.selectedPalette.color5,
+ ];
+ }
+
+ const resp = await this.orm.silent.call('website',
+ 'configurator_apply',
+ [],
+ {
+ 'selected_features': selectedFeatures,
+ 'industry_id': this.state.selectedIndustry.id,
+ 'selected_palette': selectedPalette,
+ 'theme_name': themeName,
+ 'website_purpose': WEBSITE_PURPOSES[this.state.selectedPurpose].name,
+ 'website_type': WEBSITE_TYPES[this.state.selectedType].name,
+ 'logo_attachment_id': this.state.logoAttachmentId,
+ });
+
+ this.props.clearStorage();
+
+ // Here the website service goToWebsite method is not used because
+ // the web client needs to be reloaded after the new modules have
+ // been installed.
+ window.location.replace(`/web#action=website.website_preview&website_id=${resp.website_id}&enable_editor=1`);
+ }
+ }
+}
+
+class FeaturesSelectionScreen extends ApplyConfiguratorScreen {
setup() {
+ super.setup();
+
+ this.orm = useService("orm");
this.state = useStore();
- this.router = useRouter();
- this.rpc = useService("rpc");
}
async buildWebsite() {
const industryId = this.state.selectedIndustry && this.state.selectedIndustry.id;
if (!industryId) {
- return this.router.navigate(ROUTES.descriptionScreen);
+ return this.props.navigate(ROUTES.descriptionScreen);
}
- const themes = await this.rpc({
- model: 'website',
- method: 'configurator_recommended_themes',
- kwargs: {
+ const themes = await this.orm.call('website',
+ 'configurator_recommended_themes',
+ [],
+ {
'industry_id': industryId,
'palette': this.state.selectedPalette,
},
- });
+ );
if (!themes.length) {
- await applyConfigurator(this, 'theme_default');
+ await this.applyConfigurator('theme_default');
} else {
this.state.updateRecommendedThemes(themes);
- this.router.navigate(ROUTES.themeSelectionScreen);
+ this.props.navigate(ROUTES.themeSelectionScreen);
}
}
}
@@ -393,11 +425,12 @@ Object.assign(FeaturesSelectionScreen, {
template: 'website.Configurator.FeatureSelection',
});
-class ThemeSelectionScreen extends Component {
+class ThemeSelectionScreen extends ApplyConfiguratorScreen {
setup() {
+ super.setup();
+
+ this.orm = useService('orm');
this.state = useStore();
- this.router = useRouter();
- this.rpc = useService('rpc');
this.themeSVGPreviews = [useRef('ThemePreview1'), useRef('ThemePreview2'), useRef('ThemePreview3')];
onMounted(() => {
@@ -408,56 +441,19 @@ class ThemeSelectionScreen extends Component {
}
async chooseTheme(themeName) {
- await applyConfigurator(this, themeName);
+ await this.applyConfigurator(themeName);
}
}
ThemeSelectionScreen.template = 'website.Configurator.ThemeSelectionScreen';
-class Configurator extends Component {
- setup() {
- this.router = useRouter();
- }
-}
-
-Object.assign(Configurator, {
- components: {
- WelcomeScreen,
- DescriptionScreen,
- PaletteSelectionScreen,
- FeaturesSelectionScreen,
- ThemeSelectionScreen,
- },
- template: 'website.Configurator.Configurator',
-});
-
-//------------------------------------------------------------------------------
-// Router
-//------------------------------------------------------------------------------
-
-class Router {
- constructor() {
- this.location = window.location.pathname;
- }
-
- navigate(id) {
- this.location = `/website/configurator${id ? '/' + id : ''}`;
- history.pushState({}, '', window.location.origin + this.location);
- }
-}
-
-function useRouter() {
- const env = useEnv();
- return useState(env.router);
-}
-
//------------------------------------------------------------------------------
// Store
//------------------------------------------------------------------------------
class Store {
- async start() {
- Object.assign(this, await getInitialState(Component.env.services));
+ async start(getInitialState) {
+ Object.assign(this, await getInitialState());
}
//-------------------------------------------------------------------------
@@ -572,193 +568,174 @@ class Store {
}
}
-async function getInitialState(services) {
- // Load values from python and iap
- var results = await services.rpc({
- model: 'website',
- method: 'configurator_init',
- });
- const r = {
- industries: results.industries,
- logo: results.logo ? 'data:image/png;base64,' + results.logo : false,
- };
- r.industries = r.industries.map((industry, index) => ({
- ...industry,
- wordCount: industry.label.split(" ").length,
- hitCountOrder: index,
- }));
-
- // Load palettes from the current CSS
- const palettes = {};
- const style = window.getComputedStyle(document.documentElement);
-
- PALETTE_NAMES.forEach((paletteName) => {
- const palette = {
- name: paletteName
- };
- for (let j = 1; j <= 5; j += 1) {
- palette[`color${j}`] = weUtils.getCSSVariableValue(`o-palette-${paletteName}-o-color-${j}`, style);
- }
- CUSTOM_BG_COLOR_ATTRS.forEach((attr) => {
- palette[attr] = weUtils.getCSSVariableValue(`o-palette-${paletteName}-${attr}-bg`, style);
- });
- palettes[paletteName] = palette;
- });
-
- const localState = JSON.parse(window.sessionStorage.getItem(SESSION_STORAGE_ITEM_NAME));
- if (localState) {
- let themes = [];
- if (localState.selectedIndustry && localState.selectedPalette) {
- themes = await services.rpc({
- model: 'website',
- method: 'configurator_recommended_themes',
- kwargs: {
- 'industry_id': localState.selectedIndustry.id,
- 'palette': localState.selectedPalette,
- },
- });
- }
- return Object.assign(r, {...localState, palettes, themes});
- }
-
- const features = {};
- results.features.forEach(feature => {
- features[feature.id] = Object.assign({}, feature, {selected: feature.module_state === 'installed'});
- const wtp = features[feature.id]['website_config_preselection'];
- features[feature.id]['website_config_preselection'] = wtp ? wtp.split(',') : [];
- });
-
- // Palette color used by default as background color for menu and footer.
- // Needed to build the recommended palette.
- const defaultColors = {};
- CUSTOM_BG_COLOR_ATTRS.forEach((attr) => {
- const color = weUtils.getCSSVariableValue(`o-default-${attr}-bg`, style);
- const match = color.match(/o-color-(?[1-5])/);
- const colorIdx = parseInt(match.groups['idx']);
- defaultColors[attr] = `color${colorIdx}`;
- });
-
- return Object.assign(r, {
- selectedType: undefined,
- selectedPurpose: undefined,
- selectedIndustry: undefined,
- selectedPalette: undefined,
- recommendedPalette: undefined,
- defaultColors: defaultColors,
- palettes: palettes,
- features: features,
- themes: [],
- logoAttachmentId: undefined,
- });
-}
-
function useStore() {
const env = useEnv();
- return useState(env.state);
+ return useState(env.store);
}
-//------------------------------------------------------------------------------
-// Helpers
-//------------------------------------------------------------------------------
+class Configurator extends Component {
+ setup() {
+ this.orm = useService('orm');
+ this.action = useService('action');
+ this.router = useService('router');
-async function skipConfigurator(services) {
- await services.rpc({
- model: 'website',
- method: 'configurator_skip',
- });
- window.sessionStorage.removeItem(SESSION_STORAGE_ITEM_NAME);
- window.location = '/web#action=website.theme_install_kanban_action';
-}
+ const initialStep = this.props.action.context.params && this.props.action.context.params.step;
+ const store = reactive(new Store(), () => this.updateStorage(store));
-async function applyConfigurator(self, themeName) {
- if (!self.state.selectedIndustry) {
- return self.router.navigate(ROUTES.descriptionScreen);
+ this.state = useState({
+ currentStep: initialStep,
+ });
+
+ useSubEnv({ store });
+
+ onWillStart(async () => {
+ this.websiteId = (await this.orm.call('website', 'get_current_website')).match(/\d+/)[0];
+
+ await store.start(() => this.getInitialState());
+ this.updateStorage(store);
+ if (!store.industries) {
+ await this.skipConfigurator();
+ }
+ });
+
+ // This is a hack to overwrite the history state, modified by the
+ // router service after executing an action. Ideally, the router
+ // service would let us push a state with a new pathname.
+ onMounted(() => {
+ setTimeout(() => {
+ this.router.cancelPushes();
+ this.updateBrowserUrl();
+ });
+ });
}
- if (!self.state.selectedPalette) {
- return self.router.navigate(ROUTES.paletteSelectionScreen);
+
+ get pathname() {
+ return `/website/configurator${this.state.currentStep ? `/${this.state.currentStep}` : ''}`;
}
- if (themeName !== undefined) {
- const loader = renderToString('website.ThemePreview.Loader', {showTips: true});
- $('body').append(loader);
- const selectedFeatures = Object.values(self.state.features).filter((feature) => feature.selected).map((feature) => feature.id);
- let selectedPalette = self.state.selectedPalette.name;
- if (!selectedPalette) {
- selectedPalette = [
- self.state.selectedPalette.color1,
- self.state.selectedPalette.color2,
- self.state.selectedPalette.color3,
- self.state.selectedPalette.color4,
- self.state.selectedPalette.color5,
- ];
+
+ get storageItemName() {
+ return `websiteConfigurator${this.websiteId}`;
+ }
+
+ updateBrowserUrl() {
+ history.pushState({}, '', this.pathname);
+ }
+
+ navigate(step) {
+ this.state.currentStep = step;
+ this.updateBrowserUrl();
+ }
+
+ clearStorage() {
+ window.sessionStorage.removeItem(this.storageItemName);
+ }
+
+ async getInitialState() {
+ // Load values from python and iap
+ var results = await this.orm.call('website', 'configurator_init');
+ const r = {
+ industries: results.industries,
+ logo: results.logo ? 'data:image/png;base64,' + results.logo : false,
+ };
+ r.industries = r.industries.map((industry, index) => ({
+ ...industry,
+ wordCount: industry.label.split(" ").length,
+ hitCountOrder: index,
+ }));
+
+ // Load palettes from the current CSS
+ const palettes = {};
+ const style = window.getComputedStyle(document.documentElement);
+
+ PALETTE_NAMES.forEach((paletteName) => {
+ const palette = {
+ name: paletteName
+ };
+ for (let j = 1; j <= 5; j += 1) {
+ palette[`color${j}`] = weUtils.getCSSVariableValue(`o-palette-${paletteName}-o-color-${j}`, style);
+ }
+ CUSTOM_BG_COLOR_ATTRS.forEach((attr) => {
+ palette[attr] = weUtils.getCSSVariableValue(`o-palette-${paletteName}-${attr}-bg`, style);
+ });
+ palettes[paletteName] = palette;
+ });
+
+ const localState = JSON.parse(window.sessionStorage.getItem(this.storageItemName));
+ if (localState) {
+ let themes = [];
+ if (localState.selectedIndustry && localState.selectedPalette) {
+ themes = await this.orm.call('website', 'configurator_recommended_themes', [], {
+ 'industry_id': localState.selectedIndustry.id,
+ 'palette': localState.selectedPalette,
+ });
+ }
+ return Object.assign(r, {...localState, palettes, themes});
}
- const resp = await self.rpc({
- model: 'website',
- method: 'configurator_apply',
- kwargs: {
- 'selected_features': selectedFeatures,
- 'industry_id': self.state.selectedIndustry.id,
- 'selected_palette': selectedPalette,
- 'theme_name': themeName,
- 'website_purpose': WEBSITE_PURPOSES[self.state.selectedPurpose].name,
- 'website_type': WEBSITE_TYPES[self.state.selectedType].name,
- 'logo_attachment_id': self.state.logoAttachmentId,
- },
+
+ const features = {};
+ results.features.forEach(feature => {
+ features[feature.id] = Object.assign({}, feature, {selected: feature.module_state === 'installed'});
+ const wtp = features[feature.id]['website_config_preselection'];
+ features[feature.id]['website_config_preselection'] = wtp ? wtp.split(',') : [];
+ });
+
+ // Palette color used by default as background color for menu and footer.
+ // Needed to build the recommended palette.
+ const defaultColors = {};
+ CUSTOM_BG_COLOR_ATTRS.forEach((attr) => {
+ const color = weUtils.getCSSVariableValue(`o-default-${attr}-bg`, style);
+ const match = color.match(/o-color-(?[1-5])/);
+ const colorIdx = parseInt(match.groups['idx']);
+ defaultColors[attr] = `color${colorIdx}`;
+ });
+
+ return Object.assign(r, {
+ selectedType: undefined,
+ selectedPurpose: undefined,
+ selectedIndustry: undefined,
+ selectedPalette: undefined,
+ recommendedPalette: undefined,
+ defaultColors: defaultColors,
+ palettes: palettes,
+ features: features,
+ themes: [],
+ logoAttachmentId: undefined,
+ });
+ }
+
+ updateStorage(state) {
+ const newState = JSON.stringify({
+ defaultColors: state.defaultColors,
+ features: state.features,
+ logo: state.logo,
+ logoAttachmentId: state.logoAttachmentId,
+ selectedIndustry: state.selectedIndustry,
+ selectedPalette: state.selectedPalette,
+ selectedPurpose: state.selectedPurpose,
+ selectedType: state.selectedType,
+ recommendedPalette: state.recommendedPalette,
+ });
+ window.sessionStorage.setItem(this.storageItemName, newState);
+ }
+
+ async skipConfigurator() {
+ await this.orm.call('website', 'configurator_skip');
+ this.clearStorage();
+ this.action.doAction('website.theme_install_kanban_action', {
+ clearBreadcrumbs: true,
});
- window.sessionStorage.removeItem(SESSION_STORAGE_ITEM_NAME);
- window.location = resp.url;
}
}
-function updateStorage(state) {
- const newState = JSON.stringify({
- defaultColors: state.defaultColors,
- features: state.features,
- logo: state.logo,
- logoAttachmentId: state.logoAttachmentId,
- selectedIndustry: state.selectedIndustry,
- selectedPalette: state.selectedPalette,
- selectedPurpose: state.selectedPurpose,
- selectedType: state.selectedType,
- recommendedPalette: state.recommendedPalette,
- });
- window.sessionStorage.setItem(SESSION_STORAGE_ITEM_NAME, newState);
-}
+Object.assign(Configurator, {
+ components: {
+ WelcomeScreen,
+ DescriptionScreen,
+ PaletteSelectionScreen,
+ FeaturesSelectionScreen,
+ ThemeSelectionScreen,
+ },
+ template: 'website.Configurator.Configurator',
+});
-async function makeEnvironment() {
- const state = reactive(new Store(), () => updateStorage(state));
- await state.start();
- updateStorage(state);
- const env = {
- state,
- router: reactive(new Router()),
- services: Component.env.services,
- };
- await session.is_bound;
- return env;
-}
-
-//------------------------------------------------------------------------------
-// Setup
-//------------------------------------------------------------------------------
-
-async function setup() {
- const env = await makeEnvironment();
- if (!env.state.industries) {
- await skipConfigurator(env.services);
- } else {
- const templates = await (await fetch('/website/static/src/components/configurator/configurator.xml')).text();
- const app = new App(Configurator, {
- env,
- dev: env.debug,
- templates,
- translatableAttributes: ["data-tooltip"],
- translateFn: _t,
- });
- const loaderTemplate = await (await fetch('/website/static/src/xml/theme_preview.xml')).text();
- app.addTemplates(loaderTemplate);
- renderToString.app = app;
- await app.mount(document.body);
- }
-}
-
-whenReady(setup);
+registry.category('actions').add('website_configurator', Configurator);
diff --git a/addons/website/static/src/scss/configurator.scss b/addons/website/static/src/client_actions/configurator/configurator.scss
similarity index 88%
rename from addons/website/static/src/scss/configurator.scss
rename to addons/website/static/src/client_actions/configurator/configurator.scss
index a3749ad4f03..d5f4f89e603 100644
--- a/addons/website/static/src/scss/configurator.scss
+++ b/addons/website/static/src/client_actions/configurator/configurator.scss
@@ -1,5 +1,6 @@
.o_configurator_container {
@include o-position-absolute(0, 0, 0, 0);
+ background-color: $white;
.o_configurator_screen {
@@ -110,6 +111,7 @@
border: none;
border-bottom: 3px solid theme-color(primary);
color: theme-color(primary);
+ background-color: inherit;
&:focus {
color: inherit;
@@ -121,6 +123,7 @@
content: attr(data-value) '|';
display: inline-block;
overflow: hidden;
+ opacity: 0;
}
}
@@ -154,6 +157,7 @@
@include border-bottom-radius($border-radius);
font-size: inherit;
min-width: 100%;
+ overflow-y: auto;
}
.dropdown-item, .fa-angle-down, .ui-menu-item a, .ui-menu-item a.ui-state-active {
@@ -195,6 +199,31 @@
}
&.o_palette_selection_screen {
+ @include media-breakpoint-down(md) {
+ .o_detect_logo {
+ width: 100% !important;
+ }
+
+ .o_content_container {
+ height: 100% !important;
+ flex-direction: column !important;
+ }
+
+ .o_palettes_separator {
+ width: 100% !important;
+ }
+
+ .o_palettes_container {
+ width: 100% !important;
+ }
+ }
+
+ @include media-breakpoint-down(sm) {
+ .o_palette_card_container {
+ width: 50% !important;
+ }
+ }
+
.palette_card {
cursor: pointer;
box-shadow: 0 0 0 1px gray('200');
@@ -238,6 +267,18 @@
}
&.o_feature_selection_screen {
+ @include media-breakpoint-down(md) {
+ .o_feature_card_container {
+ width: 50% !important;
+ }
+ }
+
+ @include media-breakpoint-down(sm) {
+ .o_feature_card_container {
+ width: 100% !important;
+ }
+ }
+
.card {
cursor: pointer;
diff --git a/addons/website/static/src/components/configurator/configurator.xml b/addons/website/static/src/client_actions/configurator/configurator.xml
similarity index 90%
rename from addons/website/static/src/components/configurator/configurator.xml
rename to addons/website/static/src/client_actions/configurator/configurator.xml
index 02bbbb169af..e548b70a602 100644
--- a/addons/website/static/src/components/configurator/configurator.xml
+++ b/addons/website/static/src/client_actions/configurator/configurator.xml
@@ -3,7 +3,7 @@
-
+
@@ -22,7 +22,7 @@
-
+
@@ -83,7 +83,7 @@
-
+
@@ -93,8 +93,8 @@
-
-
+
+
Detect from Logo
@@ -122,17 +122,17 @@
-
+
OR
-
+
Choose a pre-made Palette
-
+
@@ -145,7 +145,7 @@
-
+
@@ -162,7 +162,7 @@
-
+
@@ -187,7 +187,7 @@
-
+
@@ -238,11 +238,11 @@
-
-
-
-
-
+
+
+
+
+
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
new file mode 100644
index 00000000000..535fcbaacd1
--- /dev/null
+++ b/addons/website/static/src/client_actions/website_preview/website_preview.js
@@ -0,0 +1,262 @@
+/** @odoo-module **/
+
+import { registry } from '@web/core/registry';
+import { useService } from '@web/core/utils/hooks';
+import core from 'web.core';
+import { WebsiteEditorComponent } from '../../components/editor/editor';
+import { WebsiteTranslator } from '../../components/translator/translator';
+import {OptimizeSEODialog} from '@website/components/dialog/seo';
+
+const { Component, onWillStart, useRef, useEffect, useState } = owl;
+
+class BlockIframe extends Component {
+ setup() {
+ this.websiteService = useService('website');
+ this.state = useState({
+ blockIframe: false,
+ showLoader: false,
+ });
+ this.iframeLocks = 0;
+ this.websiteService.bus.addEventListener("BLOCK", this.block.bind(this));
+ this.websiteService.bus.addEventListener("UNBLOCK", this.unblock.bind(this));
+ }
+ block(event) {
+ if (event.detail.showLoader && !this.state.showLoader) {
+ setTimeout(() => {
+ this.state.showLoader = true;
+ }, event.detail.loaderDelay);
+ }
+ if (this.iframeLocks === 0) {
+ this.state.blockIframe = true;
+ }
+ this.iframeLocks++;
+ }
+ unblock() {
+ this.iframeLocks--;
+ if (this.iframeLocks < 0) {
+ this.iframeLocks = 0;
+ }
+ if (this.iframeLocks === 0) {
+ this.state.blockIframe = false;
+ this.state.showLoader = false;
+ }
+ }
+}
+BlockIframe.template = 'website.BlockIframe';
+
+export class WebsitePreview extends Component {
+ setup() {
+ this.websiteService = useService('website');
+ this.dialogService = useService('dialog');
+ this.title = useService('title');
+ this.user = useService('user');
+
+ this.iframeFallbackUrl = '/website/iframefallback';
+
+ this.iframe = useRef('iframe');
+ this.iframefallback = useRef('iframefallback');
+ this.container = useRef('container');
+ this.websiteContext = useState(this.websiteService.context);
+
+ onWillStart(async () => {
+ await this.websiteService.fetchWebsites();
+ const encodedPath = encodeURIComponent(this.path);
+ if (this.websiteDomain && this.websiteDomain !== window.location.origin) {
+ window.location.href = `${this.websiteDomain}/web#action=website.website_preview&path=${encodedPath}&website_id=${this.websiteId}`;
+ } else {
+ this.initialUrl = `/website/force/${this.websiteId}?path=${encodedPath}`;
+ }
+ });
+
+ useEffect(() => {
+ this.websiteService.currentWebsiteId = this.websiteId;
+ this.websiteService.context.showNewContentModal = this.props.action.context.params && this.props.action.context.params.display_new_content;
+ this.websiteService.context.edition = this.props.action.context.params && !!this.props.action.context.params.enable_editor;
+ this.websiteService.context.translation = this.props.action.context.params && !!this.props.action.context.params.edit_translations;
+ if (this.props.action.context.params && this.props.action.context.params.enable_seo) {
+ this.iframe.el.addEventListener('load', () => {
+ this.websiteService.pageDocument = this.iframe.el.contentDocument;
+ this.dialogService.add(OptimizeSEODialog);
+ }, {once: true});
+ }
+ return () => {
+ this.websiteService.currentWebsiteId = null;
+ this.websiteService.websiteRootInstance = undefined;
+ this.websiteService.pageDocument = null;
+ this.websiteService.contentWindow = null;
+ };
+ }, () => [this.props.action.context.params]);
+
+ useEffect(() => {
+ this.websiteService.blockIframe();
+ this.iframe.el.addEventListener('OdooFrameContentLoaded', () => this.websiteService.unblockIframe(), { once: true });
+ }, () => []);
+ }
+
+ get websiteId() {
+ let websiteId = this.props.action.context.params && this.props.action.context.params.website_id;
+ // When no parameter is passed to the client action, the current
+ // website from the website service is taken. By default, it will be
+ // the one from the session.
+ if (!websiteId) {
+ websiteId = this.websiteService.currentWebsite && this.websiteService.currentWebsite.id;
+ }
+ if (!websiteId) {
+ websiteId = this.websiteService.websites[0].id;
+ }
+ return websiteId;
+ }
+
+ get websiteDomain() {
+ return this.websiteService.websites.find(website => website.id === this.websiteId).domain;
+ }
+
+ get path() {
+ let path = this.websiteService.editedObjectPath;
+ if (!path) {
+ path = this.props.action.context.params && this.props.action.context.params.path;
+ if (path) {
+ const url = new URL(path, window.location.origin);
+ if (this._isTopWindowURL(url)) {
+ // If the client action is initialized with a path that
+ // should not be opened inside the iframe (= something we
+ // would want to open on the top window), we consider that
+ // this is not a valid flow. Instead of trying to open it on
+ // the top window, we initialize the iframe with the
+ // website homepage...
+ path = '/';
+ } else {
+ // ... otherwise, the path still needs to be normalized (as
+ // it would be if the given path was used as an href of a
+ // element).
+ path = url.pathname + url.search + url.hash;
+ }
+ } else {
+ path = '/';
+ }
+ }
+ return path;
+ }
+
+ reloadIframe(url) {
+ return new Promise((resolve, reject) => {
+ this.iframe.el.addEventListener('OdooFrameContentLoaded', resolve, { once: true });
+ if (url) {
+ this.iframe.el.contentWindow.location = url;
+ } else {
+ this.iframe.el.contentWindow.location.reload();
+ }
+ });
+ }
+
+ addWelcomeMessage() {
+ if (this.websiteService.isPublisher) {
+ const $wrap = $(this.iframe.el.contentDocument.querySelector('#wrapwrap.homepage')).find('#wrap');
+ if ($wrap.length && $wrap.html().trim() === '') {
+ this.$welcomeMessage = $(core.qweb.render('website.homepage_editor_welcome_message'));
+ this.$welcomeMessage.addClass('o_homepage_editor_welcome_message');
+ this.$welcomeMessage.css('min-height', $wrap.parent('main').height() - ($wrap.outerHeight(true) - $wrap.height()));
+ $wrap.empty().append(this.$welcomeMessage);
+ }
+ }
+ }
+
+ removeWelcomeMessage() {
+ if (this.$welcomeMessage) {
+ this.$welcomeMessage.detach();
+ }
+ }
+
+ /**
+ * Returns true if the url should be opened in the top
+ * window.
+ *
+ * @param host {string} host of the route.
+ * @param pathname {string} path of the route.
+ * @private
+ */
+ _isTopWindowURL({ host, pathname }) {
+ const backendRoutes = ['/web', '/web/session/logout'];
+ return host !== window.location.host || (pathname && backendRoutes.includes(pathname));
+ }
+
+ _onPageLoaded() {
+ this.iframe.el.contentWindow.addEventListener('beforeunload', this._onPageUnload.bind(this));
+
+ // This replaces the browser url (/web#action=website...) with
+ // the iframe's url (it is clearer for the user).
+ this.currentUrl = this.iframe.el.contentDocument.location.href;
+ this.currentTitle = this.iframe.el.contentDocument.title;
+ history.replaceState({}, this.currentTitle, this.currentUrl);
+ this.title.setParts({ action: this.currentTitle });
+
+ this.websiteService.pageDocument = this.iframe.el.contentDocument;
+ this.websiteService.contentWindow = this.iframe.el.contentWindow;
+
+ // This is needed for the registerThemeHomepageTour tours
+ const { editable, viewXmlid } = this.websiteService.currentWebsite.metadata;
+ this.container.el.dataset.viewXmlid = viewXmlid;
+ if (!editable) {
+ this.iframefallback.el.classList.add('d-none');
+ }
+
+ this.iframe.el.contentWindow.addEventListener('PUBLIC-ROOT-READY', (event) => {
+ this.iframe.el.setAttribute('is-ready', 'true');
+ if (!this.websiteContext.edition && editable) {
+ this.addWelcomeMessage();
+ }
+ this.websiteService.websiteRootInstance = event.detail.rootInstance;
+ });
+
+ // The clicks on the iframe are listened, so that links with external
+ // redirections can be opened in the top window.
+ this.iframe.el.contentDocument.addEventListener('click', (ev) => {
+ if (!this.websiteContext.edition) {
+ // Forward clicks to close backend client action's navbar
+ // dropdowns.
+ this.iframe.el.dispatchEvent(new MouseEvent('click', ev));
+ }
+
+ const linkEl = ev.target.closest('[href]');
+ if (!linkEl) {
+ return;
+ }
+
+ const { href, target } = linkEl;
+ if (href && target !== '_blank' && !this.websiteContext.edition && this._isTopWindowURL(linkEl)) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ window.location.replace(href);
+ }
+ });
+ this.iframe.el.contentDocument.addEventListener('keydown', ev => {
+ this.iframe.el.dispatchEvent(new KeyboardEvent('keydown', ev));
+ });
+ this.iframe.el.contentDocument.addEventListener('keyup', ev => {
+ this.iframe.el.dispatchEvent(new KeyboardEvent('keyup', ev));
+ });
+ }
+
+ _onPageUnload() {
+ this.websiteService.websiteRootInstance = undefined;
+ this.iframe.el.setAttribute('is-ready', 'false');
+ // Before leaving the iframe, its content is replicated on an
+ // underlying iframe, to avoid for white flashes (visible on
+ // Chrome Windows/Linux).
+ // If the iframe is currently displaying an XML file, the body does not
+ // exist, so we do not replace the iframefallback content.
+ if (!this.websiteContext.edition && this.iframe.el.contentDocument.body) {
+ this.iframefallback.el.contentDocument.body.replaceWith(this.iframe.el.contentDocument.body.cloneNode(true));
+ this.iframefallback.el.classList.remove('d-none');
+ $().getScrollingElement(this.iframefallback.el.contentDocument)[0].scrollTop = $().getScrollingElement(this.iframe.el.contentDocument)[0].scrollTop;
+ }
+ }
+}
+WebsitePreview.template = 'website.WebsitePreview';
+WebsitePreview.components = {
+ WebsiteEditorComponent,
+ BlockIframe,
+ WebsiteTranslator,
+};
+
+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
new file mode 100644
index 00000000000..e031711dcbb
--- /dev/null
+++ b/addons/website/static/src/client_actions/website_preview/website_preview.scss
@@ -0,0 +1,78 @@
+@mixin o-iframe-position($height) {
+ // TODO review this, this should not be needed as we want the iframe to
+ // be centered and potentially respect its aspect ratio whatever the size.
+ $width: $height * (9 / 16);
+ height: $height;
+ width: $width;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+}
+
+.o_website_preview {
+ position: relative;
+ height: 100%;
+ transition: margin-right ease 400ms;
+
+ &.editor_enable.editor_has_snippets {
+ margin-right: $o-we-sidebar-width !important;
+ }
+
+ .o_iframe_container {
+ position: relative;
+ height: 100%;
+ width: 100%;
+
+ iframe {
+ position: absolute;
+ border: none;
+ width: 100%;
+ height: 100%;
+ }
+
+ &.o_is_mobile {
+ @include media-breakpoint-up(md) {
+ @include o-iframe-position(720px);
+
+ @media(max-height: calc(720px + #{$o-navbar-height})) {
+ @include o-iframe-position(520px);
+ }
+
+ iframe {
+ border-radius: 25px;
+ box-shadow: 0 0 3px rgba(0, 0, 0, 0.1);
+ }
+
+ .o_block_iframe {
+ border-radius: 25px;
+ }
+ }
+ }
+ }
+}
+
+.o_website_fullscreen {
+ .o_website_preview.editor_enable.editor_has_snippets {
+ margin-right: 0 !important;
+ }
+}
+
+.o_website_fullscreen, .editor_has_snippets {
+ header {
+ .o_main_navbar {
+ height: 0px;
+ transform: translateY(-$o-navbar-height);
+ border-bottom: 0px;
+ }
+ }
+}
+
+header {
+ .o_main_navbar {
+ transition: transform 400ms ease, height 400ms ease, border-bottom 400ms ease;
+ }
+}
+
+.o_block_iframe {
+ z-index: $zindex-dropdown - 1;
+}
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
new file mode 100644
index 00000000000..1aa1ee9fc74
--- /dev/null
+++ b/addons/website/static/src/client_actions/website_preview/website_preview.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Welcome to your Homepage!
+
Click on Edit in the top right corner to start designing.
+
+
diff --git a/addons/website/static/src/components/ace_editor/ace_editor.js b/addons/website/static/src/components/ace_editor/ace_editor.js
new file mode 100644
index 00000000000..ea1ec060d4a
--- /dev/null
+++ b/addons/website/static/src/components/ace_editor/ace_editor.js
@@ -0,0 +1,146 @@
+/** @odoo-module */
+
+import {ComponentAdapter} from 'web.OwlCompatibility';
+import {useService} from '@web/core/utils/hooks';
+import AceEditor from 'web_editor.ace';
+
+const {Component} = owl;
+
+export const WebsiteAceEditor = AceEditor.extend({
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ do_hide() {
+ this.options.toggleAceEditor(false);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @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 = _.findWhere(this.views, {id: this._getSelectedResource()});
+ let context;
+ this.trigger_up('context_get', {
+ callback: (ctx) => {
+ context = ctx;
+ },
+ });
+ defs.push(this._rpc({
+ model: 'ir.ui.view',
+ method: 'search_read',
+ fields: ['id'],
+ domain: [['key', '=', selectedView.key], ['website_id', '=', context.website_id]],
+ }));
+ }
+ 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());
+ }
+ },
+ /**
+ * @override
+ */
+ _rpc(options) {
+ let context;
+ this.trigger_up('context_get', {
+ callback: (ctx) => {
+ context = ctx;
+ },
+ });
+ return this._super({...options, context: context});
+ },
+});
+
+export class AceEditorAdapterComponent extends ComponentAdapter {
+ setup() {
+ super.setup();
+
+ this.website = useService("website");
+ this.user = useService("user");
+ this.env = Component.env;
+ }
+
+ _trigger_up(event) {
+ if (event.name === 'context_get') {
+ return event.data.callback({...this.user.context, website_id: this.website.currentWebsite.id});
+ }
+ super._trigger_up(event);
+ }
+
+ /**
+ * @override
+ */
+ get widgetArgs() {
+ return [
+ this.website.pageDocument && this.website.pageDocument.documentElement.dataset.viewXmlid,
+ {
+ toggleAceEditor: this.props.toggleAceEditor,
+ defaultBundlesRestriction: [
+ 'web.assets_frontend',
+ 'web.assets_frontend_minimal',
+ 'web.assets_frontend_lazy',
+ ],
+ reload: () => {
+ this.website.contentWindow.location.reload();
+ },
+ }
+ ];
+ }
+
+}
diff --git a/addons/website/static/src/components/ace_editor/ace_editor.scss b/addons/website/static/src/components/ace_editor/ace_editor.scss
new file mode 100644
index 00000000000..c4eae6c878c
--- /dev/null
+++ b/addons/website/static/src/components/ace_editor/ace_editor.scss
@@ -0,0 +1,9 @@
+.o_ace_view_editor: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/dialog/dialog.js b/addons/website/static/src/components/dialog/dialog.js
new file mode 100644
index 00000000000..c119fe15602
--- /dev/null
+++ b/addons/website/static/src/components/dialog/dialog.js
@@ -0,0 +1,107 @@
+/** @odoo-module **/
+
+import { useAutofocus } from '@web/core/utils/hooks';
+import { Dialog } from '@web/core/dialog/dialog';
+import { Switch } from '@website/components/switch/switch';
+import { _t } from 'web.core';
+
+const { xml, useState, Component } = owl;
+
+const NO_OP = () => {};
+
+export class WebsiteDialog extends Component {
+ async primaryClick() {
+ if (this.props.primaryClick) {
+ await this.props.primaryClick();
+ }
+ if (this.props.closeOnClick) {
+ this.props.close();
+ }
+ }
+
+ async secondaryClick() {
+ if (this.props.secondaryClick) {
+ await this.props.secondaryClick();
+ }
+ if (this.props.closeOnClick) {
+ this.props.close();
+ }
+ }
+
+ get contentClasses() {
+ const websiteDialogClass = 'o_website_dialog';
+ if (this.props.contentClass) {
+ return `${websiteDialogClass} ${this.props.contentClass}`;
+ }
+ return websiteDialogClass;
+ }
+}
+WebsiteDialog.components = { Dialog };
+WebsiteDialog.props = {
+ ...Dialog.props,
+ primaryTitle: { type: String, optional: true },
+ primaryClick: { type: Function, optional: true },
+ secondaryTitle: { type: String, optional: true },
+ secondaryClick: { type: Function, optional: true },
+ showSecondaryButton: { type: Boolean, optional: true },
+ close: { type: Function, optional: true },
+ closeOnClick: { type: Boolean, optional: true },
+ body: { type: String, optional: true },
+ slots: { type: Object, optional: true },
+};
+WebsiteDialog.defaultProps = {
+ ...Dialog.defaultProps,
+ title: _t("Confirmation"),
+ primaryTitle: _t("Ok"),
+ secondaryTitle: _t("Cancel"),
+ showSecondaryButton: true,
+ size: "md",
+ closeOnClick: true,
+ close: NO_OP,
+};
+WebsiteDialog.template = "website.WebsiteDialog";
+
+export class AddPageDialog extends Component {
+ setup() {
+ super.setup();
+ useAutofocus();
+
+ this.title = this.env._t("New Page");
+ this.primaryTitle = this.env._t("Create");
+ this.switchLabel = this.env._t("Add to menu");
+
+ this.state = useState({
+ addMenu: true,
+ name: '',
+ });
+ }
+
+ onChangeAddMenu(value) {
+ this.state.addMenu = value;
+ }
+
+ async addPage() {
+ await this.props.addPage(this.state.name, this.state.addMenu);
+ }
+}
+AddPageDialog.components = {
+ Switch,
+ WebsiteDialog,
+};
+AddPageDialog.template = xml`
+
+
Here are the visuals used to help you translate efficiently:
+
+
Content to translate
+
Translated content
+
+
+ In this mode, you can only translate texts. To change the structure of the page, you must edit the master page.
+ Each modification on the master page is automatically applied to all translated versions.
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/website/static/src/components/wysiwyg_adapter/page_options.js b/addons/website/static/src/components/wysiwyg_adapter/page_options.js
new file mode 100644
index 00000000000..7184a8ecf75
--- /dev/null
+++ b/addons/website/static/src/components/wysiwyg_adapter/page_options.js
@@ -0,0 +1,53 @@
+/** @odoo-module **/
+export const pageOptionsCallbacks = {
+ header_overlay: function (value) {
+ this.document.getElementById('wrapwrap').classList.toggle('o_header_overlay', value);
+ },
+ header_color: function (value) {
+ const headerEl = this.document.querySelector('#wrapwrap > header');
+ if (this.value) {
+ headerEl.classList.remove(this.value);
+ }
+ if (value) {
+ headerEl.classList.add(value);
+ }
+ },
+ header_visible: function (value) {
+ const headerEl = this.document.querySelector('#wrapwrap > header');
+ headerEl.classList.toggle('d-none', !value);
+ headerEl.classList.toggle('o_snippet_invisible', !value);
+ },
+ footer_visible: function (value) {
+ this.document.querySelector('#wrapwrap > footer').toggleClass('d-none o_snippet_invisible', !value);
+ },
+};
+export class PageOption {
+ /***
+ * Some page options are defined with an input el hidden inside the content that we're editing.
+ *
+ * @param {HTMLInputElement} el The element holding the value of the page option
+ * @param {Document} document The document on which the option applies.
+ * @param {string} name The name of the method to be called before applying an option.
+ * @param {boolean} isDirty true when it has been modified during an edit session.
+ */
+ constructor(el, document, name, isDirty = false) {
+ this.el = el;
+ this.isDirty = isDirty;
+ this.document = document;
+ this.name = name;
+ this.callback = pageOptionsCallbacks[name].bind(this);
+ }
+ get value() {
+ if (this.el.value.toLowerCase() === 'true') {
+ return true;
+ } else if (this.el.value.toLowerCase() === 'false') {
+ return false;
+ }
+ return this.el.value;
+ }
+ set value(value) {
+ this.callback(value);
+ this.el.value = value;
+ this.isDirty = true;
+ }
+}
diff --git a/addons/website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.js b/addons/website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.js
new file mode 100644
index 00000000000..33729ff1162
--- /dev/null
+++ b/addons/website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.js
@@ -0,0 +1,716 @@
+/** @odoo-module */
+
+import { ComponentAdapter } from 'web.OwlCompatibility';
+import { _t } from '@web/core/l10n/translation';
+
+import { useWowlService } from '@web/legacy/utils';
+import { useHotkey } from '@web/core/hotkeys/hotkey_hook';
+import { setEditableWindow } from 'web_editor.utils';
+
+import { EditMenuDialog, MenuDialog } from "../dialog/edit_menu";
+import { WebsiteDialog } from '../dialog/dialog';
+import { PageOption } from "./page_options";
+
+const { onWillStart, useEffect } = owl;
+
+/**
+ * This component adapts the Wysiwyg widget from @web_editor/wysiwyg.js.
+ * It encapsulate it so that this legacy widget can work in an OWL framework.
+ */
+export class WysiwygAdapterComponent extends ComponentAdapter {
+ /**
+ * @override
+ */
+ setup() {
+ super.setup();
+ this.options = this.props.options || {};
+
+ this.websiteService = useWowlService('website');
+ this.userService = useWowlService('user');
+ this.rpc = useWowlService('rpc');
+ this.orm = useWowlService('orm');
+ this.dialogs = useWowlService('dialog');
+
+ this.oeStructureSelector = '#wrapwrap .oe_structure[data-oe-xpath][data-oe-id]';
+ this.oeFieldSelector = '#wrapwrap [data-oe-field]';
+ this.oeCoverSelector = '#wrapwrap .s_cover[data-res-model], #wrapwrap .o_record_cover_container[data-res-model]';
+ if (this.props.savableSelector) {
+ this.savableSelector = this.props.savableSelector;
+ } else {
+ this.savableSelector = `${this.oeStructureSelector}, ${this.oeFieldSelector}, ${this.oeCoverSelector}`;
+ }
+ this.pageOptions = {};
+ // Disable command palette since LinkTools take over that shortcut
+ useHotkey('control+k', () => {});
+
+ onWillStart(() => {
+ const pageOptionEls = this.websiteService.pageDocument.querySelectorAll('.o_page_option_data');
+ for (const pageOptionEl of pageOptionEls) {
+ const optionName = pageOptionEl.name;
+ this.pageOptions[optionName] = new PageOption(pageOptionEl, this.websiteService.pageDocument, optionName);
+ }
+ this.editableElements(this.$editable).addClass('o_editable');
+
+ let switchableRelatedViews = [];
+ const viewKey = this.websiteService.pageDocument.documentElement.dataset.viewXmlid;
+ if (this.websiteService.isDesigner && viewKey) {
+ switchableRelatedViews = this.rpc('/website/get_switchable_related_views', {key: viewKey});
+ }
+ this.switchableRelatedViews = Promise.resolve(switchableRelatedViews);
+ });
+
+ useEffect(() => {
+ const initWysiwyg = async () => {
+ this.$editable.on('click.odoo-website-editor', '*', this, this._preventDefault);
+ // Disable OdooEditor observer's while setting up classes
+ this.widget.odooEditor.observerUnactive();
+ this._addEditorMessages();
+ if (this.props.beforeEditorActive) {
+ await this.props.beforeEditorActive(this.$editable);
+ }
+ this._setObserver();
+
+ if (this.props.snippetSelector) {
+ const $snippetEl = $(this.websiteService.pageDocument).find(this.props.snippetSelector);
+ await this.widget.snippetsMenu.activateSnippet($snippetEl);
+ if ($snippetEl.length) {
+ $snippetEl[0].scrollIntoView();
+ }
+ }
+ // The jquery instance inside the iframe needs to be aware of the wysiwyg.
+ this.websiteService.contentWindow.$('#wrapwrap').data('wysiwyg', this.widget);
+ this._websiteRootEvent('widgets_start_request', {editableMode: true, onSuccess: () => {
+ this.widget.odooEditor.observerActive();
+ }});
+ this.props.wysiwygReady();
+ // Set utils functions' editable window to the current iframe's window.
+ // This allows those function to access the correct styles definitions,
+ // document element, etc.
+ setEditableWindow(this.websiteService.contentWindow);
+ };
+
+ initWysiwyg();
+
+ return () => {
+ this.$editable.off('click.odoo-website-editor', '*');
+ setEditableWindow(window);
+ };
+ }, () => []);
+
+ }
+ /**
+ * @override
+ */
+ onWillStart() {
+ this.props.removeWelcomeMessage();
+ return super.onWillStart();
+ }
+ /**
+ * Stop the widgets and save the content.
+ *
+ * @returns {Promise} the save promise from the Wysiwyg widget.
+ */
+ async save() {
+ const mainObject = this.websiteService.currentWebsite.metadata.mainObject;
+ if (this.observer) {
+ this.observer.disconnect();
+ delete this.observer;
+ }
+ const dirtyPageOptions = Object.entries(this.pageOptions).filter(([name, option]) => option.isDirty);
+ await Promise.all(dirtyPageOptions.map(async ([name, option]) => {
+ await this.orm.write(mainObject.model, [mainObject.id], {[name]: option.value});
+ }));
+ return this.widget.saveContent(false);
+ }
+ /**
+ * Returns the elements on the page which are editable.
+ *
+ * @param $wrapwrap
+ * @returns {*}
+ */
+ editableElements($wrapwrap) {
+ if (this.props.editableElements) {
+ return this.props.editableElements();
+ }
+ return $wrapwrap.find('[data-oe-model]')
+ .not('.o_not_editable')
+ .filter(function () {
+ var $parent = $(this).closest('.o_editable, .o_not_editable');
+ return !$parent.length || $parent.hasClass('o_editable');
+ })
+ .not('link, script')
+ .not('[data-oe-readonly]')
+ .not('img[data-oe-field="arch"], br[data-oe-field="arch"], input[data-oe-field="arch"]')
+ .not('.oe_snippet_editor')
+ .not('hr, br, input, textarea')
+ .add('.o_editable');
+ }
+ /**
+ * @override
+ */
+ get widgetArgs() {
+ return [this._wysiwygParams];
+ }
+ /**
+ * Return the editable parent element. This includes content inside it which isn't editable.
+ *
+ * @returns {HTMLElement}
+ */
+ get editable() {
+ return this.websiteService.pageDocument.getElementById('wrapwrap');
+ }
+ /**
+ * @see {editable} jQuery wrapped editable.
+ *
+ * @returns {jQuery}
+ */
+ get $editable() {
+ return $(this.editable);
+ }
+ get _context() {
+ return Object.assign({},
+ this.userService.context,
+ {
+ website_id: this.websiteService.currentWebsite.id,
+ lang: (this.websiteService.pageDocument.documentElement.getAttribute('lang') || 'en_US').replace('-', '_'),
+ },
+ );
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @return {Object} Params to pass to the wysiwyg widget.
+ */
+ get _wysiwygParams() {
+ return {
+ snippets: 'website.snippets',
+ recordInfo: {
+ context: this._context,
+ data_res_model: 'website',
+ data_res_id: this._context.website_id,
+ },
+ context: this._context,
+ editable: this.$editable,
+ discardButton: true,
+ saveButton: true,
+ devicePreview: true,
+ savableSelector: this.savableSelector,
+ isRootEditable: false,
+ controlHistoryFromDocument: true,
+ getContentEditableAreas: this._getContentEditableAreas.bind(this),
+ document: this.websiteService.pageDocument,
+ sideAttach: true,
+ isWebsite: true, // If set to true, it will trigger isolated behaviours in website patches. (.include)
+ powerboxCommands: this._getSnippetsCommands(),
+ showEmptyElementHint: false,
+ getReadOnlyAreas: this._getReadOnlyAreas.bind(this),
+ ...this.props.wysiwygOptions,
+ };
+ }
+ /**
+ * Sets the observer so that if any change happen to the body and such
+ * changes should be saved, the class 'o_dirty' is added to elements
+ * that were changed.
+ *
+ * @private
+ */
+ _setObserver() {
+ const processRecords = (records) => {
+ records = this.widget.odooEditor.filterMutationRecords(records);
+ // Skip the step for this stack because if the editor undo the first
+ // step that has a dirty element, the following code would have
+ // generated a new stack and break the "redo" of the editor.
+ this.widget.odooEditor.automaticStepSkipStack();
+ for (const record of records) {
+ const $savable = $(record.target).closest(this.savableSelector);
+
+ if (record.attributeName === 'contenteditable') {
+ continue;
+ }
+ $savable.not('.o_dirty').each(function () {
+ if (!this.hasAttribute('data-oe-readonly')) {
+ this.classList.add('o_dirty');
+ }
+ });
+ }
+ };
+ this.observer = new MutationObserver(processRecords);
+
+ this._observe();
+
+ this.widget.odooEditor.addEventListener('observerUnactive', () => {
+ if (this.observer) {
+ processRecords(this.observer.takeRecords());
+ this.observer.disconnect();
+ }
+ });
+ this.widget.odooEditor.addEventListener('observerActive', this._observe.bind(this));
+ }
+ /**
+ * @private
+ */
+ _observe() {
+ if (this.observer) {
+ this.observer.observe(this.websiteService.pageDocument.body, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeOldValue: true,
+ characterData: true,
+ });
+ }
+ }
+ /**
+ * Adds automatic editor messages on drag&drop zone elements.
+ *
+ * @private
+ */
+ _addEditorMessages() {
+ const $wrap = this.$editable.find('.oe_structure.oe_empty, [data-oe-type="html"]');
+ this.$editorMessageElement = $wrap.not('[data-editor-message]')
+ .attr('data-editor-message', _t('DRAG BUILDING BLOCKS HERE'));
+ $wrap.filter(':empty').attr('contenteditable', false);
+ }
+ /**
+ * Get the areas on the page that should be editable.
+ *
+ * @returns {Node[]} list of nodes that can be edited.
+ */
+ _getContentEditableAreas() {
+ const savableElements = $(this.websiteService.pageDocument).find(this.savableSelector)
+ .not('input, [data-oe-readonly],[data-oe-type="monetary"],[data-oe-many2one-id], [data-oe-field="arch"]:empty');
+ return Array.from(savableElements).filter(element => !element.closest('.o_not_editable'));
+ }
+ _getReadOnlyAreas() {
+ return [];
+ }
+ /**
+ * This method provides support for the legacy event system.
+ * The supported events are defined in the prototype.
+ * @see WysiwygAdapterComponent.events
+ * If the event is not supported it uses the super class method's.
+ * See {@link ComponentAdapter._trigger_up}.
+ *
+ * @override
+ * @param {Event} event
+ */
+ _trigger_up(event) {
+ if (this.events[event.name]) {
+ let method = this.events[event.name];
+ return this[method](event);
+ }
+ return super._trigger_up(...arguments);
+ }
+ /***
+ * Handles action request from inner widgets
+ *
+ * @param {Event} event the event that triggerd the action.
+ * @returns {*}
+ * @private
+ */
+ async _handleAction(event) {
+ const actionName = event.data.actionName;
+ const params = event.data.params;
+ switch (actionName) {
+ case 'get_page_option':
+ return event.data.onSuccess(this.pageOptions[params[0]].value);
+ case 'toggle_page_option':
+ return this._togglePageOption(...params);
+ case 'edit_menu':
+ return this.dialogs.add(EditMenuDialog, {
+ save: () => {
+ const snippetsMenu = this.widget.snippetsMenu;
+ snippetsMenu.trigger_up('request_save', {reload: true, _toMutex: true});
+ },
+ });
+ }
+ }
+ /**
+ * Toggles or force an option linked to the page.
+ *
+ * @see {PageOption}
+ * @param {Object} params
+ * @param {string} params.name the name of the page option,
+ * @param {*} params.value the value if needed to be forced
+ * @private
+ */
+ _togglePageOption(params) {
+ const pageOption = this.pageOptions[params.name];
+ pageOption.value = params.value === undefined ? !pageOption.value : params.value;
+ }
+ /**
+ * Triggers an event on the iframe's public root.
+ *
+ * @private
+ * @param type {string}
+ * @param eventData {*}
+ * @returns {void|OdooEvent|*}
+ */
+ _websiteRootEvent(type, eventData = {}) {
+ const websiteRootInstance = this.websiteService.websiteRootInstance;
+ return websiteRootInstance.trigger_up(type, {...eventData});
+ }
+ _preventDefault(e) {
+ e.preventDefault();
+ }
+ /**
+ * @private
+ */
+ async _reloadBundles(event) {
+ const bundles = await this.rpc('/website/theme_customize_bundle_reload');
+ let $allLinksIframe = $();
+ const proms = [];
+ const createLinksProms = (bundleURLs, $insertionEl) => {
+ let $newLinks = $();
+ for (const url of bundleURLs) {
+ $newLinks = $newLinks.add('', {
+ type: 'text/css',
+ rel: 'stylesheet',
+ href: url + `#t=${new Date().getTime()}`, // Insures that the css will be reloaded.
+ });
+ }
+ proms.push(new Promise((resolve, reject) => {
+ let nbLoaded = 0;
+ $newLinks.on('load error', () => {
+ if (++nbLoaded >= $newLinks.length) {
+ resolve();
+ }
+ });
+ }));
+ $insertionEl.after($newLinks);
+ };
+ _.map(bundles, (bundleURLs, bundleName) => {
+ const selector = `link[href*="${bundleName}"]`;
+ const $linksIframe = this.websiteService.contentWindow.$(selector);
+ if ($linksIframe.length) {
+ $allLinksIframe = $allLinksIframe.add($linksIframe);
+ createLinksProms(bundleURLs, $linksIframe.last());
+ }
+ });
+ await Promise.all(proms).then(() => {
+ $allLinksIframe.remove();
+ });
+ if (event.data.onSuccess) {
+ return event.data.onSuccess();
+ }
+ }
+ /**
+ * Returns the snippets commands for the powerbox
+ *
+ * @private
+ */
+ _getSnippetsCommands() {
+ const snippetCommandCallback = (selector) => {
+ const $separatorBody = $(selector);
+ const $clonedBody = $separatorBody.clone().removeClass('oe_snippet_body');
+ const range = this.widget.getDeepRange();
+ const block = this.widget.closestElement(range.endContainer, 'p, div, ol, ul, cl, h1, h2, h3, h4, h5, h6');
+ if (block) {
+ block.after($clonedBody[0]);
+ this.widget.snippetsMenu.callPostSnippetDrop($clonedBody);
+ }
+ };
+ return [
+ {
+ groupName: 'Website',
+ title: 'Alert',
+ description: 'Insert an alert snippet.',
+ fontawesome: 'fa-info',
+ callback: () => {
+ snippetCommandCallback('.oe_snippet_body[data-snippet="s_alert"]');
+ },
+ },
+ {
+ groupName: 'Website',
+ title: 'Rating',
+ description: 'Insert a rating snippet.',
+ fontawesome: 'fa-star-half-o',
+ callback: () => {
+ snippetCommandCallback('.oe_snippet_body[data-snippet="s_rating"]');
+ },
+ },
+ {
+ groupName: 'Website',
+ title: 'Card',
+ description: 'Insert a card snippet.',
+ fontawesome: 'fa-sticky-note',
+ callback: () => {
+ snippetCommandCallback('.oe_snippet_body[data-snippet="s_card"]');
+ },
+ },
+ {
+ groupName: 'Website',
+ title: 'Share',
+ description: 'Insert a share snippet.',
+ fontawesome: 'fa-share-square-o',
+ callback: () => {
+ snippetCommandCallback('.oe_snippet_body[data-snippet="s_share"]');
+ },
+ },
+ {
+ groupName: 'Website',
+ title: 'Text Highlight',
+ description: 'Insert a text Highlight snippet.',
+ fontawesome: 'fa-sticky-note',
+ callback: () => {
+ snippetCommandCallback('.oe_snippet_body[data-snippet="s_text_highlight"]');
+ },
+ },
+ {
+ groupName: 'Website',
+ title: 'Chart',
+ description: 'Insert a chart snippet.',
+ fontawesome: 'fa-bar-chart',
+ callback: () => {
+ snippetCommandCallback('.oe_snippet_body[data-snippet="s_chart"]');
+ },
+ },
+ {
+ groupName: 'Website',
+ title: 'Progress Bar',
+ description: 'Insert a progress bar snippet.',
+ fontawesome: 'fa-spinner',
+ callback: () => {
+ snippetCommandCallback('.oe_snippet_body[data-snippet="s_progress_bar"]');
+ },
+ },
+ {
+ groupName: 'Website',
+ title: 'Badge',
+ description: 'Insert a badge snippet.',
+ fontawesome: 'fa-tags',
+ callback: () => {
+ snippetCommandCallback('.oe_snippet_body[data-snippet="s_badge"]');
+ },
+ },
+ {
+ groupName: 'Website',
+ title: 'Blockquote',
+ description: 'Insert a blockquote snippet.',
+ fontawesome: 'fa-quote-left',
+ callback: () => {
+ snippetCommandCallback('.oe_snippet_body[data-snippet="s_blockquote"]');
+ },
+ },
+ {
+ groupName: 'Website',
+ title: 'Separator',
+ description: 'Insert an horizontal separator sippet.',
+ fontawesome: 'fa-minus',
+ callback: () => {
+ snippetCommandCallback('.oe_snippet_body[data-snippet="s_hr"]');
+ },
+ },
+ ];
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Creates a new event and dispatch it in the iframe's public widget
+ *
+ * @param {OdooEvent} event
+ * @private
+ */
+ _onRootEventRequest(event) {
+ return this._websiteRootEvent(event.name, event.data);
+ }
+
+ /**
+ * Saves the editables to the server if it's dirty. If no
+ * callbacks are given, leaves the editor, otherwise, perform
+ * the callback.
+ *
+ * @param event
+ * @param [event.data.onSuccess] {Function} Async callback
+ * @param [event.data.reload] {boolean}
+ * @param [event.data.reloadEditor] {boolean} reloads the editor.
+ * @param [event.data.reloadWebClient] reloads the Webclient.
+ * @returns {Promise}
+ * @private
+ */
+ async _onSaveRequest(event) {
+ const isDirty = this.widget.isDirty();
+ let callback = () => this.props.quitCallback();
+ if (event.data.reload || event.data.reloadEditor) {
+ this.widget.trigger_up('disable_loading_effects');
+ this.props.willReload(this.widget.el);
+ callback = async () => {
+ if (event.data.onSuccess) {
+ await event.data.onSuccess();
+ }
+ return this.props.reloadCallback({
+ snippetOptionSelector: event.data.optionSelector,
+ url: event.data.url,
+ invalidateSnippetCache: event.data.invalidateSnippetCache
+ });
+ };
+ } else if (event.data.onSuccess) {
+ callback = event.data.onSuccess;
+ } else if (event.data.reloadWebClient) {
+ const currentPath = encodeURIComponent(window.location.pathname);
+ const websiteId = this.websiteService.currentWebsite.id;
+ callback = () => window.location = `/web#action=website.website_preview&website_id=${websiteId}&path=${currentPath}&enable_editor=1`;
+ }
+ if (isDirty) {
+ return this.save().then(callback);
+ } else {
+ return callback();
+ }
+ }
+ /**
+ * Returns the user context.
+ * @link {@web/core/user_service.js}
+ *
+ * @param event
+ * @returns {*}
+ * @private
+ */
+ _onContextGet(event) {
+ return event.data.callback(this._context);
+ }
+ /**
+ * Discards changes and reload the iframe.
+ *
+ * @param event
+ * @returns {*}
+ * @private
+ */
+ _onCancelRequest(event) {
+ const isDirty = this.widget.isDirty();
+ if (isDirty) {
+ this.dialogs.add(WebsiteDialog, {
+ body: _t("If you discard the current edits, all unsaved changes will be lost. You can cancel to return to edit mode."),
+ primaryClick: () => this.props.quitCallback(),
+ secondaryClick: event.data.onReject,
+ });
+ } else {
+ return this.props.quitCallback();
+ }
+ }
+ /***
+ * Starts the widgets inside the dropped snippet.
+ *
+ * @param event {Object}
+ * @param event.data.addPostDropAsync {Function} Function used to push a promise in the stack.
+ * @see web_editor/SnippetsMenu.callPostSnippetDrop
+ * @private
+ */
+ _onSnippetDropped(event) {
+ event.data.addPostDropAsync(new Promise(resolve => {
+ this._websiteRootEvent('widgets_start_request', {
+ editableMode: true,
+ $target: event.data.$target,
+ onSuccess: () => resolve(),
+ });
+ }));
+ }
+ /***
+ * Re-add the editor message if no content is left on the page.
+ *
+ * @param event
+ * @private
+ */
+ _onSnippetRemoved(event) {
+ const $empty = this.$editable.find('.oe_empty');
+ if (!$empty.children().length) {
+ $empty.empty(); // Remove any superfluous whitespace
+ this._addEditorMessages();
+ }
+ }
+ /**
+ * Adds / Edit an entry in the website menu.
+ *
+ * @param event
+ * @private
+ */
+ _onMenuDialogRequest(event) {
+ this.dialogs.add(MenuDialog, {
+ name: event.data.name,
+ url: event.data.url,
+ isMegaMenu: event.data.isMegaMenu,
+ save: async (...args) => {
+ await event.data.save(...args);
+ },
+ });
+ }
+ /***
+ * Updates the Color Preview elements to reflect
+ * the colors that are inside the iframe.
+ * See the web_editor.color.combination.preview QWeb template.
+ *
+ * @param event
+ * @param event.data.ccPreviewEls {HTMLElement} The color combination preview element.
+ * @private
+ */
+ _onColorPreviewsUpdate(event) {
+ this.widget.setCSSVariables(this.widget.snippetsMenu.el);
+ const stylesToCopy = [
+ 'background-color',
+ 'border',
+ 'color',
+ ];
+ const copyStyles = (from, to) => {
+ const cloneStyle = this.websiteService.contentWindow.getComputedStyle(from);
+ for (const style of stylesToCopy) {
+ to.style.setProperty(style, cloneStyle.getPropertyValue(style));
+ }
+ };
+
+ for (const ccPreviewEl of event.data.ccPreviewEls) {
+ ccPreviewEl.setAttribute('style', '');
+ Object.values(ccPreviewEl.children).forEach(child => child.setAttribute('style', ''));
+ const iframeClone = ccPreviewEl.cloneNode(true);
+ this.websiteService.pageDocument.body.appendChild(iframeClone);
+ copyStyles(iframeClone, ccPreviewEl);
+ copyStyles(iframeClone.querySelector('h1'), ccPreviewEl.querySelector('h1'));
+ copyStyles(iframeClone.querySelector('.btn-primary'), ccPreviewEl.querySelector('.btn-primary'));
+ copyStyles(iframeClone.querySelector('.btn-secondary'), ccPreviewEl.querySelector('.btn-secondary'));
+ copyStyles(iframeClone.querySelector('p'), ccPreviewEl.querySelector('p'));
+ iframeClone.remove();
+ }
+ }
+ /**
+ * Update the context to trigger a mobile view.
+ * @private
+ */
+ _onMobilePreviewRequest() {
+ this.websiteService.context.isMobile = !this.websiteService.context.isMobile;
+ }
+ /**
+ * Called when a child needs to know about the views that can
+ * be toggled on or off on a specific view related to the editable.
+ *
+ * @param event
+ * @returns {Promise}
+ * @private
+ */
+ async _onGetSwitchableRelatedViews(event) {
+ const views = await this.switchableRelatedViews;
+ event.data.onSuccess(views);
+ }
+}
+WysiwygAdapterComponent.prototype.events = {
+ 'widgets_start_request': '_onRootEventRequest',
+ 'widgets_stop_request': '_onRootEventRequest',
+ 'ready_to_clean_for_save': '_onRootEventRequest',
+ 'gmap_api_request': '_onRootEventRequest',
+ 'gmap_api_key_request': '_onRootEventRequest',
+ 'request_save': '_onSaveRequest',
+ 'context_get': '_onContextGet',
+ 'action_demand': '_handleAction',
+ 'request_cancel': '_onCancelRequest',
+ 'snippet_dropped': '_onSnippetDropped',
+ 'snippet_removed': '_onSnippetRemoved',
+ 'reload_bundles': '_reloadBundles',
+ 'menu_dialog': '_onMenuDialogRequest',
+ 'update_color_previews': '_onColorPreviewsUpdate',
+ 'request_mobile_preview': '_onMobilePreviewRequest',
+ 'get_switchable_related_views': '_onGetSwitchableRelatedViews',
+};
diff --git a/addons/website/static/src/js/backend/dashboard.js b/addons/website/static/src/js/backend/dashboard.js
index 4fd02f41ccd..8610a1374fa 100644
--- a/addons/website/static/src/js/backend/dashboard.js
+++ b/addons/website/static/src/js/backend/dashboard.js
@@ -138,7 +138,7 @@ var Dashboard = AbstractAction.extend({
on_go_to_website: function (ev) {
ev.preventDefault();
var website = _.findWhere(this.websites, {selected: true});
- window.location.href = `/website/force/${website.id}`;
+ window.location.replace(`/web#action=website.website_preview&website_id=${website.id}`);
},
on_save_ga_client_id: function(ga_client_id, ga_analytics_key) {
diff --git a/addons/website/static/src/js/content/redirect.js b/addons/website/static/src/js/content/redirect.js
new file mode 100644
index 00000000000..1a40e7cf711
--- /dev/null
+++ b/addons/website/static/src/js/content/redirect.js
@@ -0,0 +1,102 @@
+/** @odoo-module */
+
+import { session } from '@web/session';
+// import { _t } from "@web/core/l10n/translation"; // FIXME don't know why it does not work
+const _t = str => str;
+
+/**
+ * This script, served with frontend pages, displays buttons in the top left
+ * corner to provide the authenticated user an access to his odoo backend.
+ * In the case of the page being viewed in the website_preview client action,
+ * it will forward some events to its parent.
+ */
+document.addEventListener('DOMContentLoaded', () => {
+ if (session.is_website_user) {
+ return;
+ }
+
+ const editInBackendUserDropdownLinkEl = document.getElementById('o_edit_in_backend_user_dropdown_link');
+
+ if (!window.frameElement) {
+ const websiteId = document.documentElement.dataset.websiteId;
+ const {pathname, search} = window.location;
+ const params = new URLSearchParams(search);
+ const enableEditor = params.get('enable_editor');
+ const editTranslations = params.get('edit_translations');
+ params.delete('enable_editor');
+ let newSearch = params.toString();
+ if (newSearch) {
+ newSearch = '?' + newSearch;
+ }
+ const backendPath = `/web#action=website.website_preview&path=${encodeURIComponent(`${pathname}${newSearch}`)}&website_id=${websiteId}`;
+
+ const autoredirectToBackendAction = enableEditor || editTranslations;
+ if (autoredirectToBackendAction) {
+ document.body.innerHTML = '';
+ if (enableEditor) {
+ window.location.replace(`${backendPath}&enable_editor=1`);
+ } else if (editTranslations) {
+ window.location.replace(`${backendPath}&edit_translations=1`);
+ } else {
+ window.location.replace(backendPath);
+ }
+ } else {
+ // FIXME this UI does not respect access rights yet but we will
+ // probably end up doing it in XML instead of JS anyway.
+ const frontendToBackendNavEl = document.createElement('div');
+ frontendToBackendNavEl.classList.add('o_frontend_to_backend_nav');
+ frontendToBackendNavEl.addEventListener('click', ev => {
+ frontendToBackendNavEl.classList.add('o_frontend_to_backend_nav_opened');
+ setTimeout(() => {
+ // TODO find a better way? Useful so that hover effects work
+ // after the opening.
+ frontendToBackendNavEl.classList.add('o_frontend_to_backend_nav_fully_opened');
+ }, 600);
+ }, { once: true });
+
+ const loadingIconEl = document.createElement('i');
+ loadingIconEl.classList.add('o_frontend_to_backend_icon', 'fa', 'fa-arrow-right');
+ frontendToBackendNavEl.appendChild(loadingIconEl);
+
+ const backendAppsButtonEl = document.createElement('a');
+ backendAppsButtonEl.href = '/web';
+ backendAppsButtonEl.title = _t("Go to your Odoo Apps");
+ backendAppsButtonEl.classList.add('o_frontend_to_backend_apps_btn', 'fa', 'fa-th');
+ frontendToBackendNavEl.appendChild(backendAppsButtonEl);
+
+ const backendEditButtonEl = document.createElement('a');
+ backendEditButtonEl.href = `${backendPath}&enable_editor=1`;
+ backendEditButtonEl.textContent = _t("Edit");
+ backendEditButtonEl.title = _t("Edit your page content");
+ backendEditButtonEl.classList.add('o_frontend_to_backend_edit_btn');
+ const backendEditButtonIconEl = document.createElement('i');
+ backendEditButtonIconEl.classList.add('fa', 'fa-pencil');
+ backendEditButtonEl.prepend(backendEditButtonIconEl);
+ frontendToBackendNavEl.appendChild(backendEditButtonEl);
+
+ if (editInBackendUserDropdownLinkEl) {
+ editInBackendUserDropdownLinkEl.href = backendPath;
+ }
+
+ document.body.appendChild(frontendToBackendNavEl);
+ }
+ } else {
+ const backendUserDropdownLinkEl = document.getElementById('o_backend_user_dropdown_link');
+ if (backendUserDropdownLinkEl) {
+ backendUserDropdownLinkEl.classList.add('d-none');
+ backendUserDropdownLinkEl.classList.remove('d-flex');
+ }
+ if (editInBackendUserDropdownLinkEl) {
+ editInBackendUserDropdownLinkEl.classList.add('d-none');
+ editInBackendUserDropdownLinkEl.classList.remove('d-flex');
+ }
+
+ // Multiple reasons to do this:
+ // - It seems like DOMContentLoaded doesn't always trigger when
+ // listened from the parent window
+ // - Having an event that's fire only when the page is from Odoo avoids
+ // weird behaviours. (e.g. if we want to clear out the iframe, it might
+ // fire an DOMContentLoaded on a non odoo page)
+ window.frameElement.dispatchEvent(new CustomEvent('OdooFrameContentLoaded'));
+ }
+});
diff --git a/addons/website/static/src/js/content/snippets.animation.js b/addons/website/static/src/js/content/snippets.animation.js
index 0d08075d5d1..36dfa621b82 100644
--- a/addons/website/static/src/js/content/snippets.animation.js
+++ b/addons/website/static/src/js/content/snippets.animation.js
@@ -513,7 +513,7 @@ registry.Parallax = Animation.extend({
*/
start: function () {
this._rebuild();
- $(window).on('resize.animation_parallax', _.debounce(this._rebuild.bind(this), 500));
+ $(this.$target[0].ownerDocument.defaultView).on('resize.animation_parallax', _.debounce(this._rebuild.bind(this), 500));
return this._super.apply(this, arguments);
},
/**
@@ -521,7 +521,7 @@ registry.Parallax = Animation.extend({
*/
destroy: function () {
this._super.apply(this, arguments);
- $(window).off('.animation_parallax');
+ $(this.$target[0].ownerDocument.defaultView).off('.animation_parallax');
},
//--------------------------------------------------------------------------
@@ -553,7 +553,7 @@ registry.Parallax = Animation.extend({
}
// Initialize parallax data according to snippet and viewport dimensions
- this.viewport = document.body.clientHeight - $('#wrapwrap').position().top;
+ this.viewport = this.$target[0].ownerDocument.body.clientHeight - $(this.$target[0].ownerDocument).find('#wrapwrap').position().top;
this.visibleArea = [this.$target.offset().top];
this.visibleArea.push(this.visibleArea[0] + this.$target.innerHeight() + this.viewport);
this.ratio = this.speed * (this.viewport / 10);
diff --git a/addons/website/static/src/js/content/website_root.js b/addons/website/static/src/js/content/website_root.js
index 44edaa02ac3..9453913ab85 100644
--- a/addons/website/static/src/js/content/website_root.js
+++ b/addons/website/static/src/js/content/website_root.js
@@ -7,14 +7,12 @@ import {Markup} from 'web.utils';
import session from 'web.session';
import publicRootData from 'web.public.root';
import "web.zoomodoo";
-import { FullscreenIndication } from '@website/js/widgets/fullscreen_indication';
export const WebsiteRoot = publicRootData.PublicRoot.extend(KeyboardNavigationMixin, {
// TODO remove KeyboardNavigationMixin in master
events: _.extend({}, KeyboardNavigationMixin.events, publicRootData.PublicRoot.prototype.events || {}, {
'click .js_change_lang': '_onLangChangeClick',
'click .js_publish_management .js_publish_btn': '_onPublishBtnClick',
- 'click .js_multi_website_switch': '_onWebsiteSwitch',
'shown.bs.modal': '_onModalShown',
}),
custom_events: _.extend({}, publicRootData.PublicRoot.prototype.custom_events || {}, {
@@ -36,16 +34,6 @@ export const WebsiteRoot = publicRootData.PublicRoot.extend(KeyboardNavigationMi
});
return this._super(...arguments);
},
- /**
- * @override
- */
- willStart: async function () {
- this.fullscreenIndication = new FullscreenIndication(this);
- return Promise.all([
- this._super(...arguments),
- this.fullscreenIndication.appendTo(document.body),
- ]);
- },
/**
* @override
*/
@@ -171,45 +159,6 @@ export const WebsiteRoot = publicRootData.PublicRoot.extend(KeyboardNavigationMi
}
return this._gmapAPILoading;
},
- /**
- * Toggles the fullscreen mode.
- *
- * @private
- * @param {boolean} state toggle fullscreen on/off (true/false)
- */
- _toggleFullscreen(state) {
- this.isFullscreen = state;
- if (this.isFullscreen) {
- this.fullscreenIndication.show();
- } else {
- this.fullscreenIndication.hide();
- }
- document.body.classList.add('o_fullscreen_transition');
- document.body.classList.toggle('o_fullscreen', this.isFullscreen);
- document.body.style.overflowX = 'hidden';
- let resizing = true;
- window.requestAnimationFrame(function resizeFunction() {
- window.dispatchEvent(new Event('resize'));
- if (resizing) {
- window.requestAnimationFrame(resizeFunction);
- }
- });
- let stopResizing;
- const onTransitionEnd = ev => {
- if (ev.target === document.body && ev.propertyName === 'padding-top') {
- stopResizing();
- }
- };
- stopResizing = () => {
- resizing = false;
- document.body.style.overflowX = '';
- document.body.removeEventListener('transitionend', onTransitionEnd);
- document.body.classList.remove('o_fullscreen_transition');
- };
- document.body.addEventListener('transitionend', onTransitionEnd);
- // Safeguard in case the transitionend event doesn't trigger for whatever reason.
- window.setTimeout(() => stopResizing(), 500);
- },
//--------------------------------------------------------------------------
// Handlers
@@ -311,20 +260,6 @@ export const WebsiteRoot = publicRootData.PublicRoot.extend(KeyboardNavigationMi
$data.parents("[data-publish]").attr("data-publish", +result ? 'on' : 'off');
});
},
- /**
- * @private
- * @param {Event} ev
- */
- _onWebsiteSwitch: function (ev) {
- var websiteId = ev.currentTarget.getAttribute('website-id');
- var websiteDomain = ev.currentTarget.getAttribute('domain');
- let url = `/website/force/${websiteId}`;
- if (websiteDomain && window.location.hostname !== websiteDomain) {
- url = websiteDomain + url;
- }
- const path = window.location.pathname + window.location.search + window.location.hash;
- window.location.href = $.param.querystring(url, {'path': path});
- },
/**
* @private
* @param {Event} ev
@@ -344,7 +279,6 @@ export const WebsiteRoot = publicRootData.PublicRoot.extend(KeyboardNavigationMi
if (ev.keyCode !== $.ui.keyCode.ESCAPE || !document.body.contains(ev.target) || ev.target.closest('.modal')) {
return KeyboardNavigationMixin._onKeyDown.apply(this, arguments);
}
- this._toggleFullscreen(!this.isFullscreen);
},
});
diff --git a/addons/website/static/src/js/content/website_root_instance.js b/addons/website/static/src/js/content/website_root_instance.js
index 63a62c2f4a8..a1f667584e6 100644
--- a/addons/website/static/src/js/content/website_root_instance.js
+++ b/addons/website/static/src/js/content/website_root_instance.js
@@ -2,5 +2,15 @@
import { createPublicRoot } from "@web/legacy/js/public/public_root";
import { WebsiteRoot } from "./website_root";
+import { loadWysiwyg } from "web_editor.loader";
-export default createPublicRoot(WebsiteRoot);
+export default createPublicRoot(WebsiteRoot).then(rootInstance => {
+ // This data attribute is set by the WebsitePreview client action for a
+ // publisher user.
+ if (window.frameElement && window.frameElement.dataset.loadWysiwyg) {
+ loadWysiwyg(['website.compiled_assets_wysiwyg']).then(() => {
+ window.dispatchEvent(new CustomEvent('PUBLIC-ROOT-READY', {detail: {rootInstance}}));
+ });
+ }
+ return rootInstance;
+});
diff --git a/addons/website/static/src/js/editor/editor.js b/addons/website/static/src/js/editor/editor.js
index 2c35d00b464..b490d2a5e61 100644
--- a/addons/website/static/src/js/editor/editor.js
+++ b/addons/website/static/src/js/editor/editor.js
@@ -11,8 +11,11 @@ weWidgets.LinkDialog.include({
* @override
*/
start: async function () {
+ const options = {
+ body: this.linkWidget.$link && this.linkWidget.$link[0].ownerDocument.body,
+ };
const result = await this._super.apply(this, arguments);
- wUtils.autocompleteWithPages(this, this.$('input[name="url"]'));
+ wUtils.autocompleteWithPages(this, this.$('input[name="url"]'), options);
return result;
},
});
diff --git a/addons/website/static/src/js/editor/snippets.editor.js b/addons/website/static/src/js/editor/snippets.editor.js
index ab0e3b83791..c2016be4277 100644
--- a/addons/website/static/src/js/editor/snippets.editor.js
+++ b/addons/website/static/src/js/editor/snippets.editor.js
@@ -11,7 +11,7 @@ const getTraversedNodes = OdooEditorLib.getTraversedNodes;
const FontFamilyPickerUserValueWidget = wSnippetOptions.FontFamilyPickerUserValueWidget;
-weSnippetEditor.SnippetsMenu.include({
+const wSnippetMenu = weSnippetEditor.SnippetsMenu.extend({
xmlDependencies: (weSnippetEditor.SnippetsMenu.prototype.xmlDependencies || [])
.concat(['/website/static/src/xml/website.editor.xml']),
events: _.extend({}, weSnippetEditor.SnippetsMenu.prototype.events, {
@@ -22,6 +22,7 @@ weSnippetEditor.SnippetsMenu.include({
custom_events: Object.assign({}, weSnippetEditor.SnippetsMenu.prototype.custom_events, {
'gmap_api_request': '_onGMapAPIRequest',
'gmap_api_key_request': '_onGMapAPIKeyRequest',
+ 'reload_bundles': '_onReloadBundles',
}),
tabs: _.extend({}, weSnippetEditor.SnippetsMenu.prototype.tabs, {
THEME: 'theme',
@@ -50,7 +51,7 @@ weSnippetEditor.SnippetsMenu.include({
destroy() {
this._super(...arguments);
this.ownerDocument.removeEventListener('selectionchange', this.__onSelectionChange);
- document.body.classList.remove('o_animated_text_highlighted');
+ this.$body[0].classList.remove('o_animated_text_highlighted');
},
//--------------------------------------------------------------------------
@@ -158,6 +159,7 @@ weSnippetEditor.SnippetsMenu.include({
finalOptions.offsetElements.$top = $header;
}
}
+ finalOptions.jQueryDraggableOptions.iframeFix = true;
return finalOptions;
},
/**
@@ -202,7 +204,7 @@ weSnippetEditor.SnippetsMenu.include({
this._super(...arguments);
this.$('#o_we_editor_toolbar_container > we-title > span').after($(`
`));
@@ -216,7 +218,8 @@ weSnippetEditor.SnippetsMenu.include({
* @private
*/
_toggleAnimatedTextButton() {
- if (!this._isValidSelection(window.getSelection())) {
+ const sel = this.options.wysiwyg.odooEditor.document.getSelection();
+ if (!this._isValidSelection(sel)) {
return;
}
const animatedText = this._getAnimatedTextElement();
@@ -287,7 +290,7 @@ weSnippetEditor.SnippetsMenu.include({
el = newEl;
}
this.bottomFakeOptionEl = el;
- this.el.appendChild(this.topFakeOptionEl);
+ this.$body[0].appendChild(this.topFakeOptionEl);
}
// Need all of this in that order so that:
@@ -316,11 +319,45 @@ weSnippetEditor.SnippetsMenu.include({
throw e;
}
},
+ /**
+ * @override
+ */
+ _onOptionsTabClick(ev) {
+ if (!ev.currentTarget.classList.contains('active')) {
+ this._activateSnippet(false);
+ this._mutex.exec(async () => {
+ const switchableViews = await new Promise((resolve, reject) => {
+ this.trigger_up('get_switchable_related_views', {
+ onSuccess: resolve,
+ onFailure: reject,
+ });
+ });
+ if (switchableViews.length) {
+ // These do not need to be awaited as we're in teh context
+ // of the mutex.
+ this._activateSnippet(this.$body.find('#wrapwrap > main'));
+ return;
+ }
+ let $pageOptionsTarget = $();
+ let i = 0;
+ const pageOptions = this.templateOptions.filter(template => template.data.pageOptions);
+ while (!$pageOptionsTarget.length && i < pageOptions.length) {
+ $pageOptionsTarget = pageOptions[i].selector.all();
+ i++;
+ }
+ if ($pageOptionsTarget.length) {
+ this._activateSnippet($pageOptionsTarget);
+ } else {
+ this._activateEmptyOptionsTab();
+ }
+ });
+ }
+ },
/**
* @private
*/
_onAnimateTextClick(ev) {
- const sel = window.getSelection();
+ const sel = this.options.wysiwyg.odooEditor.document.getSelection();
if (!this._isValidSelection(sel)) {
return;
}
@@ -371,9 +408,31 @@ weSnippetEditor.SnippetsMenu.include({
* @private
*/
_onHighlightAnimatedTextClick(ev) {
- $('body').toggleClass('o_animated_text_highlighted');
+ this.$body.toggleClass('o_animated_text_highlighted');
$(ev.target).toggleClass('fa-eye fa-eye-slash').toggleClass('text-success');
},
+ /**
+ * On reload bundles, when it's from the theme tab, destroy any
+ * snippetEditor as they might hold outdated style values. (e.g. color palettes).
+ * We do not destroy the Theme tab editors as they should have the correct
+ * values with their compute widget states.
+ * NOTE: This is a bit janky, _computeWidgetState should modify the
+ * option's widget to reflect the style accordingly. But since
+ * color_palette widget is independent of the UserValueWidget, it's hard to
+ * modify its style using the options events.
+ *
+ * @private
+ */
+ _onReloadBundles(ev) {
+ if (this._currentTab === this.tabs.THEME) {
+ const excludeSelector = this.optionsTabStructure.map(element => element[0]).join(', ');
+ for (const editor of this.snippetEditors) {
+ if (!editor.$target[0].matches(excludeSelector)) {
+ this._mutex.exec(() => editor.destroy());
+ }
+ }
+ }
+ }
});
weSnippetEditor.SnippetEditor.include({
@@ -393,4 +452,7 @@ weSnippetEditor.SnippetEditor.include({
return this._super(...arguments);
},
});
+return {
+ SnippetsMenu: wSnippetMenu,
+};
});
diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js
index b5cb4cf61c2..a761faf3486 100644
--- a/addons/website/static/src/js/editor/snippets.options.js
+++ b/addons/website/static/src/js/editor/snippets.options.js
@@ -3,7 +3,6 @@ odoo.define('website.editor.snippets.options', function (require) {
'use strict';
const {ColorpickerWidget} = require('web.Colorpicker');
-const config = require('web.config');
var core = require('web.core');
var Dialog = require('web.Dialog');
const {Markup, sprintf} = require('web.utils');
@@ -69,7 +68,8 @@ const UrlPickerUserValueWidget = InputUserValueWidget.extend({
classes: {
"ui-autocomplete": 'o_website_ui_autocomplete'
},
- }
+ body: this.getParent().$target[0].ownerDocument.body,
+ };
wUtils.autocompleteWithPages(this, $(this.inputEl), options);
},
@@ -112,7 +112,7 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({
* @override
*/
start: async function () {
- const style = window.getComputedStyle(document.documentElement);
+ const style = window.getComputedStyle(this.$target[0].ownerDocument.documentElement);
const nbFonts = parseInt(weUtils.getCSSVariableValue('number-of-fonts', style));
const googleFontsProperty = weUtils.getCSSVariableValue('google-fonts', style);
this.googleFonts = googleFontsProperty ? googleFontsProperty.split(/\s*,\s*/g) : [];
@@ -392,7 +392,6 @@ const GPSPicker = InputUserValueWidget.extend({
}
},
});
-
options.userValueWidgetsRegistry['we-urlpicker'] = UrlPickerUserValueWidget;
options.userValueWidgetsRegistry['we-fontfamilypicker'] = FontFamilyPickerUserValueWidget;
options.userValueWidgetsRegistry['we-gpspicker'] = GPSPicker;
@@ -451,26 +450,28 @@ options.Class.include({
for (const widget of widgets) {
const methodsNames = widget.getMethodsNames();
const specialMethodsNames = [];
- for (const methodName of methodsNames) {
- if (this.specialCheckAndReloadMethodsNames.includes(methodName)) {
- specialMethodsNames.push(methodName);
+ // If it's a pageOption, it's most likely to need to reload, so check the widgets.
+ if (this.data.pageOptions) {
+ specialMethodsNames.push(methodsNames);
+ } else {
+ for (const methodName of methodsNames) {
+ if (this.specialCheckAndReloadMethodsNames.includes(methodName)) {
+ specialMethodsNames.push(methodName);
+ }
}
}
if (!specialMethodsNames.length) {
continue;
}
- const isDebugAssets = config.isDebug('assets');
- let paramsReload = isDebugAssets;
- if (!isDebugAssets) {
- for (const methodName of specialMethodsNames) {
- if (widget.getMethodsParams(methodName).reload) {
- paramsReload = true;
- break;
- }
+ let paramsReload = false;
+ for (const methodName of specialMethodsNames) {
+ if (widget.getMethodsParams(methodName).reload) {
+ paramsReload = true;
+ break;
}
}
if (paramsReload) {
- return (isDebugAssets ? _t("It appears you are in debug=assets mode, all theme customization options require a page reload in this mode.") : true);
+ return true;
}
}
return false;
@@ -484,10 +485,14 @@ options.Class.include({
return this._getEnabledCustomizeValues(params.possibleValues, true);
}
case 'customizeWebsiteVariable': {
- return weUtils.getCSSVariableValue(params.variable);
+ const ownerDocument = this.$target[0].ownerDocument;
+ const style = ownerDocument.defaultView.getComputedStyle(ownerDocument.documentElement);
+ return weUtils.getCSSVariableValue(params.variable, style);
}
case 'customizeWebsiteColor': {
- return weUtils.getCSSVariableValue(params.color);
+ const ownerDocument = this.$target[0].ownerDocument;
+ const style = ownerDocument.defaultView.getComputedStyle(ownerDocument.documentElement);
+ return weUtils.getCSSVariableValue(params.color, style);
}
case 'customizeWebsiteAssets': {
return this._getEnabledCustomizeValues(params.possibleValues, false);
@@ -523,13 +528,20 @@ options.Class.include({
}
}
- if (params.reload || config.isDebug('assets') || params.noBundleReload) {
+ if (params.reload || params.noBundleReload) {
// Caller will reload the page, nothing needs to be done anymore.
return;
}
// Finally, only update the bundles as no reload is required
await this._reloadBundles();
+ // Any option that require to reload bundle should probably
+ // also update the color preview of the theme tabs, as
+ // bundles can affect the look of the previews.
+ this.trigger_up('option_update', {
+ optionName: 'ThemeColors',
+ name: 'update_color_previews',
+ });
// Some public widgets may depend on the variables that were
// customized, so we have to restart them *all*.
@@ -664,35 +676,13 @@ options.Class.include({
/**
* @private
*/
- _reloadBundles: async function () {
- const bundles = await this._rpc({
- route: '/website/theme_customize_bundle_reload',
- });
- let $allLinks = $();
- const proms = _.map(bundles, (bundleURLs, bundleName) => {
- var $links = $('link[href*="' + bundleName + '"]');
- $allLinks = $allLinks.add($links);
- var $newLinks = $();
- _.each(bundleURLs, url => {
- $newLinks = $newLinks.add($('', {
- type: 'text/css',
- rel: 'stylesheet',
- href: url,
- }));
+ _reloadBundles: async function() {
+ return new Promise((resolve, reject) => {
+ this.trigger_up('reload_bundles', {
+ onSuccess: () => resolve(),
+ onFailure: () => reject(),
});
-
- const linksLoaded = new Promise(resolve => {
- let nbLoaded = 0;
- $newLinks.on('load error', () => { // If we have an error, just ignore it
- if (++nbLoaded >= $newLinks.length) {
- resolve();
- }
- });
- });
- $links.last().after($newLinks);
- return linksLoaded;
});
- await Promise.all(proms).then(() => $allLinks.remove());
},
/**
* @override
@@ -700,7 +690,7 @@ options.Class.include({
_select: async function (previewMode, widget) {
await this._super(...arguments);
- if (!widget.$el.closest('[data-no-widget-refresh="true"]').length) {
+ if (this.options.isWebsite && !widget.$el.closest('[data-no-widget-refresh="true"]').length) {
// TODO the flag should be retrieved through widget params somehow
await this._refreshPublicWidgets();
}
@@ -857,8 +847,13 @@ options.registry.ReplaceMedia.include({
* @override
*/
async _renderCustomXML(uiFragment) {
+ if (!this.options.isWebsite) {
+ return this._super(...arguments);
+ }
await this._super(...arguments);
+
+
const oldURLWidgetEl = uiFragment.querySelector('[data-name="media_url_opt"]');
const URLWidgetEl = document.createElement('we-urlpicker');
@@ -971,11 +966,14 @@ options.registry.OptionsTab = options.Class.extend({
*/
async updateUI() {
// The bg-XXX classes have been updated (and could be updated by another
- // option like changing color palette) -> remove the inline style that
- // was added for gray previews.
- this.$el.find(".o_we_gray_preview").each((_, e) => {
- e.style.removeProperty("background-color");
- });
+ // option like changing color palette) -> update the preview element.
+ const ownerDocument = this.$target[0].ownerDocument;
+ const style = ownerDocument.defaultView.getComputedStyle(ownerDocument.documentElement);
+ const grayPreviewEls = this.$el.find(".o_we_gray_preview span");
+ for (const e of grayPreviewEls) {
+ const bgValue = weUtils.getCSSVariableValue(e.getAttribute('variable'), style);
+ e.style.setProperty("background-color", bgValue, "important");
+ }
// If the gray palette has been generated by Odoo standard option,
// the hue of all gray is the same and the saturation has been
@@ -987,11 +985,11 @@ options.registry.OptionsTab = options.Class.extend({
const saturationDiffs = [];
let oneHasNoSaturation = false;
for (let id = 100; id <= 900; id += 100) {
- const gray = weUtils.getCSSVariableValue(`${id}`);
+ const gray = weUtils.getCSSVariableValue(`${id}`, style);
const grayRGB = ColorpickerWidget.convertCSSColorToRgba(gray);
const grayHSL = ColorpickerWidget.convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue);
- const baseGray = weUtils.getCSSVariableValue(`base-${id}`);
+ const baseGray = weUtils.getCSSVariableValue(`base-${id}`, style);
const baseGrayRGB = ColorpickerWidget.convertCSSColorToRgba(baseGray);
const baseGrayHSL = ColorpickerWidget.convertRgbToHsl(baseGrayRGB.red, baseGrayRGB.green, baseGrayRGB.blue);
@@ -1202,7 +1200,8 @@ options.registry.OptionsTab = options.Class.extend({
* @returns {String} the adjusted color of gray
*/
_buildGray(id) {
- const gray = weUtils.getCSSVariableValue(`base-${id}`);
+ const ownerDocument = this.$target[0].ownerDocument;
+ const gray = weUtils.getCSSVariableValue(`base-${id}`, ownerDocument.defaultView.getComputedStyle(ownerDocument.documentElement));
const grayRGB = ColorpickerWidget.convertCSSColorToRgba(gray);
const hsl = ColorpickerWidget.convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue);
const adjustedGrayRGB = ColorpickerWidget.convertHslToRgb(this.grayParams[this.GRAY_PARAMS.HUE],
@@ -1233,6 +1232,9 @@ options.registry.OptionsTab = options.Class.extend({
extraSaturationRangeEl.dataset.max = 100 - minValue.hsl.saturation;
extraSaturationRangeEl.dataset.min = -maxValue.hsl.saturation;
}
+ uiFragment.querySelectorAll('we-colorpicker').forEach(el => {
+ el.dataset.lazyPalette = 'true';
+ });
},
/**
* @override
@@ -1310,14 +1312,6 @@ options.registry.OptionsTab = options.Class.extend({
});
return aceEditor;
},
- /**
- * @override
- */
- async _renderCustomXML(uiFragment) {
- uiFragment.querySelectorAll('we-colorpicker').forEach(el => {
- el.dataset.lazyPalette = 'true';
- });
- },
});
options.registry.ThemeColors = options.registry.OptionsTab.extend({
@@ -1338,6 +1332,11 @@ options.registry.ThemeColors = options.registry.OptionsTab.extend({
// Public
//--------------------------------------------------------------------------
+ notify(name, data) {
+ if (name === 'update_color_previews') {
+ this.updateColorPreviews = true;
+ }
+ },
/**
* @override
*/
@@ -1346,11 +1345,31 @@ options.registry.ThemeColors = options.registry.OptionsTab.extend({
const oldColorSystemEl = this.el.querySelector('.o_old_color_system_warning');
oldColorSystemEl.classList.toggle('d-none', !this._showOldColorSystemWarning);
},
+ /**
+ * @override
+ */
+ async updateUI() {
+ if (this.updateColorPreviews) {
+ const ccPreviewEls = this.el.querySelectorAll('.o_we_cc_preview_wrapper');
+ this.trigger_up('update_color_previews', {
+ ccPreviewEls,
+ });
+ this.updateColorPreviews = false;
+ }
+ await this._super(...arguments);
+ },
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
+ /**
+ * @override
+ */
+ _select() {
+ this.updateColorPreviews = true;
+ return this._super(...arguments);
+ },
/**
* @override
*/
@@ -1375,18 +1394,22 @@ options.registry.ThemeColors = options.registry.OptionsTab.extend({
}
const presetCollapseEl = uiFragment.querySelector('we-collapse.o_we_theme_presets_collapse');
+ let ccPreviewEls = [];
for (let i = 1; i <= 5; i++) {
const collapseEl = document.createElement('we-collapse');
const ccPreviewEl = $(qweb.render('web_editor.color.combination.preview'))[0];
- ccPreviewEl.classList.add('text-center', `o_cc${i}`, 'o_we_collapse_toggler');
+ ccPreviewEl.classList.add('text-center', `o_cc${i}`, 'o_colored_level', 'o_we_collapse_toggler');
collapseEl.appendChild(ccPreviewEl);
const editionEls = $(qweb.render('website.color_combination_edition', {number: i}));
for (const el of editionEls) {
collapseEl.appendChild(el);
}
+ ccPreviewEls.push(ccPreviewEl);
presetCollapseEl.appendChild(collapseEl);
}
-
+ this.trigger_up('update_color_previews', {
+ ccPreviewEls,
+ });
await this._super(...arguments);
},
});
@@ -1402,7 +1425,8 @@ options.registry.menu_data = options.Class.extend({
* @override
*/
start: function () {
- wLinkPopoverWidget.createFor(this, this.$target[0], { wysiwyg: $('#wrapwrap').data('wysiwyg') });
+ const wysiwyg = $(this.ownerDocument.getElementById('wrapwrap')).data('wysiwyg');
+ wLinkPopoverWidget.createFor(this, this.$target[0], { wysiwyg });
return this._super(...arguments);
},
/**
@@ -3176,6 +3200,82 @@ options.registry.MegaMenuNoDelete = options.Class.extend({
},
});
+options.registry.sizing.include({
+ /**
+ * @override
+ */
+ start() {
+ const defs = this._super(...arguments);
+ const self = this;
+ this.$handles.on('mousedown', function (ev) {
+ // Since website is edited in an iframe, a div that goes over the
+ // iframe is necessary to catch mousemove and mouseup events,
+ // otherwise the iframe absorbs them.
+ const $body = $(this.ownerDocument.body);
+ if (!self.divEl) {
+ self.divEl = document.createElement('div');
+ self.divEl.style.position = 'absolute';
+ self.divEl.style.height = '100%';
+ self.divEl.style.width = '100%';
+ self.divEl.setAttribute('id', 'iframeEventOverlay');
+ $body.append(self.divEl);
+ }
+ const documentMouseUp = () => {
+ // Multiple mouseup can occur if mouse goes out of the window
+ // while moving.
+ if (self.divEl) {
+ self.divEl.remove();
+ self.divEl = undefined;
+ }
+ $body.off('mouseup', documentMouseUp);
+ };
+ $body.on('mouseup', documentMouseUp);
+ });
+ return defs;
+ }
+});
+
+options.registry.SwitchableViews = options.Class.extend({
+ /**
+ * @override
+ */
+ async willStart() {
+ const _super = this._super.bind(this);
+ this.switchableRelatedViews = await new Promise((resolve, reject) => {
+ this.trigger_up('get_switchable_related_views', {
+ onSuccess: resolve,
+ onFailure: reject,
+ });
+ });
+ return _super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _renderCustomXML(uiFragment) {
+ for (const view of this.switchableRelatedViews) {
+ const weCheckboxEl = document.createElement('we-checkbox');
+ weCheckboxEl.setAttribute('string', view.name);
+ weCheckboxEl.setAttribute('data-customize-website-views', view.key);
+ weCheckboxEl.setAttribute('data-no-preview', 'true');
+ weCheckboxEl.setAttribute('data-reload', '/');
+ uiFragment.appendChild(weCheckboxEl);
+ }
+ },
+ /***
+ * @override
+ */
+ _computeVisibility() {
+ return !!this.switchableRelatedViews.length;
+ },
+ /**
+ * @override
+ */
+ _checkIfWidgetsUpdateNeedReload() {
+ return true;
+ }
+});
+
return {
UrlPickerUserValueWidget: UrlPickerUserValueWidget,
FontFamilyPickerUserValueWidget: FontFamilyPickerUserValueWidget,
diff --git a/addons/website/static/src/js/editor/widget_link.js b/addons/website/static/src/js/editor/widget_link.js
index 0203c907671..0006095c4e8 100644
--- a/addons/website/static/src/js/editor/widget_link.js
+++ b/addons/website/static/src/js/editor/widget_link.js
@@ -38,7 +38,8 @@ weWidgets.LinkTools.include({
classes: {
"ui-autocomplete": 'o_website_ui_autocomplete'
},
- }
+ body: this.$editable[0].ownerDocument.body,
+ };
wUtils.autocompleteWithPages(this, this.$('input[name="url"]'), options);
this._adaptPageAnchor();
return def;
@@ -61,7 +62,7 @@ weWidgets.LinkTools.include({
$pageAnchor.toggleClass('d-none', !isFromWebsite);
$selectMenu.empty();
const always = () => $pageAnchor.find('we-toggler').text('\u00A0');
- wUtils.loadAnchors(urlInputValue).then(anchors => {
+ wUtils.loadAnchors(urlInputValue, this.$editable[0].ownerDocument.body).then(anchors => {
for (const anchor of anchors) {
const $option = $('');
$option.text(anchor);
diff --git a/addons/website/static/src/js/editor/wysiwyg.js b/addons/website/static/src/js/editor/wysiwyg.js
index 47a068080e2..023bce0971d 100644
--- a/addons/website/static/src/js/editor/wysiwyg.js
+++ b/addons/website/static/src/js/editor/wysiwyg.js
@@ -2,7 +2,7 @@ odoo.define('website.wysiwyg', function (require) {
'use strict';
var Wysiwyg = require('web_editor.wysiwyg');
-var snippetsEditor = require('web_editor.snippet.editor');
+var snippetsEditor = require('website.snippet.editor');
/**
* Show/hide the dropdowns associated to the given toggles and allows to wait
@@ -43,15 +43,16 @@ function toggleDropdown($toggles, show) {
* class non editable: o_not_editable
*
*/
-Wysiwyg.include({
+const WebsiteWysiwyg = Wysiwyg.extend({
/**
* @override
*/
start: function () {
this.options.toolbarHandler = $('#web_editor-top-edit');
+ const $editableWindow = this.$editable[0].ownerDocument.defaultView;
// Dropdown menu initialization: handle dropdown openings by hand
- var $dropdownMenuToggles = this.$('.o_mega_menu_toggle, #top_menu_container .dropdown-toggle');
+ var $dropdownMenuToggles = $editableWindow.$('.o_mega_menu_toggle, #top_menu_container .dropdown-toggle');
$dropdownMenuToggles.removeAttr('data-toggle').dropdown('dispose');
$dropdownMenuToggles.on('click.wysiwyg_megamenu', ev => {
this.odooEditor.observerUnactive();
@@ -157,6 +158,37 @@ Wysiwyg.include({
],
});
},
+ /**
+ * @override
+ */
+ _rpc(options) {
+ // Historically, every RPC had their website_id in their context.
+ // Now it's something defined by the wysiwyg_adapter.
+ // So in order to have a full context, we request it from the wysiwyg_adapter.
+ let context;
+ this.trigger_up('context_get', {
+ callback: cxt => context = cxt,
+ });
+ context = Object.assign(context, options.context);
+ options.context = context;
+ return this._super(options);
+ },
+ /**
+ *
+ * @override
+ */
+ _createSnippetsMenuInstance(options = {}) {
+ return new snippetsEditor.SnippetsMenu(this, Object.assign({
+ wysiwyg: this,
+ selectorEditableArea: '.o_editable',
+ }, options));
+ },
+ /**
+ * @override
+ */
+ _insertSnippetMenu() {
+ return this.snippetsMenu.appendTo(this.$el);
+ },
/**
* @override
*/
@@ -232,6 +264,35 @@ snippetsEditor.SnippetsMenu.include({
this._super(...arguments);
this._notActivableElementsSelector += ', .o_mega_menu_toggle';
},
+ /**
+ * @override
+ */
+ start() {
+ const _super = this._super(...arguments);
+ if (this.$body[0].ownerDocument !== this.ownerDocument) {
+ this.$body.on('click.snippets_menu', '*', this._onClick);
+
+ // As there is now one document for the SnippetsMenu and another one
+ // for the snippets, clicking on one should blur and remove the
+ // selection of the other one.
+ this._blurSnippetsSelection = this._blurSnippetsSelection.bind(this);
+ this._blurSnippetsMenuSelection = this._blurSnippetsMenuSelection.bind(this);
+ this.$body[0].ownerDocument.addEventListener('click', this._blurSnippetsMenuSelection);
+ this.$el[0].addEventListener('click', this._blurSnippetsSelection);
+ }
+ return _super;
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ if (this.$body[0].ownerDocument !== this.ownerDocument) {
+ this.$body.off('.snippets_menu');
+ this.$body[0].ownerDocument.removeEventListener('click', this._blurSnippetsMenuSelection);
+ this.$el[0].removeEventListener('click', this._blurSnippetsSelection);
+ }
+ return this._super(...arguments);
+ },
//--------------------------------------------------------------------------
// Private
@@ -247,5 +308,39 @@ snippetsEditor.SnippetsMenu.include({
$dropzone.attr('data-editor-sub-message', $hookParent.attr('data-editor-sub-message'));
return $dropzone;
},
+ /**
+ * @private
+ */
+ _blurDocumentSelection(document) {
+ const selection = document.getSelection();
+ selection.removeAllRanges();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _blurSnippetsSelection(ev) {
+ // The selection should be kept for the toolbar buttons, as they are
+ // related to the text's style edition. It should be blurred for the
+ // link tools, as they modify the element itself.
+ const shouldKeepFocus = ev.target.closest('.oe-toolbar') && !ev.target.closest('#create-link');
+ if (shouldKeepFocus) {
+ return;
+ }
+ this._blurDocumentSelection(this.$body[0].ownerDocument);
+ },
+ /**
+ * @private
+ */
+ _blurSnippetsMenuSelection(ev) {
+ this._blurDocumentSelection(this.ownerDocument);
+ },
+
});
+
+return WebsiteWysiwyg;
});
diff --git a/addons/website/static/src/js/menu/content.js b/addons/website/static/src/js/menu/content.js
deleted file mode 100644
index 7c467f021b6..00000000000
--- a/addons/website/static/src/js/menu/content.js
+++ /dev/null
@@ -1,1139 +0,0 @@
-/** @odoo-module alias=website.contentMenu */
-
-import Class from 'web.Class';
-import core from 'web.core';
-import Dialog from 'web.Dialog';
-import time from 'web.time';
-import weWidgets from 'wysiwyg.widgets';
-import websiteNavbarData from 'website.navbar';
-import Widget from 'web.Widget';
-import { Markup } from 'web.utils';
-
-import { registry } from "@web/core/registry";
-
-var _t = core._t;
-var qweb = core.qweb;
-
-var PagePropertiesDialog = weWidgets.Dialog.extend({
- template: 'website.pagesMenu.page_info',
- xmlDependencies: weWidgets.Dialog.prototype.xmlDependencies.concat(
- ['/website/static/src/xml/website.pageProperties.xml']
- ),
- events: _.extend({}, weWidgets.Dialog.prototype.events, {
- 'keyup input#page_name': '_onNameChanged',
- 'keyup input#page_url': '_onUrlChanged',
- 'change input#create_redirect': '_onCreateRedirectChanged',
- 'click input#visibility_password': '_onPasswordClicked',
- 'change input#visibility_password': '_onPasswordChanged',
- 'change select#visibility': '_onVisibilityChanged',
- 'error.datetimepicker': '_onDateTimePickerError',
- }),
-
- /**
- * @constructor
- * @override
- */
- init: function (parent, page_id, options) {
- var self = this;
- var serverUrl = window.location.origin + '/';
- var length_url = serverUrl.length;
- var serverUrlTrunc = serverUrl;
- if (length_url > 30) {
- serverUrlTrunc = serverUrl.slice(0,14) + '..' + serverUrl.slice(-14);
- }
- this.serverUrl = serverUrl;
- this.serverUrlTrunc = serverUrlTrunc;
- this.current_page_url = window.location.pathname;
- this.page_id = page_id;
-
- var buttons = [
- {text: _t("Save"), classes: 'btn-primary', click: this.save},
- {text: _t("Discard"), classes: 'mr-auto', close: true},
- ];
- if (options.fromPageManagement) {
- buttons.push({
- text: _t("Go To Page"),
- icon: 'fa-globe',
- classes: 'btn-link',
- click: function (e) {
- window.location.href = '/' + self.page.url;
- },
- });
- }
- buttons.push({
- text: _t("Duplicate Page"),
- icon: 'fa-clone',
- classes: 'btn-link',
- click: function (e) {
- // modal('hide') will break the rpc, so hide manually
- this.$el.closest('.modal').addClass('d-none');
- _clonePage.call(this, self.page_id);
- },
- });
- buttons.push({
- text: _t("Delete Page"),
- icon: 'fa-trash',
- classes: 'btn-link',
- click: function (e) {
- _deletePage.call(this, self.page_id, options.fromPageManagement);
- },
- });
- this._super(parent, _.extend({}, {
- title: _t("Page Properties"),
- size: 'medium',
- buttons: buttons,
- }, options || {}));
- },
- /**
- * @override
- */
- willStart: function () {
- var defs = [this._super.apply(this, arguments)];
- var self = this;
-
- defs.push(this._rpc({
- model: 'website.page',
- method: 'get_page_properties',
- args: [this.page_id],
- }).then(function (page) {
- page.url = _.str.startsWith(page.url, '/') ? page.url.substring(1) : page.url;
- page.hasSingleGroup = page.group_id !== undefined;
- self.page = page;
- }));
-
- return Promise.all(defs);
- },
- /**
- * @override
- */
- start: function () {
- var self = this;
-
- var defs = [this._super.apply(this, arguments)];
-
- this.$('.ask_for_redirect').addClass('d-none');
- this.$('.redirect_type').addClass('d-none');
- this.$('.warn_about_call').addClass('d-none');
- if (this.page.visibility !== 'password') {
- this.$('.show_visibility_password').addClass('d-none');
- }
- if (this.page.visibility !== 'restricted_group') {
- this.$('.show_group_id').addClass('d-none');
- }
- this.autocompleteWithGroups(this.$('#group_id'));
-
- defs.push(this._getPageDependencies(this.page_id)
- .then(function (dependencies) {
- var dep_text = [];
- _.each(dependencies, function (value, index) {
- if (value.length > 0) {
- dep_text.push(value.length + ' ' + index.toLowerCase());
- }
- });
- dep_text = dep_text.join(', ');
- self.$('#dependencies_redirect').html(qweb.render('website.show_page_dependencies', { dependencies: dependencies, dep_text: dep_text }));
- self.$('a.o_dependencies_redirect_link').on('click', () => {
- self.$('.o_dependencies_redirect_list_popover').popover({
- html: true,
- title: _t('Dependencies'),
- boundary: 'viewport',
- placement: 'right',
- trigger: 'focus',
- content: () => {
- return qweb.render('website.get_tooltip_dependencies', {
- dependencies: dependencies,
- });
- },
- template: qweb.render('website.page_dependencies_popover'),
- }).popover('toggle');
- });
- }));
-
- defs.push(this._getSupportedMimetype()
- .then(function (mimetypes) {
- self.supportedMimetype = mimetypes;
- }));
-
- defs.push(this._getPageKeyDependencies(this.page_id)
- .then(function (dependencies) {
- var dep_text = [];
- _.each(dependencies, function (value, index) {
- if (value.length > 0) {
- dep_text.push(value.length + ' ' + index.toLowerCase());
- }
- });
- dep_text = dep_text.join(', ');
- self.$('.warn_about_call').html(qweb.render('website.show_page_key_dependencies', {dependencies: dependencies, dep_text: dep_text}));
- self.$('.warn_about_call [data-toggle="popover"]').popover({
- container: 'body',
- });
- }));
-
- defs.push(this._rpc({model: 'res.users',
- method: 'has_group',
- args: ['website.group_multi_website']})
- .then(function (has_group) {
- if (!has_group) {
- self.$('#website_restriction').addClass('hidden');
- }
- }));
-
- var datepickersOptions = {
- minDate: moment({ y: 1000 }),
- maxDate: moment().add(200, 'y'),
- calendarWeeks: true,
- icons : {
- time: 'fa fa-clock-o',
- date: 'fa fa-calendar',
- next: 'fa fa-chevron-right',
- previous: 'fa fa-chevron-left',
- up: 'fa fa-chevron-up',
- down: 'fa fa-chevron-down',
- },
- locale : moment.locale(),
- format : time.getLangDatetimeFormat(),
- widgetPositioning : {
- horizontal: 'auto',
- vertical: 'top',
- },
- widgetParent: 'body',
- };
- if (this.page.date_publish) {
- datepickersOptions.defaultDate = time.str_to_datetime(this.page.date_publish);
- }
- this.$('#date_publish_container').datetimepicker(datepickersOptions);
- return Promise.all(defs);
- },
- /**
- * @override
- */
- destroy: function () {
- $('.popover').popover('hide');
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- save: function (data) {
- var self = this;
- var context;
- this.trigger_up('context_get', {
- callback: function (ctx) {
- context = ctx;
- },
- });
- var url = this.$('#page_url').val();
-
- var $datePublish = this.$("#date_publish");
- $datePublish.closest(".form-group").removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
- var datePublish = $datePublish.val();
- if (datePublish !== "") {
- datePublish = this._parse_date(datePublish);
- if (!datePublish) {
- $datePublish.closest(".form-group").addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
- return;
- }
- }
- var params = {
- id: this.page.id,
- name: this.$('#page_name').val(),
- // Replace duplicate following '/' by only one '/'
- url: url.replace(/\/{2,}/g, '/'),
- is_menu: this.$('#is_menu').prop('checked'),
- is_homepage: this.$('#is_homepage').prop('checked'),
- website_published: this.$('#is_published').prop('checked'),
- create_redirect: this.$('#create_redirect').prop('checked'),
- redirect_type: this.$('#redirect_type').val(),
- website_indexed: this.$('#is_indexed').prop('checked'),
- visibility: this.$('#visibility').val(),
- date_publish: datePublish,
- };
- if (this.page.hasSingleGroup && this.$('#visibility').val() === 'restricted_group') {
- params['group_id'] = this.$('#group_id').data('group-id');
- }
- if (this.$('#visibility').val() === 'password') {
- var field_pwd = $('#visibility_password');
- if (!field_pwd.get(0).reportValidity()) {
- return;
- }
- if (field_pwd.data('dirty')) {
- params['visibility_pwd'] = field_pwd.val();
- }
- }
-
- this._rpc({
- model: 'website.page',
- method: 'save_page_info',
- args: [[context.website_id], params],
- }).then(function (url) {
- // If from page manager: reload url, if from page itself: go to
- // (possibly) new url
- var mo;
- self.trigger_up('main_object_request', {
- callback: function (value) {
- mo = value;
- },
- });
- if (mo.model === 'website.page') {
- window.location.href = url.toLowerCase();
- } else {
- window.location.reload(true);
- }
- });
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Retrieves the page URL dependencies for the given object id.
- *
- * @private
- * @param {integer} moID
- * @returns {Promise}
- */
- _getPageDependencies: function (moID) {
- return this._rpc({
- model: 'website',
- method: 'page_search_dependencies',
- args: [moID],
- });
- },
- /**
- * Retrieves the page's key dependencies for the given object id.
- *
- * @private
- * @param {integer} moID
- * @returns {Promise}
- */
- _getPageKeyDependencies: function (moID) {
- return this._rpc({
- model: 'website',
- method: 'page_search_key_dependencies',
- args: [moID],
- });
- },
- /**
- * Retrieves supported mimtype
- *
- * @private
- * @returns {Promise}
- */
- _getSupportedMimetype: function () {
- return this._rpc({
- model: 'website',
- method: 'guess_mimetype',
- });
- },
- /**
- * Returns information about the page main object.
- *
- * @private
- * @returns {Object} model and id
- */
- _getMainObject: function () {
- var repr = $('html').data('main-object');
- var m = repr.match(/(.+)\((\d+),(.*)\)/);
- return {
- model: m[1],
- id: m[2] | 0,
- };
- },
- /**
- * Converts a string representing the browser datetime
- * (exemple: Albanian: '2018-Qer-22 15.12.35.')
- * to a string representing UTC in Odoo's datetime string format
- * (exemple: '2018-04-22 13:12:35').
- *
- * The time zone of the datetime string is assumed to be the one of the
- * browser and it will be converted to UTC (standard for Odoo).
- *
- * @private
- * @param {String} value A string representing a datetime.
- * @returns {String|false} A string representing an UTC datetime if the given value is valid, false otherwise.
- */
- _parse_date: function (value) {
- var datetime = moment(value, time.getLangDatetimeFormat(), true);
- if (datetime.isValid()) {
- return time.datetime_to_str(datetime.toDate());
- }
- else {
- return false;
- }
- },
- /**
- * Allows the given input to propose existing groups.
- *
- * @param {jQuery} $input
- */
- autocompleteWithGroups: function ($input) {
- $input.autocomplete({
- source: (request, response) => {
- return this._rpc({
- model: 'res.groups',
- method: 'search_read',
- args: [[['name', 'ilike', request.term]], ['display_name']],
- kwargs: {
- limit: 15,
- },
- }).then(founds => {
- founds = founds.map(g => ({'id': g['id'], 'label': g['display_name']}));
- response(founds);
- });
- },
- change: (ev, ui) => {
- var $target = $(ev.target);
- if (!ui.item) {
- $target.val("");
- $target.removeData('group-id');
- } else {
- $target.data('group-id', ui.item.id);
- }
- },
- });
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _onUrlChanged: function () {
- var url = this.$('input#page_url').val();
- this.$('.ask_for_redirect').toggleClass('d-none', url === this.page.url);
- },
- /**
- * @private
- */
- _onNameChanged: function () {
- var name = this.$('input#page_name').val();
- // If the file type is a supported mimetype, check if it is t-called.
- // If so, warn user. Note: different from page_search_dependencies which
- // check only for url and not key
- var ext = '.' + this.page.name.split('.').pop();
- if (ext in this.supportedMimetype && ext !== '.html') {
- this.$('.warn_about_call').toggleClass('d-none', name === this.page.name);
- }
- },
- /**
- * @private
- */
- _onCreateRedirectChanged: function () {
- var createRedirect = this.$('input#create_redirect').prop('checked');
- this.$('.redirect_type').toggleClass('d-none', !createRedirect);
- },
- /**
- * @private
- */
- _onVisibilityChanged: function (ev) {
- this.$('.show_visibility_password').toggleClass('d-none', ev.target.value !== 'password');
- this.$('.show_group_id').toggleClass('d-none', ev.target.value !== 'restricted_group');
- this.$('#visibility_password').attr('required', ev.target.value === 'password');
- },
- /**
- * Library clears the wrong date format so just ignore error
- *
- * @private
- */
- _onDateTimePickerError: function (ev) {
- return false;
- },
- /**
- * @private
- */
- _onPasswordClicked: function (ev) {
- ev.target.value = '';
- this._onPasswordChanged();
- },
- /**
- * @private
- */
- _onPasswordChanged: function () {
- this.$('#visibility_password').data('dirty', 1);
- },
-});
-
-var MenuEntryDialog = weWidgets.LinkDialog.extend({
- /**
- * @constructor
- */
- init: function (parent, options, editable, data) {
- this._super(parent, _.extend({
- title: _t("Add a menu item"),
- }, options || {}), editable, _.extend({
- needLabel: true,
- content: data.name || '',
- isNewWindow: data.new_window,
- }, data || {}));
-
- this.linkWidget.xmlDependencies = this.linkWidget.xmlDependencies.concat(['/website/static/src/xml/website.contentMenu.xml']);
-
- const oldSave = this.linkWidget.save;
- /**
- * @override
- */
- this.linkWidget.save = () => {
- var $e = this.$('#o_link_dialog_label_input');
- if (!$e.val() || !$e[0].checkValidity()) {
- $e.closest('.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
- $e.focus();
- return Promise.reject();
- }
- return oldSave.bind(this.linkWidget)();
- };
-
- this.menuType = data.menuType;
- },
- /**
- * @override
- */
- start: async function () {
- const res = await this._super(...arguments);
-
- // Remove style related elements
- this.$('.o_link_dialog_preview').remove();
- this.$('input[name="is_new_window"], .link-style').closest('.form-group').remove();
- this.$modal.find('.modal-lg').removeClass('modal-lg');
- this.$('form.col-lg-8').removeClass('col-lg-8').addClass('col-12');
-
- // Adapt URL label
- this.$('label[for="o_link_dialog_label_input"]').text(_t("Menu Label"));
-
- // Auto add '#' URL and hide the input if for mega menu
- if (this.menuType === 'mega') {
- var $url = this.$('input[name="url"]');
- $url.val('#').trigger('change');
- $url.closest('.form-group').addClass('d-none');
- }
-
- return res;
- },
-});
-
-var SelectEditMenuDialog = weWidgets.Dialog.extend({
- template: 'website.contentMenu.dialog.select',
- xmlDependencies: weWidgets.Dialog.prototype.xmlDependencies.concat(
- ['/website/static/src/xml/website.contentMenu.xml']
- ),
-
- /**
- * @constructor
- * @override
- */
- init: function (parent, options) {
- var self = this;
- self.roots = [{id: null, name: _t("Top Menu")}];
- $('[data-content_menu_id]').each(function () {
- // Remove name fallback in master
- self.roots.push({id: $(this).data('content_menu_id'), name: $(this).attr('name') || $(this).data('menu_name')});
- });
- this._super(parent, _.extend({}, {
- title: _t("Select a Menu"),
- save_text: _t("Continue")
- }, options || {}));
- },
- /**
- * @override
- */
- save: function () {
- this.final_data = parseInt(this.$el.find('select').val() || null);
- this._super.apply(this, arguments);
- },
-});
-
-var EditMenuDialog = weWidgets.Dialog.extend({
- template: 'website.contentMenu.dialog.edit',
- xmlDependencies: weWidgets.Dialog.prototype.xmlDependencies.concat(
- ['/website/static/src/xml/website.contentMenu.xml']
- ),
- events: _.extend({}, weWidgets.Dialog.prototype.events, {
- 'click a.js_add_menu': '_onAddMenuButtonClick',
- 'click button.js_delete_menu': '_onDeleteMenuButtonClick',
- 'click button.js_edit_menu': '_onEditMenuButtonClick',
- }),
-
- /**
- * @constructor
- * @override
- */
- init: function (parent, options, rootID) {
- this._super(parent, _.extend({}, {
- title: _t("Edit Menu"),
- size: 'medium',
- }, options || {}));
- this.rootID = rootID;
- // For the EditMenuDialog, saving the dialog should not close it,
- // as a reload will be done to show the updated menus.
- this.closeOnSave = false;
- },
- /**
- * @override
- */
- willStart: function () {
- var defs = [this._super.apply(this, arguments)];
- var context;
- this.trigger_up('context_get', {
- callback: function (ctx) {
- context = ctx;
- },
- });
- defs.push(this._rpc({
- model: 'website.menu',
- method: 'get_tree',
- args: [context.website_id, this.rootID],
- }).then(menu => {
- this.menu = menu;
- this.rootMenuID = menu.fields['id'];
- this.flat = this._flatenize(menu);
- this.toDelete = [];
- }));
- return Promise.all(defs);
- },
- /**
- * @override
- */
- start: function () {
- var r = this._super.apply(this, arguments);
- this.$('.oe_menu_editor').nestedSortable({
- listType: 'ul',
- handle: 'div',
- items: 'li',
- maxLevels: 2,
- toleranceElement: '> div',
- forcePlaceholderSize: true,
- opacity: 0.6,
- placeholder: 'oe_menu_placeholder',
- tolerance: 'pointer',
- attribute: 'data-menu-id',
- expression: '()(.+)', // nestedSortable takes the second match of an expression (*sigh*)
- isAllowed: (placeholder, placeholderParent, currentItem) => {
- return !placeholderParent
- || !currentItem[0].dataset.megaMenu && !placeholderParent[0].dataset.megaMenu;
- },
- });
- return r;
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- save: function () {
- var _super = this._super.bind(this);
- var newMenus = this.$('.oe_menu_editor').nestedSortable('toArray', {startDepthCount: 0});
- var levels = [];
- var data = [];
- var context;
- this.trigger_up('context_get', {
- callback: function (ctx) {
- context = ctx;
- },
- });
- // Resequence, re-tree and remove useless data
- newMenus.forEach(menu => {
- if (menu.id) {
- levels[menu.depth] = (levels[menu.depth] || 0) + 1;
- var menuFields = this.flat[menu.id].fields;
- menuFields['sequence'] = levels[menu.depth];
- // JQuery's nestedSortable() extracts parent_ids as string.
- // They must be ints when they are actual existing ids but
- // remain as strings when they represent a creation ("new-##").
- menuFields['parent_id'] = parseInt(menu['parent_id']) || menu['parent_id'] || this.rootMenuID;
- data.push(menuFields);
- }
- });
- return this._rpc({
- model: 'website.menu',
- method: 'save',
- args: [
- context.website_id,
- {
- 'data': data,
- 'to_delete': this.toDelete,
- }
- ],
- }).then(() => {
- return _super(...arguments);
- });
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Returns a mapping id -> menu item containing all the menu items in the
- * given menu hierarchy.
- *
- * @private
- * @param {Object} node
- * @param {Object} [_dict] internal use: the mapping being built
- * @returns {Object}
- */
- _flatenize: function (node, _dict) {
- _dict = _dict || {};
- _dict[node.fields['id']] = node;
- node.children.forEach(child => {
- this._flatenize(child, _dict);
- });
- return _dict;
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when the "add menu" button is clicked -> Opens the appropriate
- * dialog to edit this new menu.
- *
- * @private
- * @param {Event} ev
- */
- _onAddMenuButtonClick: function (ev) {
- var menuType = ev.currentTarget.dataset.type;
- var dialog = new MenuEntryDialog(this, {}, null, {
- menuType: menuType,
- });
- dialog.on('save', this, link => {
- var newMenu = {
- 'fields': {
- 'id': _.uniqueId('new-'),
- 'name': _.unescape(link.content),
- 'url': link.url,
- 'new_window': link.isNewWindow,
- 'is_mega_menu': menuType === 'mega',
- 'sequence': 0,
- 'parent_id': false,
- },
- 'children': [],
- 'is_homepage': false,
- };
- this.flat[newMenu.fields['id']] = newMenu;
- this.$('.oe_menu_editor').append(
- qweb.render('website.contentMenu.dialog.submenu', {submenu: newMenu})
- );
- });
- dialog.open();
- },
- /**
- * Called when the "delete menu" button is clicked -> Deletes this menu.
- *
- * @private
- */
- _onDeleteMenuButtonClick: function (ev) {
- var $menu = $(ev.currentTarget).closest('[data-menu-id]');
- var menuID = parseInt($menu.data('menu-id'));
- if (menuID) {
- this.toDelete.push(menuID);
- }
- $menu.remove();
- },
- /**
- * Called when the "edit menu" button is clicked -> Opens the appropriate
- * dialog to edit this menu.
- *
- * @private
- */
- _onEditMenuButtonClick: function (ev) {
- var $menu = $(ev.currentTarget).closest('[data-menu-id]');
- var menuID = $menu.data('menu-id');
- var menu = this.flat[menuID];
- if (menu) {
- var dialog = new MenuEntryDialog(this, {}, null, _.extend({
- menuType: menu.fields['is_mega_menu'] ? 'mega' : undefined,
- }, menu.fields));
- dialog.on('save', this, link => {
- _.extend(menu.fields, {
- 'name': _.unescape(link.content),
- 'url': link.url,
- 'new_window': link.isNewWindow,
- });
- $menu.find('.js_menu_label').first().text(menu.fields['name']);
- });
- dialog.open();
- } else {
- Dialog.alert(null, "Could not find menu entry");
- }
- },
-});
-
-var PageOption = Class.extend({
- /**
- * @constructor
- * @param {string} name
- * the option's name = the field's name in website.page model
- * @param {*} value
- * @param {function} setValueCallback
- * a function which simulates an option's value change without
- * asking the server to change it
- */
- init: function (name, value, setValueCallback) {
- this.name = name;
- this.value = value;
- this.isDirty = false;
- this.setValueCallback = setValueCallback;
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Sets the new option's value thanks to the related callback.
- *
- * @param {*} [value]
- * by default: consider the current value is a boolean and toggle it
- */
- setValue: function (value) {
- if (value === undefined) {
- value = !this.value;
- }
- this.setValueCallback.call(this, value);
- this.value = value;
- this.isDirty = true;
- },
-});
-
-var ContentMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
- xmlDependencies: ['/website/static/src/xml/website.xml'],
- actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, {
- edit_menu: '_editMenu',
- get_page_option: '_getPageOption',
- on_save: '_onSave',
- page_properties: '_pageProperties',
- toggle_page_option: '_togglePageOption',
- }),
- pageOptionsSetValueCallbacks: {
- header_overlay: function (value) {
- $('#wrapwrap').toggleClass('o_header_overlay', value);
- },
- header_color: function (value) {
- $('#wrapwrap > header').removeClass(this.value)
- .addClass(value);
- },
- header_visible: function (value) {
- $('#wrapwrap > header').toggleClass('d-none o_snippet_invisible', !value);
- },
- footer_visible: function (value) {
- $('#wrapwrap > footer').toggleClass('d-none o_snippet_invisible', !value);
- },
- },
-
- /**
- * @override
- */
- start: function () {
- var self = this;
- this.pageOptions = {};
- _.each($('.o_page_option_data'), function (el) {
- var value = el.value;
- if (value === "True") {
- value = true;
- } else if (value === "False") {
- value = false;
- }
- self.pageOptions[el.name] = new PageOption(
- el.name,
- value,
- self.pageOptionsSetValueCallbacks[el.name]
- );
- });
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Actions
- //--------------------------------------------------------------------------
-
- /**
- * Asks the user which menu to edit if multiple menus exist on the page.
- * Then opens the menu edition dialog.
- * Then executes the given callback once the edition is saved, to finally
- * reload the page.
- *
- * @private
- * @param {function} [beforeReloadCallback]
- * @returns {Promise}
- * Unresolved if the menu is edited and saved as the page will be
- * reloaded.
- * Resolved otherwise.
- */
- _editMenu: function (beforeReloadCallback) {
- var self = this;
- return new Promise(function (resolve) {
- function resolveWhenEditMenuDialogIsCancelled(rootID) {
- return self._openEditMenuDialog(rootID, beforeReloadCallback).then(resolve);
- }
- if ($('[data-content_menu_id]').length) {
- var select = new SelectEditMenuDialog(self);
- select.on('save', self, resolveWhenEditMenuDialogIsCancelled);
- select.on('cancel', self, resolve);
- select.open();
- } else {
- resolveWhenEditMenuDialogIsCancelled(null);
- }
- });
- },
- /**
- *
- * @param {*} rootID
- * @param {function|undefied} beforeReloadCallback function that returns a promise
- * @returns {Promise}
- */
- _openEditMenuDialog: function (rootID, beforeReloadCallback) {
- var self = this;
- return new Promise(function (resolve) {
- var dialog = new EditMenuDialog(self, {}, rootID);
- dialog.on('save', self, function () {
- // Before reloading the page after menu modification, does the
- // given action to do.
- if (beforeReloadCallback) {
- // Reload the page so that the menu modification are shown
- beforeReloadCallback().then(function () {
- window.location.reload(true);
- });
- } else {
- window.location.reload(true);
- }
- });
- dialog.on('cancel', self, resolve);
- dialog.open();
- });
- },
-
- /**
- * Retrieves the value of a page option.
- *
- * @private
- * @param {string} name
- * @returns {Promise<*>}
- */
- _getPageOption: function (name) {
- var option = this.pageOptions[name];
- if (!option) {
- return Promise.reject();
- }
- return Promise.resolve(option.value);
- },
- /**
- * On save, simulated page options have to be server-saved.
- *
- * @private
- * @returns {Promise}
- */
- _onSave: function () {
- var self = this;
- var defs = _.map(this.pageOptions, function (option, optionName) {
- if (option.isDirty) {
- return self._togglePageOption({
- name: optionName,
- value: option.value,
- }, true, true);
- }
- });
- return Promise.all(defs);
- },
- /**
- * Opens the page properties dialog.
- *
- * @private
- * @returns {Promise}
- */
- _pageProperties: function () {
- var mo;
- this.trigger_up('main_object_request', {
- callback: function (value) {
- mo = value;
- },
- });
- var dialog = new PagePropertiesDialog(this, mo.id, {}).open();
- return dialog.opened();
- },
- /**
- * Toggles a page option.
- *
- * @private
- * @param {Object} params
- * @param {string} params.name
- * @param {*} [params.value] (change value by default true -> false -> true)
- * @param {boolean} [forceSave=false]
- * @param {boolean} [noReload=false]
- * @returns {Promise}
- */
- _togglePageOption: function (params, forceSave, noReload) {
- // First check it is a website page
- var mo;
- this.trigger_up('main_object_request', {
- callback: function (value) {
- mo = value;
- },
- });
- if (mo.model !== 'website.page') {
- return Promise.reject();
- }
-
- // Check if this is a valid option
- var option = this.pageOptions[params.name];
- if (!option) {
- return Promise.reject();
- }
-
- // Toggle the value
- option.setValue(params.value);
-
- // If simulate is true, it means we want the option to be toggled but
- // not saved on the server yet
- if (!forceSave) {
- return Promise.resolve();
- }
-
- // If not, write on the server page and reload the current location
- var vals = {};
- vals[params.name] = option.value;
- var prom = this._rpc({
- model: 'website.page',
- method: 'write',
- args: [[mo.id], vals],
- });
- if (noReload) {
- return prom;
- }
- return prom.then(function () {
- window.location.reload();
- return new Promise(function () {});
- });
- },
-});
-
-var PageManagement = Widget.extend({
- xmlDependencies: ['/website/static/src/xml/website.xml'],
- events: {
- 'click a.js_page_properties': '_onPagePropertiesButtonClick',
- 'click a.js_clone_page': '_onClonePageButtonClick',
- 'click a.js_delete_page': '_onDeletePageButtonClick',
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Retrieves the page dependencies for the given object id.
- *
- * @private
- * @param {integer} moID
- * @returns {Promise}
- */
- _getPageDependencies: function (moID) {
- return this._rpc({
- model: 'website',
- method: 'page_search_dependencies',
- args: [moID],
- });
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- _onPagePropertiesButtonClick: function (ev) {
- var moID = $(ev.currentTarget).data('id');
- var dialog = new PagePropertiesDialog(this,moID, {'fromPageManagement': true}).open();
- return dialog;
- },
- _onClonePageButtonClick: function (ev) {
- var pageId = $(ev.currentTarget).data('id');
- _clonePage.call(this, pageId);
- },
- _onDeletePageButtonClick: function (ev) {
- var pageId = $(ev.currentTarget).data('id');
- _deletePage.call(this, pageId, true);
- },
-});
-
-/**
- * Deletes the page after showing a dependencies warning for the given page id.
- *
- * @private
- * @param {integer} pageId - The ID of the page to be deleted
- * @param {Boolean} fromPageManagement
- * Is the function called by the page manager?
- * It will affect redirect after page deletion: reload or '/'
- */
-// TODO: This function should be integrated in a widget in the future
-async function _deletePage(pageId, fromPageManagement) {
- const dependencies = await this._getPageDependencies(pageId);
- for (const locs of Object.values(dependencies)) {
- for (const loc of locs) {
- loc.text = Markup(loc.text);
- }
- }
- Dialog.safeConfirm(this, "", {
- title: _t("Delete Page"),
- $content: $(qweb.render('website.delete_page', {dependencies: dependencies})),
- async confirm_callback() {
- await this._rpc({model: 'website.page', method: 'unlink', args: [pageId]});
- if (fromPageManagement) {
- window.location.reload(true);
- } else {
- window.location.href = '/';
- }
- }
- });
-}
-/**
- * Duplicate the page after showing the wizard to enter new page name.
- *
- * @private
- * @param {integer} pageId - The ID of the page to be duplicate
- *
- */
-function _clonePage(pageId) {
- var self = this;
- new Promise(function (resolve, reject) {
- Dialog.confirm(this, undefined, {
- title: _t("Duplicate Page"),
- $content: $(qweb.render('website.duplicate_page_action_dialog')),
- confirm_callback: function () {
- var new_page_name = this.$('#page_name').val();
- return self._rpc({
- model: 'website.page',
- method: 'clone_page',
- args: [pageId, new_page_name],
- }).then(function (path) {
- window.location.href = path;
- }).guardedCatch(reject);
- },
- cancel_callback: reject,
- }).on('closed', null, reject);
- });
-}
-
-registry.category("website_navbar_widgets").add("ContentMenu", {
- Widget: ContentMenu,
- selector: '#content-menu',
-});
-registry.category("public_root_widgets").add("PageManagement", {
- Widget: PageManagement,
- selector: '#list_website_pages',
-});
-
-export default {
- PagePropertiesDialog: PagePropertiesDialog,
- ContentMenu: ContentMenu,
- EditMenuDialog: EditMenuDialog,
- MenuEntryDialog: MenuEntryDialog,
- SelectEditMenuDialog: SelectEditMenuDialog,
-};
diff --git a/addons/website/static/src/js/menu/customize.js b/addons/website/static/src/js/menu/customize.js
deleted file mode 100644
index f2d8e923fdf..00000000000
--- a/addons/website/static/src/js/menu/customize.js
+++ /dev/null
@@ -1,228 +0,0 @@
-odoo.define('website.customizeMenu', function (require) {
-'use strict';
-
-var core = require('web.core');
-var Widget = require('web.Widget');
-var websiteNavbarData = require('website.navbar');
-var WebsiteAceEditor = require('website.ace');
-
-const { registry } = require("@web/core/registry");
-
-var qweb = core.qweb;
-
-var CustomizeMenu = Widget.extend({
- xmlDependencies: ['/website/static/src/xml/website.editor.xml'],
- events: {
- 'show.bs.dropdown': '_onDropdownShow',
- 'change .dropdown-item[data-view-key]': '_onCustomizeOptionChange',
- },
-
- /**
- * @override
- */
- willStart: function () {
- this.viewName = $(document.documentElement).data('view-xmlid');
- return this._super.apply(this, arguments);
- },
- /**
- * @override
- */
- start: function () {
- if (!this.viewName) {
- _.defer(this.destroy.bind(this));
- }
-
- if (this.$el.is('.show')) {
- this._loadCustomizeOptions();
- }
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Enables/Disables a view customization whose id is given.
- *
- * @private
- * @param {string} viewKey
- * @returns {Promise}
- * Unresolved if the customization succeeded as the page will be
- * reloaded.
- * Rejected otherwise.
- */
- _doCustomize: function (viewKey) {
- return this._rpc({
- route: '/website/toggle_switchable_view',
- params: {
- 'view_key': viewKey,
- },
- }).then(function () {
- window.location.reload();
- return new Promise(function () {});
- });
- },
- /**
- * Loads the information about the views which can be enabled/disabled on
- * the current page and shows them as switchable elements in the menu.
- *
- * @private
- * @return {Promise}
- */
- _loadCustomizeOptions: function () {
- if (this.__customizeOptionsLoaded) {
- return Promise.resolve();
- }
- this.__customizeOptionsLoaded = true;
-
- var $menu = this.$el.children('.dropdown-menu');
- return this._rpc({
- route: '/website/get_switchable_related_views',
- params: {
- key: this.viewName,
- },
- }).then(function (result) {
- var currentGroup = '';
- if (result.length) {
- $menu.append($('', {
- class: 'dropdown-divider',
- role: 'separator',
- }));
- }
- _.each(result, function (item) {
- if (currentGroup !== item.inherit_id[1]) {
- currentGroup = item.inherit_id[1];
- $menu.append('
' + currentGroup + '
');
- }
- var $label = $(qweb.render('website.components.switch', {id: 'switch-' + item.id, label: item.name}));
- $label.attr("data-view-key", item.key);
- $label.find('input').prop('checked', !!item.active);
- $menu.append($label);
- });
- });
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when a view's related switchable element is clicked -> enable /
- * disable the related view.
- *
- * @private
- * @param {Event} ev
- */
- _onCustomizeOptionChange: function (ev) {
- ev.preventDefault();
- var viewKey = $(ev.currentTarget).data('viewKey');
- this._doCustomize(viewKey);
- },
- /**
- * @private
- */
- _onDropdownShow: function () {
- this._loadCustomizeOptions();
- },
-});
-
-var AceEditorMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
- actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, {
- close_all_widgets: '_hideEditor',
- edit: '_enterEditMode',
- ace: '_launchAce',
- }),
-
- /**
- * Launches the ace editor automatically when the corresponding hash is in
- * the page URL.
- *
- * @override
- */
- start: function () {
- if (window.location.hash.substr(0, WebsiteAceEditor.prototype.hash.length) === WebsiteAceEditor.prototype.hash) {
- this._launchAce();
- }
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Actions
- //--------------------------------------------------------------------------
-
- /**
- * When handling the "edit" website action, the ace editor has to be closed.
- *
- * @private
- */
- _enterEditMode: function () {
- this._hideEditor();
- },
- /**
- * @private
- */
- _hideEditor: function () {
- if (this.globalEditor) {
- this.globalEditor.do_hide();
- }
- },
- /**
- * Launches the ace editor to be able to edit the templates and scss files
- * which are used by the current page.
- *
- * @private
- * @returns {Promise}
- */
- _launchAce: function () {
- var self = this;
- var prom = new Promise(function (resolve, reject) {
- self.trigger_up('action_demand', {
- actionName: 'close_all_widgets',
- onSuccess: resolve,
- onFailure: reject,
- });
- });
- prom.then(function () {
- if (self.globalEditor) {
- self.globalEditor.do_show();
- return Promise.resolve();
- } else {
- var currentHash = window.location.hash;
- var indexOfView = currentHash.indexOf("?res=");
- var initialResID = undefined;
- if (indexOfView >= 0) {
- initialResID = currentHash.substr(indexOfView + ("?res=".length));
- var parsedResID = parseInt(initialResID, 10);
- if (parsedResID) {
- initialResID = parsedResID;
- }
- }
-
- self.globalEditor = new WebsiteAceEditor(self, $(document.documentElement).data('view-xmlid'), {
- initialResID: initialResID,
- defaultBundlesRestriction: [
- 'web.assets_frontend',
- 'web.assets_frontend_minimal',
- 'web.assets_frontend_lazy',
- ],
- });
- return self.globalEditor.appendTo(document.body);
- }
- });
-
- return prom;
- },
-});
-
-registry.category("website_navbar_widgets").add("CustomizeMenu", {
- Widget: CustomizeMenu,
- selector: '#customize-menu',
-});
-registry.category("website_navbar_widgets").add("AceEditorMenu", {
- Widget: AceEditorMenu,
- selector: '#html_editor',
-});
-
-return CustomizeMenu;
-});
diff --git a/addons/website/static/src/js/menu/debug_menu.js b/addons/website/static/src/js/menu/debug_menu.js
deleted file mode 100644
index 0fa2f95a7e1..00000000000
--- a/addons/website/static/src/js/menu/debug_menu.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/** @odoo-module */
-import { registry } from "@web/core/registry";
-import { DebugMenuBasic } from "@web/core/debug/debug_menu_basic";
-import { createDebugContext } from "@web/core/debug/debug_context";
-
-const { mount } = owl;
-const debugMenuService = {
- dependencies: ["localization", "orm"],
- start(env) {
- if (env.debug) {
- const systray = document.querySelector('.o_menu_systray');
- if (systray) {
- Object.assign(env, createDebugContext(env, {categories: ["default"]}));
- mount(DebugMenuBasic, systray, {
- position: 'first-child',
- env,
- templates: window.__OWL_TEMPLATES__,
- });
- }
- }
- }
-};
-registry.category("services").add("website_debug_menu", debugMenuService);
diff --git a/addons/website/static/src/js/menu/edit.js b/addons/website/static/src/js/menu/edit.js
deleted file mode 100644
index 71c75f461af..00000000000
--- a/addons/website/static/src/js/menu/edit.js
+++ /dev/null
@@ -1,706 +0,0 @@
-odoo.define('website.editMenu', function (require) {
-'use strict';
-
-var core = require('web.core');
-var dom = require('web.dom');
-var wysiwygLoader = require('web_editor.loader');
-var websiteNavbarData = require('website.navbar');
-var Dialog = require('web.Dialog');
-
-const { registry } = require("@web/core/registry");
-
-var _t = core._t;
-
-/**
- * Adds the behavior when clicking on the 'edit' button (+ editor interaction)
- */
-var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
- assetLibs: ['web_editor.compiled_assets_wysiwyg', 'website.compiled_assets_wysiwyg'],
- xmlDependencies: ['/website/static/src/xml/website.editor.xml'],
- actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions, {
- edit: '_startEditMode',
- on_save: '_onSave',
- }),
- custom_events: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.custom_events || {}, {
- content_will_be_destroyed: '_onContentWillBeDestroyed',
- content_was_recreated: '_onContentWasRecreated',
- snippet_will_be_cloned: '_onSnippetWillBeCloned',
- snippet_cloned: '_onSnippetCloned',
- snippet_dropped: '_onSnippetDropped',
- snippet_removed: '_onSnippetRemoved',
- request_save: '_onSnippetRequestSave',
- request_cancel: '_onSnippetRequestCancel',
- }),
-
- /**
- * @constructor
- */
- init: function (parent, options = {}) {
- this._super.apply(this, arguments);
- this.options = options;
- this.wysiwygOptions = options.wysiwygOptions || {};
- var context;
- this.trigger_up('context_get', {
- extra: true,
- callback: function (ctx) {
- context = ctx;
- },
- });
- this.oeStructureSelector = '#wrapwrap .oe_structure[data-oe-xpath][data-oe-id]';
- this.oeFieldSelector = '#wrapwrap [data-oe-field]';
- this.oeCoverSelector = '#wrapwrap .s_cover[data-res-model], #wrapwrap .o_record_cover_container[data-res-model]';
- if (options.savableSelector) {
- this.savableSelector = options.savableSelector;
- } else {
- this.savableSelector = `${this.oeStructureSelector}, ${this.oeFieldSelector}, ${this.oeCoverSelector}`;
- }
- this.editableFromEditorMenu = options.editableFromEditorMenu || this.editableFromEditorMenu;
- this._editorAutoStart = (context.editable && window.location.search.indexOf('enable_editor') >= 0);
- var url = new URL(window.location.href);
- url.searchParams.delete('enable_editor');
- url.searchParams.delete('with_loader');
- window.history.replaceState({}, null, url);
- },
- /**
- * Auto-starts the editor if necessary or add the welcome message otherwise.
- *
- * @override
- */
- start() {
- var def = this._super.apply(this, arguments);
-
- // If we auto start the editor, do not show a welcome message
- if (this._editorAutoStart) {
- return Promise.all([def, this._startEditMode()]);
- }
-
- // Check that the page is empty
- var $wrap = this._targetForEdition().filter('#wrapwrap.homepage').find('#wrap');
-
- if ($wrap.length && $wrap.html().trim() === '') {
- // If readonly empty page, show the welcome message
- this.$welcomeMessage = $(core.qweb.render('website.homepage_editor_welcome_message'));
- this.$welcomeMessage.addClass('o_homepage_editor_welcome_message');
- this.$welcomeMessage.css('min-height', $wrap.parent('main').height() - ($wrap.outerHeight(true) - $wrap.height()));
- $wrap.empty().append(this.$welcomeMessage);
- }
-
- return def;
- },
- /**
- * Asks the snippets to clean themself, then saves the page, then reloads it
- * if asked to.
- *
- * @param {boolean} [reload=true]
- * true if the page has to be reloaded after the save
- * @returns {Promise}
- */
- save: async function (reload = true) {
- if (this._saving) {
- return false;
- }
- this._saving = true;
-
- this._prepareLeavingEditMode();
-
- const destroy = () => {
- this.wysiwyg.destroy();
- this.destroy();
- };
- if (!this.wysiwyg.isDirty()) {
- destroy();
- window.location.reload();
- return;
- }
-
- return this.wysiwyg.saveContent(false).then((result) => {
- var $wrapwrap = $('#wrapwrap');
- this.editableFromEditorMenu($wrapwrap).removeClass('o_editable');
- if (reload) {
- // remove top padding because the connected bar is not visible
- $('body').removeClass('o_connected_user');
- return this._reload();
- } else {
- destroy();
- this.editModeEnabled = false;
- }
- return true;
- }).guardedCatch(() => {
- this._unprepareLeavingEditMode();
- this._saving = false;
- });
- },
- /**
- * Asks the user if they really wants to discard their changes (if any),
- * then simply reloads the page if they want to.
- *
- * @param {boolean} [reload=true]
- * true if the page has to be reloaded when the user answers yes
- * (do nothing otherwise but add this to allow class extension)
- * @returns {Deferred}
- */
- cancel: function (reload = true) {
- const prom = new Promise((resolve, reject) => {
- if (!this.wysiwyg.isDirty()) {
- resolve();
- } else {
- var confirm = Dialog.confirm(this, _t("If you discard the current edits, all unsaved changes will be lost. You can cancel to return to edit mode."), {
- confirm_callback: resolve,
- });
- confirm.on('closed', this, reject);
- }
- });
-
- return prom.then(() => {
- this._prepareLeavingEditMode();
-
- var $wrapwrap = $('#wrapwrap');
- this.editableFromEditorMenu($wrapwrap).removeClass('o_editable');
- if (reload) {
- window.onbeforeunload = null;
- this.wysiwyg.destroy();
- return this._reload();
- } else {
- this.wysiwyg.destroy();
- this.trigger_up('readonly_mode');
- this.destroy();
-
- this.editModeEnabled = false;
- }
- });
- },
- /**
- * Returns the editable areas on the page.
- *
- * @param {DOM} $wrapwrap
- * @returns {jQuery}
- */
- editableFromEditorMenu: function ($wrapwrap) {
- return $wrapwrap.find('[data-oe-model]')
- .not('.o_not_editable')
- .filter(function () {
- var $parent = $(this).closest('.o_editable, .o_not_editable');
- return !$parent.length || $parent.hasClass('o_editable');
- })
- .not('link, script')
- .not('[data-oe-readonly]')
- .not('img[data-oe-field="arch"], br[data-oe-field="arch"], input[data-oe-field="arch"]')
- .not('.oe_snippet_editor')
- .not('hr, br, input, textarea')
- .add('.o_editable');
- },
-
- //--------------------------------------------------------------------------
- // Actions
- //--------------------------------------------------------------------------
-
- /**
- * Creates an editor instance and appends it to the DOM. Also remove the
- * welcome message if necessary.
- *
- * @private
- * @returns {Promise}
- */
- _startEditMode: async function () {
- var self = this;
- if (this.editModeEnabled) {
- return;
- }
- this.editModeEnabled = true;
-
- $.blockUI({overlayCSS: {
- backgroundColor: '#000',
- opacity: 0,
- zIndex: 1050
- }, message: false});
-
- this.trigger_up('widgets_stop_request', {
- $target: this._targetForEdition(),
- });
- if (this.$welcomeMessage) {
- this.$welcomeMessage.detach(); // detach from the readonly rendering before the clone by wysiwyg.
- }
-
- await this._createWysiwyg();
-
- var res = await new Promise(function (resolve, reject) {
- self.trigger_up('widgets_start_request', {
- editableMode: true,
- onSuccess: resolve,
- onFailure: reject,
- });
- });
-
- const $loader = $('div.o_theme_install_loader_container');
- if ($loader) {
- $loader.remove();
- }
-
- $.unblockUI();
-
- return res;
- },
- /**
- * On save, the editor will ask to parent widgets if something needs to be
- * done first. The website navbar will receive that demand and asks to its
- * action-capable components to do something. For example, the content menu
- * handles page-related options saving. However, some users with limited
- * access rights do not have the content menu... but the website navbar
- * expects that the save action is performed. So, this empty action is
- * defined here so that all users have an 'on_save' related action.
- *
- * @private
- * @todo improve the system to somehow declare required/optional actions
- */
- _onSave: function () {},
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- async _createWysiwyg() {
- var $wrapwrap = $('#wrapwrap');
- $wrapwrap.removeClass('o_editable'); // clean the dom before edition
- this.editableFromEditorMenu($wrapwrap).addClass('o_editable');
-
- this.wysiwyg = await this._wysiwygInstance();
-
- await this.wysiwyg.attachTo($('#wrapwrap'));
- this.trigger_up('edit_mode');
- this.$el.css({width: ''});
-
- // Only make the odoo structure and fields editable.
- this.wysiwyg.odooEditor.observerUnactive();
- $('#wrapwrap').on('click.odoo-website-editor', '*', this, this._preventDefault);
- this._addEditorMessages(); // Insert editor messages in the DOM without observing.
- if (this.options.beforeEditorActive) {
- this.options.beforeEditorActive();
- }
- this.wysiwyg.odooEditor.observerActive();
-
- // 1. Make sure every .o_not_editable is not editable.
- // 2. Observe changes to mark dirty structures and fields.
- const processRecords = (records) => {
- records = this.wysiwyg.odooEditor.filterMutationRecords(records);
- // Skip the step for this stack because if the editor undo the first
- // step that has a dirty element, the following code would have
- // generated a new stack and break the "redo" of the editor.
- this.wysiwyg.odooEditor.automaticStepSkipStack();
-
- for (const record of records) {
- const $savable = $(record.target).closest(this.savableSelector);
-
- if (record.attributeName === 'contenteditable') {
- continue;
- }
- $savable.not('.o_dirty').each(function () {
- if (!this.hasAttribute('data-oe-readonly')) {
- this.classList.add('o_dirty');
- }
- });
- }
- };
- this.observer = new MutationObserver(processRecords);
- this._observe();
-
- this.wysiwyg.odooEditor.addEventListener('observerUnactive', () => {
- if (this.observer) {
- processRecords(this.observer.takeRecords());
- this.observer.disconnect();
- }
- });
- this.wysiwyg.odooEditor.addEventListener('observerActive', () => {
- this._observe();
- });
-
- $('body').addClass('editor_started');
- },
-
- _getContentEditableAreas () {
- return $(this.savableSelector).not('input, [data-oe-readonly],[data-oe-type="monetary"],[data-oe-many2one-id], [data-oe-field="arch"]:empty').filter((_, el) => {
- return !$(el).closest('.o_not_editable').length;
- }).toArray();
- },
-
- _getReadOnlyAreas () {
- return [];
- },
- /**
- * @private
- */
- _observe() {
- if (this.observer) {
- this.observer.observe(document.body, {
- childList: true,
- subtree: true,
- attributes: true,
- attributeOldValue: true,
- characterData: true,
- });
- }
- },
- /**
- * Cleans what must be cleaned for both saving and discarding the changes
- * that were made during edition.
- *
- * @private
- */
- _prepareLeavingEditMode() {
- if (this.observer) {
- this.observer.disconnect();
- }
- if (this.$editorMessageElements) {
- this.$editorMessageElements.removeAttr('data-editor-message');
- }
- },
- /**
- * Restores what was done in preparation of leaving the edit mode (e.g.
- * in case leaving failed for example). @see _prepareLeavingEditMode.
- *
- * FIXME this method was created to make the existing code readable and
- * logical but it cannot actually be called and have an editor that is still
- * usable afterwards (if the save/cancel failed for some reason, the editor
- * seems dead no matter what).
- *
- * @private
- */
- _unprepareLeavingEditMode() {
- this._addEditorMessages();
- this._observe();
- },
- /**
- * Call preventDefault of an event.
- *
- * @private
- */
- _preventDefault(e) {
- e.preventDefault();
- },
- /**
- * Adds automatic editor messages on drag&drop zone elements.
- *
- * @private
- */
- _addEditorMessages: function () {
- const $editable = this._targetForEdition().find('.oe_structure.oe_empty, [data-oe-type="html"]');
- this.$editorMessageElements = $editable
- .not('[data-editor-message]')
- .attr('data-editor-message', _t('DRAG BUILDING BLOCKS HERE'));
- $editable.filter(':empty').attr('contenteditable', false);
- },
- /**
- * Returns the target for edition.
- *
- * @private
- * @returns {JQuery}
- */
- _targetForEdition: function () {
- return $('#wrapwrap'); // TODO should know about this element another way
- },
- /**
- * Reloads the page in non-editable mode, with the right scrolling.
- *
- * @private
- * @returns {Deferred} (never resolved, the page is reloading anyway)
- */
- _reload: function () {
- $('body').addClass('o_wait_reload');
- this.wysiwyg.destroy();
- this.$el.hide();
- window.location.hash = 'scrollTop=' + window.document.body.scrollTop;
- window.location.reload(true);
- return new Promise(function () {});
- },
- /**
- * @private
- */
- _wysiwygInstance: function () {
- // todo: retrieve other config if there is no #wrap element on the page (eg. product, blog, ect.)
- let collaborationConfig = {};
- // todo: To uncomment when enabling the collaboration on website.
- // const $wrap = $('#wrapwrap #wrap[data-oe-model][data-oe-field][data-oe-id]');
- // if ($wrap.length) {
- // collaborationConfig = {
- // collaborationChannel: {
- // collaborationModelName: $wrap.attr('data-oe-model'),
- // collaborationFieldName: $wrap.attr('data-oe-field'),
- // collaborationResId: parseInt($wrap.attr('data-oe-id')),
- // }
- // };
- // }
-
- var context;
- this.trigger_up('context_get', {
- callback: function (ctx) {
- context = ctx;
- },
- });
- const params = Object.assign({
- snippets: 'website.snippets',
- recordInfo: {
- context: context,
- data_res_model: 'website',
- data_res_id: context.website_id,
- },
- enableWebsite: true,
- discardButton: true,
- saveButton: true,
- devicePreview: true,
- savableSelector: this.savableSelector,
- isRootEditable: false,
- controlHistoryFromDocument: true,
- getContentEditableAreas: this._getContentEditableAreas.bind(this),
- powerboxCommands: this._getSnippetsCommands(),
- showEmptyElementHint: false,
- getReadOnlyAreas: this._getReadOnlyAreas.bind(this),
- }, collaborationConfig);
- return wysiwygLoader.createWysiwyg(this,
- Object.assign(params, this.wysiwygOptions),
- ['website.compiled_assets_wysiwyg']
- );
- },
- _getSnippetsCommands: function () {
- const snippetCommandCallback = (selector) => {
- const $separatorBody = $(selector);
- const $clonedBody = $separatorBody.clone().removeClass('oe_snippet_body');
- const range = this.wysiwyg.getDeepRange();
- const block = this.wysiwyg.closestElement(range.endContainer, 'p, div, ol, ul, cl, h1, h2, h3, h4, h5, h6');
- if (block) {
- block.after($clonedBody[0]);
- this.wysiwyg.snippetsMenu.callPostSnippetDrop($clonedBody);
- }
- };
- return [
- {
- groupName: 'Website',
- title: 'Alert',
- description: 'Insert an alert snippet.',
- fontawesome: 'fa-info',
- callback: () => {
- snippetCommandCallback('.oe_snippet_body[data-snippet="s_alert"]');
- },
- },
- {
- groupName: 'Website',
- title: 'Rating',
- description: 'Insert a rating snippet.',
- fontawesome: 'fa-star-half-o',
- callback: () => {
- snippetCommandCallback('.oe_snippet_body[data-snippet="s_rating"]');
- },
- },
- {
- groupName: 'Website',
- title: 'Card',
- description: 'Insert a card snippet.',
- fontawesome: 'fa-sticky-note',
- callback: () => {
- snippetCommandCallback('.oe_snippet_body[data-snippet="s_card"]');
- },
- },
- {
- groupName: 'Website',
- title: 'Share',
- description: 'Insert a share snippet.',
- fontawesome: 'fa-share-square-o',
- callback: () => {
- snippetCommandCallback('.oe_snippet_body[data-snippet="s_share"]');
- },
- },
- {
- groupName: 'Website',
- title: 'Text Highlight',
- description: 'Insert a text Highlight snippet.',
- fontawesome: 'fa-sticky-note',
- callback: () => {
- snippetCommandCallback('.oe_snippet_body[data-snippet="s_text_highlight"]');
- },
- },
- {
- groupName: 'Website',
- title: 'Chart',
- description: 'Insert a chart snippet.',
- fontawesome: 'fa-bar-chart',
- callback: () => {
- snippetCommandCallback('.oe_snippet_body[data-snippet="s_chart"]');
- },
- },
- {
- groupName: 'Website',
- title: 'Progress Bar',
- description: 'Insert a progress bar snippet.',
- fontawesome: 'fa-spinner',
- callback: () => {
- snippetCommandCallback('.oe_snippet_body[data-snippet="s_progress_bar"]');
- },
- },
- {
- groupName: 'Website',
- title: 'Badge',
- description: 'Insert a badge snippet.',
- fontawesome: 'fa-tags',
- callback: () => {
- snippetCommandCallback('.oe_snippet_body[data-snippet="s_badge"]');
- },
- },
- {
- groupName: 'Website',
- title: 'Blockquote',
- description: 'Insert a blockquote snippet.',
- fontawesome: 'fa-quote-left',
- callback: () => {
- snippetCommandCallback('.oe_snippet_body[data-snippet="s_blockquote"]');
- },
- },
- {
- groupName: 'Website',
- title: 'Separator',
- description: 'Insert an horizontal separator sippet.',
- fontawesome: 'fa-minus',
- callback: () => {
- snippetCommandCallback('.oe_snippet_body[data-snippet="s_hr"]');
- },
- },
- ];
- },
-
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when content will be destroyed in the page. Notifies the
- * WebsiteRoot that is should stop the public widgets.
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onContentWillBeDestroyed: function (ev) {
- this.trigger_up('widgets_stop_request', {
- $target: ev.data.$target,
- });
- },
- /**
- * Called when content was recreated in the page. Notifies the
- * WebsiteRoot that is should start the public widgets.
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onContentWasRecreated: function (ev) {
- this.trigger_up('widgets_start_request', {
- editableMode: true,
- $target: ev.data.$target,
- });
- },
- /**
- * Called when edition was stopped. Notifies the
- * WebsiteRoot that is should start the public widgets.
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onEditionWasStopped: function (ev) {
- this.editModeEnabled = false;
- },
- /**
- * Called when a snippet is about to be cloned in the page. Notifies the
- * WebsiteRoot that is should destroy the animations for this snippet.
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onSnippetWillBeCloned: function (ev) {
- this.trigger_up('widgets_stop_request', {
- $target: ev.data.$target,
- });
- },
- /**
- * Called when a snippet is cloned in the page. Notifies the WebsiteRoot
- * that is should start the public widgets for this snippet and the snippet it
- * was cloned from.
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onSnippetCloned: function (ev) {
- this.trigger_up('widgets_start_request', {
- editableMode: true,
- $target: ev.data.$target,
- });
- // TODO: remove in saas-12.5, undefined $origin will restart #wrapwrap
- if (ev.data.$origin) {
- this.trigger_up('widgets_start_request', {
- editableMode: true,
- $target: ev.data.$origin,
- });
- }
- },
- /**
- * Called when a snippet is dropped in the page. Notifies the WebsiteRoot
- * that is should start the public widgets for this snippet. Also marks the
- * wrapper element as non-empty and makes it editable.
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onSnippetDropped: function (ev) {
- this._targetForEdition().find('.oe_structure.oe_empty, [data-oe-type="html"]')
- .attr('contenteditable', true);
- ev.data.addPostDropAsync(new Promise(resolve => {
- this.trigger_up('widgets_start_request', {
- editableMode: true,
- $target: ev.data.$target,
- onSuccess: () => resolve(),
- });
- }));
- },
- /**
- * Called when a snippet is removed from the page. If the wrapper element is
- * empty, marks it as such and shows the editor messages.
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onSnippetRemoved: function (ev) {
- const $editable = this._targetForEdition().find('.oe_structure.oe_empty, [data-oe-type="html"]');
- if (!$editable.children().length) {
- $editable.empty(); // remove any superfluous whitespace
- this._addEditorMessages();
- }
- },
- /**
- * Snippet (menu_data) can request to save the document to leave the page
- *
- * @private
- * @param {OdooEvent} ev
- * @param {object} ev.data
- * @param {function} ev.data.onSuccess
- * @param {function} ev.data.onFailure
- */
- _onSnippetRequestSave: function (ev) {
- ev.stopPropagation();
- const restore = dom.addButtonLoadingEffect($('button[data-action=save]')[0]);
- this.save(ev.data.reload).then(ev.data.onSuccess, ev.data.onFailure).then(restore).guardedCatch(restore);
- },
- /**
- * Asks the user if they really wants to discard their changes (if any),
- * then simply reloads the page if they want to.
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onSnippetRequestCancel: function (ev) {
- ev.stopPropagation();
- this.cancel();
- },
-});
-
-registry.category("website_navbar_widgets").add("EditPageMenu", {
- Widget: EditPageMenu,
- selector: '#edit-page-menu',
-});
-
-return EditPageMenu;
-});
diff --git a/addons/website/static/src/js/menu/mobile_view.js b/addons/website/static/src/js/menu/mobile_view.js
deleted file mode 100644
index 7c4f3e8d09d..00000000000
--- a/addons/website/static/src/js/menu/mobile_view.js
+++ /dev/null
@@ -1,72 +0,0 @@
-odoo.define('website.mobile', function (require) {
-'use strict';
-
-const {_t} = require('web.core');
-const {Markup} = require('web.utils');
-var Dialog = require('web.Dialog');
-var websiteNavbarData = require('website.navbar');
-
-const { registry } = require("@web/core/registry");
-
-var MobilePreviewDialog = Dialog.extend({
- /**
- * Tweaks the modal so that it appears as a phone and modifies the iframe
- * rendering to show more accurate mobile view.
- *
- * @override
- */
- start: function () {
- var self = this;
- this.$modal.addClass('oe_mobile_preview');
- this.$modal.on('click', '.modal-header', function () {
- self.$el.toggleClass('o_invert_orientation');
- });
- this.$iframe = $('', {
- id: 'mobile-viewport',
- src: $.param.querystring(window.location.href, 'mobilepreview'),
- });
- this.$iframe.on('load', function (e) {
- self.$iframe.contents().find('body').removeClass('o_connected_user');
- self.$iframe.contents().find('#oe_main_menu_navbar').remove();
- });
- this.$iframe.appendTo(this.$el);
-
- return this._super.apply(this, arguments);
- },
-});
-
-var MobileMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
- actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, {
- 'show-mobile-preview': '_onMobilePreviewClick',
- }),
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when the mobile action is triggered -> instantiate the mobile
- * preview dialog.
- *
- * @private
- */
- _onMobilePreviewClick: function () {
- if (this.mobilePreview && !this.mobilePreview.isDestroyed()) {
- return this.mobilePreview.close();
- }
- this.mobilePreview = new MobilePreviewDialog(this, {
- title: Markup(_.escape(_t('Mobile preview')) + ' '),
- }).open();
- },
-});
-
-registry.category("website_navbar_widgets").add("MobileMenu", {
- Widget: MobileMenu,
- selector: '#mobile-menu',
-});
-
-return {
- MobileMenu: MobileMenu,
- MobilePreviewDialog: MobilePreviewDialog,
-};
-});
diff --git a/addons/website/static/src/js/menu/navbar.js b/addons/website/static/src/js/menu/navbar.js
deleted file mode 100644
index c03e621d20c..00000000000
--- a/addons/website/static/src/js/menu/navbar.js
+++ /dev/null
@@ -1,291 +0,0 @@
-/** @odoo-module alias=website.navbar */
-
-import core from 'web.core';
-import dom from 'web.dom';
-import publicWidget from 'web.public.widget';
-import concurrency from 'web.concurrency';
-import Widget from 'web.Widget';
-import { initAutoMoreMenu } from '@web/legacy/js/core/menu';
-import { registry } from "@web/core/registry";
-
-var WebsiteNavbar = publicWidget.RootWidget.extend({
- xmlDependencies: ['/website/static/src/xml/website.xml'],
- events: _.extend({}, publicWidget.RootWidget.prototype.events || {}, {
- 'click [data-action]': '_onActionMenuClick',
- 'mouseover > ul > li.dropdown:not(.show)': '_onMenuHovered',
- 'click .o_mobile_menu_toggle': '_onMobileMenuToggleClick',
- 'mouseenter #oe_applications:not(:has(.dropdown-item))': '_onOeApplicationsHovered',
- 'show.bs.dropdown #oe_applications:not(:has(.dropdown-item))': '_onOeApplicationsShow',
- }),
- custom_events: _.extend({}, publicWidget.RootWidget.prototype.custom_events || {}, {
- 'action_demand': '_onActionDemand',
- 'edit_mode': '_onEditMode',
- 'readonly_mode': '_onReadonlyMode',
- 'ready_to_save': '_onSave',
- }),
-
- /**
- * @constructor
- */
- init: function () {
- this._super.apply(this, arguments);
- var self = this;
- var initPromise = new Promise(function (resolve) {
- self.resolveInit = resolve;
- });
- this._widgetDefs = [initPromise];
- },
- /**
- * @override
- */
- start: function () {
- var self = this;
- initAutoMoreMenu(this.el.querySelector('.o_menu_sections'), {
- maxWidth: function () {
- // The navbar contains different elements in community and
- // enterprise, so we check for both of them here only
- return self.$el.width()
- - (self.$('.o_menu_systray').outerWidth(true) || 0)
- - (self.$('#oe_applications').outerWidth(true) || 0)
- - (self.$('.o_menu_toggle').outerWidth(true) || 0)
- - (self.$('.o_menu_brand').outerWidth(true) || 0);
- },
- });
- return this._super.apply(this, arguments).then(function () {
- self.resolveInit();
- });
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- _attachComponent: function () {
- var def = this._super.apply(this, arguments);
- this._widgetDefs.push(def);
- return def;
- },
- /**
- * As the WebsiteNavbar instance is designed to be unique, the associated
- * registry has been instantiated outside of the class and is simply
- * returned here.
- *
- * @override
- */
- _getRegistry: function () {
- return registry.category("website_navbar_widgets");
- },
- /**
- * Searches for the automatic widget {@see RootWidget} which can handle that
- * action.
- *
- * @private
- * @param {string} actionName
- * @param {Array} params
- * @returns {Promise}
- */
- _handleAction: function (actionName, params, _i) {
- var self = this;
- return this._whenReadyForActions().then(function () {
- var defs = [];
- _.each(self._widgets, function (w) {
- if (!w.handleAction) {
- return;
- }
-
- var def = w.handleAction(actionName, params);
- if (def !== null) {
- defs.push(def);
- }
- });
- if (!defs.length) {
- // Handle the case where all action-capable components are not
- // instantiated yet (rare) -> retry some times to eventually abort
- if (_i > 50) {
- console.warn(_.str.sprintf("Action '%s' was not able to be handled.", actionName));
- return Promise.reject();
- }
- return concurrency.delay(100).then(function () {
- return self._handleAction(actionName, params, (_i || 0) + 1);
- });
- }
- return Promise.all(defs).then(function (values) {
- if (values.length === 1) {
- return values[0];
- }
- return values;
- });
- });
- },
- /**
- * @private
- * @returns {Promise}
- */
- async _loadAppMenus() {
- if (!this._loadAppMenusProm) {
- this._loadAppMenusProm = this._rpc({
- model: 'ir.ui.menu',
- method: 'load_menus_root',
- args: [],
- });
- const result = await this._loadAppMenusProm;
- const menus = core.qweb.render('website.oe_applications_menu', {
- 'menu_data': result,
- });
- this.$('#oe_applications .dropdown-menu').html(menus);
- }
- return this._loadAppMenusProm;
- },
- /**
- * @private
- */
- _whenReadyForActions: function () {
- return Promise.all(this._widgetDefs);
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when the backend applications menu is hovered -> fetch the
- * available menus and insert it in DOM.
- *
- * @private
- */
- _onOeApplicationsHovered: function () {
- this._loadAppMenus();
- },
- /**
- * Called when the backend applications menu is opening -> fetch the
- * available menus and insert it in DOM. Needed on top of hovering as the
- * dropdown could be opened via keyboard (or the user could just already
- * be over the dropdown when the JS is fully loaded).
- *
- * @private
- */
- _onOeApplicationsShow: function () {
- this._loadAppMenus();
- },
- /**
- * Called when an action menu is clicked -> searches for the automatic
- * widget {@see RootWidget} which can handle that action.
- *
- * @private
- * @param {Event} ev
- */
- _onActionMenuClick: function (ev) {
- const restore = dom.addButtonLoadingEffect(ev.currentTarget);
- this._handleAction($(ev.currentTarget).data('action')).then(restore).guardedCatch(restore);
- },
- /**
- * Called when an action is asked to be executed from a child widget ->
- * searches for the automatic widget {@see RootWidget} which can handle
- * that action.
- */
- _onActionDemand: function (ev) {
- var def = this._handleAction(ev.data.actionName, ev.data.params);
- if (ev.data.onSuccess) {
- def.then(ev.data.onSuccess);
- }
- if (ev.data.onFailure) {
- def.guardedCatch(ev.data.onFailure);
- }
- },
- /**
- * Called in response to edit mode activation -> hides the navbar.
- *
- * @private
- */
- _onEditMode: function () {
- this.$el.addClass('editing_mode');
- this.do_hide();
- },
- /**
- * Called when a submenu is hovered -> automatically opens it if another
- * menu was already opened.
- *
- * @private
- * @param {Event} ev
- */
- _onMenuHovered: function (ev) {
- var $opened = this.$('> ul > li.dropdown.show');
- if ($opened.length) {
- $opened.find('.dropdown-toggle').dropdown('toggle');
- $(ev.currentTarget).find('.dropdown-toggle').dropdown('toggle');
- }
- },
- /**
- * Called when the mobile menu toggle button is click -> modifies the DOM
- * to open the mobile menu.
- *
- * @private
- */
- _onMobileMenuToggleClick: function () {
- this.$el.parent().toggleClass('o_mobile_menu_opened');
- },
- /**
- * Called in response to edit mode activation -> hides the navbar.
- *
- * @private
- */
- _onReadonlyMode: function () {
- this.$el.removeClass('editing_mode');
- this.do_show();
- },
- /**
- * Called in response to edit mode saving -> checks if action-capable
- * children have something to save.
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onSave: function (ev) {
- ev.data.defs.push(this._handleAction('on_save'));
- },
-});
-
-var WebsiteNavbarActionWidget = Widget.extend({
- /**
- * 'Action name' -> 'Handler name' object
- *
- * Any [data-action="x"] element inside the website navbar will
- * automatically trigger an action "x". This action can then be handled by
- * any `WebsiteNavbarActionWidget` instance if the action name "x" is
- * registered in this `actions` object.
- */
- actions: {},
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Checks if the widget can execute an action whose name is given, with the
- * given parameters. If it is the case, execute that action.
- *
- * @param {string} actionName
- * @param {Array} params
- * @returns {Promise|null} action's promise or null if no action was found
- */
- handleAction: function (actionName, params) {
- var action = this[this.actions[actionName]];
- if (action) {
- return Promise.resolve(action.apply(this, params || []));
- }
- return null;
- },
-});
-
-registry.category("public_root_widgets").add("WebsiteNavbar", {
- Widget: WebsiteNavbar,
- selector: '#oe_main_menu_navbar',
-});
-
-export default {
- WebsiteNavbar: WebsiteNavbar,
- WebsiteNavbarActionWidget: WebsiteNavbarActionWidget,
-};
diff --git a/addons/website/static/src/js/menu/new_content.js b/addons/website/static/src/js/menu/new_content.js
deleted file mode 100644
index 1eac6393be9..00000000000
--- a/addons/website/static/src/js/menu/new_content.js
+++ /dev/null
@@ -1,354 +0,0 @@
-odoo.define('website.newMenu', function (require) {
-'use strict';
-
-var core = require('web.core');
-var Dialog = require('web.Dialog');
-var websiteNavbarData = require('website.navbar');
-var wUtils = require('website.utils');
-var tour = require('web_tour.tour');
-
-const { registry } = require("@web/core/registry");
-
-const {qweb, _t} = core;
-
-var enableFlag = 'enable_new_content';
-
-var NewContentMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
- xmlDependencies: ['/website/static/src/xml/website.editor.xml'],
- actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, {
- close_all_widgets: '_handleCloseDemand',
- new_page: '_createNewPage',
- }),
- events: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.events || {}, {
- 'click': '_onBackgroundClick',
- 'click [data-module-id]': '_onModuleIdClick',
- 'keydown': '_onBackgroundKeydown',
- }),
- // allow text to be customized with inheritance
- newContentText: {
- failed: _t('Failed to install "%s"'),
- installInProgress: _t("The installation of an App is already in progress."),
- installNeeded: _t('Do you want to install the "%s" App?'),
- installPleaseWait: _t('Installing "%s"'),
- },
-
- /**
- * Prepare the navigation and find the modules to install.
- * Move not installed module buttons after installed modules buttons,
- * but keep the original index to be able to move back the pending install
- * button at its final position, so the user can click at the same place.
- *
- * @override
- */
- start: function () {
- this.pendingInstall = false;
- this.$newContentMenuChoices = this.$('#o_new_content_menu_choices');
-
- var $modules = this.$newContentMenuChoices.find('.o_new_content_element');
- _.each($modules, function (el, index) {
- var $el = $(el);
- $el.data('original-index', index);
- if ($el.data('module-id')) {
- $el.appendTo($el.parent());
- $el.find('a i, a p').addClass('o_uninstalled_module');
- }
- });
-
- this.$firstLink = this.$newContentMenuChoices.find('a:eq(0)');
- this.$lastLink = this.$newContentMenuChoices.find('a:last');
-
- if ($.deparam.querystring()[enableFlag] !== undefined) {
- Object.keys(tour.tours).forEach(
- el => {
- let element = tour.tours[el];
- if (element.steps[0].trigger == '#new-content-menu > a'
- && !element.steps[0].extra_trigger) {
- element.steps[0].auto = true;
- }
- }
- );
- this._showMenu();
- }
- this.$loader = $(qweb.render('website.new_content_loader'));
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Actions
- //--------------------------------------------------------------------------
-
- /**
- * Asks the user information about a new page to create, then creates it and
- * redirects the user to this new page.
- *
- * @private
- * @returns {Promise} Unresolved if there is a redirection
- */
- _createNewPage: function () {
- return wUtils.prompt({
- id: 'editor_new_page',
- window_title: _t("New Page"),
- input: _t("Page Title"),
- init: function () {
- var $group = this.$dialog.find('div.form-group');
- $group.removeClass('mb0');
-
- var $add = $('', {'class': 'form-group mb0 row'})
- .append($('', {'class': 'offset-md-3 col-md-9 text-left'})
- .append(qweb.render('website.components.switch', {id: 'switch_addTo_menu', label: _t("Add to menu")})));
- $add.find('input').prop('checked', true);
- $group.after($add);
- }
- }).then(function (result) {
- var val = result.val;
- var $dialog = result.dialog;
- if (!val) {
- return;
- }
- var url = '/website/add/' + encodeURIComponent(val);
- wUtils.sendRequest(url, {
- add_menu: $dialog.find('input[type="checkbox"]').is(':checked') || '',
- });
- return new Promise(function () {});
- });
- },
- /**
- * @private
- */
- _handleCloseDemand: function () {
- this._hideMenu();
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Set the focus on the first link
- *
- * @private
- */
- _focusFirstLink: function () {
- this.$firstLink.focus();
- },
- /**
- * Set the focus on the last link
- *
- * @private
- */
- _focusLastLink: function () {
- this.$lastLink.focus();
- },
- /**
- * Hide the menu
- *
- * @private
- */
- _hideMenu: function () {
- this.shown = false;
- this.$newContentMenuChoices.addClass('o_hidden');
- $('body').removeClass('o_new_content_open');
- },
- /**
- * Install a module
- *
- * @private
- * @param {number} moduleId: the module to install
- * @return {Promise}
- */
- _install: function (moduleId) {
- this.pendingInstall = true;
- $('body').css('pointer-events', 'none');
- return this._rpc({
- model: 'ir.module.module',
- method: 'button_immediate_install',
- args: [[moduleId]],
- }).guardedCatch(function () {
- $('body').css('pointer-events', '');
- });
- },
- /**
- * Show the menu
- *
- * @private
- * @returns {Promise}
- */
- _showMenu: function () {
- var self = this;
- return new Promise(function (resolve, reject) {
- self.trigger_up('action_demand', {
- actionName: 'close_all_widgets',
- onSuccess: resolve,
- onFailure: reject,
- });
- }).then(function () {
- self.firstTab = true;
- self.shown = true;
- self.$newContentMenuChoices.removeClass('o_hidden');
- $('body').addClass('o_new_content_open');
- self.$('> a').focus();
- });
- },
- /**
- * Called to add loader element in DOM.
- *
- * @param {string} moduleName
- * @private
- */
- _addLoader(moduleName) {
- const newContentLoaderText = _.str.sprintf(_t("Building your %s"), moduleName);
- this.$loader.find('#new_content_loader_text').replaceWith(newContentLoaderText);
- $('body').append(this.$loader);
- },
- /**
- * @private
- */
- _removeLoader() {
- this.$loader.remove();
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when the menu's toggle button is clicked:
- * -> Opens the menu and reset the tab navigation (if closed)
- * -> Close the menu (if open)
- * Called when a click outside the menu's options occurs -> Close the menu
- *
- * @private
- * @param {Event} ev
- */
- _onBackgroundClick: function (ev) {
- if (this.$newContentMenuChoices.hasClass('o_hidden')) {
- this._showMenu();
- } else {
- this._hideMenu();
- }
- },
- /**
- * Called when a keydown occurs:
- * ESC -> Closes the modal
- * TAB -> Navigation (captured in the modal)
- *
- * @private
- * @param {Event} ev
- */
- _onBackgroundKeydown: function (ev) {
- if (!this.shown) {
- return;
- }
- switch (ev.which) {
- case $.ui.keyCode.ESCAPE:
- this._hideMenu();
- ev.stopPropagation();
- break;
- case $.ui.keyCode.TAB:
- if (ev.shiftKey) {
- if (this.firstTab || document.activeElement === this.$firstLink[0]) {
- this._focusLastLink();
- ev.preventDefault();
- }
- } else {
- if (this.firstTab || document.activeElement === this.$lastLink[0]) {
- this._focusFirstLink();
- ev.preventDefault();
- }
- }
- this.firstTab = false;
- break;
- }
- },
- /**
- * Open the install dialog related to an element:
- * - open the dialog depending on access right and another pending install
- * - if ok to install, prepare the install action:
- * - call the proper action on click
- * - change the button text and style
- * - handle the result (reload on the same page or error)
- *
- * @private
- * @param {Event} ev
- */
- _onModuleIdClick: function (ev) {
- var self = this;
- var $el = $(ev.currentTarget);
- var $i = $el.find('a i');
- var $p = $el.find('a p');
-
- var title = $p.text();
- var content = '';
- var buttons;
-
- var moduleId = $el.data('module-id');
- var name = $el.data('module-shortdesc');
-
- ev.stopPropagation();
- ev.preventDefault();
-
- if (this.pendingInstall) {
- content = this.newContentText.installInProgress;
- } else {
- content = _.str.sprintf(this.newContentText.installNeeded, name);
- buttons = [{
- text: _t("Install"),
- classes: 'btn-primary',
- close: true,
- click: function () {
- // move the element where it will be after installation
- var $finalPosition = self.$newContentMenuChoices
- .find('.o_new_content_element:not([data-module-id])')
- .filter(function () {
- return $(this).data('original-index') < $el.data('original-index');
- }).last();
- if ($finalPosition) {
- $el.fadeTo(400, 0, function () {
- // if once installed, button disapeear, don't need to move it.
- if (!$el.hasClass('o_new_content_element_once')) {
- $el.insertAfter($finalPosition);
- }
- // change style to use spinner
- $i.removeClass()
- .addClass('fa fa-spin fa-circle-o-notch fa-spin')
- .css('background-image', 'none');
- $p.removeClass('o_uninstalled_module')
- .text(_.str.sprintf(self.newContentText.installPleaseWait, name));
- $el.fadeTo(1000, 1);
- self._addLoader(name);
- });
- }
-
- self._install(moduleId).then(function () {
- var origin = window.location.origin;
- var redirectURL = $el.find('a').data('url') || (window.location.pathname + '?' + enableFlag);
- window.location.href = origin + redirectURL;
- self._removeLoader();
- }, function () {
- $i.removeClass()
- .addClass('fa fa-exclamation-triangle');
- $p.text(_.str.sprintf(self.newContentText.failed, name));
- });
- }
- }, {
- text: _t("Cancel"),
- close: true,
- }];
- }
-
- new Dialog(this, {
- title: title,
- size: 'medium',
- $content: $('', {text: content}),
- buttons: buttons
- }).open();
- },
-});
-
-registry.category("website_navbar_widgets").add("NewContentMenu", {
- Widget: NewContentMenu,
- selector: '.o_new_content_menu',
-});
-
-return NewContentMenu;
-});
diff --git a/addons/website/static/src/js/menu/seo.js b/addons/website/static/src/js/menu/seo.js
deleted file mode 100644
index 54cc4b926be..00000000000
--- a/addons/website/static/src/js/menu/seo.js
+++ /dev/null
@@ -1,908 +0,0 @@
-odoo.define('website.seo', function (require) {
-'use strict';
-
-var core = require('web.core');
-var Class = require('web.Class');
-var Dialog = require('web.Dialog');
-var mixins = require('web.mixins');
-var rpc = require('web.rpc');
-var Widget = require('web.Widget');
-var weWidgets = require('wysiwyg.widgets');
-var websiteNavbarData = require('website.navbar');
-const { session } = require('@web/session');
-
-const { registry } = require("@web/core/registry");
-
-var _t = core._t;
-
-// This replaces \b, because accents(e.g. à, é) are not seen as word boundaries.
-// Javascript \b is not unicode aware, and words beginning or ending by accents won't match \b
-var WORD_SEPARATORS_REGEX = '([\\u2000-\\u206F\\u2E00-\\u2E7F\'!"#\\$%&\\(\\)\\*\\+,\\-\\.\\/:;<=>\\?¿¡@\\[\\]\\^_`\\{\\|\\}~\\s]+|^|$)';
-
-var Suggestion = Widget.extend({
- template: 'website.seo_suggestion',
- xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
- events: {
- 'click .o_seo_suggestion': 'select',
- },
-
- init: function (parent, options) {
- this.keyword = options.keyword;
- this._super(parent);
- },
- select: function () {
- this.trigger('selected', this.keyword);
- },
-});
-
-var SuggestionList = Widget.extend({
- template: 'website.seo_suggestion_list',
- xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
-
- init: function (parent, options) {
- this.root = options.root;
- this.language = options.language;
- this.htmlPage = options.htmlPage;
- this._super(parent);
- },
- start: function () {
- this.refresh();
- },
- refresh: function () {
- var self = this;
- self.$el.append(_t("Loading..."));
- var context;
- this.trigger_up('context_get', {
- callback: function (ctx) {
- context = ctx;
- },
- });
- var language = self.language || context.lang.toLowerCase();
- this._rpc({
- route: '/website/seo_suggest',
- params: {
- keywords: self.root,
- lang: language,
- },
- }).then(function (keyword_list) {
- self.addSuggestions(JSON.parse(keyword_list));
- });
- },
- addSuggestions: function (keywords) {
- var self = this;
- self.$el.empty();
- // TODO Improve algorithm + Ajust based on custom user keywords
- var regex = new RegExp(WORD_SEPARATORS_REGEX + self.root + WORD_SEPARATORS_REGEX, 'gi');
- keywords = _.map(_.uniq(keywords), function (word) {
- return word.replace(regex, '').trim();
- });
- // TODO Order properly ?
- _.each(keywords, function (keyword) {
- if (keyword) {
- var suggestion = new Suggestion(self, {
- keyword: keyword,
- });
- suggestion.on('selected', self, function (word, language) {
- self.trigger('selected', word, language);
- });
- suggestion.appendTo(self.$el);
- }
- });
- },
-});
-
-var Keyword = Widget.extend({
- template: 'website.seo_keyword',
- xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
- events: {
- 'click a[data-action=remove-keyword]': 'destroy',
- },
-
- init: function (parent, options) {
- this.keyword = options.word;
- this.language = options.language;
- this.htmlPage = options.htmlPage;
- this.used_h1 = this.htmlPage.isInHeading1(this.keyword);
- this.used_h2 = this.htmlPage.isInHeading2(this.keyword);
- this.used_content = this.htmlPage.isInBody(this.keyword);
- this._super(parent);
- },
- start: function () {
- var self = this;
- this.$('.o_seo_keyword_suggestion').empty();
- this.suggestionList = new SuggestionList(this, {
- root: this.keyword,
- language: this.language,
- htmlPage: this.htmlPage,
- });
- this.suggestionList.on('selected', this, function (word, language) {
- this.trigger('selected', word, language);
- });
- return this.suggestionList.appendTo(this.$('.o_seo_keyword_suggestion')).then(function() {
- self.htmlPage.on('title-changed', self, self._updateTitle);
- self.htmlPage.on('description-changed', self, self._updateDescription);
- self._updateTitle();
- self._updateDescription();
- });
- },
- destroy: function () {
- this.trigger('removed');
- this._super();
- },
- _updateTitle: function () {
- var $title = this.$('.js_seo_keyword_title');
- if (this.htmlPage.isInTitle(this.keyword)) {
- $title.css('visibility', 'visible');
- } else {
- $title.css('visibility', 'hidden');
- }
- },
- _updateDescription: function () {
- var $description = this.$('.js_seo_keyword_description');
- if (this.htmlPage.isInDescription(this.keyword)) {
- $description.css('visibility', 'visible');
- } else {
- $description.css('visibility', 'hidden');
- }
- },
-});
-
-var KeywordList = Widget.extend({
- template: 'website.seo_list',
- xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
- maxKeywords: 10,
-
- init: function (parent, options) {
- this.htmlPage = options.htmlPage;
- this._super(parent);
- },
- start: function () {
- var self = this;
- var existingKeywords = self.htmlPage.keywords();
- if (existingKeywords.length > 0) {
- _.each(existingKeywords, function (word) {
- self.add.call(self, word);
- });
- }
- },
- keywords: function () {
- var result = [];
- this.$('.js_seo_keyword').each(function () {
- result.push($(this).data('keyword'));
- });
- return result;
- },
- isFull: function () {
- return this.keywords().length >= this.maxKeywords;
- },
- exists: function (word) {
- return _.contains(this.keywords(), word);
- },
- add: async function (candidate, language) {
- var self = this;
- // TODO Refine
- var word = candidate ? candidate.replace(/[,;.:<>]+/g, ' ').replace(/ +/g, ' ').trim().toLowerCase() : '';
- if (word && !self.isFull() && !self.exists(word)) {
- var keyword = new Keyword(self, {
- word: word,
- language: language,
- htmlPage: this.htmlPage,
- });
- keyword.on('removed', self, function () {
- self.trigger('list-not-full');
- self.trigger('content-updated', true);
- });
- keyword.on('selected', self, function (word, language) {
- self.trigger('selected', word, language);
- });
- await keyword.appendTo(self.$el);
- }
- if (self.isFull()) {
- self.trigger('list-full');
- }
- self.trigger('content-updated');
- },
-});
-
-var Preview = Widget.extend({
- template: 'website.seo_preview',
- xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
-
- init: function (parent, options) {
- this.title = options.title;
- this.url = options.url;
- this.description = options.description;
- if (this.description.length > 160) {
- this.description = this.description.substring(0, 159) + '…';
- }
- this._super(parent);
- },
-});
-
-var HtmlPage = Class.extend(mixins.PropertiesMixin, {
- init: function () {
- mixins.PropertiesMixin.init.call(this);
- this.initTitle = this.title();
- this.defaultTitle = $('meta[name="default_title"]').attr('content');
- this.initDescription = this.description();
- },
- url: function () {
- return window.location.origin + window.location.pathname;
- },
- title: function () {
- return $('title').text().trim();
- },
- changeTitle: function (title) {
- // TODO create tag if missing
- $('title').text(title.trim() || this.defaultTitle);
- this.trigger('title-changed', title);
- },
- description: function () {
- return ($('meta[name=description]').attr('content') || '').trim();
- },
- changeDescription: function (description) {
- // TODO create tag if missing
- $('meta[name=description]').attr('content', description);
- this.trigger('description-changed', description);
- },
- keywords: function () {
- var $keywords = $('meta[name=keywords]');
- var parsed = ($keywords.length > 0) && $keywords.attr('content') && $keywords.attr('content').split(',');
- return (parsed && parsed[0]) ? parsed: [];
- },
- changeKeywords: function (keywords) {
- // TODO create tag if missing
- $('meta[name=keywords]').attr('content', keywords.join(','));
- },
- headers: function (tag) {
- return $('#wrap '+tag).map(function () {
- return $(this).text();
- });
- },
- getOgMeta: function () {
- var ogImageUrl = $('meta[property="og:image"]').attr('content');
- var title = $('meta[property="og:title"]').attr('content');
- var description = $('meta[property="og:description"]').attr('content');
- return {
- ogImageUrl: ogImageUrl && ogImageUrl.replace(window.location.origin, ''),
- metaTitle: title,
- metaDescription: description,
- };
- },
- images: function () {
- return $('#wrap img').filter(function () {
- return this.naturalHeight >= 200 && this.naturalWidth >= 200;
- }).map(function () {
- return {
- src: this.getAttribute('src'),
- alt: this.getAttribute('alt'),
- };
- });
- },
- company: function () {
- return $('html').attr('data-oe-company-name');
- },
- bodyText: function () {
- return $('body').children().not('.oe_seo_configuration').text();
- },
- heading1: function () {
- return $('body').children().not('.oe_seo_configuration').find('h1').text();
- },
- heading2: function () {
- return $('body').children().not('.oe_seo_configuration').find('h2').text();
- },
- isInBody: function (text) {
- return new RegExp(WORD_SEPARATORS_REGEX + text + WORD_SEPARATORS_REGEX, 'gi').test(this.bodyText());
- },
- isInTitle: function (text) {
- return new RegExp(WORD_SEPARATORS_REGEX + text + WORD_SEPARATORS_REGEX, 'gi').test(this.title());
- },
- isInDescription: function (text) {
- return new RegExp(WORD_SEPARATORS_REGEX + text + WORD_SEPARATORS_REGEX, 'gi').test(this.description());
- },
- isInHeading1: function (text) {
- return new RegExp(WORD_SEPARATORS_REGEX + text + WORD_SEPARATORS_REGEX, 'gi').test(this.heading1());
- },
- isInHeading2: function (text) {
- return new RegExp(WORD_SEPARATORS_REGEX + text + WORD_SEPARATORS_REGEX, 'gi').test(this.heading2());
- },
-});
-
-var MetaTitleDescription = Widget.extend({
- // Form and preview for SEO meta title and meta description
- //
- // We only want to show an alert for "description too small" on those cases
- // - at init and the description is not empty
- // - we reached past the minimum and went back to it
- // - focus out of the field
- // Basically we don't want the too small alert when the field is empty and
- // we start typing on it.
- template: 'website.seo_meta_title_description',
- xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
- events: {
- 'input input[name=website_meta_title]': '_titleChanged',
- 'input input[name=website_seo_name]': '_seoNameChanged',
- 'input textarea[name=website_meta_description]': '_descriptionOnInput',
- 'change textarea[name=website_meta_description]': '_descriptionOnChange',
- },
- maxRecommendedDescriptionSize: 300,
- minRecommendedDescriptionSize: 50,
- showDescriptionTooSmall: false,
-
- /**
- * @override
- */
- init: function (parent, options) {
- this.htmlPage = options.htmlPage;
- this.canEditTitle = !!options.canEditTitle;
- this.canEditDescription = !!options.canEditDescription;
- this.canEditUrl = !!options.canEditUrl;
- this.isIndexed = !!options.isIndexed;
- this.seoName = options.seoName;
- this.seoNameDefault = options.seoNameDefault;
- this.seoNameHelp = options.seoNameHelp;
- this.previewDescription = options.previewDescription;
- this._super(parent, options);
- },
- /**
- * @override
- */
- start: function () {
- this.$title = this.$('input[name=website_meta_title]');
- this.$seoName = this.$('input[name=website_seo_name]');
- this.$seoNamePre = this.$('span.seo_name_pre');
- this.$seoNamePost = this.$('span.seo_name_post');
- this.$description = this.$('textarea[name=website_meta_description]');
- this.$warning = this.$('div#website_meta_description_warning');
- this.$preview = this.$('.js_seo_preview');
-
- if (!this.canEditTitle) {
- this.$title.attr('disabled', true);
- }
- if (!this.canEditDescription) {
- this.$description.attr('disabled', true);
- }
- if (this.htmlPage.title().trim() !== this.htmlPage.defaultTitle.trim()) {
- this.$title.val(this.htmlPage.title());
- }
- if (this.htmlPage.description().trim() !== this.previewDescription) {
- this.$description.val(this.htmlPage.description());
- }
-
- if (this.canEditUrl) {
- this.previousSeoName = this.seoName;
- this.$seoName.val(this.seoName);
- this.$seoName.attr('placeholder', this.seoNameDefault);
- // make slug editable with input group for static text
- const splitsUrl = window.location.pathname.split(this.previousSeoName || this.seoNameDefault);
- this.$seoNamePre.text(splitsUrl[0]);
- this.$seoNamePost.text(splitsUrl.slice(-1)[0]); // at least the -id theorically
- }
- this._descriptionOnChange();
- },
- /**
- * Get the current title
- */
- getTitle: function () {
- return this.$title.val().trim() || this.htmlPage.defaultTitle;
- },
- /**
- * Get the potential new url with custom seoName as slug.
- I can differ after save if slug JS != slug Python, but it provide an idea for the preview
- */
- getUrl: function () {
- const path = window.location.pathname.replace(
- this.previousSeoName || this.seoNameDefault,
- (this.$seoName.length && this.$seoName.val() ? this.$seoName.val().trim() : this.$seoName.attr('placeholder'))
- );
- return window.location.origin + path
- },
- /**
- * Get the current description
- */
- getDescription: function () {
- return this.getRealDescription() || this.previewDescription;
- },
- /**
- * Get the current description chosen by the user
- */
- getRealDescription: function () {
- return this.$description.val() || '';
- },
- /**
- * @private
- */
- _titleChanged: function () {
- var self = this;
- self._renderPreview();
- self.trigger('title-changed');
- },
- /**
- * @private
- */
- _seoNameChanged: function () {
- var self = this;
- // don't use _, because we need to keep trailing whitespace during edition
- const slugified = this.$seoName.val().toString().toLowerCase()
- .replace(/\s+/g, '-') // Replace spaces with -
- .replace(/[^\w\-]+/g, '-') // Remove all non-word chars
- .replace(/\-\-+/g, '-'); // Replace multiple - with single -
- this.$seoName.val(slugified);
- self._renderPreview();
- },
- /**
- * @private
- */
- _descriptionOnChange: function () {
- this.showDescriptionTooSmall = true;
- this._descriptionOnInput();
- },
- /**
- * @private
- */
- _descriptionOnInput: function () {
- var length = this.getDescription().length;
-
- if (length >= this.minRecommendedDescriptionSize) {
- this.showDescriptionTooSmall = true;
- } else if (length === 0) {
- this.showDescriptionTooSmall = false;
- }
-
- if (length > this.maxRecommendedDescriptionSize) {
- this.$warning.text(_t('Your description looks too long.')).show();
- } else if (this.showDescriptionTooSmall && length < this.minRecommendedDescriptionSize) {
- this.$warning.text(_t('Your description looks too short.')).show();
- } else {
- this.$warning.hide();
- }
-
- this._renderPreview();
- this.trigger('description-changed');
- },
- /**
- * @private
- */
- _renderPreview: function () {
- var indexed = this.isIndexed;
- var preview = "";
- if (indexed) {
- preview = new Preview(this, {
- title: this.getTitle(),
- description: this.getDescription(),
- url: this.getUrl(),
- });
- } else {
- preview = new Preview(this, {
- description: _t("You have hidden this page from search results. It won't be indexed by search engines."),
- });
- }
- this.$preview.empty();
- preview.appendTo(this.$preview);
- },
-});
-
-var MetaKeywords = Widget.extend({
- // Form and table for SEO meta keywords
- template: 'website.seo_meta_keywords',
- xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
- events: {
- 'keyup input[name=website_meta_keywords]': '_confirmKeyword',
- 'click button[data-action=add]': '_addKeyword',
- },
-
- init: function (parent, options) {
- this.htmlPage = options.htmlPage;
- this._super(parent, options);
- },
- start: function () {
- var self = this;
- this.$input = this.$('input[name=website_meta_keywords]');
- this.keywordList = new KeywordList(this, {htmlPage: this.htmlPage});
- this.keywordList.on('list-full', this, function () {
- self.$input.attr({
- readonly: 'readonly',
- placeholder: "Remove a keyword first"
- });
- self.$('button[data-action=add]').prop('disabled', true).addClass('disabled');
- });
- this.keywordList.on('list-not-full', this, function () {
- self.$input.removeAttr('readonly').attr('placeholder', "");
- self.$('button[data-action=add]').prop('disabled', false).removeClass('disabled');
- });
- this.keywordList.on('selected', this, function (word, language) {
- self.keywordList.add(word, language);
- });
- this.keywordList.on('content-updated', this, function (removed) {
- self._updateTable(removed);
- });
- return this.keywordList.insertAfter(this.$('.table thead')).then(function() {
- self._getLanguages();
- self._updateTable();
- });
- },
- _addKeyword: function () {
- var $language = this.$('select[name=seo_page_language]');
- var keyword = this.$input.val();
- var language = $language.val().toLowerCase();
- this.keywordList.add(keyword, language);
- this.$input.val('').focus();
- },
- _confirmKeyword: function (e) {
- if (e.keyCode === 13) {
- this._addKeyword();
- }
- },
- _getLanguages: function () {
- var self = this;
- var context;
- this.trigger_up('context_get', {
- callback: function (ctx) {
- context = ctx;
- },
- });
- this._rpc({
- route: '/website/get_languages',
- }).then(function (data) {
- self.$('#language-box').html(core.qweb.render('Configurator.language_promote', {
- 'language': data,
- 'def_lang': context.lang
- }));
- });
- },
- /*
- * Show the table if there is at least one keyword. Hide it otherwise.
- *
- * @private
- * @param {boolean} removed: a keyword is about to be removed,
- * we need to exclude it from the count
- */
- _updateTable: function (removed) {
- var min = removed ? 1 : 0;
- if (this.keywordList.keywords().length > min) {
- this.$('table').show();
- } else {
- this.$('table').hide();
- }
- },
-});
-
-var MetaImageSelector = Widget.extend({
- template: 'website.seo_meta_image_selector',
- xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
- events: {
- 'click .o_meta_img_upload': '_onClickUploadImg',
- 'click .o_meta_img': '_onClickSelectImg',
- },
- /**
- * @override
- * @param {widget} parent
- * @param {Object} data
- */
- init: function (parent, data) {
- this.metaTitle = data.title || '';
- this.activeMetaImg = data.metaImg;
- this.serverUrl = data.htmlpage.url();
- const imgField = data.hasSocialDefaultImage ? 'social_default_image' : 'logo';
- data.pageImages.unshift(_.str.sprintf('/web/image/website/%s/%s', session.website_id, imgField));
- this.images = _.uniq(data.pageImages);
- this.customImgUrl = _.contains(
- data.pageImages.map((img)=> new URL(img, window.location.origin).pathname),
- new URL(data.metaImg, window.location.origin).pathname)
- ? false : data.metaImg;
- this.previewDescription = data.previewDescription;
- this._setDescription(this.previewDescription);
- this._super(parent);
- },
- setTitle: function (title) {
- this.metaTitle = title;
- this._updateTemplateBody();
- },
- setDescription: function (description) {
- this._setDescription(description);
- this._updateTemplateBody();
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Set the description, applying ellipsis if too long.
- *
- * @private
- */
- _setDescription: function (description) {
- this.metaDescription = description || this.previewDescription;
- if (this.metaDescription.length > 160) {
- this.metaDescription = this.metaDescription.substring(0, 159) + '…';
- }
- },
-
- /**
- * Update template.
- *
- * @private
- */
- _updateTemplateBody: function () {
- this.$el.empty();
- this.images = _.uniq(this.images);
- this.$el.append(core.qweb.render('website.og_image_body', {widget: this}));
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when a select image from list -> change the preview accordingly.
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onClickSelectImg: function (ev) {
- var $img = $(ev.currentTarget);
- this.activeMetaImg = $img.find('img').attr('src');
- this._updateTemplateBody();
- },
- /**
- * Open a mediaDialog to select/upload image.
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onClickUploadImg: function (ev) {
- var self = this;
- var $image = $('');
- var mediaDialog = new weWidgets.MediaDialog(this, {
- onlyImages: true,
- res_model: 'ir.ui.view',
- }, $image[0]);
- mediaDialog.open();
- mediaDialog.on('save', this, function (image) {
- self.activeMetaImg = image.src;
- self.customImgUrl = image.src;
- self._updateTemplateBody();
- });
- },
-});
-
-var SeoConfigurator = Dialog.extend({
- template: 'website.seo_configuration',
- xmlDependencies: Dialog.prototype.xmlDependencies.concat(
- ['/website/static/src/xml/website.seo.xml']
- ),
- canEditTitle: false,
- canEditDescription: false,
- canEditKeywords: false,
- canEditLanguage: false,
- canEditUrl: false,
-
- init: function (parent, options) {
- options = options || {};
- _.defaults(options, {
- title: _t('Optimize SEO'),
- buttons: [
- {text: _t('Save'), classes: 'btn-primary', click: this.update},
- {text: _t('Discard'), close: true},
- ],
- });
-
- this._super(parent, options);
- },
- start: function () {
- var self = this;
-
- this.$modal.addClass('oe_seo_configuration');
-
- this.htmlPage = new HtmlPage();
-
- this.disableUnsavableFields().then(function () {
- // Image selector
- self.metaImageSelector = new MetaImageSelector(self, {
- htmlpage: self.htmlPage,
- hasSocialDefaultImage: self.hasSocialDefaultImage,
- title: self.htmlPage.getOgMeta().metaTitle,
- metaImg: self.metaImg || self.htmlPage.getOgMeta().ogImageUrl,
- pageImages: _.pluck(self.htmlPage.images().get(), 'src'),
- previewDescription: _t('The description will be generated by social media based on page content unless you specify one.'),
- });
- self.metaImageSelector.appendTo(self.$('.js_seo_image'));
-
- // title and description
- self.metaTitleDescription = new MetaTitleDescription(self, {
- htmlPage: self.htmlPage,
- canEditTitle: self.canEditTitle,
- canEditDescription: self.canEditDescription,
- canEditUrl: self.canEditUrl,
- isIndexed: self.isIndexed,
- previewDescription: _t('The description will be generated by search engines based on page content unless you specify one.'),
- seoNameHelp: _t('This value will be escaped to be compliant with all major browsers and used in url. Keep it empty to use the default name of the record.'),
- seoName: self.seoName, // 'my-custom-display-name' or ''
- seoNameDefault: self.seoNameDefault, // 'display-name'
- });
- self.metaTitleDescription.on('title-changed', self, self.titleChanged);
- self.metaTitleDescription.on('description-changed', self, self.descriptionChanged);
- self.metaTitleDescription.appendTo(self.$('.js_seo_meta_title_description'));
-
- // keywords
- self.metaKeywords = new MetaKeywords(self, {htmlPage: self.htmlPage});
- self.metaKeywords.appendTo(self.$('.js_seo_meta_keywords'));
- });
- },
- /*
- * Reset meta tags to their initial value if not saved.
- *
- * @private
- */
- destroy: function () {
- if (!this.savedData) {
- this.htmlPage.changeTitle(this.htmlPage.initTitle);
- this.htmlPage.changeDescription(this.htmlPage.initDescription);
- }
- this._super.apply(this, arguments);
- },
- disableUnsavableFields: function () {
- var self = this;
- return this.loadMetaData().then(function (data) {
- // We only need a reload for COW when the copy is happening, therefore:
- // - no reload if we are not editing a view (condition: website_id === undefined)
- // - reload if generic page (condition: website_id === false)
- self.reloadOnSave = data.website_id === undefined ? false : !data.website_id;
- //If website.page, hide the google preview & tell user his page is currently unindexed
- self.isIndexed = (data && ('website_indexed' in data)) ? data.website_indexed : true;
- self.canEditTitle = data && ('website_meta_title' in data);
- self.canEditDescription = data && ('website_meta_description' in data);
- self.canEditKeywords = data && ('website_meta_keywords' in data);
- self.metaImg = data.website_meta_og_img;
- self.hasSocialDefaultImage = data.has_social_default_image;
- self.canEditUrl = data && ('seo_name' in data);
- self.seoName = self.canEditUrl && data.seo_name;
- self.seoNameDefault = self.canEditUrl && data.seo_name_default;
- if (!self.canEditTitle && !self.canEditDescription && !self.canEditKeywords) {
- // disable the button to prevent an error if the current page doesn't use the mixin
- // we make the check here instead of on the view because we don't need to check
- // at every page load, just when the rare case someone clicks on this link
- // TODO don't show the modal but just an alert in this case
- self.$footer.find('button[data-action=update]').attr('disabled', true);
- }
- });
- },
- update: function () {
- var self = this;
- var data = {};
- if (this.canEditTitle) {
- data.website_meta_title = this.metaTitleDescription.$title.val();
- }
- if (this.canEditDescription) {
- data.website_meta_description = this.metaTitleDescription.$description.val();
- }
- if (this.canEditKeywords) {
- data.website_meta_keywords = this.metaKeywords.keywordList.keywords().join(', ');
- }
- if (this.canEditUrl) {
- if (this.metaTitleDescription.$seoName.val() != this.metaTitleDescription.previousSeoName) {
- data.seo_name = this.metaTitleDescription.$seoName.val();
- self.reloadOnSave = true; // will force a refresh on old url and redirect to new slug
- }
- }
- data.website_meta_og_img = this.metaImageSelector.activeMetaImg;
- this.saveMetaData(data).then(function () {
- // We want to reload if we are editing a generic page
- // because it will become a specific page after this change (COW)
- // and we want the user to be on the page he just created.
- if (self.reloadOnSave) {
- window.location.href = self.htmlPage.url();
- } else {
- self.htmlPage.changeKeywords(self.metaKeywords.keywordList.keywords());
- self.savedData = true;
- self.close();
- }
- });
- },
- getMainObject: function () {
- var mainObject;
- this.trigger_up('main_object_request', {
- callback: function (value) {
- mainObject = value;
- },
- });
- return mainObject;
- },
- getSeoObject: function () {
- var seoObject;
- this.trigger_up('seo_object_request', {
- callback: function (value) {
- seoObject = value;
- },
- });
- return seoObject;
- },
- loadMetaData: function () {
- var obj = this.getSeoObject() || this.getMainObject();
- return new Promise(function (resolve, reject) {
- if (!obj) {
- // return Promise.reject(new Error("No main_object was found."));
- resolve(null);
- } else {
- rpc.query({
- route: "/website/get_seo_data",
- params: {
- 'res_id': obj.id,
- 'res_model': obj.model,
- },
- }).then(function (data) {
- var meta = data;
- meta.model = obj.model;
- resolve(meta);
- }).guardedCatch(reject);
- }
- });
- },
- saveMetaData: function (data) {
- var obj = this.getSeoObject() || this.getMainObject();
- if (!obj) {
- return Promise.reject();
- } else {
- return this._rpc({
- model: obj.model,
- method: 'write',
- args: [[obj.id], data],
- });
- }
- },
- titleChanged: function () {
- var self = this;
- _.defer(function () {
- var title = self.metaTitleDescription.getTitle();
- self.htmlPage.changeTitle(title);
- self.metaImageSelector.setTitle(title);
- });
- },
- descriptionChanged: function () {
- var self = this;
- _.defer(function () {
- var description = self.metaTitleDescription.getRealDescription();
- self.htmlPage.changeDescription(description);
- self.metaImageSelector.setDescription(description);
- });
- },
-});
-
-var SeoMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
- actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, {
- 'promote-current-page': '_promoteCurrentPage',
- }),
-
- init: function (parent, options) {
- this._super(parent, options);
-
- if ($.deparam.querystring().enable_seo !== undefined) {
- this._promoteCurrentPage();
- }
- },
-
- //--------------------------------------------------------------------------
- // Actions
- //--------------------------------------------------------------------------
-
- /**
- * Opens the SEO configurator dialog.
- *
- * @private
- */
- _promoteCurrentPage: function () {
- new SeoConfigurator(this).open();
- },
-});
-
-registry.category("website_navbar_widgets").add("SeoMenu", {
- Widget: SeoMenu,
- selector: '#promote-menu',
-});
-
-return {
- SeoConfigurator: SeoConfigurator,
- SeoMenu: SeoMenu,
-};
-});
diff --git a/addons/website/static/src/js/menu/translate.js b/addons/website/static/src/js/menu/translate.js
deleted file mode 100644
index dd12fa2914b..00000000000
--- a/addons/website/static/src/js/menu/translate.js
+++ /dev/null
@@ -1,360 +0,0 @@
-odoo.define('website.translateMenu', function (require) {
-'use strict';
-
-require('web.dom_ready');
-var core = require('web.core');
-var weDialog = require('wysiwyg.widgets.Dialog');
-var Dialog = require('web.Dialog');
-var EditorMenu = require('website.editMenu');
-var localStorage = require('web.local_storage');
-var websiteNavbarData = require('website.navbar');
-
-const { registry } = require("@web/core/registry");
-
-var _t = core._t;
-
-var localStorageNoDialogKey = 'website_translator_nodialog';
-
-var TranslatorInfoDialog = Dialog.extend({
- template: 'website.TranslatorInfoDialog',
- xmlDependencies: Dialog.prototype.xmlDependencies.concat(
- ['/website/static/src/xml/translator.xml']
- ),
-
- /**
- * @constructor
- */
- init: function (parent, options) {
- this._super(parent, _.extend({
- title: _t("Translation Info"),
- buttons: [
- {text: _t("Ok, never show me this again"), classes: 'btn-primary', close: true, click: this._onStrongOk.bind(this)},
- {text: _t("Ok"), close: true}
- ],
- }, options || {}));
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when the "strong" ok is clicked -> adapt localstorage to make sure
- * the dialog is never displayed again.
- *
- * @private
- */
- _onStrongOk: function () {
- localStorage.setItem(localStorageNoDialogKey, true);
- },
-});
-
-// TODO: Handle this once images are handled.
-var AttributeTranslateDialog = weDialog.extend({
- /**
- * @constructor
- */
- init: function (parent, options, node) {
- this._super(parent, _.extend({
- title: _t("Translate Attribute"),
- buttons: [
- {text: _t("Close"), classes: 'btn-primary', click: this.save}
- ],
- }, options || {}));
- this.wysiwyg = options.wysiwyg;
- this.node = node;
- this.translation = $(node).data('translation');
- },
- /**
- * @override
- */
- start: function () {
- var $group = $('', {class: 'form-group'}).appendTo(this.$el);
- _.each(this.translation, function (node, attr) {
- var $node = $(node);
- var $label = $('').text(attr);
- var $input = $('').val($node.html());
- $input.on('change keyup', function () {
- var value = $input.val();
- $node.html(value).trigger('change', node);
- if ($node.data('attribute')) {
- $node.data('$node').attr($node.data('attribute'), value).trigger('translate');
- } else {
- $node.data('$node').val(value).trigger('translate');
- }
- $node.trigger('change');
- });
- $group.append($label).append($input);
- });
- return this._super.apply(this, arguments);
- },
-});
-
-const savableSelector = '[data-oe-translation-id], ' +
- '[data-oe-model][data-oe-id][data-oe-field], ' +
- '[placeholder*="data-oe-translation-id="], ' +
- '[title*="data-oe-translation-id="], ' +
- '[value*="data-oe-translation-id="], ' +
- 'textarea:contains(data-oe-translation-id), ' +
- '[alt*="data-oe-translation-id="]';
-
-var TranslatePageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
- assetLibs: ['web_editor.compiled_assets_wysiwyg', 'website.compiled_assets_wysiwyg'],
-
- actions: _.extend({}, websiteNavbarData.WebsiteNavbar.prototype.actions || {}, {
- edit_master: '_goToMasterPage',
- translate: '_startTranslateMode',
- }),
- custom_events: {
- ready_to_save: '_onSave',
- },
-
- /**
- * @override
- */
- start: function () {
- var context;
- this.trigger_up('context_get', {
- extra: true,
- callback: function (ctx) {
- context = ctx;
- },
- });
- this._mustEditTranslations = context.edit_translations;
- if (this._mustEditTranslations) {
- var url = new URL(window.location.href);
- url.searchParams.delete('edit_translations');
- window.history.replaceState({}, null, url);
-
- this._startTranslateMode();
- }
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Actions
- //--------------------------------------------------------------------------
-
- /**
- * Redirects the user to the same page but in the original language and in
- * edit mode.
- *
- * @private
- * @returns {Promise}
- */
- _goToMasterPage: function () {
- var current = document.createElement('a');
- current.href = window.location.toString();
- current.search += (current.search ? '&' : '?') + 'enable_editor=1';
- // We are in translate mode, the pathname starts with '/'. By
- // adding a trailing slash we can simply search for the first slash
- // after the language code to remove the language part.
- const startPath = (current.pathname + '/').indexOf('/', 1);
- current.pathname = current.pathname.substring(startPath);
-
- var link = document.createElement('a');
- link.href = '/website/lang/default';
- link.search += (link.search ? '&' : '?') + 'r=' + encodeURIComponent(current.pathname + current.search + current.hash);
-
- window.location = link.href;
- return new Promise(function () {});
- },
- /**
- * Redirects the user to the same page in translation mode (or start the
- * translator is translation mode is already enabled).
- *
- * @private
- * @returns {Promise}
- */
- _startTranslateMode: async function () {
- const self = this;
- if (!this._mustEditTranslations) {
- window.location.search += '&edit_translations';
- return new Promise(function () {});
- }
-
- const params = {
- enableTranslation: true,
- devicePreview: false,
- };
-
- this.translator = new EditorMenu(this, {
- wysiwygOptions: params,
- savableSelector: savableSelector,
- editableFromEditorMenu: () => {
- return $(savableSelector)
- .not('[data-oe-readonly]');
- },
- beforeEditorActive: async () => {
- var attrs = ['placeholder', 'title', 'alt', 'value'];
- const $editable = self._getEditableArea();
- const translationRegex = /]*data-oe-translation-id="([0-9]+)"[^>]*>(.*)<\/span>/;
- let $edited = $();
- _.each(attrs, function (attr) {
- const attrEdit = $editable.filter('[' + attr + '*="data-oe-translation-id="]').filter(':empty, input, select, textarea, img');
- attrEdit.each(function () {
- var $node = $(this);
- var translation = $node.data('translation') || {};
- var trans = $node.attr(attr);
- var match = trans.match(translationRegex);
- var $trans = $(trans).addClass('d-none o_editable o_editable_translatable_attribute').appendTo('body');
- $trans.data('$node', $node).data('attribute', attr);
-
- translation[attr] = $trans[0];
- $node.attr(attr, match[2]);
-
- $node.addClass('o_translatable_attribute').data('translation', translation);
- });
- $edited = $edited.add(attrEdit);
- });
- const textEdit = $editable.filter('textarea:contains(data-oe-translation-id)');
- textEdit.each(function () {
- var $node = $(this);
- var translation = $node.data('translation') || {};
- var trans = $node.text();
- var match = trans.match(translationRegex);
- var $trans = $(trans).addClass('d-none o_editable o_editable_translatable_text').appendTo('body');
- $trans.data('$node', $node);
-
- translation['textContent'] = $trans[0];
- $node.val(match[2]);
-
- $node.addClass('o_translatable_text').data('translation', translation);
- });
- $edited = $edited.add(textEdit);
-
- $edited.each(function () {
- var $node = $(this);
- var select2 = $node.data('select2');
- if (select2) {
- select2.blur();
- $node.on('translate', function () {
- select2.blur();
- });
- $node = select2.container.find('input');
- }
- });
-
- self.translations = [];
- self.$translations = self._getEditableArea().filter('.o_translatable_attribute, .o_translatable_text');
- self.$editables = $('.o_editable_translatable_attribute, .o_editable_translatable_text');
-
- self.$editables.on('change', function () {
- self.trigger_up('rte_change', {target: this});
- });
-
- self._markTranslatableNodes();
- this.$translations.filter('input[type=hidden].o_translatable_input_hidden').prop('type', 'text');
- },
- });
-
- // We don't want the BS dropdown to close
- // when clicking in a element to translate
- $('.dropdown-menu').on('click', '.o_editable', function (ev) {
- ev.stopPropagation();
- });
-
- if (!localStorage.getItem(localStorageNoDialogKey)) {
- new TranslatorInfoDialog(this.translator).open();
- }
-
- await this.translator.prependTo(document.body);
- // Apply data-oe-readonly on nested data.
- $(savableSelector)
- .filter(':has(' + savableSelector + ')')
- .attr('data-oe-readonly', true);
- await this.translator._startEditMode();
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Return the editable area.
- *
- * @override
- * @returns {JQuery}
- */
- _getEditableArea: function () {
- return this.translator.wysiwyg.$editable.find(':o_editable').add(this.$editables);
- },
- /**
- * Returns a translation object.
- *
- * @private
- * @param {Node} node
- * @returns {Object}
- */
- _getTranlationObject: function (node) {
- var $node = $(node);
- var id = +$node.data('oe-translation-id');
- if (!id) {
- id = $node.data('oe-model') + ',' + $node.data('oe-id') + ',' + $node.data('oe-field');
- }
- var trans = _.find(this.translations, function (trans) {
- return trans.id === id;
- });
- if (!trans) {
- this.translations.push(trans = {'id': id});
- }
- return trans;
- },
- /**
- * @private
- */
- _markTranslatableNodes: function () {
- var self = this;
- this._getEditableArea().each(function () {
- var $node = $(this);
- var trans = self._getTranlationObject(this);
- trans.value = (trans.value ? trans.value : $node.html()).replace(/[ \t\n\r]+/, ' ');
- });
- this._getEditableArea().prependEvent('click.translator', function (ev) {
- if (ev.ctrlKey || !$(ev.target).is(':o_editable')) {
- return;
- }
- ev.preventDefault();
- ev.stopPropagation();
- });
-
- // attributes
-
- this.$translations.each(function () {
- var $node = $(this);
- var translation = $node.data('translation');
- _.each(translation, function (node, attr) {
- var trans = self._getTranlationObject(node);
- trans.value = (trans.value ? trans.value : $node.html()).replace(/[ \t\n\r]+/, ' ');
- $node.attr('data-oe-translation-state', (trans.state || 'to_translate'));
- });
- });
-
- this.$translations.prependEvent('mousedown.translator click.translator mouseup.translator', function (ev) {
- if (ev.ctrlKey) {
- return;
- }
- ev.preventDefault();
- ev.stopPropagation();
- if (ev.type !== 'mousedown') {
- return;
- }
-
- new AttributeTranslateDialog(self, {}, ev.target).open();
- });
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- _onSave: function (ev) {
- ev.stopPropagation();
- },
-});
-
-registry.category("website_navbar_widgets").add("TranslatePageMenu", {
- Widget: TranslatePageMenu,
- selector: '.o_menu_systray:has([data-action="translate"])',
-});
-});
diff --git a/addons/website/static/src/js/new_content_form.js b/addons/website/static/src/js/new_content_form.js
new file mode 100644
index 00000000000..81ded8295fa
--- /dev/null
+++ b/addons/website/static/src/js/new_content_form.js
@@ -0,0 +1,24 @@
+/** @odoo-module **/
+
+import FormController from 'web.FormController';
+
+export const NewContentFormController = FormController.extend({
+ /**
+ * @override
+ */
+ saveRecord() {
+ return this._super.apply(this, arguments).then(() => {
+ const state = this.model.get(this.handle);
+ this.do_action({
+ type: 'ir.actions.act_window_close',
+ infos: {path: this._getPath(state)},
+ });
+ });
+ },
+ /**
+ * @private
+ */
+ _getPath(state) {
+ return state.data.website_url;
+ }
+});
diff --git a/addons/website/static/src/js/set_view_track.js b/addons/website/static/src/js/set_view_track.js
deleted file mode 100644
index d94fcddfffd..00000000000
--- a/addons/website/static/src/js/set_view_track.js
+++ /dev/null
@@ -1,89 +0,0 @@
-odoo.define('website.set_view_track', function (require) {
-"use strict";
-
-var CustomizeMenu = require('website.customizeMenu');
-var Widget = require('web.Widget');
-
-var TrackPage = Widget.extend({
- template: 'website.track_page',
- xmlDependencies: ['/website/static/src/xml/track_page.xml'],
- events: {
- 'change #switch-track-page': '_onTrackChange',
- },
-
- /**
- * @override
- */
- start: function () {
- this.$input = this.$('#switch-track-page');
- this._isTracked().then((data) => {
- if (data[0]['track']) {
- this.track = true;
- this.$input.attr('checked', 'checked');
- } else {
- this.track = false;
- }
- });
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _isTracked: function (val) {
- var viewid = $('html').data('viewid');
- if (!viewid) {
- return Promise.reject();
- } else {
- return this._rpc({
- model: 'ir.ui.view',
- method: 'read',
- args: [[viewid], ['track']],
- });
- }
- },
- /**
- * @private
- */
- _onTrackChange: function (ev) {
- var checkboxValue = this.$input.is(':checked');
- if (checkboxValue !== this.track) {
- this.track = checkboxValue;
- this._trackPage(checkboxValue);
- }
- },
- /**
- * @private
- */
- _trackPage: function (val) {
- var viewid = $('html').data('viewid');
- if (!viewid) {
- return Promise.reject();
- } else {
- return this._rpc({
- model: 'ir.ui.view',
- method: 'write',
- args: [[viewid], {track: val}],
- });
- }
- },
-});
-
-CustomizeMenu.include({
- _loadCustomizeOptions: function () {
- var self = this;
- var def = this._super.apply(this, arguments);
- return def.then(function () {
- if (!self.__trackpageLoaded) {
- self.__trackpageLoaded = true;
- self.trackPage = new TrackPage(self);
- self.trackPage.appendTo(self.$el.children('.dropdown-menu'));
- }
- });
- },
-});
-
-});
diff --git a/addons/website/static/src/js/theme_preview_form.js b/addons/website/static/src/js/theme_preview_form.js
index aada899b7a1..593c69a3e7e 100644
--- a/addons/website/static/src/js/theme_preview_form.js
+++ b/addons/website/static/src/js/theme_preview_form.js
@@ -26,7 +26,10 @@ const ThemePreviewControllerCommon = {
this._addLoader();
switch (action) {
case 'button_choose_theme':
- actionCallback = result => this.do_action(result);
+ actionCallback = result => {
+ this.do_action(result);
+ this._removeLoader();
+ };
break;
case 'button_refresh_theme':
actionCallback = () => this._removeLoader();
diff --git a/addons/website/static/src/js/tours/tour_utils.js b/addons/website/static/src/js/tours/tour_utils.js
index 1470a259884..4ba823e18d9 100644
--- a/addons/website/static/src/js/tours/tour_utils.js
+++ b/addons/website/static/src/js/tours/tour_utils.js
@@ -121,10 +121,11 @@ function changePaddingSize(direction) {
*/
function clickOnEdit(position = "bottom") {
return {
- trigger: "a[data-action=edit]",
+ trigger: ".o_menu_systray .o_edit_website_container a",
content: Markup(_t("Click Edit to start designing your homepage.")),
- extra_trigger: ".homepage",
+ extra_trigger: "body:not(.editor_has_snippets)",
position: position,
+ timeout: 30000,
};
}
@@ -134,9 +135,10 @@ function clickOnEdit(position = "bottom") {
* @param {*} position
*/
function clickOnSnippet(snippet, position = "bottom") {
+ const snippetClass = snippet.id || snippet;
return {
- trigger: snippet.id ? `#wrapwrap .${snippet.id}` : snippet,
- extra_trigger: "body.editor_enable",
+ trigger: `iframe #wrapwrap .${snippetClass}`,
+ extra_trigger: "iframe body.editor_enable",
content: Markup(_t("Click on a snippet to access its options menu.")),
position: position,
run: "click",
@@ -145,12 +147,14 @@ function clickOnSnippet(snippet, position = "bottom") {
function clickOnSave(position = "bottom") {
return [{
- trigger: "button[data-action=save]",
+ trigger: "div:not(.o_loading_dummy) > #oe_snippets button[data-action=\"save\"]:not([disabled])",
+ extra_trigger: "body:not(:has(.o_dialog))",
in_modal: false,
content: Markup(_t("Good job! It's time to Save your work.")),
position: position,
}, {
- trigger: 'body:not(.editor_enable)',
+ trigger: 'iframe body:not(.editor_enable)',
+ noPrepend: true,
auto: true, // Just making sure save is finished in automatic tests
run: () => null,
}];
@@ -164,8 +168,8 @@ function clickOnSave(position = "bottom") {
*/
function clickOnText(snippet, element, position = "bottom") {
return {
- trigger: snippet.id ? `#wrapwrap .${snippet.id} ${element}` : snippet,
- extra_trigger: "body.editor_enable",
+ trigger: snippet.id ? `iframe #wrapwrap .${snippet.id} ${element}` : snippet,
+ extra_trigger: "iframe body.editor_enable",
content: Markup(_t("Click on a text to start editing it.")),
position: position,
run: "text",
@@ -181,13 +185,12 @@ function clickOnText(snippet, element, position = "bottom") {
function dragNDrop(snippet, position = "bottom") {
return {
trigger: `#oe_snippets .oe_snippet[name="${snippet.name}"] .oe_snippet_thumbnail:not(.o_we_already_dragging)`,
- extra_trigger: "body.editor_enable.editor_has_snippets",
- moveTrigger: '.oe_drop_zone',
+ extra_trigger: ".o_website_preview.editor_enable.editor_has_snippets",
content: Markup(_.str.sprintf(_t("Drag the %s building block and drop it at the bottom of the page."), snippet.name)),
position: position,
// Normally no main snippet can be dropped in the default footer but
// targeting it allows to force "dropping at the end of the page".
- run: "drag_and_drop #wrapwrap > footer",
+ run: "drag_and_drop iframe #wrapwrap > footer",
};
}
@@ -203,6 +206,7 @@ function goBackToBlocks(position = "bottom") {
function goToTheme(position = "bottom") {
return {
trigger: '.o_we_customize_theme_btn',
+ extra_trigger: '#oe_snippets.o_loaded',
content: _t("Go to the Theme tab"),
position: position,
run: "click",
@@ -211,7 +215,7 @@ function goToTheme(position = "bottom") {
function selectHeader(position = "bottom") {
return {
- trigger: `header#top`,
+ trigger: `iframe header#top`,
content: Markup(_t(`Click on this header to configure it.`)),
position: position,
run: "click",
@@ -220,7 +224,7 @@ function selectHeader(position = "bottom") {
function selectSnippetColumn(snippet, index = 0, position = "bottom") {
return {
- trigger: `#wrapwrap .${snippet.id} .row div[class*="col-lg-"]:eq(${index})`,
+ trigger: `iframe #wrapwrap .${snippet.id} .row div[class*="col-lg-"]:eq(${index})`,
content: Markup(_t("Click on this column to access its options.")),
position: position,
run: "click",
@@ -236,21 +240,18 @@ function prepend_trigger(steps, prepend_text='') {
return steps;
}
-function registerThemeHomepageTour(name, steps) {
- tour.register(name, {
- url: "/?enable_editor=1",
- sequence: 1010,
- saveAs: "homepage",
- }, prepend_trigger(
- steps.concat(clickOnSave()),
- "html[data-view-xmlid='website.homepage'] "
- ));
+function getClientActionUrl(path, edition) {
+ let url = `/web#action=website.website_preview&path=${encodeURIComponent(path)}`;
+ if (edition) {
+ url += '&enable_editor=1';
+ }
+ return url;
}
-function clickOnExtraMenuItem(stepOptions) {
+function clickOnExtraMenuItem(stepOptions, backend = false) {
return Object.assign({}, {
content: "Click on the extra menu dropdown toggle if it is there",
- trigger: '#top_menu',
+ trigger: `${backend ? "iframe" : ""} #top_menu:not(.o_loading)`,
run: function () {
const extraMenuButton = this.$anchor[0].querySelector('.o_extra_menu_items a.nav-link');
if (extraMenuButton) {
@@ -260,6 +261,59 @@ function clickOnExtraMenuItem(stepOptions) {
}, stepOptions);
}
+/**
+ * Registers a tour that will go in the website client action.
+ *
+ * @param name {string} The tour's name
+ * @param options {Object} The tour options
+ * @param options.url {string} the page to edit
+ * @param options.edition {boolean} If the tour starts in edit mode
+ * @param steps {[*]} The steps of the tour
+ */
+function registerEditionTour(name, options, steps) {
+ let tourSteps = steps;
+ let url = getClientActionUrl(options.url, !!options.edition);
+ if (options.edition) {
+ tourSteps = [{
+ trigger: '.o_website_preview.editor_enable.editor_has_snippets',
+ timeout: 30000,
+ run: () => {}, // It's a check
+ }].concat(tourSteps);
+ }
+ tour.register(name, Object.assign(options, { url }), tourSteps);
+}
+
+function registerThemeHomepageTour(name, steps) {
+ registerEditionTour(name, {
+ url: '/',
+ edition: true,
+ sequence: 1010,
+ saveAs: "homepage",
+ }, prepend_trigger(
+ steps.concat(clickOnSave()),
+ ".o_website_preview[data-view-xmlid='website.homepage'] "
+ ));
+}
+
+function registerBackendAndFrontend(name, options, steps) {
+ let url;
+ let tourSteps = steps;
+ if (window.location.pathname === '/web') {
+ url = getClientActionUrl(options.url);
+ for (const step of tourSteps) {
+ step.trigger = 'iframe ' + step.trigger;
+ if (step.extra_trigger) {
+ step.extra_trigger = 'iframe ' + step.trigger;
+ }
+ }
+ } else {
+ url = options.url;
+ }
+ tour.register(name, {
+ url
+ }, tourSteps);
+}
+
return {
addMedia,
changeBackground,
@@ -280,7 +334,10 @@ return {
selectHeader,
selectNested,
selectSnippetColumn,
+ getClientActionUrl,
registerThemeHomepageTour,
clickOnExtraMenuItem,
+ registerEditionTour,
+ registerBackendAndFrontend,
};
});
diff --git a/addons/website/static/src/js/utils.js b/addons/website/static/src/js/utils.js
index 0de8729c62d..7344d4599d9 100644
--- a/addons/website/static/src/js/utils.js
+++ b/addons/website/static/src/js/utils.js
@@ -10,12 +10,13 @@ const { qweb, _t } = core;
* Allows to load anchors from a page.
*
* @param {string} url
+ * @param {Node} body the editable for which to recover anchors
* @returns {Deferred}
*/
-function loadAnchors(url) {
+function loadAnchors(url, body) {
return new Promise(function (resolve, reject) {
if (url === window.location.pathname || url[0] === '#') {
- resolve(document.body.outerHTML);
+ resolve(body ? body : document.body.outerHTML);
} else if (url.length && !url.startsWith("http")) {
$.get(window.location.origin + url).then(resolve, reject);
} else { // avoid useless query
@@ -70,7 +71,7 @@ function autocompleteWithPages(self, $input, options) {
$input.urlcomplete({
source: function (request, response) {
if (request.term[0] === '#') {
- loadAnchors(request.term).then(function (anchors) {
+ loadAnchors(request.term, options && options.body).then(function (anchors) {
response(anchors);
});
} else if (request.term.startsWith('http') || request.term.length === 0) {
@@ -255,6 +256,10 @@ function sendRequest(route, params) {
let form = document.createElement('form');
form.setAttribute('action', route);
form.setAttribute('method', params.method || 'POST');
+ const isInIframe = window.frameElement && window.frameElement.classList.contains('o_iframe');
+ if (isInIframe) {
+ form.setAttribute('target', '_top');
+ }
if (core.csrf_token) {
_addInput(form, 'csrf_token', core.csrf_token);
diff --git a/addons/website/static/src/js/website_page_list.js b/addons/website/static/src/js/website_page_list.js
new file mode 100644
index 00000000000..af352deb484
--- /dev/null
+++ b/addons/website/static/src/js/website_page_list.js
@@ -0,0 +1,168 @@
+/** @odoo-module **/
+
+import ListController from 'web.ListController';
+import viewRegistry from 'web.view_registry';
+import ListView from 'web.ListView';
+import {ComponentWrapper} from 'web.OwlCompatibility';
+import {PagePropertiesDialogWrapper} from '@website/components/dialog/page_properties';
+import {qweb} from 'web.core';
+
+const WebsitePageListController = ListController.extend({
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ willStart() {
+ return Promise.all([this._super(...arguments), this._getRenderContext()]);
+ },
+ /**
+ * @override
+ */
+ renderButtons($node) {
+ this.$buttons = $(qweb.render('PageListView.WebsiteSelect', {
+ websites: this.websites,
+ active: this._getWebsite(0),
+ }));
+ this.$buttons.find('.dropdown-item').click(this._onButtonClick.bind(this));
+ if ($node) {
+ this.$buttons.appendTo($node);
+ }
+ },
+ /**
+ * Used to set the new dialog (created by PagePropertiesDialogWrapper for page
+ * record).
+ */
+ setPageManagerDialog(dialog) {
+ this.pageManagerDialog = dialog;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async _callButtonAction(attrs, record) {
+ switch (attrs.name) {
+ case 'action_optimize_seo':
+ this._goToPage(record.data.url, record.data.website_id.res_id, {
+ enable_seo: true,
+ });
+ break;
+ case 'action_manage_page':
+ await this._addDialog(record);
+ this.pageManagerDialog.open();
+ break;
+ case 'action_clone_page':
+ await this._addDialog(record, 'clone');
+ this.pageManagerDialog.open();
+ break;
+ case 'action_delete_page':
+ await this._addDialog(record, 'delete');
+ this.pageManagerDialog.open();
+ break;
+ case 'action_edit_page':
+ this.do_action({
+ type: 'ir.actions.act_window',
+ res_model: 'ir.ui.view',
+ res_id: record.data.view_id.res_id,
+ views: [[false, 'form']],
+ });
+ break;
+ default:
+ return this._super(...arguments);
+ }
+ },
+ /**
+ * @private
+ */
+ _goToPage(path, website, options = {}) {
+ this.do_action('website.website_preview', {
+ additional_context: {
+ params: {
+ path: path,
+ website_id: website || '',
+ ...options,
+ }
+ },
+ });
+ },
+ /**
+ * @private
+ */
+ async _addDialog(record, mode = '') {
+ this._pagePropertiesDialog = new ComponentWrapper(this, PagePropertiesDialogWrapper, {
+ setPagePropertiesDialog: this.setPageManagerDialog.bind(this),
+ currentPage: record.data.id,
+ onClose: this._onCloseDialog.bind(this),
+ mode: mode,
+ });
+ await this._pagePropertiesDialog.mount(this.el);
+ },
+ /**
+ * Removes page properties wrapper when dialog is closed.
+ *
+ * @private
+ */
+ _onCloseDialog() {
+ if (this._pagePropertiesDialog) {
+ this._pagePropertiesDialog.destroy();
+ }
+ this._pagePropertiesDialog = undefined;
+ },
+ /**
+ * @private
+ */
+ _getRenderContext() {
+ return this._rpc({
+ model: 'website',
+ method: 'search_read',
+ fields: ['id', 'name'],
+ }).then((result) => {
+ this.websites = [{id: 0, name: 'All Websites'}, ...result];
+ });
+ },
+ /**
+ * @private
+ */
+ _getWebsite(id) {
+ return this.websites.find(website => website.id === id);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onOpenRecord(event) {
+ const record = this.model.get(event.data.id, {raw: true});
+ this._goToPage(record.data.url, record.data.website_id);
+ },
+ _onButtonClick: async function (event) {
+ const activeWebsite = this._getWebsite(parseInt(event.target.dataset.websiteId));
+ this.$buttons[0].querySelector('.o_website_active').textContent = activeWebsite.name;
+ this.reload({
+ domain: activeWebsite.id ? [['website_id', '=', activeWebsite.id]] : [],
+ });
+ }
+});
+
+const WebsitePageListView = ListView.extend({
+ config: _.extend({}, ListView.prototype.config, {
+ Controller: WebsitePageListController,
+ }),
+});
+
+viewRegistry.add('website_page_list', WebsitePageListView);
+
+export default {
+ WebsitePageListController: WebsitePageListController,
+ WebsitePageListView: WebsitePageListView,
+};
diff --git a/addons/website/static/src/js/widgets/ace.js b/addons/website/static/src/js/widgets/ace.js
deleted file mode 100644
index f43b695cff3..00000000000
--- a/addons/website/static/src/js/widgets/ace.js
+++ /dev/null
@@ -1,108 +0,0 @@
-odoo.define("website.ace", function (require) {
-"use strict";
-
-var AceEditor = require('web_editor.ace');
-
-/**
- * Extends the default view editor so that the URL hash is updated with view ID
- */
-var WebsiteAceEditor = AceEditor.extend({
- hash: '#advanced-view-editor',
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- do_hide: function () {
- this._super.apply(this, arguments);
- window.location.hash = "";
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- _displayResource: function () {
- this._super.apply(this, arguments);
- this._updateHash();
- },
- /**
- * @override
- */
- _saveResources: function () {
- return this._super.apply(this, arguments).then((function () {
- var 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).
- var selectedView = _.findWhere(this.views, {id: this._getSelectedResource()});
- var context;
- this.trigger_up('context_get', {
- callback: function (ctx) {
- context = ctx;
- },
- });
- defs.push(this._rpc({
- model: 'ir.ui.view',
- method: 'search_read',
- fields: ['id'],
- domain: [['key', '=', selectedView.key], ['website_id', '=', context.website_id]],
- }).then((function (view) {
- if (view[0]) {
- this._updateHash(view[0].id);
- }
- }).bind(this)));
- }
- return Promise.all(defs).then((function () {
- window.location.reload();
- return new Promise(function () {});
- }));
- }).bind(this));
- },
- /**
- * @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: function () {
- return this._super.apply(this, arguments).then((function () {
- window.location.reload();
- return new Promise(function () {});
- }).bind(this));
- },
- /**
- * Adds the current resource ID in the URL.
- *
- * @private
- */
- _updateHash: function (resID) {
- window.location.hash = this.hash + "?res=" + (resID || this._getSelectedResource());
- },
-});
-
-return WebsiteAceEditor;
-});
diff --git a/addons/website/static/src/js/widgets/fullscreen_indication.js b/addons/website/static/src/js/widgets/fullscreen_indication.js
deleted file mode 100644
index 44ffc379994..00000000000
--- a/addons/website/static/src/js/widgets/fullscreen_indication.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/** @odoo-module **/
-
-import Widget from 'web.Widget';
-
-export const FullscreenIndication = Widget.extend({
- xmlDependencies: ['/website/static/src/xml/website.xml'],
- template: 'website.fullscreen_indication',
-
- init: function () {
- this.visible = false;
- this.displayTime = 2000;
- this._super(...arguments);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Displays the fullscreen indication modal
- */
- show: function () {
- clearTimeout(this.autofade);
- this.visible = true;
- this.el.classList.add('o_transition', 'o_visible');
- this.autofade = setTimeout(() => {
- this.hide(true);
- }, this.displayTime);
- },
- /**
- * Hides the fullscreen indication modal with optionnal transition
- *
- * @param {boolean} [withTransition=false]
- */
- hide: function (withTransition = false) {
- this.el.classList.toggle('o_transition', withTransition);
- this.el.classList.remove('o_visible');
- this.visible = false;
- },
-});
diff --git a/addons/website/static/src/js/widgets/link_popover_widget.js b/addons/website/static/src/js/widgets/link_popover_widget.js
index 1bcc214734a..4834d1ec4fe 100644
--- a/addons/website/static/src/js/widgets/link_popover_widget.js
+++ b/addons/website/static/src/js/widgets/link_popover_widget.js
@@ -1,9 +1,7 @@
/** @odoo-module **/
-import contentMenu from 'website.contentMenu';
import weWidgets from 'wysiwyg.widgets';
import {_t} from 'web.core';
-
weWidgets.LinkPopoverWidget.include({
/**
* @override
@@ -36,7 +34,7 @@ const NavbarLinkPopoverWidget = weWidgets.LinkPopoverWidget.extend({
const $anchor = $('', {
href: '#', class: 'ml-2 js_edit_menu', title: _t('Edit Menu'),
'data-placement': 'top', 'data-toggle': 'tooltip',
- }).append($('', {class: 'fa fa-sitemap text-secondary'}));
+ }).append($('', {class: 'fa fa-sitemap'}));
this.$('.o_we_remove_link').replaceWith($anchor);
return this._super(...arguments);
},
@@ -54,34 +52,31 @@ const NavbarLinkPopoverWidget = weWidgets.LinkPopoverWidget.extend({
_onEditLinkClick(ev) {
var self = this;
var $menu = this.$target.find('[data-oe-id]');
- var dialog = new contentMenu.MenuEntryDialog(this, {}, null, {
+ this.trigger_up('menu_dialog', {
name: $menu.text(),
url: $menu.parent().attr('href'),
+ save: (name, url) => {
+ let websiteId;
+ this.trigger_up('context_get', {
+ callback: ctx => websiteId = ctx['website_id'],
+ });
+ const data = {
+ id: $menu.data('oe-id'),
+ name,
+ url,
+ };
+ return this._rpc({
+ model: 'website.menu',
+ method: 'save',
+ args: [websiteId, {'data': [data]}],
+ }).then(function () {
+ self.options.wysiwyg.odooEditor.observerUnactive();
+ self.$target.attr('href', url);
+ $menu.text(name);
+ self.options.wysiwyg.odooEditor.observerActive();
+ });
+ },
});
- dialog.on('save', this, link => {
- let websiteId;
- this.trigger_up('context_get', {
- callback: function (ctx) {
- websiteId = ctx['website_id'];
- },
- });
- const data = {
- id: $menu.data('oe-id'),
- name: link.content,
- url: link.url,
- };
- return this._rpc({
- model: 'website.menu',
- method: 'save',
- args: [websiteId, {'data': [data]}],
- }).then(function () {
- self.options.wysiwyg.odooEditor.observerUnactive();
- self.$target.attr('href', link.url);
- $menu.text(link.content);
- self.options.wysiwyg.odooEditor.observerActive();
- });
- });
- dialog.open();
},
/**
* Opens the menu tree editor. On menu editor save, current page changes
diff --git a/addons/website/static/src/js/widgets/media.js b/addons/website/static/src/js/widgets/media.js
deleted file mode 100644
index 900f53068ba..00000000000
--- a/addons/website/static/src/js/widgets/media.js
+++ /dev/null
@@ -1,14 +0,0 @@
-odoo.define('website.widgets.media', function (require) {
-'use strict';
-
-const {ImageWidget} = require('wysiwyg.widgets.media');
-
-ImageWidget.include({
- _getAttachmentsDomain() {
- const domain = this._super(...arguments);
- domain.push('|', ['url', '=', false], '!', ['url', '=like', '/web/image/website.%']);
- domain.push(['key', '=', false]);
- return domain;
- }
-});
-});
diff --git a/addons/website/static/src/scss/color_palettes.scss b/addons/website/static/src/scss/color_palettes.scss
new file mode 100644
index 00000000000..baef0487b87
--- /dev/null
+++ b/addons/website/static/src/scss/color_palettes.scss
@@ -0,0 +1,56 @@
+:root {
+ // Override css variables to influence the default style of the editor
+ // without duplicating the css.
+ @include print-variable('o-we-toolbar-height', $o-navbar-height);
+
+ // Need the colors, name, menu background color and footer background color of each color palette
+ $o-palette-names: ();
+ $-default-cc: ();
+ $-bg-attrs: 'menu', 'footer';
+ @each $attr in $-bg-attrs {
+ // Since the variables built here are currently only used for theme previews we
+ // decided to keep things simple and we deliberately ignore potential overrides
+ // from #{$-bg-attrs}-custom and #{$-bg-attrs}-gradient.
+ $-default-cc: map-merge($-default-cc, ($attr: map-get($o-base-color-palette, $attr)))
+ }
+
+ @each $-selected-palette-name in $o-selected-color-palettes-names {
+ $-palette: map-get($o-color-palettes, $-selected-palette-name);
+
+ @if $-selected-palette-name != 'user-palette' {
+ @each $key, $value in $-palette {
+ @include print-variable('o-palette-#{$-selected-palette-name}-#{$key}', $value);
+ }
+
+ @each $attr-name, $default-cc-idx in $-default-cc {
+ $-cc-idx: $default-cc-idx;
+ @if map-has-key($-palette, $attr-name) {
+ $-cc-idx: map-get($-palette, $attr-name);
+ }
+ $-bg: $body-bg;
+ @if $-cc-idx != null {
+ $-cc: nth($o-color-combinations, $-cc-idx);
+ $-bg: o-safe-get($-palette, 'o-cc#{$-cc-idx}-bg', map-get($-cc, 'bg'));
+ @if type-of($-bg) != color {
+ $-bg: map-get($-palette, $-bg);
+ }
+ }
+ @include print-variable('o-palette-#{$-selected-palette-name}-#{$attr-name}-bg', $-bg);
+ }
+ }
+ }
+
+ // Need the palette colors used as default menu and footer background color
+ @each $attr-name, $default-cc-idx in $-default-cc {
+ $-cc: nth($o-color-combinations, $default-cc-idx);
+ $-default-bg: map-get($-cc, 'bg');
+ @include print-variable('o-default-#{$attr-name}-bg', $-default-bg);
+ }
+
+ @include print-variable('palette-names', $o-selected-color-palettes-names);
+
+ // Need info about the base grays which are used to compute the final grays
+ @each $name, $color in $o-base-gray-color-palette {
+ @include print-variable('base-#{$name}', $color);
+ }
+}
diff --git a/addons/website/static/src/scss/website.edit_mode.scss b/addons/website/static/src/scss/website.edit_mode.scss
index 4c5abba3ca9..df5f9b46084 100644
--- a/addons/website/static/src/scss/website.edit_mode.scss
+++ b/addons/website/static/src/scss/website.edit_mode.scss
@@ -182,39 +182,6 @@ table.editorbar-panel {
}
}
-body.editor_enable.editor_has_snippets {
- padding-top: 0 !important;
-
- .s_popup .modal {
- // s_popup in edit mode
- background-color: transparent;
-
- &.fade .modal-dialog {
- transform: none;
- }
- }
-
- #oe_main_menu_navbar + #wrapwrap .o_header_affixed {
- top: 0;
- }
-}
-
-.editor_has_snippets {
- .o_header_affixed {
- right: $o-we-sidebar-width !important;
- }
-}
-
-.editor_enable {
- @if o-website-value('header-template') == 'sidebar' {
- #wrapwrap > header {
- @if o-website-value('hamburger-position') != 'right' {
- right: $o-we-sidebar-width;
- }
- }
- }
-}
-
// s_countdown preview classes
body.editor_enable {
.s_countdown {
@@ -274,3 +241,8 @@ body.editor_enable {
}
}
}
+.o_homepage_editor_welcome_message {
+ padding-top: 128px;
+ padding-bottom: 128px;
+ font-family: Roboto, $font-family-sans-serif;
+}
diff --git a/addons/website/static/src/scss/website.editor.ui.scss b/addons/website/static/src/scss/website.editor.ui.scss
index d646b7e4727..98d04a5e3b4 100644
--- a/addons/website/static/src/scss/website.editor.ui.scss
+++ b/addons/website/static/src/scss/website.editor.ui.scss
@@ -1,10 +1,3 @@
-
-.o_homepage_editor_welcome_message {
- padding-top: 128px;
- padding-bottom: 128px;
- font-family: Roboto, $font-family-sans-serif;
-}
-
// INPUTS
$o-we-switch-size: 1.2em !default;
$o-we-switch-inactive-color: rgba($text-muted, 0.4) !default;
@@ -59,57 +52,6 @@ $o-we-switch-inactive-color: rgba($text-muted, 0.4) !default;
}
}
-.o_we_slider_tint input[type="range"] {
- background: linear-gradient(to right, #F00 0%, #FF0 16.66%, #0F0 33.33%, #0FF 50%, #00F 66.66%, #F0F 83.33%, #F00 100%);
- height: $o-we-sidebar-content-field-progress-control-height !important;
- padding: 0 !important;
- cursor: pointer;
-
- @mixin slider-track {
- $margin-horizontal: $o-we-sidebar-content-field-progress-control-height / -2;
- background-color: transparent !important;
- margin: 0 $margin-horizontal 0 $margin-horizontal;
- }
- &::-webkit-slider-runnable-track {
- @include slider-track;
- }
- &::-moz-range-track {
- @include slider-track;
- }
- &::-moz-range-progress {
- @include slider-track;
- }
- @mixin slider-thumb {
- appearance: none !important;
- height: $o-we-sidebar-content-field-colorpicker-size !important;
- border: 1px solid $o-we-bg-dark !important;
- box-shadow: inset 0 0 0 1px white !important;
- background: transparent !important;
- border-radius: 0 !important;
- margin-top: -5% !important;
- }
- &::-webkit-slider-thumb {
- @include slider-thumb;
- }
- &::-moz-range-thumb {
- @include slider-thumb;
- }
-}
-
-.o_we_gray_preview {
- cursor: pointer;
-
- span {
- flex: 1;
- margin: 0 !important;
- height: $o-we-sidebar-content-field-colorpicker-size;
- min-width: 0 !important;
- }
- div {
- width: 100%;
- }
-}
-
.o_new_content_loader_container {
background-color: rgba($o-shadow-color, .9);
pointer-events: all;
diff --git a/addons/website/static/src/scss/website.navbar.mobile.scss b/addons/website/static/src/scss/website.navbar.mobile.scss
deleted file mode 100644
index aac6863eb8f..00000000000
--- a/addons/website/static/src/scss/website.navbar.mobile.scss
+++ /dev/null
@@ -1,115 +0,0 @@
-// Achieve a top-menu design visually similar to the backend's 'burger' one.
-
-// = Mobile Main Navbar
-// ============================================================================
-@include media-breakpoint-down(sm) {
- #oe_main_menu_navbar.o_main_navbar {
-
- // = Scoped Variables
- //
- // Variables scopend within the current context that mirror backend's
- // boostrap values.
- // --------------------------------------------------------------------
- $-bg: darken($o-brand-odoo, 10%);
- $-color: color-yiq($-bg);
- $-font-size-base: 16px;
- $-font-size-lg: 18px;
- $-entry-hover-bg: rgba(invert($-color), .08);
-
-
- // = Invalidate top-menu native 'flex' layout
- // --------------------------------------------------------------------
- display: block !important;
-
- .o_menu_toggle {
- display: inline !important;
- }
-
- .o_menu_sections li, .dropdown-menu.show {
- display: block;
- position: relative;
- width: 100%;
- height: auto;
- }
-
- // = Design
- // --------------------------------------------------------------------
- .o_mobile_menu_toggle,
- .dropdown-toggle,
- .dropdown-item,
- .o_menu_sections > .css_published {
- height: $o-navbar-height;
- @include o-hover-text-color(rgba($-color, .9), $-color);
- }
-
- .o_menu_sections {
- @include o-position-absolute($o-navbar-height, 0, 0, 0);
- display: none;
- overflow: auto;
- background-color: $-bg;
-
- > li > a {
- font-size: $-font-size-lg;
- }
-
- li.css_published:hover {
- background-color: $-entry-hover-bg;
- }
- }
-
- .dropdown-divider {
- border-top-color: rgba($-color, .06);
- margin-left: $dropdown-item-padding-x;
- margin-right: $dropdown-item-padding-x;
- }
-
- .dropdown.show {
- &, .dropdown-toggle, .dropdown-item, .dropdown-menu {
- background-color: darken($-bg, 5%);
- }
-
- > .dropdown-toggle {
- @include o-hover-text-color($-color, $-color);
- outline: 0;
- box-shadow: inset 0 1px 0 rgba(invert($-color), .1);
- }
-
- .dropdown-menu {
- border: 0;
- padding-top: 0;
- box-shadow: inset 0 -1px 0 rgba($-color, .1);
- }
-
- .dropdown-item {
- display: flex;
- align-items: center;
- font-size: $-font-size-base;
- }
- }
-
- // = Toggle Menu (open)
- // --------------------------------------------------------------------
- body.o_mobile_menu_opened > & {
- height: 100%;
-
- .o_menu_sections {
- display: block !important;
- height: 100%;
- width: 100%;
- }
-
- .o_mobile_menu_toggle:before {
- content: "\f00d"
- }
- }
- }
-}
-
-@include media-breakpoint-down(sm) {
- .o_switch_company_menu > .dropdown-menu {
- padding-top: 0px;
- .bg-info {
- padding: 10px;
- }
- }
-}
diff --git a/addons/website/static/src/scss/website.navbar.scss b/addons/website/static/src/scss/website.navbar.scss
deleted file mode 100644
index bde5abb7ca4..00000000000
--- a/addons/website/static/src/scss/website.navbar.scss
+++ /dev/null
@@ -1,20 +0,0 @@
-// Emulate navbar backend values and avoid website customizations
-
-#oe_main_menu_navbar.o_main_navbar {
- .o_menu_brand, .o_menu_toggle {
- display: inline-block;
- font-size: 17px;
- }
-
- .o_menu_sections, .o_menu_systray {
- &, > div {
- > a, > button, > label {
- font-size: 14px;
- }
- }
-
- > .o_mobile_preview > a {
- font-size: 17px;
- }
- }
-}
diff --git a/addons/website/static/src/scss/website.scss b/addons/website/static/src/scss/website.scss
index ef8ac17fe9b..bf05674716b 100644
--- a/addons/website/static/src/scss/website.scss
+++ b/addons/website/static/src/scss/website.scss
@@ -185,23 +185,6 @@ $-seen-urls: ();
}
}
-body:not(.o_fullscreen) {
- #oe_main_menu_navbar:not(.o_hidden) + #wrapwrap {
- .o_header_affixed, .modal {
- // For these elements, in #wrapwrap, position top should be size of
- // the navbar.
- top: $o-navbar-height;
- }
- .modal {
- // As the default bootstrap style no longer uses 'bottom: 0' and
- // added 'height: 100%' since the V4.2 because of a bug on Android.
- // We have to adapt the height of the modal by removing the height
- // of the navbar.
- height: calc(100vh - #{$o-navbar-height});
- }
- }
-}
-
.navbar {
.navbar-collapse {
@@ -1021,27 +1004,6 @@ header {
padding-right: .5em;
}
}
-
- .o_connected_user:not(.editor_has_snippets):not(.o_fullscreen) header & {
- // TODO this has to be reviewed
- $-header-template: o-website-value('header-template');
- @if not index(('Contact', 'boxed', 'magazine'), $-header-template) {
- top: -$o-navbar-height;
- padding-top: $o-navbar-height;
- } @else if $-header-template == 'magazine' {
- @include media-breakpoint-up(lg) {
- padding-top: $o-navbar-height;
- }
- }
- }
-
- .editor_has_snippets:not(.o_fullscreen) header & {
- @if (o-website-value('header-template') == 'magazine') {
- @include media-breakpoint-up(lg) {
- right: $o-we-sidebar-width;
- }
- }
- }
}
&.collapsing #top_menu {
@if $-hamburger-center {
@@ -1132,16 +1094,6 @@ header {
}
}
}
- body.o_connected_user:not(.o_fullscreen) {
- &:not(.editor_has_snippets) #wrapwrap > header {
- top: $o-navbar-height;
- }
- &.editor_has_snippets #wrapwrap > header {
- @if $-hamburger-right {
- right: $o-we-sidebar-width;
- }
- }
- }
}
} @else if o-website-value('header-template') == 'boxed' {
#wrapwrap:not(.o_header_overlay) .o_header_boxed_background {
@@ -1162,8 +1114,10 @@ header {
$o-hamburger-full-navbar-height: $o-theme-navbar-logo-height + ($navbar-padding-y * 2);
> header {
.navbar-collapse {
+ max-height: unset;
+
> .container {
- height: calc(100vh - #{$o-navbar-height} - #{$o-hamburger-full-navbar-height});
+ height: calc(100vh - #{$o-hamburger-full-navbar-height});
transition: height .3s ease;
}
.nav-link {
diff --git a/addons/website/static/src/scss/website.ui.scss b/addons/website/static/src/scss/website.ui.scss
index e81e11db7ee..b77dee98527 100644
--- a/addons/website/static/src/scss/website.ui.scss
+++ b/addons/website/static/src/scss/website.ui.scss
@@ -3,454 +3,108 @@
/// and the UI components rules.
///
+$-mini-nav-size: 40px;
+
+.o_frontend_to_backend_nav {
+ @include o-position-absolute(0, auto, auto, 0);
+ position: fixed;
+ z-index: $zindex-modal;
+ display: flex;
+ font-size: $o-font-size-base * ($o-root-font-size / 16px);
+
+ &::before {
+ content: "";
+ @include o-position-absolute(0, auto, auto, 0);
+ border-top: $-mini-nav-size/2 solid $o-enterprise-color;
+ border-left: $-mini-nav-size/2 solid $o-enterprise-color;
+ border-bottom: $-mini-nav-size/2 solid transparent;
+ border-right: $-mini-nav-size/2 solid transparent;
+ cursor: pointer;
+
+ transform-origin: top left;
+ transition: transform 400ms ease 0s;
+ }
+
+ > * {
+ position: relative;
+ min-width: $-mini-nav-size;
+ height: $-mini-nav-size;
+ padding: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ cursor: pointer;
+ background-color: $o-enterprise-color;
+ color: transparent;
+ transform-origin: top left;
+ transform: translateX(-250%) scaleY(0.2);
+ transition: transform 400ms ease 0s, background-color 400ms ease 200ms, color 400ms ease 200ms;
+
+ &:hover {
+ background-color: darken($o-enterprise-color, 5%);
+ color: white;
+ text-decoration: none;
+ }
+ }
+
+ &:hover {
+ &::before {
+ transform: scale(1.3);
+ }
+ .o_frontend_to_backend_icon {
+ transform: rotateZ(45deg) translateX(#{-$-mini-nav-size / 4 * 0.3});
+ }
+ }
+
+ &.o_frontend_to_backend_nav_opened {
+ &::before {
+ transform: scaleX(1.3);
+ transition: transform 100ms ease 0s;
+ }
+
+ > * {
+ color: white;
+ transform: translateX(-#{$-mini-nav-size});
+ }
+ .o_frontend_to_backend_edit_btn {
+ background-color: $o-enterprise-primary-color;
+
+ &:hover {
+ background-color: darken($o-enterprise-primary-color, 5%);
+ }
+ }
+
+ &.o_frontend_to_backend_nav_fully_opened > * {
+ transition: none;
+ }
+ }
+}
+.o_frontend_to_backend_icon {
+ border-radius: 50rem;
+ background-color: transparent;
+ color: white;
+ transform-origin: center center;
+ transform: rotateZ(45deg) translateX(#{-$-mini-nav-size/4});
+ transition: transform 100ms ease 0s;
+
+ &:hover {
+ background-color: transparent;
+ }
+}
+.o_frontend_to_backend_edit_btn {
+ padding: 0 12px;
+
+ > .fa {
+ margin-right: 8px;
+ }
+}
+
// LAYOUTING
body {
// Set frontend direction that will be flipped with
// rtlcss for right-to-left text direction.
direction: ltr;
}
-body.o_connected_user {
- padding-top: $o-navbar-height!important;
-
- &.o_fullscreen_transition {
- transition: padding 400ms ease 0s;
-
- #oe_main_menu_navbar, #web_editor-top-edit, .o_we_website_top_actions, #oe_snippets {
- transition: transform 400ms ease 0s !important;
- }
- .o_header_affixed {
- transition: top 0.35s !important;
- }
- }
- &.o_fullscreen {
- padding-top: 0 !important;
-
- &.editor_enable.editor_has_snippets {
- padding-right: 0 !important;
-
- .modal:not(.o_technical_modal) {
- right: 0;
- }
- }
- #oe_main_menu_navbar, #web_editor-top-edit {
- transform: translateY(-100%);
- }
- .o_we_website_top_actions, #oe_snippets {
- transform: translateX(100%);
- }
- .o_header_affixed {
- top: 0 !important;
- right: 0 !important;
- }
- }
-}
-
-//FULLSCREEN INDICATION LAYOUT
-.o_fullscreen_indication {
- display: flex;
- position: absolute;
- width: 100%;
- align-items: center;
- justify-content: center;
- top: 0;
- margin-top: $o-navbar-height!important;
- opacity: 0;
- pointer-events: none;
-
- p {
- padding: 15px 30px;
- background-color: rgba(0, 0, 0, 0.7);
- color: white;
- span {
- border: 1px solid white;
- border-color: white;
- border-radius: 5px;
- padding: 7px 5px;
- margin: 5px;
- }
- }
- &.o_transition {
- transition: opacity 400ms;
- }
- &.o_visible {
- opacity: 1;
- }
-}
-
-// MAIN MENU STYLE (added above navbar.scss)
-#oe_main_menu_navbar {
- @include o-w-preserve-dropdown-menus;
- @include o-position-absolute(0, 0, auto, 0);
- position: fixed;
- z-index: $zindex-modal - 10;
- font-family: $o-we-font-family;
- font-size: 14px;
-
- a:hover, a:focus {
- text-decoration: none;
- }
- a.dropdown-toggle {
- outline: none;
- }
- .dropdown-menu {
- border-top: 0;
- margin-top: 0;
- background-clip: border-box;
- font-size: inherit;
- border-radius: 0;
- color: $dropdown-link-active-color;
- max-height: 500px; // Fallback
- max-height: calc(90vh - #{$o-navbar-height});
- overflow: auto;
- }
- #oe_applications .dropdown-menu {
- // Compensate #oe_applications's padding
- margin-left: $dropdown-item-padding-x * -0.5;
- }
-
- .o_menu_sections {
- .o_mobile_preview a {
- text-align: center;
- font-size: 20px;
- }
- }
- .o_menu_systray {
- > li > a, > div > a {
- &.css_edit_dynamic{
- padding: 0 $grid-gutter-width/4;
- }
-
- &[data-action="edit"], &[data-action="translate"], &.css_edit_dynamic {
- @include button-variant($o-brand-primary, $o-brand-primary);
- &.active, &:hover, &:focus, &:active {
- outline: none;
- box-shadow: none !important;
- }
- }
-
- &, &:hover, &:focus {
- text-decoration: none;
- }
- }
-
- .o_mobile_preview a {
- text-align: center;
- }
- }
- @include media-breakpoint-down(sm) {
- #oe_applications {
- position: inherit;
- z-index: 1002;
- }
- }
-}
-
-@mixin o-w-close-icon($size:12px, $color:#000, $color-hover:#000, $thickness: 1px, $opacity: 0.7, $opacity-hover: 1) {
- color: transparent;
- position: relative;
- display: inline-block;
- opacity: $opacity;
- width: $size;
- height: $size;
-
- &:hover, &:focus {
- outline: none;
- opacity: $opacity-hover;
-
- &::after, &::before {
- background: $color-hover;
- }
- }
-
- &:after, &:before {
- content: '';
- margin-top: -1px;
- background: $color;
- @include size(100%, $thickness);
- @include o-position-absolute(50%, $left:0);
- transform: rotate(45deg);
- }
-
- &:after {
- transform: rotate(-45deg);
- }
-}
-
-// MODALS
-body .modal {
- &.o_technical_modal {
- @include o-w-preserve-base;
- @include o-w-preserve-dropdown-menus;
- @include o-w-preserve-headings;
- @include o-w-preserve-forms;
- @include o-w-preserve-links;
- @include o-w-preserve-btn;
- @include o-w-preserve-cards;
- @include o-w-preserve-modals;
- @include o-w-preserve-tabs;
- }
-
- // MOBILE PREVIEW
- &.oe_mobile_preview {
- text-align: center;
-
- .modal-dialog {
- display: inline-block;
- width: auto;
-
- .modal-content {
- background-color: black!important;
- border: 3px outset gray;
- border-radius: 20px;
-
- .modal-header {
- border: none;
- cursor: pointer;
- font-family: $o-we-font-family;
-
- &, .close {
- color: white;
- }
-
- h4 {
- font-family: inherit;
- font-weight: normal;
- color: inherit;
-
- .fa {
- margin-left: $grid-gutter-width/2;
- }
- }
- .close {
- color: #4e525b;
- }
- }
-
- .modal-body {
- background-color: inherit!important;
- border-radius: 20px;
- padding: 15px;
-
- $mobile-preview-width: 320px;
- $mobile-preview-height: 530px;
-
- display: flex;
- width: $mobile-preview-width + 15;
- height: $mobile-preview-height;
- transition: all 400ms ease 0s;
-
- &.o_invert_orientation {
- width: $mobile-preview-height + 15;
- height: $mobile-preview-width;
- }
-
- > iframe {
- display: block;
- width: 100%;
- border: none;
- }
- }
-
- .modal-footer {
- display: none;
- }
- }
- }
- }
-
- // TOP MENU EDITOR
- .oe_menu_editor {
- ul {
- padding-left: 37px;
- }
-
- li {
- margin-top: -1px;
-
- .input-group-addon {
- border-radius: 0;
- }
- }
- }
-
- // SEO CONFIGURATION
- &.oe_seo_configuration {
- #language-box {
- padding-right: 25px;
- background-color: white;
- }
- .o_seo_og_image {
- .o_meta_img {
- position: relative;
- transition: border-color 200ms;
- display: inline-block;
- border: 2px solid gray('400');
-
- > img {
- width: 70px;
- height: 70px;
- object-fit: cover;
- cursor: pointer;
- }
-
- &:hover {
- border-color: $o-brand-primary;
- }
-
- &.o_active_image {
- border-color: $o-brand-primary;
-
- &:before {
- @include o-position-absolute($right: 0);
- content: '';
- border: 16px solid rgba($o-brand-primary, 0.8);
- border-left-color: transparent;
- border-bottom-color: transparent;
- }
-
- &:after {
- @include o-position-absolute(2px, 3px);
- display: inline-block;
- content: "\f00c";
- font-family: FontAwesome;
- color: white;
- font-size: 12px;
- }
- }
- .o-custom-label {
- @include o-position-absolute($bottom: 0px);
- background: rgba(gray('800'), 0.6);
- font-size: 12px;
- }
- }
- .o_meta_img_upload {
- transition: 200ms;
- display: inline-block;
- padding: 23px 27px;
- border: 3px dashed lighten(gray('700'), 30%);
- vertical-align: top;
- cursor: pointer;
- color: lighten(gray('600'), 30%);
-
- &:hover {
- border-color: $o-brand-primary;
- color: $o-brand-primary;
- }
- }
- .o_meta_active_img {
- height: 240px;
- object-fit: cover;
- }
- }
-
- div.oe_seo_preview_g {
- list-style: none;
- font-family: arial, sans-serif;
-
- .r {
- cursor: pointer;
- color:#1a0dab;
- font-size: 18px;
- overflow: hidden;
- text-overflow: ellipsis;
- -webkit-text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .s {
- font-size: 13px;
- line-height: 18px;
- color: #545454;
- .kv {
- color: #006621;
- font-size: 14px;
- line-height: 18px;
- }
- }
- }
-
- td.o_seo_keyword_suggestion span.o_seo_suggestion.badge {
- cursor: pointer;
- }
- }
-}
-
-// ADD NEW PAGE MODAL
-
-.o_new_content_open {
- // Kill the scroll on the body
- overflow: hidden;
-}
-
-#o_new_content_menu_choices {
- @include o-w-preserve-base;
- @include o-position-absolute($o-navbar-height, 0, 0, 0);
- position: fixed;
- display: flex;
- overflow: auto;
- background-color: rgba(0, 0, 0, 0.8);
- font-family: $o-we-font-family;
-
- &::before {
- content: " ";
- @include o-position-absolute(0, 0, 0, 0);
- z-index: -1;
- pointer-events: none;
- }
-
- .container {
- max-width: 720px;
- margin: auto;
- }
-
- .o_new_content_element {
- opacity: 0;
- animation: fadeInDownSmall 1s forwards;
-
- .o_uninstalled_module {
- filter: brightness(70%) contrast(60%);
-
- }
-
- .module_icon {
- display: block;
- margin: auto;
- }
-
- a {
- display: block;
- font-size: 34px;
- text-align: center;
- text-decoration: none;
-
- i {
- width: 110px;
- height: 110px;
- border: 3px solid lighten(#2C2C36, 10%);
- border-radius: 100%;
- line-height: 104px;
- background-color: #2C2C36;
- color: white;
-
- transition: all 0.5s cubic-bezier(0.19, 1, 0.22, 1) 0s;
- }
- p {
- color: white;
- margin-top: 0.7em;
- font-size: 0.5em;
- }
-
- &:hover, &:focus {
- text-decoration: none;
- outline: none; // remove ugly dotted border on Firefox
- i {
- border-color: #1cc1a9;
- box-shadow: 0 0 10px rgba(28, 193, 169, 0.46);
- }
- }
- }
- }
-}
// LOGIN FORM
.oe_login_form, .oe_signup_form, .oe_reset_password_form {
@@ -459,17 +113,6 @@ body .modal {
margin: 50px auto;
}
-// ACE EDITOR
-.o_ace_view_editor {
- @include o-w-preserve-base;
- @include o-w-preserve-btn;
- @include o-w-preserve-forms;
-
- @include o-position-absolute($o-navbar-height, 0, 0);
- position: fixed;
- z-index: $zindex-modal;
-}
-
// POPOVER NAVIGATION
.tour .popover-navigation {
margin-left: 13px;
@@ -499,11 +142,6 @@ body .modal {
}
// Pages Management
-.o_page_management_info {
- .o_switch {
- padding-top: 9px;
- }
-}
#list_website_pages {
th {
background-color: $o-brand-odoo;
@@ -526,47 +164,6 @@ body .modal {
}
}
-.ui-autocomplete.o_website_ui_autocomplete {
- max-width: 400px;
- font-size: $o-we-sidebar-font-size;
- border: none;
- background-color: $o-we-sidebar-content-field-dropdown-bg;
- box-shadow: $o-we-sidebar-content-field-dropdown-shadow;
- > li {
- border-bottom: $o-we-sidebar-content-field-border-width solid lighten($o-we-sidebar-content-field-dropdown-border-color, 15%);
- border-radius: $o-we-sidebar-content-field-border-radius;
- background-color: $o-we-sidebar-content-field-clickable-bg;
- color: $o-we-sidebar-content-field-clickable-color;
- &.ui-menu-item {
- > div {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- line-height: 20px;
- &.ui-state-active {
- border: $o-we-sidebar-content-field-dropdown-border-width solid transparent;
- background-color: $o-we-sidebar-content-field-dropdown-item-bg-hover;
- }
- }
- }
- &.ui-autocomplete-category {
- background-color: $o-we-bg-lighter;
- }
- }
-}
-
-// Page Properties
-.o_redirect_old_url {
- @include media-breakpoint-down(sm) {
- // Because o_technical_modal class class has a higher z-index than $zindex-modal.
- z-index: $o-we-technical-modal-zindex + ($zindex-popover - $zindex-modal);
- }
- .popover-body {
- overflow-y: auto;
- max-height: 14.2rem;
- }
-}
-
// Post Submit Links
.post_link:not(.o_post_link_js_loaded) {
pointer-events: none;
diff --git a/addons/website/static/src/scss/website.wysiwyg.scss b/addons/website/static/src/scss/website.wysiwyg.scss
index 44cfd93c2bd..bc54e4de92c 100644
--- a/addons/website/static/src/scss/website.wysiwyg.scss
+++ b/addons/website/static/src/scss/website.wysiwyg.scss
@@ -6,63 +6,6 @@
}
}
-:root {
- // Override css variables to influence the default style of the editor
- // without duplicating the css.
- @include print-variable('o-we-toolbar-height', $o-navbar-height);
-
- // Need the colors, name, menu background color and footer background color of each color palette
- $o-palette-names: ();
- $-default-cc: ();
- $-bg-attrs: 'menu', 'footer';
- @each $attr in $-bg-attrs {
- // Since the variables built here are currently only used for theme previews we
- // decided to keep things simple and we deliberately ignore potential overrides
- // from #{$-bg-attrs}-custom and #{$-bg-attrs}-gradient.
- $-default-cc: map-merge($-default-cc, ($attr: map-get($o-base-color-palette, $attr)))
- }
-
- @each $-selected-palette-name in $o-selected-color-palettes-names {
- $-palette: map-get($o-color-palettes, $-selected-palette-name);
-
- @if $-selected-palette-name != 'user-palette' {
- @each $key, $value in $-palette {
- @include print-variable('o-palette-#{$-selected-palette-name}-#{$key}', $value);
- }
-
- @each $attr-name, $default-cc-idx in $-default-cc {
- $-cc-idx: $default-cc-idx;
- @if map-has-key($-palette, $attr-name) {
- $-cc-idx: map-get($-palette, $attr-name);
- }
- $-bg: $body-bg;
- @if $-cc-idx != null {
- $-cc: nth($o-color-combinations, $-cc-idx);
- $-bg: o-safe-get($-palette, 'o-cc#{$-cc-idx}-bg', map-get($-cc, 'bg'));
- @if type-of($-bg) != color {
- $-bg: map-get($-palette, $-bg);
- }
- }
- @include print-variable('o-palette-#{$-selected-palette-name}-#{$attr-name}-bg', $-bg);
- }
- }
- }
-
- // Need the palette colors used as default menu and footer background color
- @each $attr-name, $default-cc-idx in $-default-cc {
- $-cc: nth($o-color-combinations, $default-cc-idx);
- $-default-bg: map-get($-cc, 'bg');
- @include print-variable('o-default-#{$attr-name}-bg', $-default-bg);
- }
-
- @include print-variable('palette-names', $o-selected-color-palettes-names);
-
- // Need info about the base grays which are used to compute the final grays
- @each $name, $color in $o-base-gray-color-palette {
- @include print-variable('base-#{$name}', $color);
- }
-}
-
#oe_snippets {
top: 0;
.oe-toolbar {
@@ -72,12 +15,6 @@
}
}
-// TRANSLATIONS
-.oe_translate_examples li {
- margin: 10px;
- padding: 4px;
-}
-
html[lang] > body.editor_enable [data-oe-translation-state] {
background: rgba($o-we-content-to-translate-color, 0.5) !important;
diff --git a/addons/website/static/src/services/website_service.js b/addons/website/static/src/services/website_service.js
new file mode 100644
index 00000000000..ad505daea11
--- /dev/null
+++ b/addons/website/static/src/services/website_service.js
@@ -0,0 +1,199 @@
+/** @odoo-module **/
+
+import { registry } from '@web/core/registry';
+import core from 'web.core';
+import ajax from 'web.ajax';
+import { getWysiwygClass } from 'web_editor.loader';
+
+import { FullscreenIndication } from '../components/fullscreen_indication/fullscreen_indication';
+
+const { reactive, EventBus } = owl;
+
+const websiteSystrayRegistry = registry.category('website_systray');
+
+export const unslugHtmlDataObject = (repr) => {
+ const match = repr && repr.match(/(.+)\((\d+),(.*)\)/);
+ if (!match) {
+ return null;
+ }
+ return {
+ model: match[1],
+ id: match[2] | 0,
+ };
+};
+
+export const websiteService = {
+ dependencies: ['orm', 'action', 'user', 'dialog', 'hotkey'],
+ async start(env, { orm, action, user, dialog, hotkey }) {
+ let websites = [];
+ let currentWebsiteId;
+ let currentMetadata = {};
+ let fullscreen;
+ let pageDocument;
+ let contentWindow;
+ let editedObjectPath;
+ let websiteRootInstance;
+ let Wysiwyg;
+ let isPublisher;
+ let isDesigner;
+ let hasMultiWebsites;
+ const context = reactive({
+ showNewContentModal: false,
+ showAceEditor: false,
+ edition: false,
+ isPublicRootReady: false,
+ snippetsLoaded: false,
+ isMobile: false,
+ });
+ const bus = new EventBus();
+
+ const setCurrentWebsiteId = id => {
+ currentWebsiteId = id;
+ websiteSystrayRegistry.trigger('EDIT-WEBSITE');
+ };
+ hotkey.add("escape", () => {
+ // Toggle fullscreen mode when pressing escape.
+ if (currentWebsiteId) {
+ fullscreen = !fullscreen;
+ document.body.classList.toggle('o_website_fullscreen', fullscreen);
+ bus.trigger((fullscreen ? 'FULLSCREEN-INDICATION-SHOW' : 'FULLSCREEN-INDICATION-HIDE'));
+ }
+ });
+ registry.category('main_components').add('FullscreenIndication', {
+ Component: FullscreenIndication,
+ props: { bus },
+ });
+ return {
+ set currentWebsiteId(id) {
+ setCurrentWebsiteId(id);
+ },
+ get currentWebsite() {
+ const currentWebsite = websites.find(w => w.id === currentWebsiteId);
+ if (currentWebsite) {
+ currentWebsite.metadata = currentMetadata;
+ }
+ return currentWebsite;
+ },
+ get websites() {
+ return websites;
+ },
+ get context() {
+ return context;
+ },
+ get bus() {
+ return bus;
+ },
+ set pageDocument(document) {
+ pageDocument = document;
+ if (!document) {
+ return;
+ }
+ // Not all files have a dataset. (e.g. XML)
+ if (!document.documentElement.dataset) {
+ currentMetadata = {};
+ } else {
+ const { mainObject, seoObject, isPublished, canPublish, editableInBackend, translatable, viewXmlid } = document.documentElement.dataset;
+ const contentMenuEl = document.querySelector('[data-content_menu_id]');
+ currentMetadata = {
+ path: document.location.href,
+ mainObject: unslugHtmlDataObject(mainObject),
+ seoObject: unslugHtmlDataObject(seoObject),
+ isPublished: isPublished === 'True',
+ canPublish: canPublish === 'True',
+ editableInBackend: editableInBackend === 'True',
+ title: document.title,
+ translatable: !!translatable,
+ contentMenuId: contentMenuEl && contentMenuEl.dataset.content_menu_id,
+ // TODO: Find a better way to figure out if
+ // a page is editable or not. For now, we use
+ // the editable selector because it's the common
+ // denominator of editable pages.
+ editable: !!document.getElementById('wrapwrap'),
+ viewXmlid: viewXmlid,
+ };
+ }
+ websiteSystrayRegistry.trigger('CONTENT-UPDATED');
+ },
+ get pageDocument() {
+ return pageDocument;
+ },
+ set contentWindow(window) {
+ contentWindow = window;
+ },
+ get contentWindow() {
+ return contentWindow;
+ },
+ get websiteRootInstance() {
+ return websiteRootInstance;
+ },
+ set websiteRootInstance(rootInstance) {
+ websiteRootInstance = rootInstance;
+ context.isPublicRootReady = !!rootInstance;
+ },
+ set editedObjectPath(path) {
+ editedObjectPath = path;
+ },
+ get editedObjectPath() {
+ return editedObjectPath;
+ },
+ get isPublisher() {
+ return isPublisher === true;
+ },
+ get isDesigner() {
+ return isDesigner === true;
+ },
+ get hasMultiWebsites() {
+ return hasMultiWebsites === true;
+ },
+ openMenuDialog(Component, props) {
+ return dialog.add(Component, props);
+ },
+ goToWebsite({ websiteId, path, edition, translation } = {}) {
+ action.doAction('website.website_preview', {
+ clearBreadcrumbs: true,
+ additionalContext: {
+ params: {
+ website_id: websiteId || currentWebsiteId,
+ path: path || (contentWindow && contentWindow.location.href) || '/',
+ enable_editor: edition,
+ edit_translations: translation,
+ },
+ },
+ });
+ },
+ async fetchWebsites() {
+ // Fetch user groups, before fetching the websites.
+ [isPublisher, isDesigner, hasMultiWebsites] = await Promise.all([
+ user.hasGroup('website.group_website_publisher'),
+ user.hasGroup('website.group_website_designer'),
+ user.hasGroup('website.group_multi_website'),
+ ]);
+
+ const [currentWebsiteRepr, allWebsites] = await Promise.all([
+ orm.call('website', 'get_current_website'),
+ hasMultiWebsites ? orm.searchRead('website', [], ['domain', 'id', 'name']) : [],
+ ]);
+ websites = [...allWebsites];
+ setCurrentWebsiteId(unslugHtmlDataObject(currentWebsiteRepr).id);
+ if (!websites.length) {
+ websites = [{ id: currentWebsiteId }];
+ }
+ },
+ async loadWysiwyg() {
+ if (!Wysiwyg) {
+ await ajax.loadXML('/website/static/src/xml/website.editor.xml', core.qweb);
+ Wysiwyg = await getWysiwygClass({wysiwygAlias: 'website.wysiwyg'}, ['website.compiled_assets_wysiwyg']);
+ }
+ return Wysiwyg;
+ },
+ blockIframe(showLoader = true, loaderDelay = 0) {
+ bus.trigger('BLOCK', {showLoader, loaderDelay});
+ },
+ unblockIframe() {
+ bus.trigger('UNBLOCK');
+ }
+ };
+ },
+};
+
+registry.category('services').add('website', websiteService);
diff --git a/addons/website/static/src/snippets/s_countdown/000.js b/addons/website/static/src/snippets/s_countdown/000.js
index 10074ca1752..349b89daecb 100644
--- a/addons/website/static/src/snippets/s_countdown/000.js
+++ b/addons/website/static/src/snippets/s_countdown/000.js
@@ -13,7 +13,6 @@ const CountdownWidget = publicWidget.Widget.extend({
selector: '.s_countdown',
xmlDependencies: ['/website/static/src/snippets/s_countdown/000.xml'],
disabledInEditableMode: false,
- defaultColor: 'rgba(0, 0, 0, 255)',
/**
* @override
diff --git a/addons/website/static/src/snippets/s_countdown/options.js b/addons/website/static/src/snippets/s_countdown/options.js
index 77e88cc1933..1fefc35f86c 100644
--- a/addons/website/static/src/snippets/s_countdown/options.js
+++ b/addons/website/static/src/snippets/s_countdown/options.js
@@ -3,7 +3,6 @@ odoo.define('website.s_countdown_options', function (require) {
const core = require('web.core');
const options = require('web_editor.snippets.options');
-const CountdownWidget = require('website.s_countdown');
const qweb = core.qweb;
@@ -121,10 +120,7 @@ options.registry.countdown = options.Class.extend({
case 'selectDataAttribute': {
if (params.colorNames) {
- // In this case, it is a colorpicker controlling a data
- // value on the countdown: the default value is determined
- // by the countdown public widget.
- params.attributeDefaultValue = CountdownWidget.prototype.defaultColor;
+ params.attributeDefaultValue = 'rgba(0, 0, 0, 255)';
}
break;
}
diff --git a/addons/website/static/src/snippets/s_facebook_page/000.js b/addons/website/static/src/snippets/s_facebook_page/000.js
index 7bfa45b46ee..64926fe0b4d 100644
--- a/addons/website/static/src/snippets/s_facebook_page/000.js
+++ b/addons/website/static/src/snippets/s_facebook_page/000.js
@@ -16,7 +16,7 @@ const FacebookPageWidget = publicWidget.Widget.extend({
this.options.wysiwyg && this.options.wysiwyg.odooEditor.observerUnactive();
- var params = _.pick(this.$el.data(), 'href', 'height', 'tabs', 'small_header', 'hide_cover', 'show_facepile');
+ var params = _.pick(this.$el[0].dataset, 'href', 'height', 'tabs', 'small_header', 'hide_cover', 'show_facepile');
if (!params.href) {
return def;
}
diff --git a/addons/website/static/src/snippets/s_facebook_page/options.js b/addons/website/static/src/snippets/s_facebook_page/options.js
index da6c94dce74..735bfbabf80 100644
--- a/addons/website/static/src/snippets/s_facebook_page/options.js
+++ b/addons/website/static/src/snippets/s_facebook_page/options.js
@@ -21,7 +21,7 @@ options.registry.facebookPage = options.Class.extend({
hide_cover: true,
show_facepile: false,
};
- this.fbData = _.defaults(_.pick(this.$target.data(), _.keys(defaults)), defaults);
+ this.fbData = _.defaults(_.pick(this.$target[0].dataset, _.keys(defaults)), defaults);
if (!this.fbData.href) {
// Fetches the default url for facebook page from website config
@@ -106,8 +106,7 @@ options.registry.facebookPage = options.Class.extend({
this.fbData.height = this.fbData.show_facepile ? 225 : 150;
}
_.each(this.fbData, (value, key) => {
- this.$target.attr('data-' + key, value);
- this.$target.data(key, value);
+ this.$target[0].dataset[key] = value;
});
});
},
diff --git a/addons/website/static/src/snippets/s_image_gallery/options.js b/addons/website/static/src/snippets/s_image_gallery/options.js
index 8afb5c1f95a..74f68657a2c 100644
--- a/addons/website/static/src/snippets/s_image_gallery/options.js
+++ b/addons/website/static/src/snippets/s_image_gallery/options.js
@@ -1,8 +1,9 @@
odoo.define('website.s_image_gallery_options', function (require) {
'use strict';
+const { MediaDialogWrapper } = require('@web_editor/components/media_dialog/media_dialog');
+const { ComponentWrapper } = require('web.OwlCompatibility');
var core = require('web.core');
-var weWidgets = require('wysiwyg.widgets');
var options = require('web_editor.snippets.options');
var _t = core._t;
@@ -87,29 +88,36 @@ options.registry.gallery = options.Class.extend({
addImages: function (previewMode) {
const $images = this.$('img');
var $container = this.$('> .container, > .container-fluid, > .o_container_small');
- var dialog = new weWidgets.MediaDialog(this, {multiImages: true, onlyImages: true, mediaWidth: 1920});
- var lastImage = _.last(this._getImages());
- var index = lastImage ? this._getIndex(lastImage) : -1;
- return new Promise(resolve => {
- dialog.on('save', this, function (attachments) {
- for (var i = 0; i < attachments.length; i++) {
- $('', {
+ const lastImage = _.last(this._getImages());
+ let index = lastImage ? this._getIndex(lastImage) : -1;
+ const dialog = new ComponentWrapper(this, MediaDialogWrapper, {
+ multiImages: true,
+ onlyImages: true,
+ save: images => {
+ let $newImageToSelect;
+ for (const image of images) {
+ const $img = $('', {
class: $images.length > 0 ? $images[0].className : 'img img-fluid d-block ',
- src: attachments[i].image_src,
+ src: image.src,
'data-index': ++index,
- alt: attachments[i].description || '',
+ alt: image.alt || '',
'data-name': _t('Image'),
style: $images.length > 0 ? $images[0].style.cssText : '',
}).appendTo($container);
+ if (!$newImageToSelect) {
+ $newImageToSelect = $img;
+ }
}
- if (attachments.length > 0) {
+ if (images.length > 0) {
this.mode('reset', this.getMode());
this.trigger_up('cover_update');
+ // Triggers the re-rendering of the thumbnail
+ $newImageToSelect.trigger('image_changed');
+
}
- });
- dialog.on('closed', this, () => resolve());
- dialog.open();
+ },
});
+ dialog.mount(this.el);
},
/**
* Allows to change the number of columns when displaying images with a
diff --git a/addons/website/static/src/snippets/s_popup/options.js b/addons/website/static/src/snippets/s_popup/options.js
index 722c3ada212..d7f6bfd9628 100644
--- a/addons/website/static/src/snippets/s_popup/options.js
+++ b/addons/website/static/src/snippets/s_popup/options.js
@@ -8,14 +8,19 @@ options.registry.SnippetPopup = options.Class.extend({
* @override
*/
start: function () {
+ // Editor jQuery and iFrame jQuery isn't the same instance. This means
+ // that events within the iframe (so popup events) aren't handled by
+ // the editor's jQuery. To apply and listen to events on the modal,
+ // we need to get it from the iframe's jQuery instance.
+ this.$iframeTarget = this.$target[0].ownerDocument.defaultView.$(this.$target[0]);
// Note: the link are excluded here so that internal modal buttons do
// not close the popup as we want to allow edition of those buttons.
- this.$target.on('click.SnippetPopup', '.js_close_popup:not(a, .btn)', ev => {
+ this.$iframeTarget.on('click.SnippetPopup', '.js_close_popup:not(a, .btn)', ev => {
ev.stopPropagation();
this.onTargetHide();
this.trigger_up('snippet_option_visibility_update', {show: false});
});
- this.$target.on('shown.bs.modal.SnippetPopup', () => {
+ this.$iframeTarget.on('shown.bs.modal.SnippetPopup', () => {
this.trigger_up('snippet_option_visibility_update', {show: true});
// TODO duplicated code from the popup public widget, this should
// be moved to a *video* public widget and be reviewed in master
@@ -24,7 +29,7 @@ options.registry.SnippetPopup = options.Class.extend({
iframe.src = media.dataset.oeExpression || media.dataset.src; // TODO still oeExpression to remove someday
});
});
- this.$target.on('hidden.bs.modal.SnippetPopup', () => {
+ this.$iframeTarget.on('hide.bs.modal.SnippetPopup', () => {
this.trigger_up('snippet_option_visibility_update', {show: false});
this._removeIframeSrc();
});
@@ -42,7 +47,7 @@ options.registry.SnippetPopup = options.Class.extend({
// The video should not start before the modal opens, remove it from the
// DOM. It will be added back on modal open to start the video.
this._removeIframeSrc();
- this.$target.off('.SnippetPopup');
+ this.$iframeTarget.off('.SnippetPopup');
},
/**
* @override
@@ -60,8 +65,8 @@ options.registry.SnippetPopup = options.Class.extend({
* @override
*/
onTargetShow: async function () {
- this.$target.modal('show');
- $(document.body).children('.modal-backdrop:last').addClass('d-none');
+ this.$iframeTarget.modal('show');
+ $(this.$target[0].ownerDocument.body).children('.modal-backdrop:last').addClass('d-none');
},
/**
* @override
@@ -69,14 +74,14 @@ options.registry.SnippetPopup = options.Class.extend({
onTargetHide: async function () {
return new Promise(resolve => {
const timeoutID = setTimeout(() => {
- this.$target.off('hidden.bs.modal.popup_on_target_hide');
+ this.$iframeTarget.off('hidden.bs.modal.popup_on_target_hide');
resolve();
}, 500);
- this.$target.one('hidden.bs.modal.popup_on_target_hide', () => {
+ this.$iframeTarget.one('hidden.bs.modal.popup_on_target_hide', () => {
clearTimeout(timeoutID);
resolve();
});
- this.$target.modal('hide');
+ this.$iframeTarget.modal('hide');
});
},
/**
diff --git a/addons/website/static/src/snippets/s_rating/options.js b/addons/website/static/src/snippets/s_rating/options.js
index b24d7daf9f6..782664233c8 100644
--- a/addons/website/static/src/snippets/s_rating/options.js
+++ b/addons/website/static/src/snippets/s_rating/options.js
@@ -1,7 +1,9 @@
odoo.define('website.s_rating_options', function (require) {
'use strict';
-const weWidgets = require('wysiwyg.widgets');
+const { ComponentWrapper } = require('web.OwlCompatibility');
+const { MediaDialogWrapper } = require('@web_editor/components/media_dialog/media_dialog');
+
const options = require('web_editor.snippets.options');
options.registry.Rating = options.Class.extend({
@@ -37,16 +39,15 @@ options.registry.Rating = options.Class.extend({
* @see this.selectClass for parameters
*/
customIcon: async function (previewMode, widgetValue, params) {
- return new Promise(resolve => {
- const dialog = new weWidgets.MediaDialog(
- this,
- {noImages: true, noDocuments: true, noVideos: true, mediaWidth: 1920},
- $('')
- );
- this._saving = false;
- dialog.on('save', this, function (attachments) {
- this._saving = true;
- const customClass = 'fa ' + attachments.className;
+ const media = document.createElement('i');
+ media.className = params.customActiveIcon === 'true' ? this.faClassActiveCustomIcons : this.faClassInactiveCustomIcons;
+ const dialog = new ComponentWrapper(this, MediaDialogWrapper, {
+ noImages: true,
+ noDocuments: true,
+ noVideos: true,
+ media,
+ save: icon => {
+ const customClass = icon.className;
const $activeIcons = this.$target.find('.s_rating_active_icons > i');
const $inactiveIcons = this.$target.find('.s_rating_inactive_icons > i');
const $icons = params.customActiveIcon === 'true' ? $activeIcons : $inactiveIcons;
@@ -57,15 +58,9 @@ options.registry.Rating = options.Class.extend({
this.$target[0].dataset.inactiveCustomIcon = this.faClassInactiveCustomIcons;
this.$target[0].dataset.icon = 'custom';
this.iconType = 'custom';
- resolve();
- });
- dialog.on('closed', this, function () {
- if (!this._saving) {
- resolve();
- }
- });
- dialog.open();
+ }
});
+ dialog.mount(this.el);
},
/**
* Sets the number of active icons.
diff --git a/addons/website/static/src/systray_items/edit_in_backend.js b/addons/website/static/src/systray_items/edit_in_backend.js
new file mode 100644
index 00000000000..d12586092ea
--- /dev/null
+++ b/addons/website/static/src/systray_items/edit_in_backend.js
@@ -0,0 +1,50 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+import { useService } from "@web/core/utils/hooks";
+import { Dropdown } from "@web/core/dropdown/dropdown";
+import { DropdownItem } from "@web/core/dropdown/dropdown_item";
+
+const { Component, onMounted } = owl;
+
+export class EditInBackendSystray extends Component {
+ setup() {
+ this.websiteService = useService('website');
+ this.actionService = useService('action');
+
+ onMounted(() => {
+ this.websiteService.editedObjectPath = null;
+ });
+ }
+
+ getElements() {
+ return [{
+ title: this.env._t("Settings"),
+ callback: () => this.editInBackend(),
+ }];
+ }
+
+ editInBackend() {
+ const { metadata: { mainObject } } = this.websiteService.currentWebsite;
+ this.websiteService.editedObjectPath = this.websiteService.contentWindow.location.pathname;
+ this.actionService.doAction({
+ res_model: mainObject.model,
+ res_id: mainObject.id,
+ views: [[false, "form"]],
+ type: "ir.actions.act_window",
+ view_mode: "form",
+ });
+ }
+}
+EditInBackendSystray.template = "website.EditInBackendSystray";
+EditInBackendSystray.components = {
+ Dropdown,
+ DropdownItem
+};
+
+export const systrayItem = {
+ Component: EditInBackendSystray,
+ isDisplayed: env => env.services.website.currentWebsite && env.services.website.currentWebsite.metadata.editableInBackend,
+};
+
+registry.category("website_systray").add("EditInBackend", systrayItem, { sequence: 7 });
diff --git a/addons/website/static/src/systray_items/edit_in_backend.xml b/addons/website/static/src/systray_items/edit_in_backend.xml
new file mode 100644
index 00000000000..b94cd337a56
--- /dev/null
+++ b/addons/website/static/src/systray_items/edit_in_backend.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/website/static/src/systray_items/edit_website.js b/addons/website/static/src/systray_items/edit_website.js
new file mode 100644
index 00000000000..b34b6faf5ed
--- /dev/null
+++ b/addons/website/static/src/systray_items/edit_website.js
@@ -0,0 +1,65 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+import { useService } from '@web/core/utils/hooks';
+
+const { Component, useState, useEffect } = owl;
+
+class EditWebsiteSystray extends Component {
+ setup() {
+ this.websiteService = useService('website');
+ this.websiteContext = useState(this.websiteService.context);
+
+ this.state = useState({
+ isLoading: false,
+ });
+
+ useEffect((edition) => {
+ if (edition) {
+ this.state.isLoading = true;
+ }
+ }, () => [this.websiteContext.edition]);
+
+ useEffect((snippetsLoaded) => {
+ if (snippetsLoaded) {
+ this.state.isLoading = false;
+ }
+ }, () => [this.websiteContext.snippetsLoaded]);
+ }
+
+ get translatable() {
+ return this.websiteService.currentWebsite && this.websiteService.currentWebsite.metadata.translatable;
+ }
+
+ get label() {
+ if (this.translatable) {
+ return this.env._t("or edit master");
+ }
+ return this.env._t("Edit");
+ }
+
+ startEdit() {
+ if (this.translatable) {
+ // We are in translate mode, the pathname starts with '/'. By
+ // adding a trailing slash we can simply search for the first slash
+ // after the language code to remove the language part.
+ const { pathname, search, hash } = this.websiteService.contentWindow.location;
+ const languagePrefix = `${pathname}/`.indexOf('/', 1);
+ const defaultLanguagePathname = pathname.substring(languagePrefix);
+ this.websiteService.goToWebsite({
+ path: `/website/lang/default?r=${encodeURIComponent(defaultLanguagePathname + search + hash)}`,
+ edition: true
+ });
+ } else {
+ this.websiteContext.edition = true;
+ }
+ }
+}
+EditWebsiteSystray.template = "website.EditWebsiteSystray";
+
+export const systrayItem = {
+ Component: EditWebsiteSystray,
+ isDisplayed: (env) => env.services.website.currentWebsite.metadata.editable,
+};
+
+registry.category("website_systray").add("EditWebsite", systrayItem, { sequence: 8 });
diff --git a/addons/website/static/src/systray_items/edit_website.scss b/addons/website/static/src/systray_items/edit_website.scss
new file mode 100644
index 00000000000..facb2a8946d
--- /dev/null
+++ b/addons/website/static/src/systray_items/edit_website.scss
@@ -0,0 +1,5 @@
+.o_edit_website_container {
+ &:not(.o_translatable) {
+ @include button-variant($o-brand-primary, $o-brand-primary);
+ }
+}
diff --git a/addons/website/static/src/systray_items/edit_website.xml b/addons/website/static/src/systray_items/edit_website.xml
new file mode 100644
index 00000000000..44267d62af2
--- /dev/null
+++ b/addons/website/static/src/systray_items/edit_website.xml
@@ -0,0 +1,12 @@
+
+
+
+
Here are the visuals used to help you translate efficiently:
-
-
Content to translate
-
Translated content
-
-
- In this mode, you can only translate texts. To change the structure of the page, you must edit the master page.
- Each modification on the master page is automatically applied to all translated versions.
-