[MERGE] website, *: move the frontend UI to the backend

*: auth_signup, knowledge, mass_mailing, portal, survey, test_website,
   test_website_modules, test_website_slides_full, web, web_editor,
   web_tour, web_unsplash, website_blog, website_crm,
   website_crm_partner_assign, website_customer, website_event,
   website_event_exhibitor, website_event_meet, website_event_track,
   website_forum, website_hr_recruitment, website_knowledge,
   website_links, website_livechat, website_mail_group,
   website_mass_mailing, website_membership, website_sale,
   website_sale_autocomplete, website_sale_comparison,
   website_sale_loyalty, website_sale_slides, website_sale_wishlist,
   website_slides, website_slides_forum, website_twitter, base

A new client action is introduced, to display the website in an iframe.
This allows to remove the "frontend" Odoo UI entirely. Everything
related to the website configuration (and especially the edit mode) will
now be done on the backend side.

The idea behind this mainly comes from the fact that we needed the
website in an iframe during edition to be able to render a mobile
preview properly. It was also required so that options which reloads
the page... are able to only reload the page and not the entire editor
with it. From there, it was decided that actually, since the website is
in an iframe, it could be in the backend and every website UI could
rely on this.

In the end, this comes with many functional and technical advantages.
Here are some of them (a few of them are not entirely done with this
merge but will follow the merge in the upcoming weeks, see this as a
non-exhaustive list of possibilities that are unlocked by this work):

Technical:

- No duplicated logic for website UI and backend (purple navbar is the
  backend one, slightly customized).
- No conflicting logic for website UI and website content (like dialog
  opening, ...).
- No forced CSS for our website / web_editor UI, no conflict with
  website themes.
- Everything converted in OWL, in backend code (no special OWL for
  frontend).
- "Legacy" code moved as frontend code or removed.
- No website content CSS for the case "when there is the Odoo purple
  navbar at the top".
- Backend views & features available (e.g. creating a new event -> form
  view instead of custom code with onchanges, validations, etc)

Functional:

- All website features available while editing your website (product
  configuration, settings, etc), no switch backend/frontend, no
  redirection, no reload (just the iframe).
- All other app features available while editing your website (discuss,
  calendar, all other apps, ...).
- Typing a website URL in your browser reaches your website (very small
  Odoo UI overlay at the top left as a connected user).
- Mobile preview: directly resizing the website rendering, in edit mode
  too and with a better phone image.
- Editor: mobile view edition (the editor is instanciated around the
  website content instead of inside of it (iframe))
- Can reload the website without reloading all the tools (editor, html
  editor, menu edition, etc) (customize_show options for instance ->
  moved as standard editor options which reloads the website only)
- Website UI in your language instead of website language

On the backend side, some customizations were done so that the UI feels
like it works as before (note that it will be reworked in upcoming weeks):

- A `website_systray` registry is defined to replace the systray items
  with the website's one when previewing the website.
- The webclient's navbar is patched to display additional dropdown menus
  related to the website edition.
- A website service that holds the global state of the website is
  introduced. Components from the systray and the dropdown menus can
  access and modify it.
- A component adapter for the wysiwyg (that will display the snippets
  menu) is added, that allows for caching the wysiwyg libraries, thus
  improving editions responsiveness.

See sub-commits for details. This work is known to not be bug-free and
will be bug-fixed, improved and reworked in the upcoming weeks.

task-2687506

Thanks to the whole team for reviewing and testing !
(ARD, BVR, BSO, GDI, MOU, PDE, QSM, RDE, SOBO, YOL)

closes odoo/odoo#89223

Signed-off-by: Quentin Smetz (qsm) <qsm@odoo.com>
Co-authored-by: Younn Olivier <yol@odoo.com>
Co-authored-by: Arthur Detroux <ard@odoo.com>
Co-authored-by: Mehdi Outagant <mou@odoo.com>
Co-authored-by: Benjamin Vray <bvr@odoo.com>
This commit is contained in:
Odoo's Mergebot
2022-06-24 12:39:14 +02:00
committed by GitHub
co-authored by Younn Olivier Arthur Detroux Mehdi Outagant Benjamin Vray
407 changed files with 12041 additions and 13431 deletions
+4 -2
View File
@@ -61,7 +61,8 @@ class AuthSignupHome(Home):
qcontext['error'] = _("Could not create a new account.")
response = request.render('auth_signup.signup', qcontext)
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
response.headers['Content-Security-Policy'] = "frame-ancestors 'self'"
return response
@http.route('/web/reset_password', type='http', auth='public', website=True, sitemap=False)
@@ -93,7 +94,8 @@ class AuthSignupHome(Home):
qcontext['error'] = str(e)
response = request.render('auth_signup.reset_password', qcontext)
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
response.headers['Content-Security-Policy'] = "frame-ancestors 'self'"
return response
def get_auth_signup_config(self):
@@ -0,0 +1,70 @@
/** @odoo-module */
import { qweb as QWeb } from 'web.core';
import { DocumentSelector } from '@web_editor/components/media_dialog/document_selector';
import { TABS, MediaDialog } from '@web_editor/components/media_dialog/media_dialog';
import { patch } from '@web/core/utils/patch';
/**
* Override the @see DocumentSelector to manage files in a @see MediaDialog used
* by the /file command. The purpose of this override is to redefine the
* rendering of the media (/file block), and to merge images in the documents
* tab of the MediaDialog, since the /file block displays a default mimetype for
* every files.
*/
export class KnowledgeDocumentSelector extends DocumentSelector {
/**
* Filter files for the documents tab of the MediaDialog. Any file with a
* mimetype is valid. (images and documents are displayed together)
*
* @override
*/
get attachmentsDomain() {
const domain = super.attachmentsDomain;
return domain.map(d => {
if (d[0] === 'mimetype') {
return ['mimetype', '!=', false];
}
return d;
});
}
static async createElements(...args) {
const files = await DocumentSelector.createElements(...args);
return files.map(file => {
const extension = (file.title && file.title.split('.').pop()) || file.dataset.mimetype;
const fileEl = $(QWeb.render('knowledge.file_block', {
img: {
name: file.title,
extension: extension,
},
}))[0];
file.classList.add(...DocumentSelector.mediaSpecificClasses);
fileEl.querySelector('.o_knowledge_file_image').replaceChildren(file);
return fileEl;
});
}
}
KnowledgeDocumentSelector.mediaSpecificClasses = [];
KnowledgeDocumentSelector.tagNames = DocumentSelector.tagNames;
patch(TABS, 'knowledge_media_dialog_tabs', {
KNOWLEDGE_DOCUMENTS: {
...TABS.DOCUMENTS,
id: 'KNOWLEDGE_DOCUMENTS',
Component: KnowledgeDocumentSelector,
}
});
patch(MediaDialog.prototype, 'knowledge_media_dialog', {
/**
* @override
*/
addTabs() {
this._super(...arguments);
if (this.props.knowledgeDocuments) {
this.addTab(TABS.KNOWLEDGE_DOCUMENTS);
}
}
});
+3 -65
View File
@@ -1,73 +1,10 @@
/** @odoo-module **/
import { qweb as QWeb } from 'web.core';
import { DocumentWidget } from 'wysiwyg.widgets.media';
import MediaDialog from 'wysiwyg.widgets.MediaDialog';
import Wysiwyg from 'web_editor.wysiwyg';
import { KnowledgeArticleLinkModal } from './wysiwyg/knowledge_article_link.js';
import { preserveCursor, setCursorStart } from '@web_editor/../lib/odoo-editor/src/OdooEditor';
/**
* Override the @see DocumentWidget to manage files in a @see MediaDialog used
* by the /file command. The purpose of this override is to redefine the
* rendering of the media (/file block), and to merge images in the documents
* tab of the MediaDialog, since the /file block displays a default mimetype for
* every files.
*/
const KnowledgeDocumentWidget = DocumentWidget.extend({
/**
* Filter files for the documents tab of the MediaDialog. Any file with a
* mimetype is valid. (images and documents are displayed together)
*
* @override
*/
init: function (parent, media, options) {
options = _.extend({
accept: '*/*',
mimetypeDomain: [['mimetype', '!=', false]],
}, options || {});
this._super(parent, media, options);
},
/**
* Custom rendering for the /file command
*
* @override
* @param {Object} img
* @returns {Element}
*/
_renderMedia: function (img) {
if (img.image_src) {
delete img.image_src;
}
const file = this._super(...arguments);
const extension = (img.name && img.name.split('.').pop()) || img.mimetype;
this.$media = $(QWeb.render('knowledge.file_block', {
img: {
name: img.name,
extension: extension,
},
}));
this.media = this.$media[0];
this.media.querySelector('.o_knowledge_file_image').replaceChildren(file);
return this.media;
}
});
/**
* Override the @see MediaDialog to manage documents with the
* @see KnowledgeDocumentWidget for the /file command
*/
const KnowledgeMediaDialog = MediaDialog.extend({
/**
* @override
* @param {Object} media
* @param {Object} options
* @returns {Widget}
*/
getDocumentWidget: function (media, options) {
return new KnowledgeDocumentWidget(this, media, options);
}
});
Wysiwyg.include({
/**
* @override
@@ -131,8 +68,9 @@ Wysiwyg.include({
noVideos: true,
noImages: true,
noIcons: true,
noDocuments: false
}, KnowledgeMediaDialog);
noDocuments: true,
knowledgeDocuments: true,
});
}
}, {
groupName: 'Knowledge',
@@ -336,6 +336,9 @@ body.editor_enable.o_basic_theme.o_in_iframe {
display: block;
}
}
#oe_snippets.o_loaded {
transform: translateX(0);
}
}
@@ -1,7 +1,8 @@
odoo.define('mass_mailing.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 +38,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},
$('<i/>')
);
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 +57,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(document.body);
},
/**
* Sets the number of active icons.
+4 -2
View File
@@ -213,7 +213,8 @@ class CustomerPortal(Controller):
})
response = request.render("portal.portal_my_details", values)
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
response.headers['Content-Security-Policy'] = "frame-ancestors 'self'"
return response
def on_account_update(self, values, partner):
@@ -233,7 +234,8 @@ class CustomerPortal(Controller):
))
return request.render('portal.portal_my_security', values, headers={
'X-Frame-Options': 'DENY'
'X-Frame-Options': 'SAMEORIGIN',
'Content-Security-Policy': "frame-ancestors 'self'"
})
def _update_password(self, old, new1, new2):
+3
View File
@@ -93,6 +93,9 @@
<span t-if="_user_name" t-attf-class="#{_user_name_class}" t-esc="user_id.name[:23] + '...' if user_id.name and len(user_id.name) &gt; 25 else user_id.name"/>
</a>
<div t-attf-class="dropdown-menu js_usermenu #{_dropdown_menu_class}" role="menu">
<a groups="base.group_user" href="/web" role="menuitem" class="dropdown-item d-flex align-items-center" id="o_backend_user_dropdown_link">
Apps <i class="fa fa-th ml-auto pl-4"/>
</a>
<div id="o_logout_divider" class="dropdown-divider"/>
<a t-attf-href="/web/session/logout?redirect=/" role="menuitem" id="o_logout" class="dropdown-item">Logout</a>
</div>
@@ -1,13 +1,10 @@
/**********************************************************
Remove website navbar : Should be done in website survey
but we won't do a bridge module only for this.
Remove website backend redirection button : Should be
done in website survey but we won't do a bridge module
only for this.
TODO: SmartPeople Fixme - cleaner solution? be my guest!
**********************************************************/
body.o_connected_user {
padding-top: 0px !important;
}
nav#oe_main_menu_navbar {
nav.o_frontend_to_backend_nav {
display: none !important;
}
@@ -122,9 +122,6 @@
<field name="key">test_website.test_image_progress</field>
<field name="arch" type="xml">
<t t-call="website.layout">
<t t-set="head">
<script defer="defer" type="text/javascript" src="/test_website/static/src/js/mock_image_widget.js"></script>
</t>
<div id="wrap" class="oe_structure oe_empty"/>
</t>
</field>
@@ -1,41 +0,0 @@
odoo.define('test_website.mock_image_widgets', function (require) {
'use strict';
const widgetsMedia = require('wysiwyg.widgets.media');
widgetsMedia.FileWidget.include({
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
_onFileInputChange: function () {
function getFileFromB64(fileData) {
const binary = atob(fileData[2]);
let len = binary.length;
const arr = new Uint8Array(len);
while (len--) {
arr[len] = binary.charCodeAt(len);
}
return new File([arr], fileData[1], {type: fileData[0]});
}
let files = [
getFileFromB64(['image/png', 'image.png', "iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAApElEQVR42u3RAQ0AAAjDMO5fNCCDkC5z0HTVrisFCBABASIgQAQEiIAAAQJEQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAQECBAgAgJEQIAIyPcGFY7HnV2aPXoAAAAASUVORK5CYII="]),
getFileFromB64(['image/jpeg', 'image.jpeg', "/9j/4AAQSkZJRgABAQAAAQABAAD//gAfQ29tcHJlc3NlZCBieSBqcGVnLXJlY29tcHJlc3P/2wCEAA0NDQ0ODQ4QEA4UFhMWFB4bGRkbHi0gIiAiIC1EKjIqKjIqRDxJOzc7STxsVUtLVWx9aWNpfZeHh5e+tb75+f8BDQ0NDQ4NDhAQDhQWExYUHhsZGRseLSAiICIgLUQqMioqMipEPEk7NztJPGxVS0tVbH1pY2l9l4eHl761vvn5///CABEIAEsASwMBIgACEQEDEQH/xAAVAAEBAAAAAAAAAAAAAAAAAAAABv/aAAgBAQAAAACHAAAAAAAAAAAAAAAAH//EABUBAQEAAAAAAAAAAAAAAAAAAAAH/9oACAECEAAAAKYAAAB//8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/2gAIAQMQAAAAngAAAf/EABQQAQAAAAAAAAAAAAAAAAAAAGD/2gAIAQEAAT8ASf/EABQRAQAAAAAAAAAAAAAAAAAAAED/2gAIAQIBAT8AT//EABQRAQAAAAAAAAAAAAAAAAAAAED/2gAIAQMBAT8AT//Z"]),
getFileFromB64(['image/vnd.microsoft.icon', 'icon.ico', "AAABAAEAAQEAAAEAIAAwAAAAFgAAACgAAAABAAAAAgAAAAEAIAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAA=="]),
getFileFromB64(['image/webp', 'image.webp', "UklGRhwAAABXRUJQVlA4TBAAAAAvE8AEAAfQhuh//wMR0f8A"]),
];
if (!this.options.multiImages) {
if (this.media.classList.contains('o_mock_show_error')) {
files = [files[2]];
} else {
files = [files[0]];
}
}
this.$fileInput = [{'files': files}];
return this._super(...arguments);
}
});
});
@@ -1,7 +1,7 @@
odoo.define('test_website.custom_snippets', function (require) {
'use strict';
var tour = require('web_tour.tour');
const wTourUtils = require('website.tour_utils');
/**
* The purpose of this tour is to check the custom snippets flow:
@@ -22,24 +22,21 @@ var tour = require('web_tour.tour');
* -> ensure it was deleted
*/
tour.register('test_custom_snippet', {
wTourUtils.registerEditionTour('test_custom_snippet', {
url: '/',
test: true
edition: true,
test: true,
}, [
{
content: "enter edit mode",
trigger: "a[data-action=edit]"
},
{
content: "drop a snippet",
trigger: "#oe_snippets .oe_snippet[name='Banner'] .oe_snippet_thumbnail:not(.o_we_already_dragging)",
extra_trigger: "body.editor_enable.editor_has_snippets",
trigger: "#oe_snippets.o_loaded .oe_snippet[name='Banner'] .oe_snippet_thumbnail:not(.o_we_already_dragging)",
extra_trigger: "iframe body.editor_enable",
moveTrigger: ".oe_drop_zone",
run: "drag_and_drop #wrap",
run: "drag_and_drop iframe #wrap",
},
{
content: "customize snippet",
trigger: "#wrapwrap .s_banner h1",
trigger: "iframe #wrapwrap .s_banner h1",
run: "text",
consumeEvent: "input",
},
@@ -53,7 +50,7 @@ tour.register('test_custom_snippet', {
},
{
content: "ensure custom snippet appeared",
trigger: "#oe_snippets .oe_snippet[name='Custom Banner']",
trigger: "#oe_snippets.o_loaded .oe_snippet[name='Custom Banner']",
run: function () {
$("#oe_snippets .oe_snippet[name='Custom Banner'] .o_rename_btn").attr("style", "display: block;");
// hover is needed for rename button to appear
@@ -76,18 +73,18 @@ tour.register('test_custom_snippet', {
{
content: "drop custom snippet",
trigger: ".oe_snippet[name='Bruce Banner'] .oe_snippet_thumbnail:not(.o_we_already_dragging)",
extra_trigger: "body.editor_enable.editor_has_snippets",
extra_trigger: "iframe body.editor_enable",
moveTrigger: ".oe_drop_zone",
run: "drag_and_drop #wrap",
run: "drag_and_drop iframe #wrap",
},
{
content: "ensure banner section exists",
trigger: "#wrap section[data-name='Banner']",
trigger: "iframe #wrap section[data-name='Banner']",
run: function () {}, // check
},
{
content: "ensure custom banner section exists",
trigger: "#wrap section[data-name='Bruce Banner']",
trigger: "iframe #wrap section[data-name='Bruce Banner']",
run: function () {
$("#oe_snippets .oe_snippet[name='Bruce Banner'] .o_delete_btn").attr("style", "display: block;");
// hover is needed for delete button to appear
@@ -1,36 +1,75 @@
odoo.define('test_website.image_upload_progress', function (require) {
'use strict';
const tour = require('web_tour.tour');
const wTourUtils = require('website.tour_utils');
const { FileSelectorControlPanel } = require('@web_editor/components/media_dialog/file_selector');
const { patch, unpatch } = require('web.utils');
let patchWithError = false;
const patchMediaDialog = () => patch(FileSelectorControlPanel.prototype, 'test_website.mock_image_widgets', {
async onChangeFileInput() {
const getFileFromB64 = (fileData) => {
const binary = atob(fileData[2]);
let len = binary.length;
const arr = new Uint8Array(len);
while (len--) {
arr[len] = binary.charCodeAt(len);
}
return new File([arr], fileData[1], {type: fileData[0]});
};
let files = [
getFileFromB64(['image/vnd.microsoft.icon', 'icon.ico', "AAABAAEAAQEAAAEAIAAwAAAAFgAAACgAAAABAAAAAgAAAAEAIAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAA=="]),
getFileFromB64(['image/webp', 'image.webp', "UklGRhwAAABXRUJQVlA4TBAAAAAvE8AEAAfQhuh//wMR0f8A"]),
getFileFromB64(['image/png', 'image.png', "iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAApElEQVR42u3RAQ0AAAjDMO5fNCCDkC5z0HTVrisFCBABASIgQAQEiIAAAQJEQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAQECBAgAgJEQIAIyPcGFY7HnV2aPXoAAAAASUVORK5CYII="]),
getFileFromB64(['image/jpeg', 'image.jpeg', "/9j/4AAQSkZJRgABAQAAAQABAAD//gAfQ29tcHJlc3NlZCBieSBqcGVnLXJlY29tcHJlc3P/2wCEAA0NDQ0ODQ4QEA4UFhMWFB4bGRkbHi0gIiAiIC1EKjIqKjIqRDxJOzc7STxsVUtLVWx9aWNpfZeHh5e+tb75+f8BDQ0NDQ4NDhAQDhQWExYUHhsZGRseLSAiICIgLUQqMioqMipEPEk7NztJPGxVS0tVbH1pY2l9l4eHl761vvn5///CABEIAEsASwMBIgACEQEDEQH/xAAVAAEBAAAAAAAAAAAAAAAAAAAABv/aAAgBAQAAAACHAAAAAAAAAAAAAAAAH//EABUBAQEAAAAAAAAAAAAAAAAAAAAH/9oACAECEAAAAKYAAAB//8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/2gAIAQMQAAAAngAAAf/EABQQAQAAAAAAAAAAAAAAAAAAAGD/2gAIAQEAAT8ASf/EABQRAQAAAAAAAAAAAAAAAAAAAED/2gAIAQIBAT8AT//EABQRAQAAAAAAAAAAAAAAAAAAAED/2gAIAQMBAT8AT//Z"]),
];
if (!this.props.multiSelect) {
if (patchWithError) {
files = [files[0]];
} else {
files = [files[2]];
}
}
await this.props.uploadFiles(files);
}
});
const unpatchMediaDialog = () => unpatch(FileSelectorControlPanel.prototype, 'test_website.mock_image_widgets');
const setupSteps = [{
content: "enter edit mode",
trigger: "a[data-action=edit]"
content: "reload to load patch",
trigger: ".o_website_preview",
run: () => {
patchMediaDialog();
},
}, {
content: "drop a snippet",
trigger: "#oe_snippets .oe_snippet[name='Text - Image'] .oe_snippet_thumbnail:not(.o_we_already_dragging)",
extra_trigger: "body.editor_enable.editor_has_snippets",
moveTrigger: ".oe_drop_zone",
run: "drag_and_drop #wrap",
moveTrigger: "iframe .oe_drop_zone",
run: "drag_and_drop iframe #wrap",
}, {
content: "drop a snippet",
trigger: "#oe_snippets .oe_snippet[name='Image Gallery'] .oe_snippet_thumbnail:not(.o_we_already_dragging)",
extra_trigger: "body.editor_enable.editor_has_snippets",
extra_trigger: "body.editor_has_snippets",
moveTrigger: ".oe_drop_zone",
run: "drag_and_drop #wrap",
run: "drag_and_drop iframe #wrap",
}];
const formatErrorMsg = "format is not supported. Try with: .gif, .jpe, .jpeg, .jpg, .png, .svg";
tour.register('test_image_upload_progress', {
wTourUtils.registerEditionTour('test_image_upload_progress', {
url: '/test_image_progress',
test: true
test: true,
edition: true,
}, [
...setupSteps,
// 1. Check multi image upload
{
content: "click on dropped snippet",
trigger: "#wrap .s_image_gallery .img",
trigger: "iframe #wrap .s_image_gallery .img",
}, {
content: "click on add images to open image dialog (in multi mode)",
trigger: 'we-customizeblock-option [data-add-images]',
@@ -38,11 +77,9 @@ tour.register('test_image_upload_progress', {
content: "manually trigger input change",
trigger: ".o_select_media_dialog .o_upload_media_button",
run: () => {
const fileInput = $('.o_select_media_dialog .o_file_input').first();
// This will trigger upload of dummy files for test purpose, as a
// test can't select local files to upload into the input.
// See `mock_image_widgets`.
fileInput.change();
document.body.querySelector('.o_select_media_dialog .o_file_input').dispatchEvent(new Event('change'));
},
}, {
content: "check upload progress bar is correctly shown (1)",
@@ -85,20 +122,17 @@ tour.register('test_image_upload_progress', {
// 2. Check success single image upload
{
content: "click on dropped snippet",
trigger: "#wrap .s_text_image .img",
trigger: "iframe #wrap .s_text_image .img",
}, {
content: "click on replace media to open image dialog",
trigger: 'we-customizeblock-option [data-replace-media]',
}, {
content: "manually trigger input change",
trigger: ".o_select_media_dialog .o_upload_media_button",
in_modal: false,
run: () => {
const fileInput = $('.o_select_media_dialog .o_file_input').first();
// This will trigger upload of dummy files for test purpose, as a
// test can't select local files to upload into the input.
// See `mock_image_widgets`.
fileInput.change();
document.body.querySelector('.o_select_media_dialog .o_file_input').dispatchEvent(new Event('change'));
},
}, {
content: "check upload progress bar is correctly shown",
@@ -126,7 +160,7 @@ tour.register('test_image_upload_progress', {
// 3. Check error single image upload
{
content: "click on dropped snippet",
trigger: "#wrap .s_text_image .img",
trigger: "iframe #wrap .s_text_image .img",
}, {
content: "click on replace media to open image dialog",
trigger: 'we-customizeblock-option [data-replace-media]',
@@ -135,18 +169,19 @@ tour.register('test_image_upload_progress', {
trigger: ".o_select_media_dialog .o_upload_media_button",
in_modal: false,
run: () => {
$("#wrap .s_text_image .img").addClass('o_mock_show_error');
const fileInput = $('.o_select_media_dialog .o_file_input').first();
patchWithError = true;
// This will trigger upload of dummy files for test purpose, as a
// test can't select local files to upload into the input.
// See `mock_image_widgets`.
fileInput.change();
document.body.querySelector('.o_select_media_dialog .o_file_input').dispatchEvent(new Event('change'));
},
}, {
content: "check upload progress bar is correctly shown",
trigger: `.o_we_progressbar:contains('icon.ico'):contains('${formatErrorMsg}')`,
in_modal: false,
run: function () {}, // it's a check
run: function () {
patchWithError = false;
},
}, {
content: "there should only have one notification toaster",
trigger: ".o_notification",
@@ -156,20 +191,22 @@ tour.register('test_image_upload_progress', {
if (notificationCount !== 1) {
console.error("There should be one noficiation toaster opened, and only one.");
}
unpatchMediaDialog();
}
},
]);
tour.register('test_image_upload_progress_unsplash', {
wTourUtils.registerEditionTour('test_image_upload_progress_unsplash', {
url: '/test_image_progress',
test: true
test: true,
edition: true,
}, [
...setupSteps,
// 1. Check multi image upload
{
content: "click on dropped snippet",
trigger: "#wrap .s_image_gallery .img",
trigger: "iframe #wrap .s_image_gallery .img",
}, {
content: "click on replace media to open image dialog",
trigger: 'we-customizeblock-option [data-replace-media]',
@@ -179,7 +216,7 @@ tour.register('test_image_upload_progress_unsplash', {
run: "text fox",
}, {
content: "click on unsplash result", // note that unsplash is mocked
trigger: ".o_unsplash_attachment_cell"
trigger: "img[alt~=fox]"
}, {
content: "check that the upload progress bar is correctly shown",
// ensure it is there so we are sure next step actually test something
@@ -193,7 +230,10 @@ tour.register('test_image_upload_progress_unsplash', {
in_modal: false,
}, {
content: "unsplash image (mocked to logo) should have been used",
trigger: "#wrap .s_image_gallery .img[data-original-src^='/unsplash/HQqIOc8oYro/fox']",
trigger: "iframe #wrap .s_image_gallery .img[data-original-src^='/unsplash/HQqIOc8oYro/fox']",
run: () => {
unpatchMediaDialog();
},
},
]);
@@ -2,7 +2,7 @@
odoo.define('test_website.reset_views', function (require) {
'use strict';
var tour = require("web_tour.tour");
const wTourUtils = require('website.tour_utils');
var BROKEN_STEP = {
// because saving a broken template opens a recovery page with no assets
@@ -13,36 +13,33 @@ var BROKEN_STEP = {
trigger: 'body',
run: function () {}
};
tour.register('test_reset_page_view_complete_flow_part1', {
wTourUtils.registerEditionTour('test_reset_page_view_complete_flow_part1', {
test: true,
url: '/test_page_view',
// 1. Edit the page through Edit Mode, it will COW the view
edition: true,
},
[
// 1. Edit the page through Edit Mode, it will COW the view
{
content: "enter edit mode",
trigger: "a[data-action=edit]"
},
{
content: "drop a snippet",
trigger: "#oe_snippets .oe_snippet:has(.s_cover) .oe_snippet_thumbnail",
trigger: "#oe_snippets.o_loaded .oe_snippet:has(.s_cover) .oe_snippet_thumbnail",
// id starting by 'oe_structure..' will actually create an inherited view
run: "drag_and_drop #oe_structure_test_website_page",
run: "drag_and_drop iframe #oe_structure_test_website_page",
},
{
content: "save the page",
extra_trigger: '#oe_structure_test_website_page.o_dirty',
extra_trigger: 'iframe #oe_structure_test_website_page.o_dirty',
trigger: "button[data-action=save]",
},
// 2. Edit that COW'd view in the HTML editor to break it.
{
content: "open customize menu",
extra_trigger: "body:not(.editor_enable)",
trigger: '#customize-menu > a',
content: "open site menu",
extra_trigger: "iframe body:not(.editor_enable)",
trigger: 'button[data-menu-xmlid="website.menu_site"]',
},
{
content: "open html editor",
trigger: '#html_editor',
trigger: 'a[data-menu-xmlid="website.menu_ace_editor"]',
},
{
content: "add a broken t-field in page DOM",
@@ -60,29 +57,29 @@ tour.register('test_reset_page_view_complete_flow_part1', {
]
);
tour.register('test_reset_page_view_complete_flow_part2', {
wTourUtils.registerEditionTour('test_reset_page_view_complete_flow_part2', {
test: true,
url: '/test_page_view',
},
[
{
content: "check that the view got fixed",
trigger: 'p:containsExact("Test Page View")',
trigger: 'iframe p:containsExact("Test Page View")',
run: function () {}, // it's a check
},
{
content: "check that the inherited COW view is still there (created during edit mode)",
trigger: '#oe_structure_test_website_page .s_cover',
trigger: 'iframe #oe_structure_test_website_page .s_cover',
run: function () {}, // it's a check
},
//4. Now break the inherited view created when dropping a snippet
{
content: "open customize menu",
trigger: '#customize-menu > a',
content: "open site menu",
trigger: 'button[data-menu-xmlid="website.menu_site"]',
},
{
content: "open html editor",
trigger: '#html_editor',
trigger: 'a[data-menu-xmlid="website.menu_ace_editor"]',
},
{
content: "select oe_structure view",
@@ -10,4 +10,4 @@ class TestCustomSnippet(odoo.tests.HttpCase):
@mute_logger('odoo.addons.http_routing.models.ir_http', 'odoo.http')
def test_01_run_tour(self):
self.start_tour("/", 'test_custom_snippet', login="admin")
self.start_tour(self.env['website'].get_client_action_url('/'), 'test_custom_snippet', login="admin")
@@ -13,7 +13,7 @@ from odoo import http
class TestImageUploadProgress(odoo.tests.HttpCase):
def test_01_image_upload_progress(self):
self.start_tour("/test_image_progress", 'test_image_upload_progress', login="admin")
self.start_tour(self.env['website'].get_client_action_url('/test_image_progress'), 'test_image_upload_progress', login="admin")
def test_02_image_upload_progress_unsplash(self):
BASE_URL = self.base_url()
@@ -53,4 +53,4 @@ class TestImageUploadProgress(odoo.tests.HttpCase):
media_library_search.routing_type = 'json'
Web_Editor.media_library_search = http.route(['/web_editor/media_library_search'], type='json', auth="user", website=True)(media_library_search)
self.start_tour("/", 'test_image_upload_progress_unsplash', login="admin")
self.start_tour(self.env['website'].get_client_action_url('/test_image_progress'), 'test_image_upload_progress_unsplash', login="admin")
@@ -90,9 +90,9 @@ class TestWebsiteResetViews(odoo.tests.HttpCase):
@mute_logger('odoo.http')
def test_07_reset_page_view_complete_flow(self):
self.start_tour("/", 'test_reset_page_view_complete_flow_part1', login="admin")
self.start_tour(self.env['website'].get_client_action_url('/test_page_view'), 'test_reset_page_view_complete_flow_part1', login="admin")
self.fix_it('/test_page_view')
self.start_tour("/", 'test_reset_page_view_complete_flow_part2', login="admin")
self.start_tour(self.env['website'].get_client_action_url('/test_page_view'), 'test_reset_page_view_complete_flow_part2', login="admin")
self.fix_it('/test_page_view')
@mute_logger('odoo.http')
@@ -2,6 +2,7 @@ odoo.define('test_website_modules.tour.configurator_flow', function (require) {
'use strict';
const tour = require('web_tour.tour');
const wTourUtils = require('website.tour_utils');
tour.register('configurator_flow', {
test: true,
@@ -74,27 +75,27 @@ tour.register('configurator_flow', {
run: function () {}, // it's a check
}, {
content: "Wait untill the configurator is finished",
trigger: 'body.editor_started',
trigger: '#oe_snippets.o_loaded',
timeout: 30000,
}, {
content: "exit edit mode",
trigger: '.o_we_website_top_actions button.btn-primary:contains("Save")',
}, {
},
...wTourUtils.clickOnSave(),
{
content: "check menu and footer links are correct",
trigger: '#oe_main_menu_navbar', // edit mode left and landed on regular frontend
trigger: 'body:not(.editor_enable)', // edit mode left
run: function () {
const $iframe = this.$anchor.find('iframe.o_iframe:not(.o_ignore_in_tour)');
for (const menu of ['Home', 'Events', 'Courses', 'Pricing', 'News', 'Success Stories', 'Contact us']) {
if (!$(`#top_menu a:contains(${menu})`).length) {
if (!$iframe.contents().find(`#top_menu a:contains(${menu})`).length) {
console.error(`Missing ${menu} menu. It should have been created by the configurator.`);
}
}
for (const url of ['/', '/event', '/slides', '/pricing', '/blog/', '/blog/', '/contactus']) {
if (!$(`#top_menu a[href^='${url}'`).length) {
if (!$iframe.contents().find(`#top_menu a[href^='${url}'`).length) {
console.error(`Missing ${url} menu URL. It should have been created by the configurator.`);
}
}
for (const link of ['Privacy Policy', 'Contact us']) {
if (!$(`#footer ul a:contains(${link})`).length) {
if (!$iframe.contents().find(`#footer ul a:contains(${link})`).length) {
console.error(`Missing ${link} footer link. It should have been created by the configurator.`);
}
}
@@ -41,7 +41,7 @@ var initTourSteps = [{
var buyCertificationSteps = [{
content: 'eLearning: try to buy course',
trigger: 'a:contains("Add to Cart")'
},
},
tourUtils.goToCart(),
{
content: 'eCommerce: Process Checkout',
+2 -1
View File
@@ -119,7 +119,8 @@ class Home(http.Controller):
values['disable_database_manager'] = True
response = request.render('web.login', values)
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
response.headers['Content-Security-Policy'] = "frame-ancestors 'self'"
return response
@http.route('/web/login_successful', type='http', auth='user', website=True, sitemap=False)
@@ -130,6 +130,7 @@ function redirect (url, wait) {
function Reload(parent, action) {
var params = action.params || {};
var menu_id = params.menu_id || false;
var action_id = params.action_id || false;
var l = window.location;
var sobj = $.deparam(l.search.substr(1));
@@ -141,6 +142,11 @@ function Reload(parent, action) {
var hash = l.hash;
if (menu_id) {
hash = "#menu_id=" + menu_id;
if (action_id) {
hash += "&action=" + action_id;
}
} else if (action_id) {
hash = "#action=" + action_id;
}
var url = l.protocol + "//" + l.host + l.pathname + search + hash;
@@ -254,6 +254,14 @@ const SmoothScrollOnDrag = Class.extend(mixins.ParentedMixin, {
- this.options.offsetElementsManager.left(),
};
if (this.iframeOffset) {
const { x, y } = this.iframeOffset;
visibleOffset.left -= x;
visibleOffset.top -= y;
visibleOffset.bottom += y;
visibleOffset.right += x;
}
// If this.$scrollTarget is the html tag, we need to take the scroll position in to account
// as offsets positions are calculated relative to the document (thus <html>).
if (this.scrollTargetIsDocument) {
@@ -361,6 +369,8 @@ const SmoothScrollOnDrag = Class.extend(mixins.ParentedMixin, {
*/
_onSmoothDragStart(ev, ui, onDragStartCallBack) {
this.scrollTargetIsDocument = this.$scrollTarget.is('html');
const iframeEl = this.$scrollTarget[0].ownerDocument.defaultView.frameElement;
this.iframeOffset = iframeEl && iframeEl.getBoundingClientRect();
this.scrollTargetIsParent = this.$scrollTarget.get(0).contains(this.$element.get(0));
this._updatePositionOptions(ui);
this._startSmoothScroll(ui);
@@ -21,7 +21,7 @@ import {
} from "../../utils";
import { standaloneAdapter } from "web.OwlCompatibility";
import { fetchAndProcessTemplates } from "@web/core/assets";
import { fetchAndProcessTemplates, loadBundle } from "@web/core/assets";
import { makeEnv, startServices } from "@web/env";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { browser } from '@web/core/browser/browser';
@@ -185,7 +185,6 @@ export const PublicRoot = publicWidget.RootWidget.extend({
$from = this.$el;
}
}
options = Object.assign({}, options, {
wysiwyg: $('#wrapwrap').data('wysiwyg'),
});
@@ -411,14 +410,16 @@ export async function createPublicRoot(RootWidget) {
mapLegacyEnvToWowlEnv(legacyEnv, wowlEnv);
// The root widget's parent is a standalone adapter so that it has _trigger_up
const publicRoot = new RootWidget(standaloneAdapter({ Component }));
const app = new App(MainComponentsContainer, {
env: wowlEnv,
dev: wowlEnv.debug,
templates: window.__OWL_TEMPLATES__,
translateFn: _t,
translatableAttributes: ["data-tooltip"],
});
loadBundle.app = app;
await Promise.all([
new App(MainComponentsContainer, {
env: wowlEnv,
dev: wowlEnv.debug,
templates: window.__OWL_TEMPLATES__,
translateFn: _t,
translatableAttributes: ["data-tooltip"],
}).mount(document.body),
app.mount(document.body),
publicRoot.attachTo(document.body),
]);
return publicRoot;
@@ -41,6 +41,7 @@ var ColorpickerWidget = Widget.extend({
// Needs to be bound on document to work in all possible cases.
const $document = $(
parent.options.ownerDocument ||
(parent.el && parent.el.parentElement && parent.el.ownerDocument)
|| (parent.options && parent.options.$editable && parent.options.$editable[0] && parent.options.$editable[0].ownerDocument)
|| document);
@@ -84,6 +84,10 @@ export class NavBar extends Component {
);
}
// This dummy setter is only here to prevent conflicts between the
// Enterprise NavBar extension and the Website NavBar patch.
set currentAppSections(_) {}
get systrayItems() {
return systrayRegistry
.getEntries()
@@ -92,6 +96,10 @@ export class NavBar extends Component {
.reverse();
}
// This dummy setter is only here to prevent conflicts between the
// Enterprise NavBar extension and the Website NavBar patch.
set systrayItems(_) {}
/**
* Adapt will check the available width for the app sections to get displayed.
* If not enough space is available, it will replace by a "more" menu
@@ -30,6 +30,8 @@
<!-- Systray -->
<div class="o_menu_systray d-flex flex-shrink-0 ml-auto" role="menu">
<t t-foreach="systrayItems" t-as="item" t-key="item.key">
<!-- This ensures the correct order of the systray items -->
<div t-att-data-index="item.index"/>
<ErrorHandler onError="error => this.handleItemError(error, item)">
<t t-component="item.Component" t-props="item.props"/>
</ErrorHandler>
@@ -10,7 +10,7 @@ import { uiService } from "@web/core/ui/ui_service";
import { useSetupView } from "@web/views/helpers/view_hook";
import { ActionDialog } from "@web/webclient/actions/action_dialog";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { makeTestEnv, prepareRegistriesWithCleanup } from "../../helpers/mock_env";
import { makeTestEnv, utils } from "../../helpers/mock_env";
import {
fakeCommandService,
makeFakeDialogService,
@@ -31,6 +31,7 @@ import { editSearchView, editView } from "@web/views/debug_items";
import { setDefaults } from "@web/legacy/debug_manager";
const { Component, xml } = owl;
const { prepareRegistriesWithCleanup } = utils;
export class DebugMenuParent extends Component {
setup() {
+6 -1
View File
@@ -44,7 +44,7 @@ export function clearServicesMetadataWithCleanup() {
registerCleanup(() => patch(SERVICES_METADATA, servicesMetadata));
}
export function prepareRegistriesWithCleanup() {
function prepareRegistriesWithCleanup() {
// Clone registries
cloneRegistryWithCleanup(registry.category("actions"));
cloneRegistryWithCleanup(registry.category("views"));
@@ -72,6 +72,11 @@ export function prepareRegistriesWithCleanup() {
// better design for the way we clear these registries...
}
// This is exported in a utils object to allow for patching
export const utils = {
prepareRegistriesWithCleanup,
};
/**
* @typedef {import("@web/env").OdooEnv} OdooEnv
*/
+3 -1
View File
@@ -6,7 +6,7 @@ import { browser, makeRAMLocalStorage } from "@web/core/browser/browser";
import { patchTimeZone, patchWithCleanup } from "@web/../tests/helpers/utils";
import { legacyProm } from "web.test_legacy";
import { registerCleanup } from "./helpers/cleanup";
import { prepareRegistriesWithCleanup } from "./helpers/mock_env";
import { utils } from "./helpers/mock_env";
import { session as sessionInfo } from "@web/session";
import { prepareLegacyRegistriesWithCleanup } from "./helpers/legacy_env_utils";
import { config as transitionConfig } from "@web/core/transition";
@@ -17,6 +17,8 @@ import { patch } from "@web/core/utils/patch";
import { processTemplates } from "@web/core/assets";
const { App, whenReady, loadFile } = owl;
const { prepareRegistriesWithCleanup } = utils;
patch(App.prototype, "TestOwlApp", {
destroy() {
if (!this.destroyed) {
+14 -2
View File
@@ -24,9 +24,9 @@ Odoo Web Editor widget.
'web.assets_qweb': [
'web_editor/static/src/xml/*.xml',
'web_editor/static/src/components/**/*.xml',
],
'web_editor.assets_wysiwyg': [
# lib
'web_editor/static/lib/cropperjs/cropper.css',
'web_editor/static/lib/cropperjs/cropper.js',
@@ -70,7 +70,6 @@ Odoo Web Editor widget.
'web_editor/static/src/scss/wysiwyg_iframe.scss',
'web_editor/static/src/scss/wysiwyg_snippets.scss',
'web_editor/static/src/js/wysiwyg/fonts.js',
'web_editor/static/src/js/base.js',
'web_editor/static/src/js/editor/perspective_utils.js',
'web_editor/static/src/js/editor/image_processing.js',
@@ -86,13 +85,24 @@ Odoo Web Editor widget.
'web_editor/static/src/js/wysiwyg/wysiwyg.js',
'web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js',
],
'web_editor.assets_media_dialog': [
'web_editor/static/src/components/media_dialog/*.js',
'web_editor/static/src/components/media_dialog/*.scss',
'web_editor/static/src/components/media_dialog/*.xml',
'web_editor/static/src/components/upload_progress_toast/*.js',
'web_editor/static/src/components/upload_progress_toast/*.scss',
'web_editor/static/src/components/upload_progress_toast/*.xml',
],
'web.assets_common': [
'web_editor/static/lib/odoo-editor/src/base_style.css',
'web_editor/static/lib/vkbeautify/**/*',
'web_editor/static/src/js/common/**/*',
'web_editor/static/lib/odoo-editor/src/utils/utils.js',
'web_editor/static/src/js/wysiwyg/fonts.js',
],
'web.assets_backend': [
('include', 'web_editor.assets_media_dialog'),
'web_editor/static/src/scss/web_editor.common.scss',
'web_editor/static/src/scss/web_editor.backend.scss',
@@ -104,6 +114,8 @@ Odoo Web Editor widget.
'web_editor/static/src/js/frontend/loader_loading.js',
],
'web.assets_frontend': [
('include', 'web_editor.assets_media_dialog'),
'web_editor/static/src/scss/web_editor.common.scss',
'web_editor/static/src/scss/web_editor.frontend.scss',
@@ -0,0 +1,74 @@
/** @odoo-module */
import { Attachment, FileSelector, IMAGE_MIMETYPES } from './file_selector';
export class DocumentAttachment extends Attachment {}
DocumentAttachment.template = 'web_editor.DocumentAttachment';
export class DocumentSelector extends FileSelector {
setup() {
super.setup();
this.uploadText = this.env._t("Upload a document");
this.urlPlaceholder = "https://www.odoo.com/mydocument";
this.addText = this.env._t("Add document");
this.searchPlaceholder = this.env._t("Search a document");
this.allLoadedText = this.env._t("All documents have been loaded");
}
get attachmentsDomain() {
const domain = super.attachmentsDomain;
domain.push(['mimetype', 'not in', IMAGE_MIMETYPES]);
// The assets should not be part of the documents.
// All assets begin with '/web/assets/', see _get_asset_template_url().
domain.unshift('&', '|', ['url', '=', null], '!', ['url', '=like', '/web/assets/%']);
return domain;
}
async onClickDocument(document) {
this.selectAttachment(document);
await this.props.save();
}
async fetchAttachments(...args) {
const attachments = await super.fetchAttachments(...args);
if (this.selectInitialMedia()) {
for (const attachment of attachments) {
if (`/web/content/${attachment.id}` === this.props.media.getAttribute('href').replace(/[?].*/, '')) {
this.selectAttachment(attachment);
}
}
}
return attachments;
}
/**
* Utility method used by the MediaDialog component.
*/
static async createElements(selectedMedia, { orm }) {
return Promise.all(selectedMedia.map(async attachment => {
const linkEl = document.createElement('a');
let href = `/web/content/${attachment.id}?unique=${attachment.checksum}&dowload=true`;
if (!attachment.public) {
const [accessToken] = await orm.call(
'ir.attachment',
'generate_access_token',
[attachment.id],
);
href += `&access_token=${accessToken}`;
}
linkEl.href = href;
linkEl.title = attachment.name;
linkEl.dataset.mimetype = attachment.mimetype;
return linkEl;
}));
}
}
DocumentSelector.mediaSpecificClasses = ['o_image'];
DocumentSelector.tagNames = ['A'];
DocumentSelector.attachmentsListTemplate = 'web_editor.DocumentsListTemplate';
DocumentSelector.components = {
...FileSelector.components,
DocumentAttachment,
};
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_editor.DocumentAttachment" owl="1">
<div class="col-2 o_existing_attachment_cell o_we_attachment_highlight my-2" t-att-class="{ o_we_attachment_selected: props.selected }" t-on-click="props.selectAttachment">
<RemoveButton remove="() => this.remove()"/>
<div t-att-data-url="props.url" role="img" t-att-aria-label="props.name" t-att-title="props.name" t-att-data-mimetype="props.mimetype" class="o_image d-flex align-items-center justify-content-center"/>
<small class="o_file_name" t-esc="props.name"/>
</div>
</t>
<t t-name="web_editor.DocumentsListTemplate" owl="1">
<div class="o_we_existing_attachments o_we_documents">
<div t-if="!hasContent" class="o_nocontent_help">
<p class="o_empty_folder_image">No documents found.</p>
<p class="o_empty_folder_subtitle">You can upload documents with the button located in the top left of the screen.</p>
</div>
<div t-else="" class="row mx-auto">
<t t-foreach="state.attachments" t-as="attachment" t-key="attachment.id">
<DocumentAttachment url="attachment.url"
name="attachment.name"
mimetype="attachment.mimetype"
id="attachment.id"
onRemoved="(attachmentId) => this.onRemoved(attachmentId)"
selected="this.selectedAttachmentIds.includes(attachment.id)"
selectAttachment="() => this.onClickDocument(attachment)"/>
</t>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,250 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { ConfirmationDialog } from '@web/core/confirmation_dialog/confirmation_dialog';
import { Dialog } from '@web/core/dialog/dialog';
import { SearchMedia } from './search_media';
const { Component, xml, useState, useRef, onWillStart } = owl;
export const IMAGE_MIMETYPES = ['image/jpg', 'image/jpeg', 'image/jpe', 'image/png', 'image/svg+xml', 'image/gif'];
export const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.jpe', '.png', '.svg', '.gif'];
class RemoveButton extends Component {
setup() {
this.removeTitle = this.env._t("This file is attached to the current record.");
if (this.props.model === 'ir.ui.view') {
this.removeTitle = this.env._t("This file is a public view attachment.");
}
}
remove(ev) {
ev.stopPropagation();
this.props.remove();
}
}
RemoveButton.template = xml`<i class="fa fa-trash o_existing_attachment_remove p-2" t-att-title="removeTitle" role="img" t-att-aria-label="removeTitle" t-on-click="this.remove"/>`;
export class AttachmentError extends Component {
setup() {
this.title = this.env._t("Alert");
}
}
AttachmentError.components = { Dialog };
AttachmentError.template = xml `
<Dialog title="title">
<div class="form-text">
<p>The image could not be deleted because it is used in the
following pages or views:</p>
<ul t-foreach="props.views" t-as="view" t-key="view.id">
<li>
<a t-att-href="'/web#model=ir.ui.view&amp;id=' + view.id">
<t t-esc="view.name"/>
</a>
</li>
</ul>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="() => this.props.close()">
Ok
</button>
</t>
</Dialog>`;
export class Attachment extends Component {
setup() {
this.dialogs = useService('dialog');
this.rpc = useService('rpc');
}
remove() {
this.dialogs.add(ConfirmationDialog, {
body: this.env._t("Are you sure you want to delete this file ?"),
confirm: async () => {
const prevented = await this.rpc('/web_editor/attachment/remove', {
ids: [this.props.id],
});
if (!Object.keys(prevented).length) {
this.props.onRemoved(this.props.id);
} else {
this.dialogs.add(AttachmentError, {
views: prevented[this.props.id],
});
}
},
});
}
}
Attachment.components = {
RemoveButton,
};
export class FileSelectorControlPanel extends Component {
setup() {
this.state = useState({
showUrlInput: false,
urlInput: '',
isValidUrl: false,
isValidFileFormat: false
});
this.fileInput = useRef('file-input');
}
get showSearchServiceSelect() {
return this.props.searchService && this.props.needle;
}
get enableUrlUploadClick() {
return !this.state.showUrlInput || (this.state.urlInput && this.state.isValidUrl && this.state.isValidFileFormat);
}
async onUrlUploadClick() {
if (!this.state.showUrlInput) {
this.state.showUrlInput = true;
} else {
await this.props.uploadUrl(this.state.urlInput);
this.state.urlInput = '';
}
}
onUrlInput(ev) {
const { isValidUrl, isValidFileFormat } = this.props.validateUrl(ev.target.value);
this.state.isValidFileFormat = isValidFileFormat;
this.state.isValidUrl = isValidUrl;
}
onClickUpload() {
this.fileInput.el.click();
}
async onChangeFileInput() {
const inputFiles = this.fileInput.el.files;
if (!inputFiles.length) {
return;
}
await this.props.uploadFiles(inputFiles);
}
}
FileSelectorControlPanel.template = 'web_editor.FileSelectorControlPanel';
FileSelectorControlPanel.components = {
SearchMedia,
};
export class FileSelector extends Component {
setup() {
this.orm = useService('orm');
this.uploadService = useService('upload');
this.state = useState({
attachments: [],
canLoadMoreAttachments: true,
isFetchingAttachments: false,
needle: '',
});
this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY = 10;
onWillStart(async () => {
this.state.attachments = await this.fetchAttachments(this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY, 0);
});
}
get canLoadMore() {
return this.state.canLoadMoreAttachments;
}
get hasContent() {
return this.state.attachments.length;
}
get isFetching() {
return this.state.isFetchingAttachments;
}
get selectedAttachmentIds() {
return this.props.selectedMedia[this.props.id].filter(media => media.mediaType === 'attachment').map(({ id }) => id);
}
get attachmentsDomain() {
const domain = [
'&',
['res_model', '=', this.props.resModel],
['res_id', '=', this.props.resId || 0]
];
domain.unshift('|', ['public', '=', true]);
domain.push(['name', 'ilike', this.state.needle]);
return domain;
}
validateUrl(url) {
const path = url.split('?')[0];
const isValidUrl = /^.+\..+$/.test(path); // TODO improve
const isValidFileFormat = true;
return { isValidUrl, isValidFileFormat, path };
}
async fetchAttachments(limit, offset) {
this.state.isFetchingAttachments = true;
const attachments = await this.orm.call(
'ir.attachment',
'search_read',
[],
{
domain: this.attachmentsDomain,
fields: ['name', 'mimetype', 'description', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height', 'original_id'],
order: 'id desc',
// Try to fetch first record of next page just to know whether there is a next page.
limit: limit + 1,
offset,
}
);
this.state.canLoadMoreAttachments = attachments.length >= this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY;
this.state.isFetchingAttachments = false;
return attachments;
}
async loadMore() {
const newAttachments = await this.fetchAttachments(this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY, this.state.attachments.length);
this.state.attachments.push(...newAttachments);
}
async search(needle) {
this.state.needle = needle;
this.state.attachments = await this.fetchAttachments(this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY, 0);
}
async uploadFiles(files) {
await this.uploadService.uploadFiles(files, { resModel: this.props.resModel, resId: this.props.resId }, attachment => this.onUploaded(attachment));
}
async uploadUrl(url) {
await this.uploadService.uploadUrl(url, { resModel: this.props.resModel, resId: this.props.resId }, attachment => this.onUploaded(attachment));
}
async onUploaded(attachment) {
this.state.attachments = [attachment, ...this.state.attachments];
this.selectAttachment(attachment);
if (!this.props.multiSelect) {
await this.props.save();
}
}
onRemoved(attachmentId) {
this.state.attachments = this.state.attachments.filter(attachment => attachment.id !== attachmentId);
}
selectAttachment(attachment) {
this.props.selectMedia({ ...attachment, mediaType: 'attachment' });
}
selectInitialMedia() {
return this.props.media
&& this.constructor.tagNames.includes(this.props.media.tagName)
&& !this.selectedAttachmentIds.length;
}
}
FileSelector.template = 'web_editor.FileSelector';
FileSelector.components = {
FileSelectorControlPanel,
};
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_editor.FileSelectorControlPanel" owl="1">
<div class="form-inline align-items-center py-4">
<SearchMedia searchPlaceholder="props.searchPlaceholder" needle="props.needle" search="props.search"/>
<div class="d-flex justify-content-start align-items-center flex-grow-1 ml-2">
<div t-if="props.showOptimizedOption" class="custom-control custom-switch align-items-center mr-2" t-on-change="props.changeShowOptimized">
<input class="o_we_show_optimized ml-2 custom-control-input" type="checkbox" t-att-checked="props.showOptimized" id="o_we_show_optimized_switch"/>
<label class="custom-control-label" for="o_we_show_optimized_switch">
Show optimized images
</label>
</div>
<select t-if="showSearchServiceSelect" class="custom-select o_we_search_select" t-on-change="ev => props.changeSearchService(ev.target.value)">
<option t-att-selected="props.searchService === 'all'" value="all">All</option>
<option t-att-selected="props.searchService === 'database'" value="database">My Images</option>
<option t-if="props.useMediaLibrary" t-att-selected="props.searchService === 'media-library'" value="media-library">Illustrations</option>
</select>
</div>
<div class="input-group align-items-center ml-2">
<input type="text" class="form-control o_we_url_input o_we_transition_ease mr-1" t-att-class="{ o_we_horizontal_collapse: !state.showUrlInput }" name="url" t-att-placeholder="props.urlPlaceholder" t-model="state.urlInput" t-on-input="onUrlInput"/>
<div class="input-group-append align-items-center">
<button type="button" class="btn o_upload_media_url_button" t-att-class="{ 'btn-primary': state.urlInput, 'btn-secondary': !state.urlInput}" t-on-click="onUrlUploadClick" t-att-disabled="!enableUrlUploadClick">
<t t-esc="props.addText"/>
</button>
<div class="mx-2">
<span t-if="state.urlInput and state.isValidUrl and state.isValidFileFormat" class="o_we_url_success text-success fa fa-lg fa-check" title="The URL seems valid."/>
<span t-if="state.urlInput and !state.isValidUrl" class="o_we_url_error text-danger fa fa-lg fa-times" title="The URL does not seem to work."/>
<span t-if="props.urlWarningTitle and state.urlInput and state.isValidUrl and !state.isValidFileFormat" class="o_we_url_warning text-warning fa fa-lg fa-warning" t-att-title="props.urlWarningTitle"/>
</div>
</div>
</div>
<input type="file" class="d-none o_file_input" t-on-change="onChangeFileInput" t-ref="file-input" t-att-accept="props.accept" t-att-multiple="props.multiSelect and 'multiple'"/>
<div class="btn-group">
<button type="button" class="btn btn-primary o_upload_media_button" t-on-click="onClickUpload">
<t t-esc="props.uploadText"/>
</button>
</div>
</div>
</t>
<t t-name="web_editor.FileSelector" owl="1">
<form>
<FileSelectorControlPanel uploadText="uploadText"
accept="fileMimetypes"
urlPlaceholder="urlPlaceholder"
addText="addText"
searchPlaceholder="searchPlaceholder"
urlWarningTitle="urlWarningTitle"
uploadUrl="(url) => this.uploadUrl(url)"
uploadFiles="(files) => this.uploadFiles(files)"
showOptimizedOption="showOptimizedOption"
showOptimized="state.showOptimized"
changeShowOptimized="showOptimized => this.state.showOptimized = !this.state.showOptimized"
changeSearchService="service => this.state.searchService = service"
searchService="state.searchService"
needle="state.needle"
search="(needle) => this.search(needle)"
useMediaLibrary="props.useMediaLibrary"
validateUrl="validateUrl"
multiSelect="props.multiSelect"/>
<t t-call="{{ constructor.attachmentsListTemplate }}"/>
<div name="load_more_attachments" class="mt-4 text-center mx-auto o_we_load_more">
<button t-if="canLoadMore" class="btn btn-primary o_load_more" type="button" t-on-click="loadMore">Load more...</button>
<div t-if="hasContent and !canLoadMore" class="mt-4 o_load_done_msg">
<span><i t-esc="allLoadedText"/></span>
</div>
</div>
</form>
</t>
</templates>
@@ -0,0 +1,70 @@
/** @odoo-module */
import fonts from 'wysiwyg.fonts';
import { SearchMedia } from './search_media';
const { Component, useState } = owl;
export class IconSelector extends Component {
setup() {
this.state = useState({
fonts: this.props.fonts,
needle: '',
});
this.searchPlaceholder = this.env._t("Search a pictogram");
}
get selectedMediaIds() {
return this.props.selectedMedia[this.props.id].map(({ id }) => id);
}
search(needle) {
this.state.needle = needle;
if (!this.state.needle) {
this.state.fonts = this.props.fonts;
} else {
this.state.fonts = this.props.fonts.map(font => {
const icons = font.icons.filter(icon => icon.alias.indexOf(this.state.needle) >= 0);
return {...font, icons};
});
}
}
async onClickIcon(font, icon) {
this.props.selectMedia({
...icon,
fontBase: font.base,
});
await this.props.save();
}
/**
* Utility methods, used by the MediaDialog component.
*/
static createElements(selectedMedia) {
return selectedMedia.map(icon => {
const iconEl = document.createElement('span');
iconEl.classList.add(icon.fontBase, icon.names[0]);
return iconEl;
});
}
static initFonts() {
fonts.computeFonts();
const allFonts = fonts.fontIcons.map(({cssData, base}) => {
const uniqueIcons = Array.from(new Map(cssData.map(icon => {
const alias = icon.names.join(',');
const id = `${base}_${alias}`;
return [id, { ...icon, alias, id }];
})).values());
return { base, icons: uniqueIcons };
});
return allFonts;
}
}
IconSelector.mediaSpecificClasses = [];
IconSelector.tagNames = ['SPAN', 'I'];
IconSelector.template = 'web_editor.IconSelector';
IconSelector.components = {
SearchMedia,
};
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_editor.IconSelector" owl="1">
<form action="#">
<div class="form-inline align-items-center py-4">
<SearchMedia searchPlaceholder="searchPlaceholder"
search.bind="this.search"
needle="state.needle"/>
</div>
<div class="font-icons-icons">
<t t-foreach="state.fonts" t-as="font" t-key="font.base">
<span t-foreach="font.icons" t-as="icon" t-key="icon.id"
t-att-title="icon.names[0]"
t-att-aria-label="icon.names[0]" role="img"
class="font-icons-icon"
t-att-class="{ o_we_attachment_selected: this.selectedMediaIds.includes(icon.id) }"
t-attf-class="{{ font.base }} {{ icon.names[0] }}"
t-on-click="() => this.onClickIcon(font, icon)"/>
</t>
</div>
</form>
</t>
</templates>
@@ -0,0 +1,261 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { getCSSVariableValue } from 'web_editor.utils';
import { Attachment, FileSelector, IMAGE_MIMETYPES, IMAGE_EXTENSIONS } from './file_selector';
const { useRef, useState, useEffect } = owl;
export class AutoResizeImage extends Attachment {
setup() {
super.setup();
this.image = useRef('auto-resize-image');
this.container = useRef('auto-resize-image-container');
this.state = useState({
loaded: false,
});
useEffect(() => {
this.image.el.addEventListener('load', () => this.onImageLoaded());
return this.image.el.removeEventListener('load', () => this.onImageLoaded());
}, () => []);
}
onImageLoaded() {
const aspectRatio = this.image.el.offsetWidth / this.image.el.offsetHeight;
const width = aspectRatio * this.props.minRowHeight;
this.container.el.style.flexGrow = width;
this.container.el.style.flexBasis = `${width}px`;
this.state.loaded = true;
}
}
AutoResizeImage.template = 'web_editor.AutoResizeImage';
export class ImageSelector extends FileSelector {
setup() {
super.setup();
this.rpc = useService('rpc');
this.state.libraryMedia = [];
this.state.libraryResults = null;
this.state.isFetchingLibrary = false;
this.state.searchService = 'all';
this.state.showOptimized = false;
this.uploadText = this.env._t("Upload an image");
this.urlPlaceholder = "https://www.odoo.com/logo.png";
this.addText = this.env._t("Add URL");
this.searchPlaceholder = this.env._t("Search an image");
this.urlWarningTitle = this.env._t("Uploaded image's format is not supported. Try with: " + IMAGE_EXTENSIONS.join(', '));
this.allLoadedText = this.env._t("All images have been loaded");
this.showOptimizedOption = this.env.debug;
this.MIN_ROW_HEIGHT = 128;
this.fileMimetypes = IMAGE_MIMETYPES.join(',');
}
get canLoadMore() {
if (this.state.searchService === 'all') {
return super.canLoadMore || (this.state.libraryResults && this.state.libraryMedia.length < this.state.libraryResults);
} else if (this.state.searchService === 'media-library') {
return this.state.libraryResults && this.state.libraryMedia.length < this.state.libraryResults;
}
return super.canLoadMore;
}
get hasContent() {
if (this.state.searchService === 'all') {
return super.hasContent || !!this.state.libraryMedia.length;
} else if (this.state.searchService === 'media-library') {
return !!this.state.libraryMedia.length;
}
return super.hasContent;
}
get isFetching() {
return super.isFetching || this.state.isFetchingLibrary;
}
get selectedMediaIds() {
return this.props.selectedMedia[this.props.id].filter(media => media.mediaType === 'libraryMedia').map(({ id }) => id);
}
get attachmentsDomain() {
const domain = super.attachmentsDomain;
domain.push(['mimetype', 'in', IMAGE_MIMETYPES]);
if (!this.props.useMediaLibrary) {
domain.push('|', ['url', '=', false], '!', ['url', '=ilike', '/web_editor/shape/%']);
}
domain.push('!', ['name', '=like', '%.crop']);
domain.push('|', ['type', '=', 'binary'], '!', ['url', '=like', '/%/static/%']);
return domain;
}
async uploadFiles(files) {
await this.uploadService.uploadFiles(files, { resModel: this.props.resModel, resId: this.props.resId, isImage: true }, (attachment) => this.onUploaded(attachment));
}
validateUrl(...args) {
const { isValidUrl, path } = super.validateUrl(...args);
const isValidFileFormat = IMAGE_EXTENSIONS.some(format => path.endsWith(format));
return { isValidFileFormat, isValidUrl };
}
isInitialMedia(attachment) {
if (this.props.media.dataset.originalSrc) {
return this.props.media.dataset.originalSrc === attachment.image_src;
}
return this.props.media.getAttribute('src') === attachment.image_src;
}
async fetchAttachments(limit, offset) {
const attachments = await super.fetchAttachments(limit, offset);
// Color-substitution for dynamic SVG attachment
const primaryColors = {};
for (let color = 1; color <= 5; color++) {
primaryColors[color] = getCSSVariableValue('o-color-' + color);
}
return attachments.map(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.selectInitialMedia() && this.isInitialMedia(attachment)) {
this.selectAttachment(attachment);
}
return attachment;
});
}
async fetchLibraryMedia(offset) {
if (!this.state.needle) {
return { media: [], results: null };
}
this.state.isFetchingLibrary = true;
try {
const response = await this.rpc(
'/web_editor/media_library_search',
{
'query': this.state.needle,
'offset': offset,
},
{
silent: true,
}
);
this.state.isFetchingLibrary = false;
return { media: response.media || [], results: response.results };
} catch {
// Either API endpoint doesn't exist or is misconfigured.
console.error(`Couldn't reach API endpoint.`);
this.state.isFetchingLibrary = false;
return { media: [], results: null };
}
}
async loadMore(...args) {
await super.loadMore(...args);
if (!this.props.useMediaLibrary) {
return;
}
const { media } = await this.fetchLibraryMedia(this.state.libraryMedia.length);
this.state.libraryMedia.push(...media);
}
async search(...args) {
await super.search(...args);
if (!this.props.useMediaLibrary) {
return;
}
if (!this.state.needle) {
this.state.searchService = 'all';
}
const { media, results } = await this.fetchLibraryMedia(0);
this.state.libraryMedia = media;
this.state.libraryResults = results;
}
async onClickAttachment(attachment) {
this.selectAttachment(attachment);
if (!this.props.multiSelect) {
await this.props.save();
}
}
async onClickMedia(media) {
this.props.selectMedia({ ...media, mediaType: 'libraryMedia' });
if (!this.props.multiSelect) {
await this.props.save();
}
}
/**
* Utility method used by the MediaDialog component.
*/
static async createElements(selectedMedia, { orm, rpc }) {
// Create all media-library attachments.
const toSave = Object.fromEntries(selectedMedia.filter(media => media.mediaType === 'libraryMedia').map(media => [
media.id, {
query: media.query || '',
is_dynamic_svg: !!media.isDynamicSVG,
dynamic_colors: media.dynamicColors,
}
]));
let savedMedia = [];
if (Object.keys(toSave).length !== 0) {
savedMedia = await rpc('/web_editor/save_library_media', { media: toSave });
}
const selected = selectedMedia.filter(media => media.mediaType === 'attachment').concat(savedMedia).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;
});
return Promise.all(selected.map(async (attachment) => {
const imageEl = document.createElement('img');
let src = attachment.image_src;
if (!attachment.public) {
const [accessToken] = await orm.call(
'ir.attachment',
'generate_access_token',
[attachment.id],
);
src += `?access_token=${accessToken}`;
}
imageEl.src = src;
imageEl.alt = attachment.description || '';
return imageEl;
}));
}
}
ImageSelector.mediaSpecificClasses = ['img', 'img-fluid', 'o_we_custom_image'];
ImageSelector.tagNames = ['IMG'];
ImageSelector.attachmentsListTemplate = 'web_editor.ImagesListTemplate';
ImageSelector.components = {
...FileSelector.components,
AutoResizeImage,
};
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_editor.AutoResizeImage" owl="1">
<div t-ref="auto-resize-image-container" class="o_existing_attachment_cell o_we_image position-relative align-items-center justify-content-center" t-att-class="{ o_we_attachment_optimized: props.isOptimized, o_loaded: state.loaded, o_we_attachment_selected: props.selected }" t-on-click="props.onImageClick">
<RemoveButton t-if="props.isRemovable" model="props.model" remove="() => this.remove()"/>
<div class="o_we_media_dialog_img_wrapper">
<img t-ref="auto-resize-image" class="img img-fluid o_we_attachment_highlight" t-att-src="props.src" t-att-alt="props.altDescription" t-att-title="props.title" loading="lazy"/>
<a t-if="props.author" class="o_we_media_author" t-att-href="props.authorLink" target="_blank" t-esc="props.author"/>
</div>
<span t-if="props.isOptimized" class="badge badge-success">Optimized</span>
</div>
</t>
<t t-name="web_editor.ImagesListTemplate" owl="1">
<div class="o_we_existing_attachments o_we_images d-flex flex-wrap w-100 justify-content-between align-items-stretch my-0">
<t t-if="!hasContent and !isFetching">
<div t-if="state.needle" class="o_nocontent_help">
<p class="o_empty_folder_image">No images found.</p>
<p class="o_empty_folder_subtitle">You can upload images with the button located in the top left of the screen.</p>
</div>
<div t-else="" class="o_we_search_prompt">
<h2>Get the perfect image by searching in our library of copyright free photos and illustrations.</h2>
</div>
</t>
<t t-else="">
<t t-if="['all', 'database'].includes(state.searchService)">
<t t-foreach="state.attachments" t-as="attachment" t-key="attachment.id">
<AutoResizeImage t-if="!attachment.original_id or state.showOptimized"
id="attachment.id"
isOptimized="!!attachment.original_id"
isRemovable="true"
onRemoved="(attachmentId) => this.onRemoved(attachmentId)"
selected="this.selectedAttachmentIds.includes(attachment.id)"
src="attachment.thumbnail_src or attachment.image_src"
name="attachment.name"
title="attachment.name"
altDescription="attachment.altDescription"
model="attachment.res_model"
minRowHeight="MIN_ROW_HEIGHT"
onImageClick="() => this.onClickAttachment(attachment)"/>
</t>
</t>
<t t-if="['all', 'media-library'].includes(state.searchService)">
<t t-foreach="state.libraryMedia" t-as="media" t-key="media.id">
<AutoResizeImage author="media.author"
src="media.thumbnail_url"
authorLink="media.author_link"
title="media.tooltip"
altDescription="media.tooltip"
minRowHeight="MIN_ROW_HEIGHT"
selected="this.selectedMediaIds.includes(media.id)"
onImageClick="() => this.onClickMedia(media)"/>
</t>
</t>
<!-- 20 placeholders is just enough for a 5K screen, change this if ImageWidget.MIN_ROW_HEIGHT changes -->
<t t-foreach="[...Array(20).keys()]" t-as="i" t-key="i">
<div class="o_we_attachment_placeholder"/>
</t>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,196 @@
/** @odoo-module **/
import { useService } from '@web/core/utils/hooks';
import { useWowlService } from '@web/legacy/utils';
import { Dialog } from '@web/core/dialog/dialog';
import { ImageSelector } from './image_selector';
import { DocumentSelector } from './document_selector';
import { IconSelector } from './icon_selector';
import { VideoSelector } from './video_selector';
const { Component, useState, onRendered, xml } = owl;
export const TABS = {
IMAGES: {
id: 'IMAGES',
title: "Images",
Component: ImageSelector,
},
DOCUMENTS: {
id: 'DOCUMENTS',
title: "Documents",
Component: DocumentSelector,
},
ICONS: {
id: 'ICONS',
title: "Icons",
Component: IconSelector,
},
VIDEOS: {
id: 'VIDEOS',
title: "Videos",
Component: VideoSelector,
},
};
export class MediaDialog extends Component {
setup() {
this.size = 'xl';
this.contentClass = 'o_select_media_dialog';
this.title = this.env._t("Select a media");
this.rpc = useService('rpc');
this.orm = useService('orm');
this.tabs = [];
this.selectedMedia = useState({});
this.initialIconClasses = [];
this.addTabs();
this.state = useState({
activeTab: this.initialActiveTab,
});
}
get initialActiveTab() {
if (this.props.activeTab) {
return this.props.activeTab;
}
if (this.props.media) {
const correspondingTab = Object.keys(TABS).find(id => TABS[id].Component.tagNames.includes(this.props.media.tagName));
if (correspondingTab) {
return correspondingTab;
}
}
return this.tabs[0].id;
}
addTab(tab, additionalProps = {}) {
this.selectedMedia[tab.id] = [];
this.tabs.push({
...tab,
props: {
...tab.props,
...additionalProps,
id: tab.id,
resModel: this.props.resModel,
resId: this.props.resId,
media: this.props.media,
multiImages: this.props.multiImages,
selectedMedia: this.selectedMedia,
selectMedia: (...args) => this.selectMedia(...args, tab.id, additionalProps.multiSelect),
save: this.save.bind(this),
},
});
}
addTabs() {
const onlyImages = this.props.onlyImages || this.props.multiImages || (this.props.media && this.props.media.parentElement && (this.props.media.parentElement.dataset.oeField === 'image' || this.props.media.parentElement.dataset.oeType === 'image'));
const noDocuments = onlyImages || this.props.noDocuments;
const noIcons = onlyImages || this.props.noIcons;
const noVideos = onlyImages || this.props.noVideos;
if (!this.props.noImages) {
this.addTab(TABS.IMAGES, {
useMediaLibrary: this.props.useMediaLibrary,
multiSelect: this.props.multiImages,
});
}
if (!noDocuments) {
this.addTab(TABS.DOCUMENTS);
}
if (!noIcons) {
const fonts = TABS.ICONS.Component.initFonts();
this.addTab(TABS.ICONS, {
fonts,
});
if (this.props.media && TABS.ICONS.Component.tagNames.includes(this.props.media.tagName)) {
const classes = this.props.media.className.split(/\s+/);
const mediaFont = fonts.find(font => classes.includes(font.base));
if (mediaFont) {
const selectedIcon = mediaFont.icons.find(icon => icon.names.some(name => classes.includes(name)));
if (selectedIcon) {
this.initialIconClasses.push(...selectedIcon.names);
this.selectMedia(selectedIcon, TABS.ICONS.id);
}
}
}
}
if (!noVideos) {
this.addTab(TABS.VIDEOS, {
vimeoPreviewIds: this.props.vimeoPreviewIds,
isForBgVideo: this.props.isForBgVideo,
});
}
}
selectMedia(media, tabId, multiSelect) {
if (multiSelect) {
const isMediaSelected = this.selectedMedia[tabId].map(({ id }) => id).includes(media.id);
if (!isMediaSelected) {
this.selectedMedia[tabId].push(media);
} else {
this.selectedMedia[tabId] = this.selectedMedia[tabId].filter(m => m.id !== media.id);
}
} else {
this.selectedMedia[tabId] = [media];
}
}
async save() {
const selectedMedia = this.selectedMedia[this.state.activeTab];
if (selectedMedia.length) {
const elements = await TABS[this.state.activeTab].Component.createElements(selectedMedia, { rpc: this.rpc, orm: this.orm });
elements.forEach(element => {
if (this.props.media) {
element.classList.add(...this.props.media.classList);
const style = this.props.media.getAttribute('style');
if (style) {
element.setAttribute('style', style);
}
if (this.props.media.dataset.shape) {
element.dataset.shape = this.props.media.dataset.shape;
}
if (this.props.media.dataset.shapeColors) {
element.dataset.shapeColors = this.props.media.dataset.shapeColors;
}
}
for (const otherTab of Object.keys(TABS).filter(key => key !== this.state.activeTab)) {
element.classList.remove(...TABS[otherTab].Component.mediaSpecificClasses);
}
element.classList.remove(...this.initialIconClasses);
element.classList.remove('o_modified_image_to_save');
element.classList.remove('oe_edited_link');
element.classList.add(...TABS[this.state.activeTab].Component.mediaSpecificClasses);
});
if (this.props.multiImages) {
this.props.save(elements);
} else {
this.props.save(elements[0]);
}
}
this.props.close();
}
}
MediaDialog.template = 'web_editor.MediaDialog';
MediaDialog.defaultProps = {
useMediaLibrary: true,
};
MediaDialog.components = {
...Object.keys(TABS).map(key => TABS[key].Component),
Dialog,
};
export class MediaDialogWrapper extends Component {
setup() {
this.dialogs = useWowlService('dialog');
onRendered(() => {
this.dialogs.add(MediaDialog, this.props);
});
}
}
MediaDialogWrapper.template = xml``;
@@ -0,0 +1,141 @@
$min-row-height: 128;
.modal:not(.o_legacy_dialog) .o_select_media_dialog {
.nav-item {
cursor: pointer;
}
.o_we_horizontal_collapse {
width: 0 !important;
padding: 0 !important;
border: none !important;
}
.o_we_transition_ease {
transition: all ease 0.25s;
}
.o_we_existing_attachments {
min-height: $min-row-height + px;
.o_we_attachment_placeholder {
flex-grow: $min-row-height;
flex-basis: $min-row-height + px;
}
.o_existing_attachment_cell {
cursor: pointer;
margin: 1px;
&.o_we_image {
position: fixed !important;
opacity: 0;
transition: opacity 0.5s;
transition-delay: 0.5s;
&.o_loaded {
position: relative !important;
opacity: 1;
}
img {
width: 100%;
}
}
.o_existing_attachment_remove {
background-color: rgba(white, 0.4);
cursor: pointer;
opacity: 0;
transition: color 0.2s ease;
}
.o_existing_attachment_remove {
@include o-position-absolute($top: 0, $right: 0);
z-index: 1;
border-radius: 0 0 0 2px;
&:hover {
color: $o-we-color-danger;
}
}
.o_file_name {
@include o-text-overflow;
}
&:hover {
.o_existing_attachment_remove {
opacity: 1;
}
&.o_we_attachment_highlight, .o_we_attachment_highlight {
border-color: $card-border-color;
box-shadow: 0px 0px 2px 2px $card-border-color;
}
}
&.o_we_attachment_optimized .badge {
position: absolute;
bottom: 0;
right: 0;
margin: 2px;
}
.o_we_media_author {
font-size: 11px;
@include o-position-absolute($bottom: 0, $left: 0, $right: 0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
background-color: rgba(255, 255, 255, .7);
}
}
}
.o_we_attachment_selected {
@include o-we-active-wrapper($top: 5px, $left: 5px);
}
.font-icons-icons {
> span {
text-align: center;
font-size: 22px;
margin: 5px;
width: 50px;
height: 50px;
padding: 15px 0; // 0 allows to center properly
cursor: pointer;
}
}
.o_video_dialog_form {
textarea {
min-height: 95px;
}
}
.o_video_preview {
position: relative;
@include o-we-preview-box();
border: none;
.media_iframe_video {
width: 100%;
}
.o_video_dialog_iframe {
@include o-we-preview-content;
max-width: 100%;
max-height: 100%;
&.alert {
animation: fadeInDownSmall 700ms forwards;
margin: 0 auto;
}
}
}
#video-suggestion > .o_sample_video {
cursor: pointer;
}
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_editor.MediaDialog" owl="1">
<Dialog contentClass="contentClass"
size="size"
title="title">
<ul class="nav nav-tabs" role="tablist">
<t t-foreach="tabs" t-as="tab" t-key="tab.id">
<li class="nav-item"><a class="nav-link" t-att-class="{ active: tab.id === state.activeTab }" t-on-click="() => state.activeTab = tab.id"><t t-esc="tab.title"/></a></li>
</t>
</ul>
<!-- Tab panes -->
<div class="tab-content">
<t t-foreach="tabs" t-as="tab" t-key="tab.id">
<div class="tab-pane fade" t-att-class="tab.id === state.activeTab ? 'active show' : ''"><t t-component="tab.Component" t-props="tab.props"/></div>
</t>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="() => this.save()">Add</button>
<button class="btn btn-secondary" t-on-click="() => this.props.close()">Discard</button>
</t>
</Dialog>
</t>
</templates>
@@ -0,0 +1,25 @@
/** @odoo-module **/
import { debounce } from '@web/core/utils/timing';
import { useAutofocus } from '@web/core/utils/hooks';
const { Component, onWillUnmount, xml } = owl;
export class SearchMedia extends Component {
setup() {
useAutofocus();
this.search = debounce((ev) => this.props.search(ev.target.value), 1000);
onWillUnmount(() => {
this.search.cancel();
});
}
}
SearchMedia.template = xml`
<div class="input-group mr-auto">
<input type="text" class="form-control o_we_search" t-att-placeholder="props.searchPlaceholder.trim()" t-att-value="props.needle" t-on-input="search" t-ref="autofocus"/>
<div class="input-group-append">
<div class="input-group-text o_we_search_icon">
<i class="fa fa-search" title="Search" role="img" aria-label="Search"/>
</div>
</div>
</div>`;
@@ -0,0 +1,204 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { throttle } from '@web/core/utils/timing';
import { qweb } from 'web.core';
const { Component, useState, useRef, onMounted, onWillStart } = owl;
class VideoOption extends Component {}
VideoOption.template = 'web_editor.VideoOption';
export class VideoSelector extends Component {
setup() {
this.rpc = useService('rpc');
this.http = useService('http');
this.PLATFORMS = {
youtube: 'youtube',
dailymotion: 'dailymotion',
vimeo: 'vimeo',
youku: 'youku',
};
this.OPTIONS = {
autoplay: {
label: this.env._t("Autoplay"),
description: this.env._t("Videos are muted when autoplay is enabled"),
platforms: [this.PLATFORMS.youtube, this.PLATFORMS.dailymotion, this.PLATFORMS.vimeo],
urlParameter: 'autoplay=1',
},
loop: {
label: this.env._t("Loop"),
platforms: [this.PLATFORMS.youtube, this.PLATFORMS.vimeo],
urlParameter: 'loop=1',
},
hide_controls: {
label: this.env._t("Hide player controls"),
platforms: [this.PLATFORMS.youtube, this.PLATFORMS.dailymotion],
urlParameter: 'controls=0',
},
hide_fullscreen: {
label: this.env._t("Hide fullscreen button"),
platforms: [this.PLATFORMS.youtube],
urlParameter: 'fs=0',
isHidden: () => this.state.options.filter(option => option.id === 'hide_controls')[0].value,
},
hide_yt_logo: {
label: this.env._t("Hide Youtube logo"),
platforms: [this.PLATFORMS.youtube],
urlParameter: 'modestbranding=1',
isHidden: () => this.state.options.filter(option => option.id === 'hide_controls')[0].value,
},
hide_dm_logo: {
label: this.env._t("Hide Dailymotion logo"),
platforms: [this.PLATFORMS.dailymotion],
urlParameter: 'ui-logo=0',
},
hide_dm_share: {
label: this.env._t("Hide sharing button"),
platforms: [this.PLATFORMS.dailymotion],
urlParameter: 'sharing-enable=0',
},
};
this.state = useState({
options: [],
src: '',
urlInput: '',
platform: null,
vimeoPreviews: [],
errorMessage: '',
});
this.urlInputRef = useRef('url-input');
onWillStart(async () => {
if (this.props.media) {
const src = this.props.media.dataset.oeExpression || this.props.media.dataset.src || (this.props.media.tagName === 'IFRAME' && this.props.media.getAttribute('src')) || '';
if (src) {
this.state.urlInput = src;
await this.updateVideo();
this.state.options = this.state.options.map((option) => {
const { urlParameter } = this.OPTIONS[option.id];
return { ...option, value: src.indexOf(urlParameter) >= 0 };
});
}
}
});
onMounted(async () => {
await Promise.all(this.props.vimeoPreviewIds.map(async (videoId) => {
const { thumbnail_url: thumbnailSrc } = await this.http.get(`https://vimeo.com/api/oembed.json?url=http%3A//vimeo.com/${videoId}`);
this.state.vimeoPreviews.push({
id: videoId,
thumbnailSrc,
src: `https://player.vimeo.com/video/${videoId}`
});
}));
});
this.onChangeUrl = throttle((ev) => this.updateVideo(ev.target.value), 500);
}
get shownOptions() {
if (this.props.isForBgVideo) {
return [];
}
return this.state.options.filter(option => !this.OPTIONS[option.id].isHidden || !this.OPTIONS[option.id].isHidden());
}
async onChangeOption(optionId) {
this.state.options = this.state.options.map(option => {
if (option.id === optionId) {
return { ...option, value: !option.value };
}
return option;
});
await this.updateVideo();
}
async onClickSuggestion(src) {
this.state.urlInput = src;
await this.updateVideo();
}
async updateVideo() {
if (!this.state.urlInput) {
this.state.src = '';
this.state.urlInput = '';
this.state.options = [];
this.state.platform = null;
this.state.errorMessage = '';
return;
}
// Detect if we have an embed code rather than an URL
const embedMatch = this.state.urlInput.match(/(src|href)=["']?([^"']+)?/);
if (embedMatch && embedMatch[2].length > 0 && embedMatch[2].indexOf('instagram')) {
embedMatch[1] = embedMatch[2]; // Instagram embed code is different
}
const url = embedMatch ? embedMatch[1] : this.state.urlInput;
const options = {};
if (this.props.isForBgVideo) {
Object.keys(this.OPTIONS).forEach(key => {
options[key] = true;
});
} else {
for (const option of this.shownOptions) {
options[option.id] = option.value;
}
}
const { embed_url: src, platform } = await this.rpc('/web_editor/video_url/data', {
video_url: url,
...options,
});
if (!src) {
this.state.errorMessage = this.env._t("The provided url is not valid");
} else if (!platform) {
this.env._t("The provided url does not reference any supported video");
} else {
this.state.errorMessage = '';
}
const newOptions = [];
if (platform && platform !== this.state.platform) {
Object.keys(this.OPTIONS).forEach(key => {
if (this.OPTIONS[key].platforms.includes(platform)) {
const { label, description } = this.OPTIONS[key];
newOptions.push({ id: key, label, description });
}
});
}
this.state.src = src;
this.props.selectMedia({ id: src, src });
if (platform !== this.state.platform) {
this.state.platform = platform;
this.state.options = newOptions;
}
}
/**
* Utility method, called by the MediaDialog component.
*/
static createElements(selectedMedia) {
return selectedMedia.map(video => {
const template = document.createElement('template');
template.innerHTML = qweb.render('web_editor.videoWrapper', { src: video.src });
return template.content.firstChild;
});
}
}
VideoSelector.mediaSpecificClasses = ['media_iframe_video'];
VideoSelector.tagNames = ['IFRAME', 'DIV'];
VideoSelector.template = 'web_editor.VideoSelector';
VideoSelector.components = {
VideoOption,
};
VideoSelector.defaultProps = {
vimeoPreviewIds: [],
isForBgVideo: false,
};
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<div t-name="web_editor.videoWrapper" t-att-data-oe-expression="src">
<div class="css_editable_mode_display"/>
<div class="media_iframe_video_size" contenteditable="false"/>
<iframe t-att-src="src" frameborder="0" contenteditable="false" allowfullscreen="allowfullscreen"/>
</div>
<t t-name="web_editor.VideoOption" owl="1">
<div>
<label class="o_switch" t-on-change="props.onChangeOption">
<input type="checkbox" t-att-checked="props.value"/><span/>
<span t-esc="props.label"/>
<span t-if="props.description" class="small text-muted ml-auto" t-esc="props.description"/>
</label>
</div>
</t>
<t t-name="web_editor.VideoSelector" owl="1">
<form action="#" class="row">
<div class="col mt-4 o_video_dialog_form">
<div class="form-group mb-2">
<label class="col-form-label" for="o_video_text">
<b>Video code </b>(URL or Embed)
</label>
<div class="text-left">
<small class="text-muted">Accepts <b><i>Youtube</i></b>, <b><i>Vimeo</i></b>, <b><i>Dailymotion</i></b> and <b><i>Youku</i></b> videos</small>
</div>
<textarea t-model="state.urlInput" class="form-control" id="o_video_text" placeholder="Copy-paste your URL or embed code here" t-on-input="onChangeUrl" t-att-class="{ 'is-valid': state.urlInput and !this.state.errorMessage, 'is-invalid': state.urlInput and this.state.errorMessage }"/>
</div>
<div t-if="shownOptions.length" class="o_video_dialog_options mt-4">
<VideoOption t-foreach="shownOptions" t-as="option" t-key="option.id"
value="option.value"
onChangeOption="() => this.onChangeOption(option.id)"
label="option.label"
description="option.description"/>
</div>
<t t-if="state.vimeoPreviews.length">
<span class="font-weight-bold">Suggestions</span>
<div id="video-suggestion" class="mt-4 d-flex flex-wrap mh-75 overflow-auto">
<t t-foreach="state.vimeoPreviews" t-as="vimeoPreview" t-key="vimeoPreview.id">
<div class="o_sample_video w-25 mh-100" t-on-click="() => this.onClickSuggestion(vimeoPreview.src)">
<img class="mw-100 mh-100 p-1" t-att-src="vimeoPreview.thumbnailSrc"/>
</div>
</t>
</div>
</t>
</div>
<div class="col-md-6">
<div class="o_video_preview p-3">
<div t-if="this.state.src and !this.state.errorMessage" class="o_video_dialog_preview_text mb-2">Preview</div>
<div class="media_iframe_video">
<div class="media_iframe_video_size"/>
<iframe t-if="this.state.src and !this.state.errorMessage" height="720" width="1280" class="o_video_dialog_iframe" allowfullscreen="allowfullscreen" frameborder="0" t-att-src="this.state.src"/>
<div t-if="this.state.errorMessage" class="alert alert-warning o_video_dialog_iframe mb-2 mt-2" t-esc="this.state.errorMessage"/>
</div>
</div>
</div>
</form>
</t>
</templates>
@@ -0,0 +1,36 @@
/** @odoo-module */
const { Component, useEffect, useState } = owl;
export class ProgressBar extends Component {
get progress() {
return Math.round(this.props.progress);
}
}
ProgressBar.template = 'web_editor.ProgressBar';
export class UploadProgressToast extends Component {
setup() {
this.state = useState({ hide: false });
useEffect((numberOfFiles) => {
if (numberOfFiles) {
this.state.hide = false;
}
}, () => [Object.keys(this.props.files).length]);
}
get show() {
if (!this.props.files || !Object.keys(this.props.files).length) {
return false;
}
return !this.state.hide;
}
}
UploadProgressToast.defaultProps = {
files: {},
};
UploadProgressToast.template = 'web_editor.UploadProgressToast';
UploadProgressToast.components = {
ProgressBar
};
@@ -0,0 +1,9 @@
.o_upload_progress_toast {
font-size: 16px;
.o_we_progressbar:last-child {
hr {
display: none;
}
}
}
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_editor.ProgressBar" owl="1">
<small class="text-info d-flex align-items-center mr-2">
<span t-if="!props.hasError and !props.uploaded"><i class="fa fa-circle-o-notch fa-spin mr-2"/></span>
<span class="font-italic font-weight-bold text-truncate flex-grow-1 mr-2" t-esc="props.name"/>
<span class="font-weight-bold text-nowrap" t-esc="props.size"/>
</small>
<small t-if="props.uploaded or props.hasError" class="d-flex align-items-center mt-1">
<span t-if="props.uploaded" class="text-success"><i class="fa fa-check my-1 mr-1"/> File has been uploaded</span>
<span t-else="" class="text-danger"><i class="fa fa-times float-left my-1 mr-1"/> <span class="o_we_error_text" t-esc="props.errorMessage ? props.errorMessage : 'File could not be saved'"/></span>
</small>
<div t-else="" class="progress">
<div class="progress-bar bg-info progress-bar-striped progress-bar-animated" role="progressbar" t-attf-style="width: {{this.progress}}%;"><span t-esc="this.progress + '%'"/></div>
</div>
<hr/>
</t>
<t t-name="web_editor.UploadProgressToast" owl="1">
<div class="o_notification_manager o_upload_progress_toast">
<div t-if="show" class="o_notification position-relative show fade mb-2 border border-info bg-white" role="alert" aria-live="assertive" aria-atomic="true">
<button type="button" class="close o_notification_close" aria-label="Close" t-on-click="() => this.state.hide = true">
<i class="oi oi-close"/>
</button>
<div class="o_notification_body pl-2 pr-4 py-2">
<div class="mr-auto o_notification_content">
<div t-foreach="props.files" t-as="file" t-key="file" class="o_we_progressbar">
<ProgressBar progress="file_value.progress"
errorMessage="file_value.errorMessage"
hasError="file_value.hasError"
name="file_value.name"
uploaded="file_value.uploaded"
size="file_value.size"
id="file_value.id"/>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -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);
-37
View File
@@ -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';
@@ -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);
},
/**
@@ -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,
};
});
@@ -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<void>}
* @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 {
@@ -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);
@@ -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;
@@ -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,
};
});
@@ -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.
@@ -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');
}
@@ -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);
File diff suppressed because it is too large Load Diff
@@ -1,307 +0,0 @@
odoo.define('wysiwyg.widgets.MediaDialog', function (require) {
'use strict';
var core = require('web.core');
var MediaModules = require('wysiwyg.widgets.media');
var Dialog = require('wysiwyg.widgets.Dialog');
var _t = core._t;
/**
* Lets the user select a media. The media can be existing or newly uploaded.
*
* The media can be one of the following types: image, document, video or
* font awesome icon (only existing icons).
*
* The user may change a media into another one depending on the given options.
*/
var MediaDialog = Dialog.extend({
template: 'wysiwyg.widgets.media',
xmlDependencies: Dialog.prototype.xmlDependencies.concat(
['/web_editor/static/src/xml/wysiwyg.xml']
),
events: _.extend({}, Dialog.prototype.events, {
'click #editor-media-image-tab': '_onClickImageTab',
'click #editor-media-document-tab': '_onClickDocumentTab',
'click #editor-media-icon-tab': '_onClickIconTab',
'click #editor-media-video-tab': '_onClickVideoTab',
}),
custom_events: _.extend({}, Dialog.prototype.custom_events || {}, {
save_request: '_onSaveRequest',
show_parent_dialog_request: '_onShowRequest',
hide_parent_dialog_request: '_onHideRequest',
}),
/**
* @constructor
* @param {Element} media
*/
init: function (parent, options, media) {
var $media = $(media);
media = $media[0];
this.media = media;
options = _.extend({}, options);
var onlyImages = options.onlyImages || this.multiImages || (media && ($media.parent().data('oeField') === 'image' || $media.parent().data('oeType') === 'image'));
options.noDocuments = onlyImages || options.noDocuments;
options.noIcons = onlyImages || options.noIcons;
options.noVideos = onlyImages || options.noVideos;
this._super(parent, _.extend({}, {
title: _t("Select a Media"),
save_text: _t("Add"),
}, options));
if (!options.noImages) {
this.imageWidget = this.getImageWidget(media, options);
}
if (!options.noDocuments) {
this.documentWidget = this.getDocumentWidget(media, options);
}
if (!options.noIcons) {
this.iconWidget = this.getIconWidget(media, options);
}
if (!options.noVideos) {
this.videoWidget = this.getVideoWidget(media, options);
}
if (this.imageWidget && $media.is('img')) {
this.activeWidget = this.imageWidget;
} else if (this.documentWidget && $media.is('a.o_image')) {
this.activeWidget = this.documentWidget;
} else if (this.videoWidget && $media.is('.media_iframe_video, .o_bg_video_iframe')) {
this.activeWidget = this.videoWidget;
} else if (this.iconWidget && $media.is('span, i')) {
this.activeWidget = this.iconWidget;
} else {
this.activeWidget = [this.imageWidget, this.documentWidget, this.videoWidget, this.iconWidget].find(w => !!w);
}
this.initiallyActiveWidget = this.activeWidget;
},
/**
* Adds the appropriate class to the current modal and appends the media
* widgets to their respective tabs.
*
* @override
*/
start: function () {
var promises = [this._super.apply(this, arguments)];
this.$modal.find('.modal-dialog').addClass('o_select_media_dialog');
if (this.imageWidget) {
promises.push(this.imageWidget.appendTo(this.$("#editor-media-image")));
}
if (this.documentWidget) {
promises.push(this.documentWidget.appendTo(this.$("#editor-media-document")));
}
if (this.iconWidget) {
promises.push(this.iconWidget.appendTo(this.$("#editor-media-icon")));
}
if (this.videoWidget) {
promises.push(this.videoWidget.appendTo(this.$("#editor-media-video")));
}
this.opened(() => this.$('input.o_we_search:visible:first').focus());
return Promise.all(promises);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @param {Object} media
* @param {Object} options
* @returns {ImageWidget}
*/
getImageWidget: function (media, options) {
return new MediaModules.ImageWidget(this, media, options);
},
/**
* @param {Object} media
* @param {Object} options
* @returns {DocumentWidget}
*/
getDocumentWidget: function (media, options) {
return new MediaModules.DocumentWidget(this, media, options);
},
/**
* @param {Object} media
* @param {Object} options
* @returns {IconWidget}
*/
getIconWidget: function (media, options) {
return new MediaModules.IconWidget(this, media, options);
},
/**
* @param {Object} media
* @param {Object} options
* @returns {VideoWidget}
*/
getVideoWidget: function (media, options) {
return new MediaModules.VideoWidget(this, media, options);
},
/**
* Returns whether the document widget is currently active.
*
* @returns {boolean}
*/
isDocumentActive: function () {
return this.activeWidget === this.documentWidget;
},
/**
* Returns whether the icon widget is currently active.
*
* @returns {boolean}
*/
isIconActive: function () {
return this.activeWidget === this.iconWidget;
},
/**
* Returns whether the image widget is currently active.
*
* @returns {boolean}
*/
isImageActive: function () {
return this.activeWidget === this.imageWidget;
},
/**
* Returns whether the video widget is currently active.
*
* @returns {boolean}
*/
isVideoActive: function () {
return this.activeWidget === this.videoWidget;
},
/**
* Saves the currently selected media from the currently active widget.
*
* The save event data `final_data` will be one Element in general, but it
* will be an Array of Element if `multiImages` is set.
*
* @override
*/
save: function () {
var self = this;
var _super = this._super;
var args = arguments;
return this.activeWidget.save().then(function (data) {
if (self.activeWidget !== self.initiallyActiveWidget) {
self._clearWidgets();
}
// Restore classes if the media was replaced (when changing type)
if (self.media !== data) {
var oldClasses = self.media && _.toArray(self.media.classList);
if (oldClasses) {
data.className = _.union(_.toArray(data.classList), oldClasses).join(' ');
}
}
self.final_data = data;
_super.apply(self, args);
$(data).trigger('content_changed');
});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Call clear on all the widgets except the activeWidget.
* We clear because every widgets are modifying the "media" element.
* All widget have the responsibility to clear a previous element that
* was created from them.
*/
_clearWidgets: function () {
[ this.imageWidget,
this.documentWidget,
this.iconWidget,
this.videoWidget
].forEach( (widget) => {
if (widget !== this.activeWidget) {
widget && widget.clear();
}
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Sets the document widget as the active widget.
*
* @private
*/
_onClickDocumentTab: function () {
this.activeWidget = this.documentWidget;
},
/**
* Sets the icon widget as the active widget.
*
* @private
*/
_onClickIconTab: function () {
this.activeWidget = this.iconWidget;
},
/**
* Sets the image widget as the active widget.
*
* @private
*/
_onClickImageTab: function () {
this.activeWidget = this.imageWidget;
},
/**
* Sets the video widget as the active widget.
*
* @private
*/
_onClickVideoTab: function () {
this.activeWidget = this.videoWidget;
},
/**
* Handles hide request from child widgets.
*
* This is for usability, to allow hiding the modal for example when another
* smaller modal would be displayed on top.
*
* @private
* @param {OdooEvent} ev
*/
_onHideRequest: function (ev) {
this.$modal.addClass('d-none');
},
/**
* Handles save request from the child widgets.
*
* This is for usability, to allow the user to save from other ways than
* click on the modal button, such as double clicking a media to select it.
*
* @private
* @param {OdooEvent} ev
*/
_onSaveRequest: function (ev) {
ev.stopPropagation();
this.save();
},
/**
* Handles show request from the child widgets.
*
* This is for usability, it is the counterpart of @see _onHideRequest.
*
* @private
* @param {OdooEvent} ev
*/
_onShowRequest: function (ev) {
this.$modal.removeClass('d-none');
},
});
return MediaDialog;
});
@@ -1,112 +0,0 @@
/** @odoo-module */
import Widget from "web.Widget";
import {qweb} from 'web.core';
export const UploadProgressToast = Widget.extend({
xmlDependencies: ['/web_editor/static/src/xml/wysiwyg.xml'],
template: 'wysiwyg.widgets.upload.progress_toast',
events: {
'click .o_notification_close': '_onCloseClick',
},
/**
* @override
*/
init(parent, files) {
this._super(...arguments);
this.files = files;
},
/**
* @override
*/
start() {
this.$progress = $('<div/>');
_.each(this.files, (file, index) => {
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";
}
this.$progress.append(qweb.render('wysiwyg.widgets.upload.progressbar', {
fileId: index,
fileName: file.name,
fileSize: fileSize,
}));
});
this.$el.find('.o_notification_content').append(this.$progress);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Calls a RPC and shows its progress status.
*
* @public
* @param {Number} delay time to wait before closing the toast
*/
close(delay = 0) {
window.setTimeout(() => {
this.el.querySelector('.fade').classList.remove('show');
window.setTimeout(() => {
this.destroy();
}, 150);
}, delay);
},
/**
* Calls a RPC and shows its progress status.
*
* @param {Object} params regular `_rpc()` parameters
* @param {integer} index file index to retrieve its related progress bar
* @returns {Promise}
*/
async rpcShowProgress(params, index) {
let $progressBar = this.$el.find(`.js_progressbar_${index}`);
try {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', ev => {
const prcComplete = ev.loaded / ev.total * 100;
$progressBar.find('.progress-bar').css({
width: Math.floor(prcComplete) + '%',
}).text(prcComplete.toFixed(2) + '%');
});
xhr.upload.addEventListener('load', function () {
// Don't show yet success as backend code only starts now
$progressBar.find('.progress-bar').css({width: '100%'}).text('100%');
});
const attachment = await this._rpc(params, {xhr});
$progressBar.find('.fa-spinner, .progress').addClass('d-none');
if (attachment.error) {
this.hasError = true;
$progressBar.find('.js_progressbar_txt .text-danger').removeClass('d-none');
$progressBar.find('.js_progressbar_txt .text-danger .o_we_error_text').text(attachment.error);
} else {
$progressBar.find('.js_progressbar_txt .text-success').removeClass('d-none');
}
return attachment;
} catch (error) {
this.hasError = true;
$progressBar.find('.fa-spinner, .progress').addClass('d-none');
$progressBar.find('.js_progressbar_txt .text-danger').removeClass('d-none');
throw error;
}
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onCloseClick() {
this.close();
},
});
@@ -3,31 +3,19 @@ odoo.define('wysiwyg.widgets', function (require) {
var Dialog = require('wysiwyg.widgets.Dialog');
var AltDialog = require('wysiwyg.widgets.AltDialog');
var MediaDialog = require('wysiwyg.widgets.MediaDialog');
var LinkDialog = require('wysiwyg.widgets.LinkDialog');
var LinkTools = require('wysiwyg.widgets.LinkTools');
var ImageCropWidget = require('wysiwyg.widgets.ImageCropWidget');
const LinkPopoverWidget = require('@web_editor/js/wysiwyg/widgets/link_popover_widget')[Symbol.for("default")];
const {ColorpickerDialog} = require('web.Colorpicker');
var media = require('wysiwyg.widgets.media');
return {
Dialog: Dialog,
AltDialog: AltDialog,
MediaDialog: MediaDialog,
LinkDialog: LinkDialog,
LinkTools: LinkTools,
ImageCropWidget: ImageCropWidget,
LinkPopoverWidget: LinkPopoverWidget,
ColorpickerDialog: ColorpickerDialog,
MediaWidget: media.MediaWidget,
SearchableMediaWidget: media.SearchableMediaWidget,
FileWidget: media.FileWidget,
ImageWidget: media.ImageWidget,
DocumentWidget: media.DocumentWidget,
IconWidget: media.IconWidget,
VideoWidget: media.VideoWidget,
};
});
@@ -1,6 +1,9 @@
odoo.define('web_editor.wysiwyg', function (require) {
'use strict';
const { ComponentWrapper } = require('web.OwlCompatibility');
const { MediaDialogWrapper } = require('@web_editor/components/media_dialog/media_dialog');
const { saveVideos, videoSpecificClasses } = require('@web_editor/components/media_dialog/video_selector');
const dom = require('web.dom');
const core = require('web.core');
const session = require('web.session');
@@ -9,9 +12,9 @@ const Dialog = require('web.Dialog');
const customColors = require('web_editor.custom_colors');
const {ColorPaletteWidget} = require('web_editor.ColorPalette');
const {ColorpickerWidget} = require('web.Colorpicker');
const {compatibilityColorNames} = require('web_editor.ColorPalette');
const concurrency = require('web.concurrency');
const { device } = require('web.config');
const weContext = require('web_editor.context');
const { localization } = require('@web/core/l10n/localization');
const OdooEditorLib = require('@web_editor/../lib/odoo-editor/src/OdooEditor');
const snippetsEditor = require('web_editor.snippet.editor');
@@ -37,7 +40,6 @@ const setSelection = OdooEditorLib.setSelection;
const endPos = OdooEditorLib.endPos;
var id = 0;
const faZoomClassRegex = RegExp('fa-[0-9]x');
const basicMediaSelector = 'img, .fa, .o_image, .media_iframe_video';
// (see isImageSupportedForStyle).
const mediaSelector = basicMediaSelector.split(',').map(s => `${s}:not([data-oe-xpath])`).join(',');
@@ -92,6 +94,7 @@ const Wysiwyg = Widget.extend({
// more heuristics to bypass the limitation.
this._isOnline = true;
this._signalOnline = this._signalOnline.bind(this);
this.tooltipTimeouts = [];
},
/**
*
@@ -128,9 +131,13 @@ const Wysiwyg = Widget.extend({
}
const getYoutubeVideoElement = async (url) => {
const videoWidget = new weWidgets.VideoWidget(this, undefined, {});
const src = (await videoWidget.createVideoNode(url)).$video.attr('src');
return videoWidget.getWrappedIframe(src)[0];
const { embed_url: src } = await this._rpc({
route: '/web_editor/video_url/data',
params: { video_url: url },
});
const [savedVideo] = saveVideos([{src}]);
savedVideo.classList.add(...videoSpecificClasses);
return savedVideo;
};
this.odooEditor = new OdooEditor(this.$editable[0], Object.assign({
@@ -256,16 +263,9 @@ const Wysiwyg = Widget.extend({
this.$editable.on('dblclick', mediaSelector, function () {
self.showTooltip = false;
const $el = $(this);
let params = {node: $el};
let params = {node: this};
$el.selectElement();
if ($el.is('.fa')) {
// save layouting classes from icons to not break the page if you edit an icon
params.htmlClass = [...$el[0].classList].filter((className) => {
return !className.startsWith('fa') || faZoomClassRegex.test(className);
}).join(' ');
}
if (!$el.parent().hasClass('o_stars')) {
self.openMediaDialog(params);
}
@@ -343,6 +343,7 @@ const Wysiwyg = Widget.extend({
this._onSelectionChange = this._onSelectionChange.bind(this);
this.odooEditor.document.addEventListener('selectionchange', this._onSelectionChange);
this.setCSSVariables(this.snippetsMenu ? this.snippetsMenu.el : this.toolbar.el);
return _super.apply(this, arguments).then(() => {
if (this.options.autohideToolbar) {
@@ -680,6 +681,9 @@ const Wysiwyg = Widget.extend({
window.removeEventListener('offline', this._checkConnectionChange);
}
window.removeEventListener('beforeunload', this._onBeforeUnload);
for (const timeout of this.tooltipTimeouts) {
clearTimeout(timeout);
}
this._super();
},
/**
@@ -738,7 +742,11 @@ const Wysiwyg = Widget.extend({
};
this.$resizer.on('mousedown', resizerMousedown);
} else {
this._replaceElement(this.$editable);
if (this.options.sideAttach) {
return this._super(...arguments);
} else {
this._replaceElement(this.$editable);
}
}
},
//--------------------------------------------------------------------------
@@ -1077,9 +1085,11 @@ const Wysiwyg = Widget.extend({
this.linkTools && this.linkTools.destroy();
this.linkTools = undefined;
this.odooEditor.document.removeEventListener('mousedown', _onMousedown, true);
document.removeEventListener('mousedown', _onMousedown, true);
}
};
this.odooEditor.document.addEventListener('mousedown', _onMousedown, true);
document.addEventListener('mousedown', _onMousedown, true);
if (!this.linkTools.$el) {
this.linkTools.appendTo(this.toolbar.$el);
}
@@ -1157,18 +1167,10 @@ const Wysiwyg = Widget.extend({
* @param {object} params
* @param {Node} [params.node] Optionnal
* @param {Node} [params.htmlClass] Optionnal
* @param {Class} MediaDialog Optionnal
*/
openMediaDialog(params = {}, MediaDialog = weWidgets.MediaDialog) {
openMediaDialog(params = {}) {
const sel = this.odooEditor.document.getSelection();
const fontawesomeIcon = getInSelection(this.odooEditor.document, '.fa');
if (fontawesomeIcon && sel.toString().trim() === "") {
params.node = $(fontawesomeIcon);
// save layouting classes from icons to not break the page if you edit an icon
params.htmlClass = [...fontawesomeIcon.classList].filter((className) => {
return !className.startsWith('fa') || faZoomClassRegex.test(className);
}).join(' ');
}
if (!sel.rangeCount) {
return;
}
@@ -1178,36 +1180,51 @@ const Wysiwyg = Widget.extend({
// selection when the modal is closed.
const restoreSelection = preserveCursor(this.odooEditor.document);
const $node = $(params.node);
// We need to keep track of FA icon because media.js will _clear those classes
const wasFontAwesome = $node.hasClass('fa');
const $editable = $(OdooEditorLib.closestElement(range.startContainer, '.o_editable'));
const model = $editable.data('oe-model');
const field = $editable.data('oe-field');
const type = $editable.data('oe-type');
const mediaParams = Object.assign({
res_model: model,
res_id: $editable.data('oe-id'),
this.mediaDialogWrapper = new ComponentWrapper(this, MediaDialogWrapper, {
resModel: model,
resId: $editable.data('oe-id'),
domain: $editable.data('oe-media-domain'),
useMediaLibrary: field && (model === 'ir.ui.view' && field === 'arch' || type === 'html'),
}, this.options.mediaModalParams, params);
const mediaDialog = new MediaDialog(this, mediaParams, $node);
mediaDialog.open();
mediaDialog.on('save', this, this._onMediaDialogSave.bind(this, {
$node: $node,
wasFontAwesome: wasFontAwesome,
htmlClass: params.htmlClass,
restoreSelection: restoreSelection,
}));
mediaDialog.on('closed', this, function () {
// if the mediaDialog content has been saved
// the previous selection in not relevant anymore
if (mediaDialog.destroyAction !== 'save') {
restoreSelection();
}
useMediaLibrary: !!(field && (model === 'ir.ui.view' && field === 'arch' || type === 'html')),
media: params.node,
save: this._onMediaDialogSave.bind(this, {
node: params.node,
restoreSelection: restoreSelection,
}),
close: () => restoreSelection(),
...this.options.mediaModalParams,
...params,
});
// The wysiwyg can be instanciated inside an iframe. The dialog
// component is mounted on the global document.
return this.mediaDialogWrapper.mount(document.body);
},
/**
* Sets custom CSS Variables on the snippet menu element.
* Used for color previews and color palette.
*/
setCSSVariables(element) {
for (let i = 1; i <= 5; i++) {
element.style.setProperty(`--we-cp-o-color-${i}`, weUtils.getCSSVariableValue(`o-color-${i}`));
element.style.setProperty(`--we-cp-o-cc${i}-bg`, weUtils.getCSSVariableValue(`o-cc${i}-bg`));
element.style.setProperty(`--we-cp-o-cc${i}-h1`, weUtils.getCSSVariableValue(`o-cc${i}-h1`));
element.style.setProperty(`--we-cp-o-cc${i}-text`, weUtils.getCSSVariableValue(`o-cc${i}-text`));
element.style.setProperty(`--we-cp-o-cc${i}-btn-primary`, weUtils.getCSSVariableValue(`o-cc${i}-btn-primary`));
element.style.setProperty(`--we-cp-o-cc${i}-btn-primary-border`, weUtils.getCSSVariableValue(`o-cc${i}-btn-primary-border`));
element.style.setProperty(`--we-cp-o-cc${i}-btn-secondary`, weUtils.getCSSVariableValue(`o-cc${i}-btn-secondary`));
element.style.setProperty(`--we-cp-o-cc${i}-btn-secondary-border`, weUtils.getCSSVariableValue(`o-cc${i}-btn-secondary-border`));
}
for (let i = 100; i <= 900; i += 100) {
element.style.setProperty(`--we-cp-${i}`, weUtils.getCSSVariableValue(`${i}`));
}
for (const name of compatibilityColorNames) {
element.style.setProperty(`--we-cp-${name}`, weUtils.getCSSVariableValue(name));
}
},
/**
* Detached function to allow overriding.
@@ -1216,23 +1233,28 @@ const Wysiwyg = Widget.extend({
* @param {Element} element provided by the dialog
*/
_onMediaDialogSave: function (params, element) {
params.restoreSelection();
if (!element) {
return;
}
// restore saved html classes
if (params.htmlClass) {
element.className += " " + params.htmlClass;
}
params.restoreSelection();
if (wysiwygUtils.isImg(params.$node[0]) || params.wasFontAwesome) {
params.$node.replaceWith(element);
if (params.node) {
params.node.replaceWith(element);
this.odooEditor.unbreakableStepUnactive();
this.odooEditor.historyStep();
} else if (element) {
} else {
const fragment = new DocumentFragment();
fragment.append(element);
return this.odooEditor.execCommand('insertFragment', fragment);
}
if (this.snippetsMenu) {
this.snippetsMenu.activateSnippet($(element)).then(() => {
if (element.tagName === 'IMG') {
$(element).trigger('image_changed');
}
});
}
},
//--------------------------------------------------------------------------
@@ -1682,16 +1704,19 @@ const Wysiwyg = Widget.extend({
if (isInMedia || (link && link.isContentEditable)) {
// Handle the media/link's tooltip.
this.showTooltip = true;
setTimeout(() => {
this.tooltipTimeouts.push(setTimeout(() => {
// Do not show tooltip on double-click and if there is already one
if (!this.showTooltip || $target.attr('title') !== undefined) {
return;
}
this.odooEditor.observerUnactive();
$target.tooltip({title: _t('Double-click to edit'), trigger: 'manual', container: 'body'}).tooltip('show');
// Tooltips need to be cleared before leaving the editor.
this.saving_mutex.exec(() => {
$target.tooltip({title: _t('Double-click to edit'), trigger: 'manual', container: 'body'}).tooltip('show');
});
this.odooEditor.observerActive();
setTimeout(() => $target.tooltip('dispose'), 800);
}, 400);
this.tooltipTimeouts.push(setTimeout(() => $target.tooltip('dispose'), 800));
}, 400));
}
// Update color of already opened colorpickers.
setTimeout(() => {
@@ -1901,7 +1926,9 @@ const Wysiwyg = Widget.extend({
* @return {Promise} rejected if the save cannot be done
*/
_saveViewBlocks: function (context) {
// TODO should be review to probably not search in the whole body,
// iframe or not.
const $ = (this.$editable[0].ownerDocument.defaultView.$ || window.$);
const $allBlocks = $((this.options || {}).savableSelector).filter('.o_dirty');
const $dirty = $('.o_dirty');
@@ -1927,7 +1954,7 @@ const Wysiwyg = Widget.extend({
if (this.options.enableTranslation) {
saveElement = '_saveTranslationElement';
}
return this[saveElement]($el, context || weContext.get())
return this[saveElement]($el, context || this.options.context)
.then(function () {
$el.removeClass('o_dirty');
}).guardedCatch(function (response) {
@@ -2193,7 +2220,6 @@ odoo.define('web_editor.widget', function (require) {
'use strict';
return {
Dialog: require('wysiwyg.widgets.Dialog'),
MediaDialog: require('wysiwyg.widgets.MediaDialog'),
LinkDialog: require('wysiwyg.widgets.LinkDialog'),
};
});
@@ -69,6 +69,8 @@
@include print-variable('o-grid-gutter-width', $grid-gutter-width);
@include print-variable('o-md-container-max-width', map-get($container-max-widths, md));
@include print-variable('o-we-content-to-translate-color', $o-we-content-to-translate-color);
@include print-variable('o-we-translated-content-color', $o-we-translated-content-color);
}
html, body {
@@ -287,7 +289,11 @@ ul.oe_menu_editor {
list-style: none;
}
li div {
cursor: move;
@include o-grab-cursor;
:active {
cursor: grabbing;
}
}
}
@@ -853,7 +859,6 @@ section, .oe_img_bg, [data-oe-shape-data] {
.cover_mid {
@extend .o_half_screen_height;
}
// Smaller container
.o_container_small {
@extend .container;
@@ -935,3 +940,59 @@ section, .oe_img_bg, [data-oe-shape-data] {
z-index: 1051;
background-color: white;
}
// Background shapes
@function compute-shape-url-params($colors, $color-to-cc-bg-map) {
$url-params: '';
@each $i in $colors {
$mapped-color: map-get($color-to-cc-bg-map, $i);
// %23 is the url-encoded form of '#'
$color: str-replace("#{o-color($mapped-color)}", '#', '%23');
$url-params: '#{$url-params}&c#{$i}=#{$color}';
}
@return $url-params;
}
.o_we_shape {
@extend %o-we-background-layer;
// Default map to use to map shape file colors to color combination
// background colors.
$default-color-to-cc-bg-map: (
1: 4,
2: 3,
3: 2,
4: 1,
5: 5,
);
@each $module, $shapes in $o-bg-shapes {
@each $shape, $style in $shapes {
$colors: map-get($style, 'colors');
$color-to-cc-bg-map: map-merge($default-color-to-cc-bg-map, map-get($style, 'color-to-cc-bg-map') or ());
$url-params: compute-shape-url-params($colors, $color-to-cc-bg-map);
$extra-mappings: map-get($style, 'extra-mappings') or ();
// eg: o_website_shape_bg_1
&.o_#{$module}_#{str-replace($shape, '/', '_')} {
// When the shape is not customized, this URL, built in SCSS,
// allows for the shape to respond to palette changes.
// Mainly useful for default pages built by the configurator.
background-image: url("/web_editor/shape/#{$module}/#{$shape}.svg?#{str-slice($url-params, 2)}");
background-position: map-get($style, 'position');
background-size: map-get($style, 'size');
background-repeat:
if(map-get($style, 'repeat-x'), repeat, no-repeat)
if(map-get($style, 'repeat-y'), repeat, no-repeat);
@each $mapping-name, $mapping in $extra-mappings {
$color-to-cc-bg-map: map-merge($default-color-to-cc-bg-map, $mapping or ());
$url-params: compute-shape-url-params($colors, $color-to-cc-bg-map);
&.o_#{$mapping-name}_extra_shape_mapping {
background-image: url("/web_editor/shape/#{$module}/#{$shape}.svg?#{str-slice($url-params, 2)}");
}
}
}
}
}
}
@@ -29,61 +29,6 @@
}
}
}
// Background shapes
@function compute-shape-url-params($colors, $color-to-cc-bg-map) {
$url-params: '';
@each $i in $colors {
$mapped-color: map-get($color-to-cc-bg-map, $i);
// %23 is the url-encoded form of '#'
$color: str-replace("#{o-color($mapped-color)}", '#', '%23');
$url-params: '#{$url-params}&c#{$i}=#{$color}';
}
@return $url-params;
}
.o_we_shape {
@extend %o-we-background-layer;
// Default map to use to map shape file colors to color combination
// background colors.
$default-color-to-cc-bg-map: (
1: 4,
2: 3,
3: 2,
4: 1,
5: 5,
);
@each $module, $shapes in $o-bg-shapes {
@each $shape, $style in $shapes {
$colors: map-get($style, 'colors');
$color-to-cc-bg-map: map-merge($default-color-to-cc-bg-map, map-get($style, 'color-to-cc-bg-map') or ());
$url-params: compute-shape-url-params($colors, $color-to-cc-bg-map);
$extra-mappings: map-get($style, 'extra-mappings') or ();
// eg: o_website_shape_bg_1
&.o_#{$module}_#{str-replace($shape, '/', '_')} {
// When the shape is not customized, this URL, built in SCSS,
// allows for the shape to respond to palette changes.
// Mainly useful for default pages built by the configurator.
background-image: url("/web_editor/shape/#{$module}/#{$shape}.svg?#{str-slice($url-params, 2)}");
background-position: map-get($style, 'position');
background-size: map-get($style, 'size');
background-repeat:
if(map-get($style, 'repeat-x'), repeat, no-repeat)
if(map-get($style, 'repeat-y'), repeat, no-repeat);
@each $mapping-name, $mapping in $extra-mappings {
$color-to-cc-bg-map: map-merge($default-color-to-cc-bg-map, $mapping or ());
$url-params: compute-shape-url-params($colors, $color-to-cc-bg-map);
&.o_#{$mapping-name}_extra_shape_mapping {
background-image: url("/web_editor/shape/#{$module}/#{$shape}.svg?#{str-slice($url-params, 2)}");
}
}
}
}
}
}
@include media-breakpoint-down(sm) {
.o_we_shape {
display: none;
@@ -98,3 +43,4 @@
.o_we_flip_x.o_we_flip_y {
transform: scale(-1);
}
@@ -45,8 +45,8 @@ $o-we-dropzone-border-width: 2px !default;
$o-we-dropzone-border: $o-we-dropzone-border-width dashed $o-brand-odoo !default;
// Translations
$o-we-content-to-translate-color: rgb(255, 255, 90) !default;
$o-we-translated-content-color: rgb(120, 215, 110) !default;
$o-we-content-to-translate-color: rgba(255, 255, 90, 0.5) !default;
$o-we-translated-content-color: rgba(120, 215, 110, 0.5) !default;
$o-we-toolbar-height: 40px !default;
@@ -208,216 +208,6 @@ $o-we-technical-modal-zindex: 2001;
box-shadow: 0 0 15px 2px #000;
}
//------------------------------------------------------------------------------
// Mixins to shield UI from themed bootstrap
//------------------------------------------------------------------------------
@mixin o-w-preserve-base {
font-size: $o-we-font-size;
font-family: $o-we-font-family;
line-height: 1.5;
color: #33363e;
.text-muted {
color: #999999 !important;
}
}
@mixin o-w-preserve-headings {
h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
font-family: $o-we-font-family;
line-height: 1.5;
color: $o-we-bg-light;
font-weight: bold;
}
h1, .h1 {
font-size: 2.4 * $o-we-font-size;
}
h2, .h2 {
font-size: 1.5 * $o-we-font-size;
}
h3, .h3 {
font-size: 1.3 * $o-we-font-size;
}
h4, .h4 {
font-size: 1.2 * $o-we-font-size;
}
h5, .h5 {
font-size: 1.1 * $o-we-font-size;
}
h6, .h6 {
font-size: $o-we-font-size;
}
}
@mixin o-w-preserve-links {
a:not(.o_btn_preview) {
color: $o-brand-primary;
&:focus, &:active, &:focus:active {
outline: none!important;
}
}
.badge {
&:hover a, a {
color: #fff;
}
}
}
@mixin o-w-preserve-forms {
:not(.input-group):not(.form-group):not(.input-group-append):not(.input-group-prepend) > .form-control {
height: 34px;
}
.form-control {
padding: 6px 12px;
font-size: 14px;
line-height: 1.5;
border: 1px solid #d4d5d7;
color: #555;
background-color: #fff;
border-radius: 0;
&.is-invalid {
border-color: $danger;
}
}
.input-group .form-control {
height: auto;
}
.input-group-text {
background-color: #e9ecef;
}
.was-validated {
.form-control:invalid {
border-color: $danger;
}
}
select.form-control {
appearance: none;
background: url('data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPScxLjEnIHhtbG5zPSdodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZycgeG1 sbnM6eGxpbms9J2h0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsnIHdpZHRoPScyNCcgaGVpZ2 h0PScyNCcgdmlld0JveD0nMCAwIDI0IDI0Jz48cGF0aCBpZD0nc3ZnXzEnIGQ9J203LjQwNiw3L jgyOGw0LjU5NCw0LjU5NGw0LjU5NCwtNC41OTRsMC40MDYsMS40MDZsLTUsNC43NjZsLTUsLTQu NzY2bDAuNDA2LC0xLjQwNnonIGZpbGw9JyM4ODgnLz48L3N2Zz4=');
background-position: 100% 65%;
background-repeat: no-repeat;
}
}
@mixin o-w-preserve-modals {
.modal-content {
border-radius: 0;
background-color: white;
.modal-header {
border-bottom-color: #e9ecef;
}
.modal-body {
background-color: white;
}
.modal-footer {
border-top-color: #e9ecef;
text-align: left;
}
}
.close {
font-size: 1.5 * $o-we-font-size;
}
}
@mixin o-w-preserve-tabs {
.nav-tabs {
border-bottom: 1px solid #e9ecef;
> li {
> a {
line-height: 1.5;
color: #4e525b;
&:hover {
border-color: #dee2e6;
}
&.active {
&, &:hover, &:focus {
color: #3D4047;
background-color: white;
border-color: #dee2e6 #dee2e6 #FFFFFF;
}
}
}
}
}
}
@mixin o-w-preserve-btn {
.btn:not(.o_btn_preview) {
border-radius: 0;
font-weight: normal;
text-transform: none;
@include button-size(.375rem, .75rem, 0.875rem, 1.5, 0);
&.btn-primary {
@include button-variant($o-brand-primary, $o-brand-primary);
color: white;
}
&.btn-secondary {
@include button-variant(white, white);
color: $o-brand-primary;
}
&.btn-link {
@include button-variant(white, white);
color: $o-brand-primary;
}
&.btn-success {
@include button-variant($o-we-color-success, $o-we-color-success);
color: white;
}
&.btn-info {
@include button-variant($o-we-color-info, $o-we-color-info);
color: white;
}
&.btn-warning {
@include button-variant($o-we-color-warning, $o-we-color-warning);
color: #33363e;
}
&.btn-danger {
@include button-variant($o-we-color-danger, $o-we-color-danger);
color: #33363e;
}
}
}
@mixin o-w-preserve-cards {
.card {
padding: 19px;
margin-bottom: 20px;
background-color: white;
border: 1px solid darken(white, 5%);
border-radius: 0;
box-shadow: none;
}
}
@mixin o-w-preserve-dropdown-menus {
.dropdown-menu {
background-color: white;
}
.dropdown-item {
color: #212529;
@include hover-focus {
color: darken(#212529, 5%);
}
&.active,
&:active {
color: white;
@include gradient-bg($o-brand-primary);
}
}
}
// ------------------------------------------------------------------
// Selection wrapper
// ------------------------------------------------------------------
+1 -133
View File
@@ -479,138 +479,6 @@ body:not(.editor_has_snippets) {
// MODALS
body .modal {
// SELECT MEDIA
.o_select_media_dialog {
max-width: 80%;
.modal-body {
.tab-pane {
min-height: 300px;
}
.o_we_images > .o_existing_attachment_cell .o_we_media_dialog_img_wrapper {
@extend %o-preview-alpha-background;
&, > img {
width: 100%;
}
}
.o_existing_attachment_cell {
cursor: pointer;
margin: 1px;
.o_existing_attachment_optimize, .o_existing_attachment_remove {
background-color: rgba(white, 0.4);
opacity: 0;
cursor: pointer;
transition: color 0.2s ease;
}
.o_existing_attachment_optimize {
@include o-position-absolute($top: 0, $left: 0);
border-radius: 0 0 2px 0;
}
.o_existing_attachment_remove {
@include o-position-absolute($top: 0, $right: 0);
z-index: 1;
border-radius: 0 0 0 2px;
&:hover {
color: $o-we-color-danger;
}
}
.o_file_name {
@include o-text-overflow;
}
&:hover {
.o_existing_attachment_optimize, .o_existing_attachment_remove {
opacity: 1;
}
&.o_we_attachment_highlight, .o_we_attachment_highlight {
border-color: $card-border-color;
box-shadow: 0px 0px 2px 2px $card-border-color;
}
}
}
.o_we_attachment_selected {
@include o-we-active-wrapper($top: 5px, $left: 5px);
}
.o_we_attachment_optimized .badge {
position: absolute;
bottom: 0;
right: 0;
margin: 2px;
}
.font-icons-icons {
> span {
text-align: center;
font-size: 22px;
margin: 5px;
width: 50px;
height: 50px;
padding: 15px 0; // 0 allows to center properly
cursor: pointer;
}
}
#editor-media-image,
#editor-media-document {
.o_we_url_input {
width: 300px;
}
}
// VIDEO TAB
#editor-media-video {
.o_video_dialog_form {
#o_video_form_group {
position: relative;
width: 100%;
> textarea {
width: 100%;
min-height: 95px;
padding-bottom: 25px;
overflow-y: scroll;
}
}
}
#video-preview {
position: relative;
@include o-we-preview-box();
border: none;
.media_iframe_video {
width: 100%;
}
.o_video_dialog_iframe {
@include o-we-preview-content;
max-width: 100%;
max-height: 100%;
&.alert {
animation: fadeInDownSmall 700ms forwards;
margin: 0 auto;
}
}
}
#video-suggestion > .o_sample_video {
cursor: pointer;
}
}
}
}
// LINK EDITOR DIALOG COLOR SELECTOR
.o_link_dialog {
input.link-style:checked + span::after {
@@ -669,7 +537,7 @@ img::selection {
}
// User modal in edit mode
.editor_enable, .note-editable {
.note-editable {
.modal:not(.o_technical_modal) {
top: 40px;
right: 0;
@@ -17,6 +17,8 @@ body.editor_enable.editor_has_snippets {
// just for fake backdrop effect
background-color: rgba(66, 66, 66, 0.4);
}
}
.editor_has_snippets {
> .oe_overlay.ui-draggable {
.o_handles {
display: none;
@@ -225,8 +227,6 @@ body.editor_enable.editor_has_snippets {
// SNIPPET PANEL
#oe_snippets {
@include o-w-preserve-btn;
@include o-position-absolute(var(--o-we-toolbar-height), 0, 0, auto);
position: fixed;
z-index: $o-we-zindex;
@@ -244,10 +244,6 @@ body.editor_enable.editor_has_snippets {
transition: transform 400ms ease 0s;
transform: translateX(100%);
&.o_loaded {
transform: none;
}
*::selection {
background: lighten($o-we-accent, 9);
color: $o-we-bg-darkest;
@@ -2163,6 +2159,7 @@ body.editor_enable.editor_has_snippets {
&.o_we_background_position_overlay {
background-color: rgba(0,0,0,.7);
pointer-events: auto;
bottom: 0;
.o_we_overlay_content {
@include o-grab-cursor;
@@ -80,314 +80,6 @@
</div>
</form>
<!-- Media Dialog (allows to choose an img/pictogram/video) -->
<div t-name="wysiwyg.widgets.media">
<ul class="nav nav-tabs" role="tablist">
<li t-if="!widget.options.noImages" class="nav-item"><a t-attf-class="nav-link #{widget.isImageActive() ? 'active' : ''}" id="editor-media-image-tab" data-toggle="tab" href="#editor-media-image" role="tab" aria-controls="editor-media-image" t-att-aria-selected="widget.isImageActive().toString()">Image</a></li>
<li t-if="!widget.options.noDocuments" class="nav-item"><a t-attf-class="nav-link #{widget.isDocumentActive() ? 'active' : ''}" id="editor-media-document-tab" data-toggle="tab" href="#editor-media-document" role="tab" aria-controls="editor-media-document" t-att-aria-selected="widget.isDocumentActive().toString()">Document</a></li>
<li t-if="!widget.options.noIcons" class="nav-item"><a t-attf-class="nav-link #{widget.isIconActive() ? 'active' : ''}" id="editor-media-icon-tab" data-toggle="tab" href="#editor-media-icon" role="tab" aria-controls="editor-media-icon" t-att-aria-selected="widget.isIconActive().toString()">Pictogram</a></li>
<li t-if="!widget.options.noVideos" class="nav-item"><a t-attf-class="nav-link #{widget.isVideoActive() ? 'active' : ''}" id="editor-media-video-tab" data-toggle="tab" href="#editor-media-video" role="tab" aria-controls="editor-media-video" t-att-aria-selected="widget.isVideoActive().toString()">Video</a></li>
</ul>
<!-- Tab panes -->
<div class="tab-content">
<div t-if="!widget.options.noImages" t-attf-class="tab-pane fade #{widget.isImageActive() ? 'show active': ''}" id="editor-media-image" role="tabpanel" aria-labelledby="editor-media-image-tab"/>
<div t-if="!widget.options.noDocuments" t-attf-class="tab-pane fade #{widget.isDocumentActive() ? 'show active': ''}" id="editor-media-document" role="tabpanel" aria-labelledby="editor-media-document-tab"/>
<div t-if="!widget.options.noIcons" t-attf-class="tab-pane fade #{widget.isIconActive() ? 'show active': ''}" id="editor-media-icon" role="tabpanel" aria-labelledby="editor-media-icon-tab"/>
<div t-if="!widget.options.noVideos" t-attf-class="tab-pane fade #{widget.isVideoActive() ? 'show active': ''}" id="editor-media-video" role="tabpanel" aria-labelledby="editor-media-video-tab"/>
</div>
</div>
<t t-name="wysiwyg.widgets.media.search">
<div class="input-group mr-auto">
<input type="text" class="form-control o_we_search" t-att-placeholder="searchPlaceholder.trim()"/>
<div class="input-group-append">
<div class="input-group-text o_we_search_icon">
<i class="fa fa-search" title="Search" role="img" aria-label="Search"/>
</div>
</div>
</div>
</t>
<!-- File choosing part of the Media Dialog -->
<t t-name="wysiwyg.widgets.file">
<form>
<t t-call="wysiwyg.widgets.files.submenu"/>
<div class="form-text o_we_error_text"/>
<div class="o_we_existing_attachments"/>
<div class="mt-4 text-center mx-auto o_we_load_more">
<button class="btn btn-primary o_load_more d-none" type="button">Load more...</button>
<div class="mt-4 o_load_done_msg d-none">
<span><i>All images have been loaded</i></span>
</div>
</div>
</form>
</t>
<t t-name="wysiwyg.widgets.files.submenu">
<div class="form-inline align-items-center py-4">
<t t-call="wysiwyg.widgets.media.search"/>
<t t-out="0"/>
<input type="file" class="d-none o_file_input" name="upload" t-att-accept="widget.options.accept" t-att-multiple="widget.options.multiImages &amp;&amp; 'multiple'"/>
<div class="input-group align-items-center ml-2">
<input type="text" class="form-control o_we_url_input o_we_horizontal_collapse o_we_transition_ease" name="url" t-att-placeholder="urlPlaceholder"/>
<div class="input-group-append align-items-center">
<button type="button" class="btn btn-secondary o_upload_media_url_button">
<t t-out="addText"/>
</button>
<div class="mx-2">
<span class="o_we_url_success text-success d-none fa fa-lg fa-check" title="The URL seems valid."/>
<span t-if="urlWarningTitle" class="o_we_url_warning text-warning d-none fa fa-lg fa-warning" t-att-title="urlWarningTitle"/>
<span class="o_we_url_error text-danger d-none fa fa-lg fa-times" title="The URL does not seem to work."/>
</div>
</div>
</div>
<div class="btn-group">
<button type="button" class="btn btn-primary o_upload_media_button">
<t t-out="uploadText"/>
</button>
</div>
</div>
</t>
<t t-name="wysiwyg.widgets.image">
<t t-call="wysiwyg.widgets.file">
<t t-set="uploadText">Upload an image</t>
<t t-set="urlPlaceholder">https://www.odoo.com/logo.png</t>
<t t-set="addText">Add URL</t>
<t t-set="searchPlaceholder">Search an image</t>
<t t-set="urlWarningTitle">Uploaded image's format is not supported. Try with: </t>
<t t-set="urlWarningTitle" t-value="urlWarningTitle + widget.IMAGE_EXTENSIONS.join(', ')"/>
<div class="d-flex justify-content-start flex-grow-1 ml-2">
<div t-attf-class="custom-control custom-switch #{__debug__ and 'd-flex' or 'd-none'} align-items-center mr-2">
<input class="o_we_show_optimized ml-2 custom-control-input" type="checkbox" id="o_we_show_optimized_switch"/>
<label class="custom-control-label" for="o_we_show_optimized_switch">
Show optimized images
</label>
</div>
<select class="custom-select o_we_search_select d-none">
<option value="all">All</option>
<option value="database">My Images</option>
<option t-if="widget.options.useMediaLibrary" value="media-library">Illustrations</option>
</select>
</div>
</t>
</t>
<t t-name="wysiwyg.widgets.document">
<t t-call="wysiwyg.widgets.file">
<t t-set="uploadText">Upload a document</t>
<t t-set="urlPlaceholder">https://www.odoo.com/mydocument</t>
<t t-set="addText">Add document</t>
<t t-set="searchPlaceholder">Search a document</t>
</t>
</t>
<t t-name="wysiwyg.widgets.image.existing.attachments">
<div class="o_we_existing_attachments o_we_images d-flex flex-wrap w-100 justify-content-between align-items-stretch my-0">
<t t-if="!widget.hasContent()">
<div t-if="widget.needle" class="o_nocontent_help">
<p class="o_empty_folder_image">No images found.</p>
<p class="o_empty_folder_subtitle">You can upload images with the button located in the top left of the screen.</p>
</div>
<div t-else="" class="o_we_search_prompt">
<h2>Get the perfect image by searching in our library of copyright free photos and illustrations.</h2>
</div>
</t>
<t t-else="">
<t t-if="['all', 'database'].includes(widget.searchService)" t-foreach="attachments" t-as="attachment">
<t t-call="wysiwyg.widgets.image.existing.attachment"/>
</t>
<t t-if="['all', 'media-library'].includes(widget.searchService)" t-foreach="libraryMedia" t-as="media">
<t t-call="wysiwyg.widgets.image.library_media"/>
</t>
<!-- 20 placeholders is just enough for a 5K screen, change this if ImageWidget.MIN_ROW_HEIGHT changes -->
<t t-foreach="20">
<div class="o_we_attachment_placeholder"/>
</t>
</t>
</div>
</t>
<t t-name="wysiwyg.widgets.image.existing.attachment">
<t t-set="isOptimized" t-value="!!attachment.original_id"/>
<div t-attf-class="o_existing_attachment_cell position-relative bg-light #{isOptimized and 'o_we_attachment_optimized d-none' or ''} align-items-center justify-content-center" t-att-data-id="attachment.id">
<t t-call="wysiwyg.widgets.file.existing.remove"/>
<div class="o_we_media_dialog_img_wrapper">
<img class="img img-fluid o_we_attachment_highlight" t-attf-src="#{attachment.thumbnail_src or attachment.image_src}" t-att-alt="attachment.name" t-att-title="attachment.name"/>
</div>
<span t-if="isOptimized" class="badge badge-success">Optimized</span>
</div>
</t>
<t t-name="wysiwyg.widgets.image.library_media">
<div t-attf-class="o_existing_attachment_cell position-relative bg-light align-items-center justify-content-center o_library_media_cell" t-att-data-media-id="media.id">
<div class="o_we_media_dialog_img_wrapper">
<img class="img img-fluid o_we_attachment_highlight" t-attf-src="#{media.thumbnail_url}" t-att-title="media.tooltip or ''" crossorigin="anonymous"/>
<a t-if="media.author" class="o_we_media_author" t-att-href="media.author_link" target="_blank" t-esc="media.author"/>
</div>
</div>
</t>
<t t-name="wysiwyg.widgets.document.existing.attachments">
<div class="o_we_existing_attachments o_we_documents">
<div t-if="!attachments.length" class="o_nocontent_help">
<p class="o_empty_folder_image">No documents found.</p>
<p class="o_empty_folder_subtitle">You can upload documents with the button located in the top left of the screen.</p>
</div>
<div t-else="" class="row mx-auto">
<t t-foreach="attachments" t-as="attachment">
<div class="col-2 o_existing_attachment_cell o_we_attachment_highlight my-2" t-att-data-id="attachment.id">
<t t-call="wysiwyg.widgets.file.existing.remove"/>
<div t-att-data-url="attachment.url" role="img" t-att-aria-label="attachment.name" t-att-title="attachment.name" t-att-data-mimetype="attachment.mimetype" t-attf-class="o_image d-flex align-items-center justify-content-center"/>
<small class="o_file_name" t-esc="attachment.name"/>
</div>
</t>
</div>
</div>
</t>
<t t-name="wysiwyg.widgets.file.existing.remove">
<t t-set="removeTitle" t-if="attachment.res_model === 'ir.ui.view'">This file is a public view attachment.</t>
<t t-set="removeTitle" t-else="">This file is attached to the current record.</t>
<i class="fa fa-trash o_existing_attachment_remove p-2" t-att-title="removeTitle" role="img" t-att-aria-label="removeTitle"/>
</t>
<t t-name="wysiwyg.widgets.image.existing.error">
<div class="form-text">
<p>The image could not be deleted because it is used in the
following pages or views:</p>
<ul t-as="view" t-foreach="views">
<li>
<a t-attf-href="/web#model=ir.ui.view&amp;id=#{view.id}">
<t t-esc="view.name"/>
</a>
</li>
</ul>
</div>
</t>
<t t-name="wysiwyg.widgets.upload.progressbar">
<div t-attf-class="o_we_progressbar js_progressbar_#{fileId}">
<small class="text-info d-flex align-items-center mr-2">
<span><i class="fa fa-circle-o-notch fa-spin mr-2"/></span>
<span class="font-italic text-truncate flex-grow-1 mr-2" t-esc="fileName"/>
<span class="font-weight-bold text-nowrap" t-esc="fileSize"/>
</small>
<small class='js_progressbar_txt d-flex align-items-center mt-1'>
<span class="text-success d-none"><i class='fa fa-check my-1 mr-1'/> File has been uploaded</span>
<span class="text-danger d-none"><i class='fa fa-times my-1 mr-1'/> <span class="o_we_error_text">File could not be saved</span></span>
</small>
<div class="progress">
<div class="progress-bar bg-info progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%;">0%</div>
</div>
<hr/>
</div>
</t>
<t t-name="wysiwyg.widgets.upload.progress_toast">
<div class="o_notification_manager">
<div t-attf-class="o_notification position-relative show fade mb-2 border border-info bg-white" role="alert" aria-live="assertive" aria-atomic="true">
<button type="button" class="o_notification_close btn" aria-label="Close" t-on-click="props.close">
<i class="oi oi-close"/>
</button>
<div class="o_notification_body pl-2 pr-4 py-2">
<div class="mr-auto o_notification_content"/>
</div>
</div>
</div>
</t>
<!-- Icon choosing part of the Media Dialog -->
<t t-name="wysiwyg.widgets.font-icons">
<form action="#">
<div class="form-inline align-items-center py-4">
<t t-call="wysiwyg.widgets.media.search">
<t t-set="searchPlaceholder">Search a pictogram</t>
</t>
</div>
<div class="font-icons-icons">
<t t-call="wysiwyg.widgets.font-icons.icons">
<t t-set="iconsParser" t-value="widget.iconsParser"/>
</t>
</div>
</form>
</t>
<t t-name="wysiwyg.widgets.font-icons.icons">
<t t-as="data" t-foreach="iconsParser">
<span t-foreach="data.cssData" t-as="cssData"
t-att-data-id="cssData.names[0]"
t-att-title="cssData.names[0]"
t-att-aria-label="cssData.names[0]" role="img"
t-attf-class="font-icons-icon #{data.base} #{cssData.names[0]}"
t-att-data-alias="cssData.names.join(',')"/>
</t>
</t>
<!-- Video choosing part of the Media Dialog -->
<t t-name="wysiwyg.widgets.video">
<form action="#" class="row">
<div class="col mt-4 o_video_dialog_form">
<div class="form-group mb-2" id="o_video_form_group">
<label class="col-form-label" for="o_video_text">
Video code <small>(URL or Embed)</small>
</label>
<div class="text-left">
<small class="text-muted">Accepts <b><i>Youtube</i></b>, <b><i>Vimeo</i></b>, <b><i>Dailymotion</i></b> and <b><i>Youku</i></b> videos</small>
</div>
<textarea class="form-control" id="o_video_text" placeholder="Copy-paste your URL or embed code here"/>
</div>
<div class="o_video_dialog_options d-none mt-4">
<div class="o_youtube_option o_vimeo_option o_dailymotion_option">
<label class="o_switch">
<input id="o_video_autoplay" type="checkbox"/><span/>
<span style="margin-right: 8px;">Autoplay</span>
<span class="small text-muted" style="margin-left: auto;">Videos are muted when autoplay is enabled</span>
</label>
</div>
<div class="o_youtube_option o_vimeo_option">
<label class="o_switch"><input id="o_video_loop" type="checkbox"/><span/>Loop</label>
</div>
<div class="o_youtube_option o_dailymotion_option">
<label class="o_switch"><input id="o_video_hide_controls" type="checkbox"/><span/>Hide player controls</label>
</div>
<div class="o_youtube_option">
<label class="o_switch"><input id="o_video_hide_fullscreen" type="checkbox"/><span/>Hide fullscreen button</label>
</div>
<div class="o_youtube_option">
<label class="o_switch"><input id="o_video_hide_yt_logo" type="checkbox"/><span/>Hide Youtube logo</label>
</div>
<div class="o_dailymotion_option">
<label class="o_switch"><input id="o_video_hide_dm_logo" type="checkbox"/><span/>Hide Dailymotion logo</label>
</div>
<div class="o_dailymotion_option">
<label class="o_switch"><input id="o_video_hide_dm_share" type="checkbox"/><span/>Hide sharing button</label>
</div>
</div>
<t t-if="widget._vimeoPreviewIds || widget._youtubePreviewIds">
<span class="font-weight-bold">Suggestions</span>
<div id="video-suggestion" class="mt-4 d-flex flex-wrap mh-75 overflow-auto">
<t t-foreach="widget._vimeoPreviewIds" t-as="vimeoId">
<div class="o_sample_video w-25 mh-100" t-att-data-vimeo="vimeoId"/>
</t>
</div>
</t>
</div>
<div class="col-md-6">
<div id="video-preview" class="p-3">
<div class="o_video_dialog_preview_text small mb-2 d-none">Preview</div>
<div class="media_iframe_video">
<div class="media_iframe_video_size"/>
<iframe class="o_video_dialog_iframe" allowfullscreen="allowfullscreen" frameborder="0" src=""/>
</div>
</div>
</div>
</form>
</t>
<!-- Link Dialog (allows to choose a style and content for a link on the page) -->
<div t-name="wysiwyg.widgets.link" class="o_link_dialog">
<div class="row">
@@ -8,11 +8,13 @@ var testUtils = require('web.test_utils');
var weTestUtils = require('web_editor.test_utils');
var core = require('web.core');
var Wysiwyg = require('web_editor.wysiwyg');
var MediaDialog = require('wysiwyg.widgets.MediaDialog');
const { MediaDialogWrapper } = require('@web_editor/components/media_dialog/media_dialog');
var LinkDialog = require('wysiwyg.widgets.LinkDialog');
const { legacyExtraNextTick, patchWithCleanup } = require("@web/../tests/helpers/utils");
const { useEffect } = owl;
var _t = core._t;
let _formResolveTestPromise;
@@ -306,7 +308,7 @@ QUnit.module('web_editor', {}, function () {
// text is selected
await openColorpicker('#toolbar .note-back-color-preview');
await testUtils.dom.click($('#toolbar .note-back-color-preview .o_we_color_btn.bg-o-color-3'));
await testUtils.dom.click($('#toolbar .note-back-color-preview [style="background-color: var(--we-cp-o-color-3);"]'));
assert.strictEqual($field.find('.note-editable').html(),
'<p>t<font style="background-color: rgb(0, 255, 255);">oto t</font><font style="" class="bg-o-color-3">oto to</font>to</p><p>tata</p>',
@@ -347,34 +349,36 @@ QUnit.module('web_editor', {}, function () {
await testUtils.form.clickEdit(form);
var $field = form.$('.oe_form_field[name="body"]');
// the dialog load some xml assets
var defMediaDialog = testUtils.makeTestPromise();
testUtils.mock.patch(MediaDialog, {
init: function () {
this._super.apply(this, arguments);
this.opened(defMediaDialog.resolve.bind(defMediaDialog));
}
});
var pText = $field.find('.note-editable p').first().contents()[0];
Wysiwyg.setRange(pText, 1, pText, 2);
await new Promise((resolve) => setTimeout(resolve));
const wysiwyg = $field.find('.note-editable').data('wysiwyg');
// Mock the MediaDialogWrapper
const defMediaDialog = testUtils.makeTestPromise();
patchWithCleanup(MediaDialogWrapper.prototype, {
setup() {
useEffect(() => {
this.save();
}, () => []);
},
save() {
const imageEl = document.createElement('img');
imageEl.src = '/web/image/123/transparent.png';
this.props.save(imageEl);
defMediaDialog.resolve();
},
});
wysiwyg.openMediaDialog();
// load static xml file (dialog, media dialog, unsplash image widget)
await defMediaDialog;
await testUtils.dom.click($('.modal #editor-media-image .o_existing_attachment_cell:first').removeClass('d-none'));
var $editable = form.$('.oe_form_field[name="body"] .note-editable');
assert.ok($editable.find('img')[0].dataset.src.includes('/web/image/123/transparent.png'),
"should have the image in the dom");
testUtils.mock.unpatch(MediaDialog);
form.destroy();
});
@@ -402,26 +406,30 @@ QUnit.module('web_editor', {}, function () {
await testUtils.form.clickEdit(form);
var $field = form.$('.oe_form_field[name="body"]');
// the dialog load some xml assets
var defMediaDialog = testUtils.makeTestPromise();
testUtils.mock.patch(MediaDialog, {
init: function () {
this._super.apply(this, arguments);
this.opened(defMediaDialog.resolve.bind(defMediaDialog));
}
});
var pText = $field.find('.note-editable p').first().contents()[0];
Wysiwyg.setRange(pText, 1, pText, 2);
const wysiwyg = $field.find('.note-editable').data('wysiwyg');
wysiwyg.openMediaDialog();
// load static xml file (dialog, media dialog, unsplash image widget)
// Mock the MediaDialogWrapper
const defMediaDialog = testUtils.makeTestPromise();
patchWithCleanup(MediaDialogWrapper.prototype, {
setup() {
useEffect(() => {
this.save();
}, () => []);
},
save() {
const iconEl = document.createElement('span');
iconEl.classList.add('fa', 'fa-glass');
this.props.save(iconEl);
defMediaDialog.resolve();
},
});
wysiwyg.openMediaDialog();
await defMediaDialog;
$('.modal .tab-content .tab-pane').removeClass('fade'); // to be sync in test
await testUtils.dom.click($('.modal a[aria-controls="editor-media-icon"]'));
await testUtils.dom.click($('.modal #editor-media-icon .font-icons-icon.fa-glass'));
var $editable = form.$('.oe_form_field[name="body"] .note-editable');
@@ -429,8 +437,6 @@ QUnit.module('web_editor', {}, function () {
'<p>t<span class="fa fa-glass"></span>to toto toto</p><p>tata</p>',
"should have the image in the dom");
testUtils.mock.unpatch(MediaDialog);
form.destroy();
});
+2 -2
View File
@@ -18,7 +18,7 @@
<span>Blocks</span>
</button>
<button type="button" tabindex="2" class="o_we_customize_snippet_btn text-uppercase">
<span>Style</span>
<span>Customize</span>
</button>
</div>
@@ -222,7 +222,7 @@
/>
</div>
<div data-js="BackgroundShape" string="Shape"
<div data-js="BackgroundShape"
t-att-data-selector="selector"
t-att-data-exclude="exclude"
t-att-data-target="target">
+4 -1
View File
@@ -47,7 +47,10 @@ function do_before_unload(if_unload_callback, if_not_unload_callback) {
function get_jquery_element_from_selector(selector) {
if (_.isString(selector) && selector.indexOf('iframe') !== -1) {
var $iframe = $(selector.split('iframe')[0] + ' iframe');
var $iframe = $(selector.split('iframe')[0] + ' iframe:not(.o_ignore_in_tour)');
if ($iframe.is('[is-ready="false"]')) {
return $();
}
var $el = $iframe.contents()
.find(selector.split('iframe')[1]);
$el.iframeContainer = $iframe[0];
+7 -4
View File
@@ -12,13 +12,16 @@
],
'auto_install': True,
'assets': {
'web_editor.assets_wysiwyg': [
'web_unsplash/static/src/js/unsplashapi.js',
'web_unsplash/static/src/js/unsplash_image_widget.js',
],
'web.assets_frontend': [
'web_unsplash/static/src/js/unsplash_beacon.js',
],
'web_editor.assets_media_dialog': [
'web_unsplash/static/src/components/media_dialog/*.js',
'web_unsplash/static/src/services/unsplash_service.js',
],
'web.assets_qweb': [
'web_unsplash/static/src/components/*/*.xml',
],
},
'license': 'LGPL-3',
}
@@ -0,0 +1,262 @@
/** @odoo-module **/
import { patch } from 'web.utils';
import { MediaDialog, TABS } from '@web_editor/components/media_dialog/media_dialog';
import { ImageSelector } from '@web_editor/components/media_dialog/image_selector';
import { useService } from '@web/core/utils/hooks';
import { uploadService, AUTOCLOSE_DELAY } from '@web_editor/components/upload_progress_toast/upload_service';
const { useState, Component } = owl;
class UnsplashCredentials extends Component {
setup() {
this.state = useState({
key: '',
appId: '',
hasKeyError: this.props.hasCredentialsError,
hasAppIdError: this.props.hasCredentialsError,
});
}
submitCredentials() {
if (this.state.key === '') {
this.state.hasKeyError = true;
} else if (this.state.appId === '') {
this.state.hasAppIdError = true;
} else {
this.props.submitCredentials(this.state.key, this.state.appId);
}
}
}
UnsplashCredentials.template = 'web_unsplash.UnsplashCredentials';
class UnsplashError extends Component {}
UnsplashError.template = 'web_unsplash.UnsplashError';
UnsplashError.components = {
UnsplashCredentials,
};
patch(ImageSelector.prototype, 'image_selector_unsplash', {
setup() {
this._super();
this.unsplash = useService('unsplash');
this.state.unsplashRecords = [];
this.state.isFetchingUnsplash = false;
this.state.isMaxed = false;
this.state.unsplashError = null;
this.errorMessages = {
'key_not_found': {
title: this.env._t("Setup Unsplash to access royalty free photos."),
subtitle: "",
},
401: {
title: this.env._t("Unauthorized Key"),
subtitle: this.env._t("Please check your Unsplash access key and application ID."),
},
403: {
title: this.env._t("Search is temporarily unavailable"),
subtitle: this.env._t("The max number of searches is exceeded. Please retry in an hour or extend to a better account."),
},
};
},
get canLoadMore() {
if (this.state.searchService === 'all') {
return this._super() || this.state.needle && !this.state.isMaxed && !this.state.unsplashError;
} else if (this.state.searchService === 'unsplash') {
return this.state.needle && !this.state.isMaxed && !this.state.unsplashError;
}
return this._super();
},
get hasContent() {
if (this.state.searchService === 'all') {
return this._super() || !!this.state.unsplashRecords.length;
} else if (this.state.searchService === 'unsplash') {
return !!this.state.unsplashRecords.length;
}
return this._super();
},
get errorTitle() {
if (this.errorMessages[this.state.unsplashError]) {
return this.errorMessages[this.state.unsplashError].title;
}
return this.env._t("Something went wrong");
},
get errorSubtitle() {
if (this.errorMessages[this.state.unsplashError]) {
return this.errorMessages[this.state.unsplashError].subtitle;
}
return this.env._t("Please check your internet connection or contact administrator.");
},
get selectedRecordIds() {
return this.props.selectedMedia[this.props.id].filter(media => media.mediaType === 'unsplashRecord').map(({ id }) => id);
},
// It seems that setters are mandatory when patching a component that
// extends another component.
set canLoadMore(_) {},
set hasContent(_) {},
set isFetching(_) {},
set selectedMediaIds(_) {},
set attachmentsDomain(_) {},
set errorTitle(_) {},
set errorSubtitle(_) {},
set selectedRecordIds(_) {},
async fetchUnsplashRecords(offset) {
this.state.isFetchingUnsplash = true;
if (!this.state.needle) {
return { records: [], isMaxed: false };
}
try {
const { isMaxed, images } = await this.unsplash.getImages(this.state.needle, offset, this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY);
this.state.isFetchingUnsplash = false;
this.state.unsplashError = false;
const records = images.map(record => {
const url = new URL(record.urls.regular);
// In small windows, row height could get quite a bit larger than the min, so we keep some leeway.
url.searchParams.set('h', 2 * this.MIN_ROW_HEIGHT);
url.searchParams.delete('w');
return Object.assign({}, record, {
url: url.toString(),
});
});
return { isMaxed, records };
} catch (e) {
this.state.isFetchingUnsplash = false;
this.state.unsplashError = e;
return { records: [], isMaxed: false };
}
},
async loadMore(...args) {
await this._super(...args);
const { records, isMaxed } = await this.fetchUnsplashRecords(this.state.unsplashRecords.length);
this.state.unsplashRecords.push(...records);
this.state.isMaxed = isMaxed;
},
async search(...args) {
await this._super(...args);
await this.searchUnsplash();
},
async searchUnsplash() {
if (!this.state.needle) {
this.state.unsplashError = false;
this.state.unsplashRecords = [];
this.state.isMaxed = false;
}
const { records, isMaxed } = await this.fetchUnsplashRecords(0);
this.state.unsplashRecords = records;
this.state.isMaxed = isMaxed;
},
async onClickRecord(media) {
this.props.selectMedia({ ...media, mediaType: 'unsplashRecord', query: this.state.needle });
if (!this.props.multiSelect) {
await this.props.save();
}
},
async submitCredentials(key, appId) {
this.state.unsplashError = null;
await this.rpc('/web_unsplash/save_unsplash', { key, appId });
await this.searchUnsplash();
},
});
ImageSelector.components = {
...ImageSelector.components,
UnsplashError,
};
patch(MediaDialog.prototype, 'media_dialog_unsplash', {
setup() {
this._super();
this.uploadService = useService('upload');
},
async save() {
const _super = this._super.bind(this);
const selectedImages = this.selectedMedia[TABS.IMAGES.id];
if (selectedImages) {
const unsplashRecords = selectedImages.filter(media => media.mediaType === 'unsplashRecord');
if (unsplashRecords.length) {
await this.uploadService.uploadUnsplashRecords(unsplashRecords, { resModel: this.props.resModel, resId: this.props.resId }, (attachments) => {
this.selectedMedia[TABS.IMAGES.id] = this.selectedMedia[TABS.IMAGES.id].filter(media => media.mediaType !== 'unsplashRecord');
this.selectedMedia[TABS.IMAGES.id] = this.selectedMedia[TABS.IMAGES.id].concat(attachments.map(attachment => ({...attachment, mediaType: 'attachment'})));
});
}
}
return _super(...arguments);
},
});
patch(uploadService, 'upload_service_unsplash', {
start(env, { rpc }) {
const service = this._super(...arguments);
return {
...service,
async uploadUnsplashRecords(records, { resModel, resId }, onUploaded) {
service.incrementId();
const file = service.addFile({
id: service.fileId,
name: records.length > 1 ?
_.str.sprintf(env._t("Uploading %s '%s' images."), records.length, records[0].query) :
_.str.sprintf(env._t("Uploading '%s' image."), records[0].query),
size: null,
progress: 0,
});
try {
const urls = {};
for (const record of records) {
const _1920Url = new URL(record.urls.regular);
_1920Url.searchParams.set('w', '1920');
urls[record.id] = {
url: _1920Url.href,
download_url: record.links.download_location,
description: record.alt_description,
};
}
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 attachments = await rpc('/web_unsplash/attachment/add', {
'res_id': resId,
'res_model': resModel,
'unsplashurls': urls,
'query': records[0].query,
}, {xhr});
if (attachments.error) {
file.hasError = true;
file.errorMessage = attachments.error;
} else {
file.uploaded = true;
await onUploaded(attachments);
}
setTimeout(() => service.deleteFile(file.id), AUTOCLOSE_DELAY);
} catch (error) {
file.hasError = true;
setTimeout(() => service.deleteFile(file.id), AUTOCLOSE_DELAY);
throw error;
}
}
};
}
});
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_unsplash.UnsplashError" owl="1">
<div class="alert alert-info w-100">
<h4><t t-esc="props.title"/></h4>
<p><t t-esc="props.subtitle"/></p>
<UnsplashCredentials t-if="props.showCredentials" submitCredentials="props.submitCredentials" hasCredentialsError="props.hasCredentialsError"/>
</div>
</t>
<t t-name="web_unsplash.UnsplashCredentials" owl="1">
<div class="form-inline">
<div class="form-group">
<span>
<a href="https://www.odoo.com/documentation/master/applications/websites/website/optimize/unsplash.html#generate-an-unsplash-access-key" target="_blank">Get an Access key</a>
and paste it here:
<input type="text"
class="form-control w-auto"
id="accessKeyInput"
placeholder="Paste your access key here"
t-model="state.key"
t-on-input="() => this.state.hasKeyError = false"
t-att-class="{ 'is-invalid': state.hasKeyError }"/>
</span>
</div>
<div class="form-group my-md-3">
<span class="ml-xl-1">
and paste
<a href="https://www.odoo.com/documentation/master/applications/websites/website/optimize/unsplash.html#generate-an-unsplash-application-id" target="_blank">Application ID</a>
here:
<input type="text"
class="form-control w-auto"
placeholder="Paste your application ID here"
t-model="state.appId"
t-on-input="() => this.state.hasAppIdError = false"
t-att-class="{ 'is-invalid': state.hasAppIdError }"/>
</span>
</div>
<button type="button" class="btn btn-primary btn-block w-auto ml-2 save_unsplash" t-on-click="() => this.submitCredentials()">Apply</button>
</div>
</t>
<t t-inherit="web_editor.ImagesListTemplate" t-inherit-mode="extension">
<xpath expr="//t[@t-foreach='state.libraryMedia']" position="after">
<t t-if="['all', 'unsplash'].includes(state.searchService)" t-foreach="state.unsplashRecords" t-as="record" t-key="record.id">
<AutoResizeImage src="record.url"
author="record.user.name"
authorLink="record.user.links.html"
name="record.user.name"
title="record.user.name"
altDescription="record.alt_description"
selected="this.selectedRecordIds.includes(record.id)"
onImageClick="() => this.onClickRecord(record)"
minRowHeight="MIN_ROW_HEIGHT"/>
</t>
</xpath>
</t>
<t t-inherit="web_editor.FileSelector" t-inherit-mode="extension">
<xpath expr="//div[@name='load_more_attachments']" position="after">
<div t-if="state.unsplashError" class="d-flex mt-2 unsplash_error">
<UnsplashError
title="errorTitle"
subtitle="errorSubtitle"
showCredentials="['key_not_found', 401].includes(state.unsplashError)"
submitCredentials="(key, appId) => this.submitCredentials(key, appId)"
hasCredentialsError="state.unsplashError === 401"/>
</div>
</xpath>
</t>
<t t-inherit="web_editor.FileSelectorControlPanel" t-inherit-mode="extension">
<xpath expr="//option[@value='media-library']" position="after">
<option t-att-selected="props.searchService === 'unsplash'" value="unsplash">Photos (via Unsplash)</option>
</xpath>
</t>
</templates>
@@ -1,269 +0,0 @@
odoo.define('web_unsplash.image_widgets', function (require) {
'use strict';
var core = require('web.core');
var UnsplashAPI = require('unsplash.api');
var widgetsMedia = require('wysiwyg.widgets.media');
const {_t} = require('web.core');
var unsplashAPI = null;
// Prevent base class from treating unsplash images like regular attachments
const originalEvents = widgetsMedia.ImageWidget.prototype.events;
const clickHandler = originalEvents['click .o_existing_attachment_cell'];
if (!clickHandler) {
throw new Error(`Couldn't find a handler for o_existing_attachment_cell clicks.
The unsplash image widget needs to prevent this handler from executing on unsplash attachments.`);
}
_.extend(originalEvents, {
'click .o_existing_attachment_cell:not(.o_unsplash_attachment_cell)': clickHandler,
});
delete originalEvents['click .o_existing_attachment_cell'];
widgetsMedia.ImageWidget.include({
xmlDependencies: widgetsMedia.ImageWidget.prototype.xmlDependencies.concat(
['/web_unsplash/static/src/xml/unsplash_image_widget.xml']
),
events: _.extend({}, widgetsMedia.ImageWidget.prototype.events, {
'click .o_unsplash_attachment_cell[data-imgid]': '_onUnsplashImgClick',
'click button.save_unsplash': '_onSaveUnsplashCredentials',
}),
/**
* @override
*/
init: function () {
this._super.apply(this, arguments);
this._unsplash = {
selectedImages: {},
isMaxed: false,
query: false,
error: false,
records: [],
};
// TODO improve this
//
// This is a `hack` to prevent the UnsplashAPI to be destroyed every
// time the media dialog is closed. Indeed, UnsplashAPI has a cache
// system to recude unsplash call, it is then better to keep its state
// to take advantage from it from one media dialog call to another.
//
// Unsplash API will either be (it's still being discussed):
// * a service (ideally coming with an improvement to not auto load
// the service)
// * initialized in the website_root (trigger_up)
if (unsplashAPI === null) {
this.unsplashAPI = new UnsplashAPI(this);
unsplashAPI = this.unsplashAPI;
} else {
this.unsplashAPI = unsplashAPI;
this.unsplashAPI.setParent(this);
}
},
/**
* @override
*/
destroy: function () {
// TODO See `hack` explained in `init`. This prevent the media dialog destroy
// to destroy unsplashAPI when destroying the children
this.unsplashAPI.setParent(undefined);
this._super.apply(this, arguments);
},
// --------------------------------------------------------------------------
// Public
// --------------------------------------------------------------------------
/**
* @override
*/
_save: async function () {
const _super = this._super;
const selectedImages = this._unsplash.selectedImages;
const imagesCount = Object.keys(selectedImages).length;
if (imagesCount) {
this.saved = true;
await this._setUpProgressToast([{
name: imagesCount > 1 ?
_.str.sprintf(_t("Uploading %s '%s' images."), imagesCount, this._unsplash.query) :
_.str.sprintf(_t("Uploading '%s' image."), this._unsplash.query),
size: null,
}]);
const images = await this.uploader.rpcShowProgress({
route: '/web_unsplash/attachment/add',
params: {
unsplashurls: selectedImages,
res_model: this.options.res_model,
res_id: this.options.res_id,
query: this._unsplash.query,
},
}, 0);
this.uploader.close(3000);
this.attachments.push(...images);
this.selectedAttachments.push(...images);
}
return _super.apply(this, arguments);
},
/**
* @override
*/
search: async function (needle) {
var self = this;
await this._super(...arguments);
this._unsplash.query = needle;
if (!needle) {
this._unsplash.records = [];
return;
}
await this.unsplashAPI.getImages(needle, this.numberOfAttachmentsToDisplay).then(function (res) {
self._unsplash.isMaxed = res.isMaxed;
self._unsplash.records = res.images;
self._unsplash.error = false;
}, function (err) {
self._unsplash.error = err;
});
},
/**
* @override
*/
hasContent() {
if (this.searchService === 'all') {
return this._super(...arguments) || (this.unsplashRecords && this.unsplashRecords.length);
} else if (this.searchService === 'unsplash') {
return (this.unsplashRecords && this.unsplashRecords.length);
}
return this._super(...arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
_highlightSelected: function () {
this._super.apply(this, arguments);
const $select = this.$('.o_unsplash_attachment_cell[data-imgid]').filter((i, el) => {
return $(el).data('imgid') in this._unsplash.selectedImages;
}).addClass('o_we_attachment_selected');
return $select;
},
/**
* @private
*/
_loadMoreImages: function (forceSearch) {
if (!this.$('.o_we_search').val()) {
return this._super(forceSearch);
}
this.numberOfAttachmentsToDisplay += 10;
this.search(this.$('.o_we_search').val()).then(() => this._renderThumbnails());
},
/**
* @override
*/
_renderThumbnails: function () {
this._super(...arguments);
this.$('.unsplash_error').empty();
if (!['all', 'unsplash'].includes(this.searchService)) {
return;
}
if (this._unsplash.query && this._unsplash.error) {
this.$('.unsplash_error').html(
core.qweb.render('web_unsplash.dialog.error.content', {
status: this._unsplash.error,
})
);
return;
}
if (['all', 'unsplash'].includes(this.searchService) && this._unsplash.query && !this._unsplash.isMaxed) {
this.$('.o_load_more').removeClass('d-none');
this.$('.o_load_done_msg').addClass('d-none');
}
},
/**
* @override
*/
_renderExisting: function (attachments) {
this.unsplashRecords = this._unsplash.records.map(record => {
const url = new URL(record.urls.regular);
// In small windows, row height could get quite a bit larger than the min, so we keep some leeway.
url.searchParams.set('h', 2 * this.MIN_ROW_HEIGHT);
url.searchParams.delete('w');
return Object.assign({}, record, {
url: url.toString(),
});
});
return this._super(...arguments);
},
/**
* @override
*/
_selectAttachement: function (attachment, save) {
if (!this.options.multiImages) {
this._unsplash.selectedImages = {};
}
this._super(...arguments);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onSaveUnsplashCredentials: function () {
var self = this;
var key = this.$('#accessKeyInput').val().trim();
var appId = this.$('#appIdInput').val().trim();
this.$('#accessKeyInput').toggleClass('is-invalid', !key);
this.$('#appIdInput').toggleClass('is-invalid', !appId);
if (key && appId) {
if (!this.$el.find('.is-invalid').length) {
this._rpc({
route: '/web_unsplash/save_unsplash',
params: {key: key, appId: appId},
}).then(function () {
self.unsplashAPI.clientId = key;
self._unsplash.error = false;
self.search(self._unsplash.query).then(() => self._renderThumbnails());
});
}
}
},
/**
* @private
*/
_onUnsplashImgClick: function (ev) {
if (this.saved) {
// already saved, probably a double click. Ignore.
return;
}
const {imgid, url, downloadUrl, description} = ev.currentTarget.dataset;
if (!this.options.multiImages) {
this._unsplash.selectedImages = {};
this.selectedAttachments = [];
}
if (imgid in this._unsplash.selectedImages) {
delete this._unsplash.selectedImages[imgid];
} else {
const _1920Url = new URL(url);
_1920Url.searchParams.set('w', '1920');
this._unsplash.selectedImages[imgid] = {url: _1920Url.href, download_url: downloadUrl, description: description};
}
this._highlightSelected();
if (!this.options.multiImages) {
this.trigger_up('save_request');
}
},
});
});
@@ -1,88 +0,0 @@
odoo.define('unsplash.api', function (require) {
'use strict';
var Class = require('web.Class');
var Mixins = require('web.mixins');
var ServicesMixin = require('web.ServicesMixin');
var UnsplashCore = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
/**
* @constructor
*/
init: function (parent) {
Mixins.EventDispatcherMixin.init.call(this, arguments);
this.setParent(parent);
this._cache = {};
this.clientId = false;
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Gets unsplash images from query string.
*
* @param {String} query search terms
* @param {Integer} pageSize number of image to display per page
* @returns {Promise}
*/
getImages: function (query, pageSize) {
var from = 0;
var to = pageSize;
var cachedData = this._cache[query];
if (cachedData && (cachedData.images.length >= to || (cachedData.totalImages !== 0 && cachedData.totalImages < to))) {
return Promise.resolve({ images: cachedData.images.slice(from, to), isMaxed: to > cachedData.totalImages });
}
return this._fetchImages(query).then(function (cachedData) {
return { images: cachedData.images.slice(from, to), isMaxed: to > cachedData.totalImages };
});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Fetches images from unsplash and stores it in cache
*
* @param {String} query search terms
* @returns {Promise}
* @private
*/
_fetchImages: function (query) {
if (!this._cache[query]) {
this._cache[query] = {
images: [],
maxPages: 0,
totalImages: 0,
pageCached: 0
};
}
var cachedData = this._cache[query];
var payload = {
query: query,
page: cachedData.pageCached + 1,
per_page: 30, // max size from unsplash API
};
return this._rpc({
route: '/web_unsplash/fetch_images',
params: payload,
}).then(function (result) {
if (result.error) {
return Promise.reject(result.error);
}
cachedData.pageCached++;
cachedData.images.push.apply(cachedData.images, result.results);
cachedData.maxPages = result.total_pages;
cachedData.totalImages = result.total;
return cachedData;
});
},
});
return UnsplashCore;
});
@@ -0,0 +1,53 @@
/** @odoo-module **/
import { registry } from '@web/core/registry';
export const unsplashService = {
dependencies: ['rpc'],
async start(env, { rpc }) {
const _cache = {};
return {
async getImages(query, offset = 0, pageSize = 30) {
const from = offset;
const to = offset + pageSize;
let cachedData = _cache[query];
if (cachedData && (cachedData.images.length >= to || (cachedData.totalImages !== 0 && cachedData.totalImages < to))) {
return { images: cachedData.images.slice(from, to), isMaxed: to > cachedData.totalImages };
}
cachedData = await this._fetchImages(query);
return { images: cachedData.images.slice(from, to), isMaxed: to > cachedData.totalImages };
},
/**
* Fetches images from unsplash and stores it in cache
*/
async _fetchImages(query) {
if (!_cache[query]) {
_cache[query] = {
images: [],
maxPages: 0,
totalImages: 0,
pageCached: 0
};
}
const cachedData = _cache[query];
const payload = {
query: query,
page: cachedData.pageCached + 1,
per_page: 30, // max size from unsplash API
};
const result = await rpc('/web_unsplash/fetch_images', payload);
if (result.error) {
return Promise.reject(result.error);
}
cachedData.pageCached++;
cachedData.images.push(...result.results);
cachedData.maxPages = result.total_pages;
cachedData.totalImages = result.total;
return cachedData;
},
};
},
};
registry.category('services').add('unsplash', unsplashService);
@@ -1,94 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-extend="wysiwyg.widgets.file">
<t t-jquery=".o_we_load_more" t-operation="after">
<div class="unsplash_error"></div>
</t>
</t>
<t t-extend="wysiwyg.widgets.image">
<t t-jquery="option[value='media-library']" t-operation="after">
<option value="unsplash">Photos (via Unsplash)</option>
</t>
</t>
<t t-extend="wysiwyg.widgets.image.existing.attachments">
<t t-jquery="t[t-foreach='libraryMedia']" t-operation="after">
<t t-if="['all', 'unsplash'].includes(widget.searchService)" t-foreach="widget.unsplashRecords" t-as="record">
<t t-call="web_unsplash.dialog.image.content"/>
</t>
</t>
</t>
<t t-name="web_unsplash.dialog.image.content">
<div class="o_existing_attachment_cell o_unsplash_attachment_cell position-relative align-items-center justify-content-center bg-light" t-att-data-imgid="record.id" t-att-data-id="record.id" t-att-data-url="record.urls.regular" t-att-data-download-url="record.links.download_location" t-att-data-description="record.alt_description">
<img class="img img-fluid o_we_attachment_highlight" t-att-src="record.url" t-att-alt="record.alt_description" style="max-height: 100%;"/>
<a class="o_we_media_author" t-att-href="record.user.links.html" target="_blank" t-esc="record.user.name" t-att-title="record.user.name"/>
</div>
</t>
<t t-name="web_unsplash.dialog.error.credentials">
<div class="alert alert-info w-100">
<h4><t t-esc="title"/></h4>
<p><t t-esc="subtitle"/></p>
<t t-if="showUnsplashSetup">
<div class="form-inline">
<div class="form-group">
<span>
<a href="https://www.odoo.com/documentation/master/applications/websites/website/optimize/unsplash.html#generate-an-unsplash-access-key" target="_blank">Get an Access key</a>
and paste it here:
<input type="text" class="form-control w-auto" id="accessKeyInput" placeholder="Paste your access key here"/>
</span>
</div>
<div class="form-group my-md-3">
<span class="ml-xl-1">
and paste
<a href="https://www.odoo.com/documentation/master/applications/websites/website/optimize/unsplash.html#generate-an-unsplash-application-id" target="_blank">Application ID</a>
here:
<input type="text" class="form-control w-auto" id="appIdInput" placeholder="Paste your application ID here"/>
</span>
</div>
<button type="button" class="btn btn-primary btn-block w-auto ml-2 save_unsplash">Apply</button>
</div>
</t>
</div>
</t>
<t t-name="web_unsplash.dialog.error.content">
<div class="d-flex mt-2 unsplash_error">
<t t-call="web_unsplash.dialog.error.credentials">
<t t-if="status == 'key_not_found'">
<t t-set="title">
Setup Unsplash to access royalty free photos.
</t>
<t t-set="showUnsplashSetup" t-value="true"/>
</t>
<t t-elif="status == 403">
<t t-set="title">
Search is temporarily unavailable
</t>
<t t-set="subtitle">
The max number of searches is exceeded. Please retry in an hour or extend to a better account.
</t>
</t>
<t t-elif="status == 401">
<t t-set="title">
Unauthorized Key
</t>
<t t-set="subtitle">
Please check your Unsplash access key and application ID.
</t>
<t t-set="showUnsplashSetup" t-value="true"/>
</t>
<t t-else="">
<t t-set="title">
Something went wrong
</t>
<t t-set="subtitle">
Please check your internet connection or contact administrator.
</t>
</t>
</t>
</div>
</t>
</templates>
+44 -31
View File
@@ -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',
}
+4
View File
@@ -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'):
+17 -56
View File
@@ -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/<string: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/<int: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/<path:path>'], 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),
}
# ------------------------------------------------------
@@ -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):
+1 -5
View File
@@ -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']
+6 -2
View File
@@ -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',
}
+30 -25
View File
@@ -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 <b>%s</b> contains a link to this page', page.url),
'content': Markup(_("Page <b>%s</b> 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 <b>%s (id:%s)</b> contains a link to this page') % (view.key or view.name, view.id),
'content': Markup(_('Template <b>%s (id:%s)</b> 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 <b>%s</b>', menu.name),
'content': Markup(_('This page is in the menu <b>%s</b>')) % 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 <b>%s</b> is calling this file', p.url),
'content': Markup(_('Page <b>%s</b> 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 <b>%s (id:%s)</b> is calling this file') % (view.key or view.name, view.id),
'content': Markup(_('Template <b>%s (id:%s)</b> 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,
+73 -93
View File
@@ -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():
@@ -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-(?<idx>[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-(?<idx>[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);
@@ -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;
@@ -3,7 +3,7 @@
<t t-name="website.Configurator.SkipButton" owl="1">
<div class="container-fluid py-2 pb-md-3 text-right pr-lg-5">
<button class="btn btn-link" t-on-click="skip">Skip and start from scratch</button>
<button class="btn btn-link" t-on-click="this.props.skip">Skip and start from scratch</button>
</div>
</t>
@@ -22,7 +22,7 @@
<button class="o_configurator_show btn btn-primary btn-lg px-4 py-2" t-on-click="goToDescription">Let's do it</button>
</div>
</div>
<SkipButton/>
<SkipButton skip="this.props.skip"/>
</div>
</t>
@@ -83,7 +83,7 @@
</div>
</div>
</div>
<SkipButton/>
<SkipButton skip="this.props.skip"/>
</div>
</t>
@@ -93,8 +93,8 @@
<img class="ml-lg-5" style="height: 31px; width: 99px;" src="/website/static/src/img/odoo_logo.svg" title="Odoo Logo"/>
</div>
<div class="o_configurator_screen_content container palette_selection d-flex flex-grow-1 h-100">
<div class="d-flex flex-column flex-lg-row w-100 h-100 h-lg-auto align-self-md-center o_configurator_show">
<div class="w-100 w-lg-25 order-lg-3 my-4 my-md-0 d-flex flex-column">
<div class="o_content_container d-flex flex-row w-100 h-auto align-self-md-center o_configurator_show">
<div class="o_detect_logo w-25 order-lg-3 my-4 my-md-0 d-flex flex-column">
<div class="h4 text-center">
<b>Detect</b> from Logo</div>
<div class="d-flex flex-column flex-grow-1 py-4">
@@ -122,17 +122,17 @@
</div>
</div>
</div>
<div class="position-relative d-flex justify-content-center order-lg-2 w-100 w-lg-0 py-3 py-lg-0 px-lg-5 mb-4 mb-lg-0">
<div class="o_palettes_separator position-relative d-flex justify-content-center order-lg-2 w-0 py-3 py-lg-0 px-lg-5 mb-4 mb-lg-0">
<div class="border-top w-100"></div>
<b class="palette_selection_or bg-white text-muted w-lg-100 text-center px-3 py-lg-3">OR</b>
<div class="border-left d-none d-lg-inline h-100 mx-auto w-0"></div>
</div>
<div class="w-100 w-lg-auto flex-grow-1 o_configurator_show_fast">
<div class="o_palettes_container w-auto flex-grow-1 o_configurator_show_fast">
<div class="h4 text-center">
<b>Choose</b> a pre-made Palette</div>
<div class="d-flex flex-wrap align-items-end">
<t t-foreach="state.getPalettes()" t-as="palette" t-key="palette_index">
<div class="w-50 w-md-25 px-2 pt-3">
<div class="o_palette_card_container w-25 px-2 pt-3">
<div t-attf-class="palette_card rounded-pill overflow-hidden d-flex {{state.getSelectedPaletteName() == palette.name ? 'selected' : ''}}"
t-on-click="() => this.selectPalette(palette.name)" t-attf-style="background-color: {{palette.color3}}">
<div class="color_sample w-100" t-attf-style="background-color: {{palette.color1}}"/>
@@ -145,7 +145,7 @@
</div>
</div>
</div>
<SkipButton/>
<SkipButton skip="this.props.skip"/>
</div>
</t>
@@ -162,7 +162,7 @@
<div class="w-100 page_feature_selection d-flex flex-wrap">
<t t-foreach="state.getFeatures()" t-as="feature" t-key="feature_index">
<t t-set='isInstalled' t-value="feature.module_state == 'installed'"/>
<div class="p-2 w-100 w-md-50 w-lg-25" t-if="feature.type != 'empty'">
<div class="o_feature_card_container p-2 w-25" t-if="feature.type != 'empty'">
<div t-attf-class="card h-100 {{isInstalled ? 'card_installed' : (feature.selected ? 'border-success' : '')}}" t-on-click="() => state.toggleFeature(feature.id)">
<div class="card-body py-2">
<t t-if="isInstalled">
@@ -187,7 +187,7 @@
</div>
</div>
</div>
<SkipButton/>
<SkipButton skip="this.props.skip"/>
</div>
</t>
@@ -238,11 +238,11 @@
<t t-name="website.Configurator.Configurator" owl="1">
<div class="o_configurator_container">
<DescriptionScreen t-if="router.location === '/website/configurator/2'"/>
<PaletteSelectionScreen t-elif="router.location === '/website/configurator/3'"/>
<FeaturesSelectionScreen t-elif="router.location === '/website/configurator/4'"/>
<ThemeSelectionScreen t-elif="router.location === '/website/configurator/5'"/>
<WelcomeScreen t-else=""/>
<DescriptionScreen t-if="state.currentStep === 2" navigate="(screen) => this.navigate(screen)" skip.bind="skipConfigurator"/>
<PaletteSelectionScreen t-elif="state.currentStep === 3" navigate="(screen) => this.navigate(screen)" skip.bind="skipConfigurator"/>
<FeaturesSelectionScreen t-elif="state.currentStep === 4" navigate="(screen) => this.navigate(screen)" skip.bind="skipConfigurator" clearStorage.bind="clearStorage"/>
<ThemeSelectionScreen t-elif="state.currentStep === 5" navigate="(screen) => this.navigate(screen)" clearStorage.bind="clearStorage"/>
<WelcomeScreen t-else="" navigate="(screen) => this.navigate(screen)" skip.bind="skipConfigurator"/>
</div>
</t>
@@ -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
// <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);
@@ -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;
}
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="website.BlockIframe" owl="1">
<t t-if="this.state.blockIframe">
<div class="o_block_iframe position-absolute d-flex justify-content-center align-items-center flex-column h-100 w-100" t-att-class="{ 'bg-black-50': this.state.showLoader }">
<t t-if="this.state.showLoader">
<div class="o_spinner mb-4">
<img src="/web/static/img/spin.svg" alt="Loading..."/>
</div>
</t>
</div>
</t>
</t>
<t t-name="website.WebsitePreview" owl="1">
<div class="o_website_preview" t-att-class="{ 'editor_enable editor_has_snippets': this.websiteContext.snippetsLoaded }" t-ref="container">
<div class="o_iframe_container" t-att-class="{ 'o_is_mobile': this.websiteContext.isMobile }">
<BlockIframe/>
<iframe t-att-src="iframeFallbackUrl"
class="o_ignore_in_tour"
t-att-class="{ 'd-none': this.websiteContext.edition }"
t-ref="iframefallback"/>
<iframe t-att-src="initialUrl"
class="o_iframe"
t-ref="iframe"
t-on-load="_onPageLoaded"
is-ready="false"
t-att-data-load-wysiwyg="this.websiteService.isPublisher ? 'true': 'false'"/>
</div>
<WebsiteEditorComponent t-if="websiteContext.edition"
reloadIframe.bind="this.reloadIframe"
removeWelcomeMessage.bind="this.removeWelcomeMessage"/>
<WebsiteTranslator t-if="websiteContext.translation"
reloadIframe.bind="this.reloadIframe"
removeWelcomeMessage.bind="this.removeWelcomeMessage"/>
</div>
</t>
<div t-name="website.homepage_editor_welcome_message" class="container text-center o_homepage_editor_welcome_message">
<h2 class="mt0">Welcome to your <b>Homepage</b>!</h2>
<p class="lead d-none d-md-block">Click on <b>Edit</b> in the top right corner to start designing.</p>
</div>
</templates>
@@ -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();
},
}
];
}
}
@@ -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;
}
}
@@ -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`
<WebsiteDialog
title="title"
primaryTitle="primaryTitle"
primaryClick="() => this.addPage()"
close="props.close">
<div class="form-group row">
<label class="col-form-label col-md-3">
Page Title
</label>
<div class="col-md-9">
<input type="text" t-model="state.name" class="form-control" required="required" t-ref="autofocus"/>
</div>
</div>
<Switch extraClasses="'offset-md-3 col-md-9 text-left'" label="switchLabel" value="state.addMenu" onChange="(value) => this.onChangeAddMenu(value)"/>
</WebsiteDialog>
`;
@@ -0,0 +1,5 @@
.o_website_dialog {
label {
font-weight: $font-weight-bold;
}
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="website.WebsiteDialog" owl="1">
<Dialog contentClass="contentClasses"
size="props.size"
title="props.title">
<t t-set-slot="default">
<t t-if="props.slots and props.slots.default" t-slot="default"/>
<t t-else="" t-esc="props.body"/>
</t>
<t t-set-slot="footer">
<t t-if="props.slots and props.slots.footer" t-slot="footer"/>
<t t-else="">
<button class="btn btn-primary" t-on-click="primaryClick">
<t t-esc="props.primaryTitle"/>
</button>
<button t-if="props.showSecondaryButton" class="btn btn-secondary" t-on-click="secondaryClick">
<t t-esc="props.secondaryTitle"/>
</button>
</t>
</t>
</Dialog>
</t>
</templates>
@@ -0,0 +1,237 @@
/** @odoo-module **/
import { useService, useAutofocus } from '@web/core/utils/hooks';
import wUtils from 'website.utils';
import { WebsiteDialog } from './dialog';
const { Component, useState, useEffect, onWillStart, useRef, onMounted } = owl;
const useControlledInput = (initialValue, validate) => {
const input = useState({
value: initialValue,
hasError: false,
});
const isValid = () => {
if (validate(input.value)) {
return true;
}
input.hasError = true;
return false;
};
useEffect(() => {
input.hasError = false;
}, () => [input.value]);
return {
input,
isValid,
};
};
export class MenuDialog extends Component {
setup() {
this.rpc = useService('rpc');
this.website = useService('website');
this.title = this.env._t("Add a menu item");
useAutofocus();
this.name = useControlledInput(this.props.name, value => !!value);
this.url = useControlledInput(this.props.url, value => !!value);
this.urlInputRef = useRef('url-input');
useEffect(() => {
const $input = $(this.urlInputRef.el);
// This is only there to avoid changing the
// wUtils.autocompleteWithPages api
const fakeWidget = {
_rpc: ({ route, params }) => this.rpc(route, params),
trigger_up: () => {
this.url.input.value = this.urlInputRef.el.value;
},
};
const options = {
body: this.website.pageDocument.body,
classes: {
'ui-autocomplete': 'o_edit_menu_autocomplete'
},
};
wUtils.autocompleteWithPages(fakeWidget, $input, options);
return () => $input.urlcomplete('destroy');
}, () => []);
}
onClickOk() {
if (this.name.isValid()) {
if (this.props.isMegaMenu || this.url.isValid()) {
this.props.save(this.name.input.value, this.url.input.value);
this.props.close();
}
}
}
}
MenuDialog.template = 'website.MenuDialog';
MenuDialog.props = {
name: { type: String, optional: true },
url: { type: String, optional: true },
isMegaMenu: { type: Boolean, optional: true },
save: Function,
close: Function,
};
MenuDialog.components = { WebsiteDialog };
class MenuRow extends Component {
edit() {
this.props.edit(this.props.menu.fields['id']);
}
delete() {
this.props.delete(this.props.menu.fields['id']);
}
}
MenuRow.props = {
menu: Object,
edit: Function,
delete: Function,
};
MenuRow.template = 'website.MenuRow';
MenuRow.components = {
MenuRow,
};
export class EditMenuDialog extends Component {
setup() {
this.orm = useService('orm');
this.website = useService('website');
this.dialogs = useService('dialog');
this.title = this.env._t("Edit Menu");
this.saveButton = this.env._t("Save");
this.menuEditor = useRef('menu-editor');
this.state = useState({ rootMenu: {} });
onWillStart(async () => {
const menu = await this.orm.call('website.menu', 'get_tree', [this.website.currentWebsite.id, this.props.rootID]);
this.state.rootMenu = menu;
this.map = new Map();
this.populate(this.map, this.state.rootMenu);
this.toDelete = [];
});
onMounted(() => {
this.$sortables = $(this.menuEditor.el);
this.$sortables.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.isMegaMenu && !placeholderParent[0].dataset.isMegaMenu;
},
});
});
}
populate(map, menu) {
map.set(menu.fields['id'], menu);
for (const submenu of menu.children) {
this.populate(map, submenu);
}
}
addMenu(isMegaMenu) {
this.dialogs.add(MenuDialog, {
isMegaMenu,
save: (name, url, isNewWindow) => {
const newMenu = {
fields: {
id: `menu_${(new Date).toISOString()}`,
name,
url: isMegaMenu ? '#' : url,
new_window: isNewWindow,
'is_mega_menu': isMegaMenu,
sequence: 0,
'parent_id': false,
},
'children': [],
'is_homepage': false,
};
this.map.set(newMenu.fields['id'], newMenu);
this.state.rootMenu.children.push(newMenu);
},
});
}
editMenu(id) {
const menuToEdit = this.map.get(id);
this.dialogs.add(MenuDialog, {
name: menuToEdit.fields['name'],
url: menuToEdit.fields['url'],
isMegaMenu: menuToEdit.fields['is_mega_menu'],
save: (name, url) => {
menuToEdit.fields['name'] = name;
menuToEdit.fields['url'] = url;
// Forces a rerender
this.state.rootMenu.children = [...this.state.rootMenu.children];
},
});
}
deleteMenu(id) {
const menuToDelete = this.map.get(id);
const parentId = menuToDelete.fields['parent_id'] || this.state.rootMenu.fields['id'];
const parent = this.map.get(parentId);
parent.children = parent.children.filter(menu => menu.fields['id'] !== id);
this.map.delete(id);
this.toDelete.push(id);
}
async onClickSave() {
const newMenus = this.$sortables.nestedSortable('toArray', {startDepthCount: 0});
const levels = [];
const data = [];
// Resequence, re-tree and remove useless data
for (const menu of newMenus) {
if (menu.id) {
levels[menu.depth] = (levels[menu.depth] || 0) + 1;
const {fields: menuFields} = this.map.get(menu.id) || this.map.get(parseInt(menu.id, 10));
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.state.rootMenu.fields['id'];
data.push(menuFields);
}
}
await this.orm.call('website.menu', 'save', [
this.website.currentWebsite.id,
{
'data': data,
'to_delete': this.toDelete,
}
]);
if (this.props.save) {
this.props.save();
} else {
this.website.goToWebsite();
}
}
}
EditMenuDialog.template = 'website.EditMenuDialog';
EditMenuDialog.components = {
MenuRow,
WebsiteDialog,
};
@@ -0,0 +1,16 @@
.ui-autocomplete.o_edit_menu_autocomplete {
max-height: 50vh;
overflow-y: auto;
overflow-x: hidden;
.ui-menu-item {
padding: 0;
> .ui-state-active {
border: none;
font-weight: normal;
margin: 0;
}
}
.font-weight-bold {
font-weight: 700 !important;
}
}
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="website.MenuDialog" owl="1">
<WebsiteDialog title="title" closeOnClick="false" primaryClick.bind="this.onClickOk" secondaryClick="props.close">
<div class="o_menu_dialog_form form-group row">
<label class="col-form-label col-md-3">
Name
</label>
<div class="col-md-9">
<input type="text" t-model="name.input.value" class="form-control" t-att-class="{ 'is-invalid': name.input.hasError }" t-ref="autofocus"/>
</div>
</div>
<div t-if="!props.isMegaMenu" class="form-group row">
<label class="col-form-label col-md-3">
Url or Email
</label>
<div class="col-md-9">
<input id="url_input" t-ref="url-input" type="text" t-model="url.input.value" class="form-control" t-att-class="{ 'is-invalid': url.input.hasError }"/>
</div>
</div>
</WebsiteDialog>
</t>
<t t-name="website.MenuRow" owl="1">
<li t-att-data-menu-id="props.menu.fields['id']" t-att-data-is-mega-menu="props.menu.fields['is_mega_menu'] ? 'true' : undefined">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text fa fa-bars" role="img" aria-label="Dropdown menu" title="Dropdown menu"/>
</div>
<span class="form-control d-flex align-items-center">
<span class="js_menu_label o_text_overflow flex-grow-1">
<t t-esc="props.menu.fields['name']"/>
</span>
<span t-if="props.menu.fields['is_mega_menu']" class="badge badge-primary">Mega Menu</span>
<i t-if="props.menu.is_homepage" class="fa fa-home ml-3" role="img" aria-label="Home" title="Home"/>
</span>
<span class="input-group-append">
<button t-on-click="edit" type="button" class="btn btn-primary js_edit_menu fa fa-pencil-square-o" aria-label="Edit Menu Item" title="Edit Menu Item"/>
<button t-on-click="delete" type="button" class="btn btn-danger js_delete_menu fa fa-trash-o" aria-label="Delete Menu Item" title="Delete Menu Item"/>
</span>
</div>
<ul t-if="props.menu.children">
<t t-foreach="props.menu.children" t-as="submenu" t-key="submenu.fields['id']">
<MenuRow menu="submenu" edit="props.edit" delete="props.delete"/>
</t>
</ul>
</li>
</t>
<t t-name="website.EditMenuDialog" owl="1">
<WebsiteDialog close="props.close" title="title" primaryTitle="saveButton" primaryClick="() => this.onClickSave()">
<ul t-ref="menu-editor" class="oe_menu_editor list-unstyled">
<t t-foreach="state.rootMenu.children" t-as="menu" t-key="menu.fields['id']">
<MenuRow menu="menu" edit="(id) => this.editMenu(id)" delete="(id) => this.deleteMenu(id)"/>
</t>
</ul>
<div class="mt32">
<small class="float-right text-muted">
Drag to the right to get a submenu
</small>
<a href="#" t-on-click="() => this.addMenu(false)">
<i class="fa fa-plus-circle"/> Add Menu Item
</a><br/>
<a href="#" t-on-click="() => this.addMenu(true)">
<i class="fa fa-plus-circle"/> Add Mega Menu Item
</a>
</div>
</WebsiteDialog>
</t>
</templates>

Some files were not shown because too many files have changed in this diff Show More