diff --git a/addons/mass_mailing/__manifest__.py b/addons/mass_mailing/__manifest__.py index dc7d96a7e4cab..c597be1188853 100644 --- a/addons/mass_mailing/__manifest__.py +++ b/addons/mass_mailing/__manifest__.py @@ -149,7 +149,11 @@ ], 'mass_mailing.assets_wysiwyg': [ 'mass_mailing/static/src/js/mass_mailing_snippets.js', + 'mass_mailing/static/src/js/snippets.options.xml', + 'mass_mailing/static/src/snippets/s_alert/options.js', + 'mass_mailing/static/src/snippets/s_alert/options.xml', 'mass_mailing/static/src/snippets/s_masonry_block/options.js', + 'mass_mailing/static/src/snippets/s_masonry_block/options.xml', 'mass_mailing/static/src/snippets/s_media_list/options.js', 'mass_mailing/static/src/snippets/s_showcase/options.js', 'mass_mailing/static/src/snippets/s_rating/options.js' @@ -157,6 +161,7 @@ 'mass_mailing.assets_snippets_menu': [ ('include', 'web_editor.assets_snippets_menu'), 'mass_mailing/static/src/js/snippets.editor.js', + 'mass_mailing/static/src/js/snippets.registry.js', 'mass_mailing/static/src/xml/mass_mailing.editor.xml', ], 'web.assets_frontend': [ @@ -171,6 +176,7 @@ 'mass_mailing/static/src/snippets/s_media_list/options.js', 'mass_mailing/static/src/snippets/s_showcase/options.js', 'mass_mailing/static/src/snippets/s_rating/options.js', + 'mass_mailing/static/src/snippets/s_rating/options.xml', 'mass_mailing/static/tests/mass_mailing_html_tests.js', ], }, diff --git a/addons/mass_mailing/static/src/js/mass_mailing_snippets.js b/addons/mass_mailing/static/src/js/mass_mailing_snippets.js index fede7a6f9cbf6..aa11013cef18e 100644 --- a/addons/mass_mailing/static/src/js/mass_mailing_snippets.js +++ b/addons/mass_mailing/static/src/js/mass_mailing_snippets.js @@ -1,7 +1,8 @@ /** @odoo-module **/ -import options from "@web_editor/js/editor/snippets.options"; +import options from "@web_editor/js/editor/snippets.options.legacy"; import { loadImage } from "@web_editor/js/editor/image_processing"; +import { registerSnippetAdditionSelector } from "@web_editor/js/editor/snippets.registry"; const SelectUserValueWidget = options.userValueWidgetsRegistry['we-select']; import weUtils from "@web_editor/js/common/utils"; import { @@ -11,12 +12,15 @@ import { transformFontFamilySelector, } from "@mass_mailing/js/mass_mailing_design_constants"; import { isCSSColor, normalizeCSSColor } from "@web/core/utils/colors"; +import { registerMassMailingOption } from "./snippets.registry"; //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- +registerSnippetAdditionSelector(".o_mail_snippet_general"); + // Adding compatibility for the outlook compliance of mailings. // Commit of such compatibility : a14f89c8663c9cafecb1cc26918055e023ecbe42 options.registry.MassMailingBackgroundImage = options.registry.BackgroundImage.extend({ @@ -322,3 +326,13 @@ options.registry.DesignTab = options.Class.extend({ return [...(this.styleSheet.cssRules || this.styleSheet.rules)].find(rule => rule.selectorText === selectorText); }, }); + +registerMassMailingOption("mass_mailing_block_width", { + template: "mass_mailing.block_width_option", + selector: ".s_mail_blockquote, .s_mail_text_highlight", +}); + +registerMassMailingOption("mass_mailing_block_align", { + template: "mass_mailing.block_align_option", + selector: ".s_mail_blockquote, .s_mail_text_highlight", +}); diff --git a/addons/mass_mailing/static/src/js/snippets.editor.js b/addons/mass_mailing/static/src/js/snippets.editor.js index 7df787c1d05eb..57768dfe15f1c 100644 --- a/addons/mass_mailing/static/src/js/snippets.editor.js +++ b/addons/mass_mailing/static/src/js/snippets.editor.js @@ -84,6 +84,15 @@ export class MassMailingSnippetsMenu extends snippetsEditor.SnippetsMenu { html.querySelectorAll('img').forEach(img => img.setAttribute("loading", "lazy")); return super._computeSnippetTemplates(html); } + /** + * @override + */ + getOptions() { + const options = super.getOptions().filter(([, option]) => { + return ["mass_mailing", "web_editor"].includes(option.module); + }); + return options; + } /** * @override */ diff --git a/addons/mass_mailing/static/src/js/snippets.options.xml b/addons/mass_mailing/static/src/js/snippets.options.xml new file mode 100644 index 0000000000000..be6a2a7266eb0 --- /dev/null +++ b/addons/mass_mailing/static/src/js/snippets.options.xml @@ -0,0 +1,62 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + +<t t-name="mass_mailing.block_width_option"> + <WeSelect title.translate="Width"> + <WeButton selectClass="'w-25'">25%</WeButton> + <WeButton selectClass="'w-50'">50%</WeButton> + <WeButton selectClass="'w-75'">75%</WeButton> + <WeButton selectClass="'w-100'" name="'so_width_100'">100%</WeButton> + </WeSelect> +</t> + +<t t-name="mass_mailing.block_align_option"> + <WeButtonGroup title.translate="Alignment" dependencies="'!so_width_100'"> + <WeButton class="'fa fa-fw fa-align-left'" tooltip.translate="Left" selectClass="'me-auto'"/> + <WeButton class="'fa fa-fw fa-align-center'" tooltip.translate="Center" selectClass="'mx-auto'"/> + <WeButton class="'fa fa-fw fa-align-right'" tooltip.translate="Right" selectClass="'ms-auto'"/> + </WeButtonGroup> +</t> + +<!-- Border --> +<t t-name="mass_mailing.snippet_options_border_line_widgets"> + <WeRow title="label"> + <WeInput name="'border_width_opt'" + applyTo="apply_to || ''" + selectStyle="'0'" + cssProperty="'border-' + (direction ? direction + '-' : '') + 'width'" + unit="'px'" + extraClass="with_bs_class ? 'border' : ''" + variable="width_variable || ''"/> + <WeSelect dependencies="'border_width_opt'" + cssProperty="'border-' + (direction ? direction + '-' : '') + 'style'" + applyTo="apply_to || ''" + variable="style_variable || ''"> + <WeButton tooltip.translate="Solid" selectStyle="'solid'"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: solid;"/></WeButton> + <WeButton tooltip.translate="Dashed" selectStyle="'dashed'"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: dashed;"/></WeButton> + <WeButton tooltip.translate="Dotted" selectStyle="'dotted'"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: dotted;"/></WeButton> + <WeButton tooltip.translate="Double" selectStyle="'double'"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: double; border-left: none; border-right: none;"/></WeButton> + </WeSelect> + <WeColorpicker dependencies="'border_width_opt'" + applyTo="apply_to || ''" + selectStyle="'true'" + cssProperty="'border-' + (direction ? direction + '-' : '') + 'color'" + colorPrefix="'border-'" + color="color_variable || ''"/> + </WeRow> +</t> + +<t t-name="mass_mailing.snippet_options_border_widgets"> + <t t-call="mass_mailing.snippet_options_border_line_widgets"> + <t t-set="label">Border</t> + <t t-set="with_bs_class" t-value="true"/> + </t> + <WeInput title.translate="Round Corners" + applyTo="apply_to || ''" + dependencies="!so_rounded_no_dependencies and 'border_width_opt,bg_color_opt'" + selectStyle="'0'" cssProperty="'border-radius'" + unit="'px'" extraClass="'rounded'" + variable="radius_variable || ''"/> +</t> + +</templates> diff --git a/addons/mass_mailing/static/src/js/snippets.registry.js b/addons/mass_mailing/static/src/js/snippets.registry.js new file mode 100644 index 0000000000000..83bfdb882eab6 --- /dev/null +++ b/addons/mass_mailing/static/src/js/snippets.registry.js @@ -0,0 +1,8 @@ +import { registerOption } from "@web_editor/js/editor/snippets.registry"; + +export function registerMassMailingOption(name, def, options) { + if (!def.module) { + def.module = "mass_mailing"; + } + return registerOption(name, def, options); +} diff --git a/addons/mass_mailing/static/src/snippets/s_alert/002.scss b/addons/mass_mailing/static/src/snippets/s_alert/002.scss new file mode 100644 index 0000000000000..8448c31a0eb22 --- /dev/null +++ b/addons/mass_mailing/static/src/snippets/s_alert/002.scss @@ -0,0 +1,36 @@ +.o_mail_snippet_general .s_mail_alert [class^="col-lg"]{ + padding-left: 0!important; + padding-right: 0!important; +} + +.s_mail_alert { + .s_alert { + border-width: $alert-border-width; + border-style: solid; + border-radius: $alert-border-radius; + p, ul, ol { + &:last-child { + margin-bottom: 0; + } + } + } + .s_alert_sm { + padding: $grid-gutter-width/3; + font-size: $font-size-sm; + } + .s_alert_md { + padding: $grid-gutter-width/2; + font-size: $font-size-base; + } + .s_alert_lg { + padding: $grid-gutter-width; + font-size: $font-size-lg; + } + .s_alert_icon { + float: left; + margin-right: 10px; + } + .s_alert_content { + overflow: hidden; + } +} diff --git a/addons/mass_mailing/static/src/snippets/s_alert/options.js b/addons/mass_mailing/static/src/snippets/s_alert/options.js new file mode 100644 index 0000000000000..827158059cbf4 --- /dev/null +++ b/addons/mass_mailing/static/src/snippets/s_alert/options.js @@ -0,0 +1,6 @@ +import { registerMassMailingOption } from "@mass_mailing/js/snippets.registry"; + +registerMassMailingOption("MassMailingAlert", { + template: "mass_mailing.s_alert_options", + selector: ".s_mail_alert .s_alert", +}); diff --git a/addons/mass_mailing/static/src/snippets/s_alert/options.xml b/addons/mass_mailing/static/src/snippets/s_alert/options.xml new file mode 100644 index 0000000000000..741773e6a184f --- /dev/null +++ b/addons/mass_mailing/static/src/snippets/s_alert/options.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="mass_mailing.s_alert_options"> + <t t-call="mass_mailing.block_width_option"/> + <WeSelect title.translate="Size"> + <WeButton selectClass="'s_alert_sm'">Small</WeButton> + <WeButton selectClass="'s_alert_md'">Medium</WeButton> + <WeButton selectClass="'s_alert_lg'">Large</WeButton> + </WeSelect> + <WeColorpicker title.translate="Background Color" + name="'alert_colorpicker_opt'" + selectStyle="'true'" + cssProperty="'background-color'" + colorPrefix="'alert-'"/> + <t t-call="mass_mailing.snippet_options_border_widgets"> + <t t-set="so_rounded_no_dependencies" t-value="True"/> + </t> + <t t-call="mass_mailing.block_align_option"/> + </t> + +</templates> diff --git a/addons/mass_mailing/static/src/snippets/s_masonry_block/options.js b/addons/mass_mailing/static/src/snippets/s_masonry_block/options.js index f9d5a83076497..734e0c7ea49ea 100644 --- a/addons/mass_mailing/static/src/snippets/s_masonry_block/options.js +++ b/addons/mass_mailing/static/src/snippets/s_masonry_block/options.js @@ -1,14 +1,18 @@ -/** @odoo-module **/ +import { SelectTemplate } from "@web_editor/js/editor/snippets.options"; +import { registerMassMailingOption } from "@mass_mailing/js/snippets.registry"; -import options from "@web_editor/js/editor/snippets.options"; - -options.registry.MasonryLayout = options.registry.SelectTemplate.extend({ - /** - * @constructor - */ - init() { - this._super(...arguments); +export class MasonryLayout extends SelectTemplate { + constructor() { + super(...arguments); this.containerSelector = '> .container, > .container-fluid, > .o_container_small'; this.selectTemplateWidgetName = 'masonry_template_opt'; - }, + } +} + +registerMassMailingOption("MassMailingMasonryLayout", { + Class: MasonryLayout, + template: "mass_mailing.s_masonry_block_options", + selector: ".s_masonry_block", +}, { + sequence: 10, }); diff --git a/addons/mass_mailing/static/src/snippets/s_masonry_block/options.xml b/addons/mass_mailing/static/src/snippets/s_masonry_block/options.xml new file mode 100644 index 0000000000000..4422cdbc5a8e0 --- /dev/null +++ b/addons/mass_mailing/static/src/snippets/s_masonry_block/options.xml @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="mass_mailing.s_masonry_block_options" inherit_id="mass_mailing.snippet_options"> + <WeSelect title.translate="Template" + name="'masonry_template_opt'" + attributeName="'masonryTemplate'" + attributeDefaultValue="'default'"> + <WeButton tooltip.translate="Default" + selectTemplate="'mass_mailing.s_masonry_block_default_template'" + selectDataAttribute="'default'" + img="'/mass_mailing/static/src/img/snippets_options/masonry_template_default.svg'"/> + <WeButton tooltip.translate="Default Reversed" + selectTemplate="'mass_mailing.s_masonry_block_reversed_template'" + selectDataAttribute="'default_reversed'" + img="'/mass_mailing/static/src/img/snippets_options/masonry_template_reversed.svg'"/> + <WeButton tooltip.translate="Images" + selectTemplate="'mass_mailing.s_masonry_block_images_template'" + selectDataAttribute="'images'" + img="'/mass_mailing/static/src/img/snippets_options/masonry_template_images.svg'"/> + <WeButton tooltip.translate="Image Text Image" + selectTemplate="'mass_mailing.s_masonry_block_image_texts_image_template'" + selectDataAttribute="'image_text_image'" + img="'/mass_mailing/static/src/img/snippets_options/masonry_template_image_texts_image.svg'"/> + <WeButton tooltip.translate="Mosaic" + selectTemplate="'mass_mailing.s_masonry_block_mosaic_template'" + selectDataAttribute="'mosaic'" + img="'/mass_mailing/static/src/img/snippets_options/masonry_template_mosaic.svg'"/> + <WeButton tooltip.translate="Text Image Text" + selectTemplate="'mass_mailing.s_masonry_block_texts_image_texts_template'" + selectDataAttribute="'text_image_text'" + img="'/mass_mailing/static/src/img/snippets_options/masonry_template_texts_image_texts.svg'"/> + <WeButton tooltip.translate="Alternate Text" + selectTemplate="'mass_mailing.s_masonry_block_alternation_text_template'" + selectDataAttribute="'alternate_text'" + img="'/mass_mailing/static/src/img/snippets_options/masonry_template_alternate_texts.svg'"/> + <WeButton tooltip.translate="Alternate Text Image" + selectTemplate="'mass_mailing.s_masonry_block_alternation_text_image_template'" + selectDataAttribute="'alternate_text_image'" + img="'/mass_mailing/static/src/img/snippets_options/masonry_template_alternate_text_image.svg'"/> + <WeButton tooltip.translate="Alternate Image Text" + selectTemplate="'mass_mailing.s_masonry_block_alternation_image_text_template'" + selectDataAttribute="'alternate_image_text'" + img="'/mass_mailing/static/src/img/snippets_options/masonry_template_alternate_image_text.svg'"/> + <WeButton tooltip.translate="Alternate Text Image Text" + selectTemplate="'mass_mailing.s_masonry_block_alternation_text_image_text_template'" + selectDataAttribute="'alternate_text_image_text'" + img="'/mass_mailing/static/src/img/snippets_options/masonry_template_alternate_text_image_text.svg'"/> + </WeSelect> + </t> + +</templates> diff --git a/addons/mass_mailing/static/src/snippets/s_media_list/options.js b/addons/mass_mailing/static/src/snippets/s_media_list/options.js index 66030ae3bbf32..34c343e354e46 100644 --- a/addons/mass_mailing/static/src/snippets/s_media_list/options.js +++ b/addons/mass_mailing/static/src/snippets/s_media_list/options.js @@ -1,8 +1,9 @@ -/** @odoo-module **/ +import { SnippetOption } from "@web_editor/js/editor/snippets.options"; -import options from "@web_editor/js/editor/snippets.options"; - -options.registry.MediaItemLayout = options.Class.extend({ +// FIXME: this is unused (no option definition in mass_mailing), +// see addons/website/static/src/snippets/s_media_list/options.js +// This is never attached to any snippet (no selector). +export class MediaItemLayout extends SnippetOption { //-------------------------------------------------------------------------- // Options @@ -13,7 +14,7 @@ options.registry.MediaItemLayout = options.Class.extend({ * * @see this.selectClass for parameters */ - layout: function (previewMode, widgetValue, params) { + layout(previewMode, widgetValue, params) { const $image = this.$target.find('.s_media_list_img_wrapper'); const $content = this.$target.find('.s_media_list_body'); @@ -23,7 +24,7 @@ options.registry.MediaItemLayout = options.Class.extend({ } $image.addClass(`col-lg-${widgetValue}`); $content.addClass(`col-lg-${12 - widgetValue}`); - }, + } //-------------------------------------------------------------------------- // Private @@ -32,7 +33,7 @@ options.registry.MediaItemLayout = options.Class.extend({ /** * @override */ - _computeWidgetState(methodName, params) { + async _computeWidgetState(methodName, params) { switch (methodName) { case 'layout': { const $image = this.$target.find('.s_media_list_img_wrapper'); @@ -43,6 +44,6 @@ options.registry.MediaItemLayout = options.Class.extend({ } } } - return this._super(...arguments); - }, -}); + return super._computeWidgetState(...arguments); + } +} diff --git a/addons/mass_mailing/static/src/snippets/s_rating/options.js b/addons/mass_mailing/static/src/snippets/s_rating/options.js index 138f57ae81132..ed458fc4fe1fe 100644 --- a/addons/mass_mailing/static/src/snippets/s_rating/options.js +++ b/addons/mass_mailing/static/src/snippets/s_rating/options.js @@ -1,18 +1,28 @@ /** @odoo-module **/ import { MediaDialog } from "@web_editor/components/media_dialog/media_dialog"; -import options from "@web_editor/js/editor/snippets.options"; -options.registry.Rating = options.Class.extend({ +import { + SnippetOption, +} from "@web_editor/js/editor/snippets.options"; +import { + registerMassMailingOption, +} from "@mass_mailing/js/snippets.registry"; + +/** + * TODO: @owl-options mass_mailing.s_rating and website.s_rating are exactly + * identical, refactor? + */ +export class RatingOption extends SnippetOption { /** * @override */ - start: function () { + willStart() { this.iconType = this.$target[0].dataset.icon; this.faClassActiveCustomIcons = this.$target[0].dataset.activeCustomIcon || ''; this.faClassInactiveCustomIcons = this.$target[0].dataset.inactiveCustomIcon || ''; - return this._super.apply(this, arguments); - }, + return super.willStart(...arguments); + } //-------------------------------------------------------------------------- // Options @@ -23,22 +33,22 @@ options.registry.Rating = options.Class.extend({ * * @see this.selectClass for parameters */ - setIcons: function (previewMode, widgetValue, params) { + setIcons(previewMode, widgetValue, params) { this.iconType = widgetValue; this._renderIcons(); this.$target[0].dataset.icon = widgetValue; delete this.$target[0].dataset.activeCustomIcon; delete this.$target[0].dataset.inactiveCustomIcon; - }, + } /** * Allows to select a font awesome icon with media dialog. * * @see this.selectClass for parameters */ - customIcon: async function (previewMode, widgetValue, params) { + async customIcon(previewMode, widgetValue, params) { const media = document.createElement('i'); media.className = params.customActiveIcon === 'true' ? this.faClassActiveCustomIcons : this.faClassInactiveCustomIcons; - this.call("dialog", "add", MediaDialog, { + this.env.services.dialog.add(MediaDialog, { noImages: true, noDocuments: true, noVideos: true, @@ -57,25 +67,25 @@ options.registry.Rating = options.Class.extend({ this.iconType = 'custom'; } }); - }, + } /** * Sets the number of active icons. * * @see this.selectClass for parameters */ - activeIconsNumber: function (previewMode, widgetValue, params) { + activeIconsNumber(previewMode, widgetValue, params) { this.nbActiveIcons = parseInt(widgetValue); this._createIcons(); - }, + } /** * Sets the total number of icons. * * @see this.selectClass for parameters */ - totalIconsNumber: function (previewMode, widgetValue, params) { + totalIconsNumber(previewMode, widgetValue, params) { this.nbTotalIcons = Math.max(parseInt(widgetValue), 1); this._createIcons(); - }, + } //-------------------------------------------------------------------------- // Private @@ -84,7 +94,7 @@ options.registry.Rating = options.Class.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { switch (methodName) { case 'setIcons': { return this.$target[0].dataset.icon; @@ -98,14 +108,14 @@ options.registry.Rating = options.Class.extend({ return this.nbTotalIcons; } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * Creates the icons. * * @private */ - _createIcons: function () { + _createIcons() { const $activeIcons = this.$target.find('.s_rating_active_icons'); const $inactiveIcons = this.$target.find('.s_rating_inactive_icons'); this.$target.find('.s_rating_icons i').remove(); @@ -117,13 +127,13 @@ options.registry.Rating = options.Class.extend({ } } this._renderIcons(); - }, + } /** * Renders icons with selected fonts. * * @private */ - _renderIcons: function () { + _renderIcons() { const icons = { 'fa-star': 'fa-star-o', 'fa-thumbs-up': 'fa-thumbs-o-up', @@ -137,5 +147,11 @@ options.registry.Rating = options.Class.extend({ const $inactiveIcons = this.$target.find('.s_rating_inactive_icons > i'); $activeIcons.removeClass().addClass(faClassActiveIcons); $inactiveIcons.removeClass().addClass(faClassInactiveIcons); - }, + } +} + +registerMassMailingOption("Rating", { + Class: RatingOption, + template: "website.s_rating_option", + selector: ".s_rating", }); diff --git a/addons/mass_mailing/static/src/snippets/s_rating/options.xml b/addons/mass_mailing/static/src/snippets/s_rating/options.xml new file mode 100644 index 0000000000000..44cef0c78c5c1 --- /dev/null +++ b/addons/mass_mailing/static/src/snippets/s_rating/options.xml @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="mass_mailing.s_rating_option"> + <WeRow title.translate="Icon"> + <WeSelect> + <WeButton setIcons="'fa-star'"><i class="fa fa-fw fa-star"/> Stars</WeButton> + <WeButton setIcons="'fa-thumbs-up'"><i class="fa fa-fw fa-thumbs-up"/> Thumbs</WeButton> + <WeButton setIcons="'fa-circle'"><i class="fa fa-fw fa-circle"/> Circles</WeButton> + <WeButton setIcons="'fa-square'"><i class="fa fa-fw fa-square"/> Squares</WeButton> + <WeButton setIcons="'fa-heart'"><i class="fa fa-fw fa-heart"/> Hearts</WeButton> + <WeButton setIcons="'custom'" class="'d-none'">Custom</WeButton> + </WeSelect> + </WeRow> + <WeRow title.translate="Active" class="'o_we_sublevel_1'"> + <WeColorpicker selectStyle="''" applyTo="'.s_rating_active_icons'" cssProperty="'color'" colorPrefix="'text-'"/> + <WeButton customIcon="'true'" customActiveIcon="'true'" noPreview="'true'"> + <i class="fa fa-fw fa-refresh me-1"/> Replace Icon + </WeButton> + </WeRow> + <WeRow title.translate="Inactive" class="'o_we_sublevel_1'"> + <WeColorpicker selectStyle="''" applyTo="'.s_rating_inactive_icons'" cssProperty="'color'" colorPrefix="'text-'"/> + <WeButton customIcon="'true'" customActiveIcon="'false'" noPreview="'true'"> + <i class="fa fa-fw fa-refresh me-1"/> Replace Icon + </WeButton> + </WeRow> + <WeRow title.translate="Score"> + <WeInput activeIconsNumber="'true'" step="'1'"/> + <span class="mx-2">/</span> + <WeInput totalIconsNumber="'true'" step="'1'"/> + </WeRow> + <WeRow title.translate="Size"> + <WeButtonGroup applyTo="'.s_rating_icons'"> + <WeButton selectClass="''" tooltip.translate="Small" img="'/website/static/src/img/snippets_options/size_small.svg'"/> + <WeButton selectClass="'fa-2x'" tooltip.translate="Medium" img="'/website/static/src/img/snippets_options/size_medium.svg'"/> + <WeButton selectClass="'fa-3x'" tooltip.translate="Large" img="'/website/static/src/img/snippets_options/size_large.svg'"/> + </WeButtonGroup> + </WeRow> + <WeRow title.translate="Title Position"> + <WeSelect> + <WeButton selectClass="''">Top</WeButton> + <WeButton selectClass="'s_rating_inline'">Left</WeButton> + <WeButton selectClass="'s_rating_no_title'">None</WeButton> + </WeSelect> + </WeRow> +</t> + +</templates> diff --git a/addons/mass_mailing/static/src/snippets/s_showcase/options.js b/addons/mass_mailing/static/src/snippets/s_showcase/options.js index 296ad494d2e3e..5e701da2e28a8 100644 --- a/addons/mass_mailing/static/src/snippets/s_showcase/options.js +++ b/addons/mass_mailing/static/src/snippets/s_showcase/options.js @@ -1,17 +1,20 @@ -/** @odoo-module **/ +import { SnippetOption } from "@web_editor/js/editor/snippets.options"; -import options from "@web_editor/js/editor/snippets.options"; - -options.registry.Showcase = options.Class.extend({ +// FIXME: this is unused (no option definition in mass_mailing), +// see addons/website/static/src/snippets/s_showcase/options.js +// This is never attached to any snippet (no selector). Even if +// it was, it is impossible to move Showcase sub-elements (that start +// with a title and an icon), and it would therefor have no effect. +export class Showcase extends SnippetOption { /** * @override */ - onMove: function () { + onMove() { const $showcaseCol = this.$target.parent().closest('.row > div'); const isLeftCol = $showcaseCol.index() <= 0; const $title = this.$target.children('.s_showcase_title'); $title.toggleClass('flex-lg-row-reverse', isLeftCol); $showcaseCol.find('.s_showcase_icon.ms-3').removeClass('ms-3').addClass('ms-lg-3'); // For compatibility with old version $title.find('.s_showcase_icon').toggleClass('me-lg-0 ms-lg-3', isLeftCol); - }, -}); + } +} diff --git a/addons/mass_mailing/views/snippets/s_alert.xml b/addons/mass_mailing/views/snippets/s_alert.xml index bc283760ae47c..08d0829fd0c5c 100644 --- a/addons/mass_mailing/views/snippets/s_alert.xml +++ b/addons/mass_mailing/views/snippets/s_alert.xml @@ -20,33 +20,17 @@ </div> </template> -<template id="s_alert_options" inherit_id="mass_mailing.snippet_options"> - <!-- Keep those options in separate xpath for options order --> - <xpath expr="//div[@id='so_width']" position="after"> - <div data-selector=".s_mail_alert .s_alert"> - <we-select string="Size"> - <we-button data-select-class="s_alert_sm">Small</we-button> - <we-button data-select-class="s_alert_md">Medium</we-button> - <we-button data-select-class="s_alert_lg">Large</we-button> - </we-select> - <we-colorpicker string="Background Color" data-name="alert_colorpicker_opt" - data-select-style="true" - data-css-property="background-color" - data-color-prefix="alert-"/> - </div> - <div data-selector=".s_mail_alert .s_alert"> - <t t-call="mass_mailing.snippet_options_border_widgets"> - <t t-set="so_rounded_no_dependencies" t-value="True"/> - </t> - </div> - </xpath> -</template> - <!-- Assets --> <record id="mass_mailing.s_alert_001_scss" model="ir.asset"> <field name="name">Alert 001 SCSS</field> <field name="bundle">web.assets_frontend</field> <field name="path">mass_mailing/static/src/snippets/s_alert/000.scss</field> + <field name="active" eval="False"/> +</record> +<record id="mass_mailing.s_alert_002_scss" model="ir.asset"> + <field name="name">Alert 002 SCSS</field> + <field name="bundle">web.assets_frontend</field> + <field name="path">mass_mailing/static/src/snippets/s_alert/002.scss</field> </record> </odoo> diff --git a/addons/mass_mailing/views/snippets/s_hr.xml b/addons/mass_mailing/views/snippets/s_hr.xml index 1b1444411188c..82243b3b909d1 100644 --- a/addons/mass_mailing/views/snippets/s_hr.xml +++ b/addons/mass_mailing/views/snippets/s_hr.xml @@ -13,10 +13,12 @@ <template id="s_hr_options" inherit_id="mass_mailing.snippet_options"> <xpath expr="." position="inside"> <div data-selector=".s_hr" data-target="hr"> - <t t-call="mass_mailing.snippet_options_border_line_widgets"> + <!-- TODO @owl-options: do convert & uncomment this. Just commented + in legacy to avoid errors. --> + <!-- <t t-call="mass_mailing.snippet_options_border_line_widgets"> <t t-set="label">Border</t> <t t-set="direction" t-value="'top'"/> - </t> + </t> --> <we-select string="Width"> <we-button data-select-class="w-25">25%</we-button> <we-button data-select-class="w-50">50%</we-button> diff --git a/addons/mass_mailing/views/snippets/s_masonry_block.xml b/addons/mass_mailing/views/snippets/s_masonry_block.xml index 574d8e9e3d481..a893a31ee3124 100644 --- a/addons/mass_mailing/views/snippets/s_masonry_block.xml +++ b/addons/mass_mailing/views/snippets/s_masonry_block.xml @@ -250,59 +250,6 @@ </div> </template> -<!-- Options --> -<template id="s_masonry_block_options" inherit_id="mass_mailing.snippet_options"> - <xpath expr="//div[@data-js='layout_column']" position="after"> - <div data-js="MasonryLayout" data-selector=".s_masonry_block"> - <we-select string="Template" - data-name="masonry_template_opt" - data-attribute-name="masonryTemplate" - data-attribute-default-value="default"> - <we-button title="Default" - data-select-template="mass_mailing.s_masonry_block_default_template" - data-select-data-attribute="default" - data-img="/mass_mailing/static/src/img/snippets_options/masonry_template_default.svg"/> - <we-button title="Default Reversed" - data-select-template="mass_mailing.s_masonry_block_reversed_template" - data-select-data-attribute="default_reversed" - data-img="/mass_mailing/static/src/img/snippets_options/masonry_template_reversed.svg"/> - <we-button title="Images" - data-select-template="mass_mailing.s_masonry_block_images_template" - data-select-data-attribute="images" - data-img="/mass_mailing/static/src/img/snippets_options/masonry_template_images.svg"/> - <we-button title="Image Text Image" - data-select-template="mass_mailing.s_masonry_block_image_texts_image_template" - data-select-data-attribute="image_text_image" - data-img="/mass_mailing/static/src/img/snippets_options/masonry_template_image_texts_image.svg"/> - <we-button title="Mosaic" - data-select-template="mass_mailing.s_masonry_block_mosaic_template" - data-select-data-attribute="mosaic" - data-img="/mass_mailing/static/src/img/snippets_options/masonry_template_mosaic.svg"/> - <we-button title="Text Image Text" - data-select-template="mass_mailing.s_masonry_block_texts_image_texts_template" - data-select-data-attribute="text_image_text" - data-img="/mass_mailing/static/src/img/snippets_options/masonry_template_texts_image_texts.svg"/> - <we-button title="Alternate Text" - data-select-template="mass_mailing.s_masonry_block_alternation_text_template" - data-select-data-attribute="alternate_text" - data-img="/mass_mailing/static/src/img/snippets_options/masonry_template_alternate_texts.svg"/> - <we-button title="Alternate Text Image" - data-select-template="mass_mailing.s_masonry_block_alternation_text_image_template" - data-select-data-attribute="alternate_text_image" - data-img="/mass_mailing/static/src/img/snippets_options/masonry_template_alternate_text_image.svg"/> - <we-button title="Alternate Image Text" - data-select-template="mass_mailing.s_masonry_block_alternation_image_text_template" - data-select-data-attribute="alternate_image_text" - data-img="/mass_mailing/static/src/img/snippets_options/masonry_template_alternate_image_text.svg"/> - <we-button title="Alternate Text Image Text" - data-select-template="mass_mailing.s_masonry_block_alternation_text_image_text_template" - data-select-data-attribute="alternate_text_image_text" - data-img="/mass_mailing/static/src/img/snippets_options/masonry_template_alternate_text_image_text.svg"/> - </we-select> - </div> - </xpath> -</template> - <!-- Assets --> <record id="mass_mailing.s_masonry_block_001_scss" model="ir.asset"> <field name="name">Masonry block 001 SCSS</field> diff --git a/addons/mass_mailing/views/snippets/s_rating.xml b/addons/mass_mailing/views/snippets/s_rating.xml index ee10b0195b2ca..3f4ae21c16af2 100644 --- a/addons/mass_mailing/views/snippets/s_rating.xml +++ b/addons/mass_mailing/views/snippets/s_rating.xml @@ -18,48 +18,6 @@ </div> </template> -<template id="s_rating_options" inherit_id="mass_mailing.snippet_options"> - <xpath expr="." position="inside"> - <div data-js="Rating" data-selector=".s_rating"> - <we-select string="Icon"> - <we-button data-set-icons="fa-star"><i class="fa fa-fw fa-star"/> Stars</we-button> - <we-button data-set-icons="fa-thumbs-up"><i class="fa fa-fw fa-thumbs-up"/> Thumbs</we-button> - <we-button data-set-icons="fa-circle"><i class="fa fa-fw fa-circle"/> Circles</we-button> - <we-button data-set-icons="fa-square"><i class="fa fa-fw fa-square"/> Squares</we-button> - <we-button data-set-icons="fa-heart"><i class="fa fa-fw fa-heart"/> Hearts</we-button> - <we-button data-set-icons="custom" class="d-none">Custom</we-button> - </we-select> - <we-row string="⌙ Active"> - <we-colorpicker data-select-style="" data-apply-to=".s_rating_active_icons" data-css-property="color" data-color-prefix="text-"/> - <we-button data-custom-icon="true" data-custom-active-icon="true" data-no-preview="true"> - <i class="fa fa-fw fa-refresh me-1"/> Replace Icon - </we-button> - </we-row> - <we-row string="⌙ Inactive"> - <we-colorpicker data-select-style="" data-apply-to=".s_rating_inactive_icons" data-css-property="color" data-color-prefix="text-"/> - <we-button data-custom-icon="true" data-custom-active-icon="false" data-no-preview="true"> - <i class="fa fa-fw fa-refresh me-1"/> Replace Icon - </we-button> - </we-row> - <we-row string="Score"> - <we-input data-active-icons-number="true" data-step="1"/> - <span class="mx-2">/</span> - <we-input data-total-icons-number="true" data-step="1"/> - </we-row> - <we-button-group string="Size" data-apply-to=".s_rating_icons"> - <we-button data-select-class="" title="Small" data-img="/website/static/src/img/snippets_options/size_small.svg"/> - <we-button data-select-class="fa-2x" title="Medium" data-img="/website/static/src/img/snippets_options/size_medium.svg"/> - <we-button data-select-class="fa-3x" title="Large" data-img="/website/static/src/img/snippets_options/size_large.svg"/> - </we-button-group> - <we-select string="Title Position"> - <we-button data-select-class="">Top</we-button> - <we-button data-select-class="s_rating_inline">Left</we-button> - <we-button data-select-class="s_rating_no_title">None</we-button> - </we-select> - </div> - </xpath> -</template> - <record id="mass_mailing.s_rating_000_scss" model="ir.asset"> <field name="name">Rating 000 SCSS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/mass_mailing/views/snippets_themes.xml b/addons/mass_mailing/views/snippets_themes.xml index da168857abe73..f6cdc984fa64f 100644 --- a/addons/mass_mailing/views/snippets_themes.xml +++ b/addons/mass_mailing/views/snippets_themes.xml @@ -227,45 +227,10 @@ </template> <!-- Border --> -<template id="snippet_options_border_line_widgets"> - <we-row t-att-string="label"> - <we-input data-name="border_width_opt" - t-att-data-apply-to="apply_to" - data-select-style="0" - t-attf-data-css-property="border-#{direction and ('%s-' % direction) or ''}width" - data-unit="px" - t-att-data-extra-class="with_bs_class and 'border'" - t-att-data-variable="width_variable"/> - <we-select t-attf-data-css-property="border-#{direction and ('%s-' % direction) or ''}style" - data-dependencies="border_width_opt" - t-att-data-apply-to="apply_to" - t-att-data-variable="style_variable"> - <we-button title="Solid" data-select-style="solid"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: solid;"/></we-button> - <we-button title="Dashed" data-select-style="dashed"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: dashed;"/></we-button> - <we-button title="Dotted" data-select-style="dotted"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: dotted;"/></we-button> - <we-button title="Double" data-select-style="double"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: double; border-left: none; border-right: none;"/></we-button> - </we-select> - <we-colorpicker data-dependencies="border_width_opt" - t-att-data-apply-to="apply_to" - data-select-style="true" - t-attf-data-css-property="border-#{direction and ('%s-' % direction) or ''}color" - data-color-prefix="border-" - t-att-data-color="color_variable"/> - </we-row> -</template> - -<template id="snippet_options_border_widgets"> - <t t-call="mass_mailing.snippet_options_border_line_widgets"> - <t t-set="label">Border</t> - <t t-set="with_bs_class" t-value="True"/> - </t> - <we-input string="Round Corners" - t-att-data-apply-to="apply_to" - t-att-data-dependencies="not so_rounded_no_dependencies and 'border_width_opt,bg_color_opt'" - data-select-style="0" data-css-property="border-radius" - data-unit="px" data-extra-class="rounded" - t-att-data-variable="radius_variable"/> -</template> + <!-- TODO @owl-options: remove snippet_options_border_line_widgets and + snippet_options_border_widgets once everything is refactored. --> +<!-- <template id="snippet_options_border_line_widgets"> --> +<!-- <template id="snippet_options_border_widgets"> --> <template id="snippet_options_background_options" inherit_id="web_editor.snippet_options_background_options" primary="True"> <xpath expr="//div[@data-js='BackgroundImage']" position="attributes"> @@ -295,10 +260,11 @@ <we-button data-select-class="float-end" title="Align Right">Right</we-button> </we-select> - <t t-call="mass_mailing.snippet_options_border_line_widgets"> - <t t-set="label">Border</t> - <t t-set="with_bs_class" t-value="True"></t> - </t> +<!-- TODO: @owl-options - Uncomment and adapt --> +<!-- <t t-call="mass_mailing.snippet_options_border_line_widgets">--> +<!-- <t t-set="label">Border</t>--> +<!-- <t t-set="with_bs_class" t-value="True"></t>--> +<!-- </t>--> <we-input string="Round Corners" data-select-style="0" data-css-property="border-radius" @@ -333,7 +299,9 @@ <div data-js="Box" data-selector=".row > div" data-exclude=".o_mail_wrapper_td, .s_col_no_bgcolor, .s_col_no_bgcolor.row > div, .s_image_gallery .row > div"> - <t t-call="mass_mailing.snippet_options_border_widgets"/> + <!-- TODO @owl-options: do convert & uncomment this. Just commented + in legacy to avoid errors. --> + <!-- <t t-call="mass_mailing.snippet_options_border_widgets"/> --> </div> <div data-js="layout_column" data-selector=".o_mail_snippet_general" @@ -363,23 +331,6 @@ <we-button class="fa fa-fw fa-angle-right" data-move-snippet="next" data-no-preview="true" data-name="move_right_opt"/> </div> - <div id="so_width" data-selector=".s_mail_alert .s_alert, .s_mail_blockquote, .s_mail_text_highlight"> - <we-select string="Width"> - <we-button data-select-class="w-25">25%</we-button> - <we-button data-select-class="w-50">50%</we-button> - <we-button data-select-class="w-75">75%</we-button> - <we-button data-select-class="w-100" data-name="so_width_100">100%</we-button> - </we-select> - </div> - - <div id="so_block_align" data-selector=".s_mail_alert .s_alert, .s_mail_blockquote, .s_mail_text_highlight"> - <we-button-group string="Alignment" data-dependencies="!so_width_100"> - <we-button class="fa fa-fw fa-align-left" title="Left" data-select-class="me-auto"/> - <we-button class="fa fa-fw fa-align-center" title="Center" data-select-class="mx-auto"/> - <we-button class="fa fa-fw fa-align-right" title="Right" data-select-class="ms-auto"/> - </we-button-group> - </div> - <div data-selector=".o_mail_snippet_general" data-exclude=".o_mail_snippet_general .row > div *"> <we-button-group string="Height"> <we-button data-name="minheight_auto_opt" data-select-class="" title="Fit content">Auto</we-button> @@ -410,17 +361,6 @@ data-drop-near="[data-oe-field='body_html']:not(:has(.o_layout)) > *, .oe_structure > *" data-drop-in="[data-oe-field='body_html']:not(:has(.o_layout)), .oe_structure"/> - <t t-set="so_snippet_addition_selector" t-translation="off">.o_mail_snippet_general</t> - <div id="so_snippet_addition" - t-att-data-selector="so_snippet_addition_selector" - data-drop-in=":not(p).oe_structure:not(.oe_structure_solo), :not(.o_mega_menu):not(p)[data-oe-type=html], :not(p).oe_structure.oe_structure_solo:not(:has(> section, > div))"/> - - <t t-set="so_content_addition_selector" t-translation="off">.s_mail_blockquote, .s_mail_alert, .s_rating, .s_hr, .s_mail_text_highlight</t> - <div id="so_content_addition" - t-att-data-selector="so_content_addition_selector" - t-attf-data-drop-near="p, h1, h2, h3, ul, ol, .row > div > img, #{so_content_addition_selector}" - data-drop-in=".content, nav"/> - <div data-js="sizing_y" data-selector=".o_mail_snippet_general, .o_mail_snippet_general .row > div" data-exclude=".o_mail_no_resize, .o_mail_no_options, .s_col_no_resize.row > div, .s_col_no_resize"/> @@ -479,16 +419,20 @@ <div data-js="Box" data-selector=".s_three_columns .row > div, .s_comparisons .row > div, .s_mail_block_event .row > div" data-target=".card"> - <t t-call="mass_mailing.snippet_options_border_widgets"> + <!-- TODO @owl-options: do convert & uncomment this. Just commented + in legacy to avoid errors. --> + <!-- <t t-call="mass_mailing.snippet_options_border_widgets"> <t t-set="so_rounded_no_dependencies" t-value="True"/> - </t> + </t> --> </div> <!-- COLOR, BORDER | .o_mail_block_discount2 --> <div data-js="Box" data-selector=".o_mail_block_discount2" data-target="table"> - <t t-call="mass_mailing.snippet_options_border_widgets"> - </t> + <!-- TODO @owl-options: do convert & uncomment this. Just commented + in legacy to avoid errors. --> + <!-- <t t-call="mass_mailing.snippet_options_border_widgets"> + </t> --> </div> <!-- Vertical Alignment --> diff --git a/addons/web/static/src/core/utils/draggable_hook_builder.js b/addons/web/static/src/core/utils/draggable_hook_builder.js index 0ab399ce6d168..1fac63391f4ff 100644 --- a/addons/web/static/src/core/utils/draggable_hook_builder.js +++ b/addons/web/static/src/core/utils/draggable_hook_builder.js @@ -1,6 +1,7 @@ import { clamp } from "@web/core/utils/numbers"; import { omit } from "@web/core/utils/objects"; import { closestScrollableX, closestScrollableY } from "@web/core/utils/scrolling"; +import { camelToKebab } from "@web/core/utils/strings"; import { setRecurringAnimationFrame } from "@web/core/utils/timing"; import { browser } from "../browser/browser"; import { hasTouch, isBrowserFirefox, isIOS } from "../browser/feature_detection"; @@ -121,17 +122,6 @@ const WHITE_LISTED_KEYS = ["Alt", "Control", "Meta", "Shift"]; */ const elCache = {}; -/** - * Transforms a camelCased string to return its kebab-cased version. - * Typically used to generate CSS properties from JS objects. - * - * @param {string} str - * @returns {string} - */ -function camelToKebab(str) { - return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); -} - /** * @template T * @param {T | () => T} valueOrFn diff --git a/addons/web/static/src/core/utils/strings.js b/addons/web/static/src/core/utils/strings.js index 8d54cd444e078..fca28a6aa9762 100644 --- a/addons/web/static/src/core/utils/strings.js +++ b/addons/web/static/src/core/utils/strings.js @@ -355,3 +355,14 @@ export function isNumeric(value) { export function exprToBoolean(str, trueIfEmpty = false) { return str ? !/^false|0$/i.test(str) : trueIfEmpty; } + +/** + * Transforms a camelCased string to return its kebab-cased version. + * Typically used to generate CSS properties from JS objects. + * + * @param {string} str + * @returns {string} + */ +export function camelToKebab(str) { + return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +} diff --git a/addons/web_editor/__manifest__.py b/addons/web_editor/__manifest__.py index 9d573efda6a36..2086cd02637f0 100644 --- a/addons/web_editor/__manifest__.py +++ b/addons/web_editor/__manifest__.py @@ -25,6 +25,9 @@ 'web_editor.assets_snippets_menu': [ 'web_editor/static/src/js/editor/snippets.editor.js', 'web_editor/static/src/js/editor/snippets.options.js', + 'web_editor/static/src/js/editor/snippets.options.xml', + 'web_editor/static/src/js/editor/snippets.options.legacy.js', + 'web_editor/static/src/js/editor/snippets.registry.js', ], 'web_editor.wysiwyg_iframe_editor_assets': [ ('include', 'web._assets_helpers'), diff --git a/addons/web_editor/static/src/js/common/column_layout_mixin.js b/addons/web_editor/static/src/js/common/column_layout_mixin.js index 2452e0f677642..c0354110cc732 100644 --- a/addons/web_editor/static/src/js/common/column_layout_mixin.js +++ b/addons/web_editor/static/src/js/common/column_layout_mixin.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -export const ColumnLayoutMixin = { +export const ColumnLayoutUtils = { /** * Calculates the number of columns for the mobile or desktop version. * If all elements don't have the same size, returns "custom". @@ -112,3 +112,10 @@ export const ColumnLayoutMixin = { }); }, }; + +export const ColumnLayoutMixin = (T) => class extends T { + constructor() { + super(...arguments); + Object.assign(this, ColumnLayoutUtils); + } +}; diff --git a/addons/web_editor/static/src/js/common/utils.js b/addons/web_editor/static/src/js/common/utils.js index bbc56b80222b4..7cde596edc250 100644 --- a/addons/web_editor/static/src/js/common/utils.js +++ b/addons/web_editor/static/src/js/common/utils.js @@ -341,11 +341,19 @@ function _normalizeColor(color) { /** * Parse an element's background-image's url. * - * @param {string} string a css value in the form 'url("...")' + * @param {string|HTMLElement} string a css value in the form 'url("...")' * @returns {string|false} the src of the image or false if not parsable */ function _getBgImageURL(el) { - const parts = _backgroundImageCssToParts($(el).css('background-image')); + let value = el; + // Testing if the element is HTMLElement would make more sense, but in + // some browsers the HTMLElement is different in the window and iframe. + // So just assume that the arguments passed are either string, jQuery + // or HTMLElement. + if (!(el instanceof String)) { + value = $(el).css('background-image'); + } + const parts = _backgroundImageCssToParts(value); const string = parts.url || ''; const match = string.match(/^url\((['"])(.*?)\1\)$/); if (!match) { diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js index f6cc777a0d62b..111adaa0f6d2a 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -2,39 +2,47 @@ import { clamp } from "@web/core/utils/numbers"; import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import { registry } from "@web/core/registry"; import { useService, useBus } from "@web/core/utils/hooks"; import dom from "@web/legacy/js/core/dom"; import Widget from "@web/legacy/js/core/widget"; import { useDragAndDrop } from "@web_editor/js/editor/drag_and_drop"; -import options from "@web_editor/js/editor/snippets.options"; +import options from "@web_editor/js/editor/snippets.options.legacy"; import weUtils from "@web_editor/js/common/utils"; import * as gridUtils from "@web_editor/js/common/grid_layout_utils"; import { escape } from "@web/core/utils/strings"; import { closestElement, isUnremovable } from "@web_editor/js/editor/odoo-editor/src/utils/utils"; -import { debounce, throttleForAnimation } from "@web/core/utils/timing"; +import { batched, debounce, throttleForAnimation } from "@web/core/utils/timing"; import { uniqueId } from "@web/core/utils/functions"; import { sortBy, unique } from "@web/core/utils/arrays"; import { browser } from "@web/core/browser/browser"; +import { + CONTENT_ADDITION_OPTION_ID, + SNIPPET_ADDITION_OPTION_ID, +} from "@web_editor/js/editor/snippets.registry"; import { Toolbar } from "@web_editor/js/editor/toolbar"; import { Component, EventBus, markup, + markRaw, onMounted, onWillStart, onWillUnmount, useEffect, useRef, useState, + useSubEnv, } from "@odoo/owl"; import { LinkTools } from '@web_editor/js/wysiwyg/widgets/link_tools'; import { touching, closest, addLoadingEffect as addButtonLoadingEffect } from "@web/core/utils/ui"; import { _t } from "@web/core/l10n/translation"; import { renderToElement } from "@web/core/utils/render"; import { RPCError } from "@web/core/network/rpc"; -import { ColumnLayoutMixin } from "@web_editor/js/common/column_layout_mixin"; +import { ColumnLayoutUtils } from "@web_editor/js/common/column_layout_mixin"; import { Tooltip as OdooTooltip } from "@web/core/tooltip/tooltip"; import { AddSnippetDialog } from "@web_editor/js/editor/add_snippet_dialog"; +import { SnippetOption, SnippetOptionComponent, clearServiceCache } from "./snippets.options"; let cacheSnippetTemplate = {}; @@ -91,6 +99,7 @@ var SnippetEditor = Widget.extend({ this.$target = $(target); this.$target.data('snippet-editor', this); this.templateOptions = templateOptions; + this.key = uniqueId("snippet-editor"); this.isTargetParentEditable = false; this.isTargetMovable = false; this.$scrollingElement = $().getScrollingElement(this.$editable[0].ownerDocument); @@ -193,6 +202,9 @@ var SnippetEditor = Widget.extend({ * @override */ destroy: function () { + for (const option of this.snippetOptions) { + option.instance.destroy(); + } // Before actually destroying a snippet editor, notify the parent // about it so that it can update its list of alived snippet editors. this.trigger_up('snippet_editor_destroyed'); @@ -614,7 +626,15 @@ var SnippetEditor = Widget.extend({ if (editorUIsToUpdate.length > 0 && !optionsSectionVisible) { return null; } - return this._customize$Elements; + return this.getOptions(); + }, + /** + * Returns the OWL Options templates to mount their widgets. + * The widgets will handle requesting the initial updateUI + * @TODO owl-options Implement visibility. + */ + getOptions() { + return this.snippetOptions.filter((option) => !option.instance.isRestrictedGroup); }, /** * @param {boolean} [show] @@ -740,6 +760,7 @@ var SnippetEditor = Widget.extend({ */ _initializeOptions: function () { this._customize$Elements = []; + this.snippetOptions = []; this.styles = {}; this.selectorSiblings = []; this.selectorChildren = []; @@ -752,6 +773,11 @@ var SnippetEditor = Widget.extend({ if (parentEditor) { this._customize$Elements = this._customize$Elements .concat(parentEditor._customize$Elements); + // TODO: @owl-options This was meant to have the same behavior + // as the old editor, but as of now, options are just added + // with a loop on "enabledEditorHierarchy", so this would lead + // to duplicate option Components. + // this.snippetOptions = [...this.snippetOptions,...parentEditor.snippetOptions]; break; } $element = $element.parent(); @@ -783,7 +809,7 @@ var SnippetEditor = Widget.extend({ if (!val.selector.is(this.$target)) { return; } - if (val.data.string) { + if (val.data.string && !val.isOwl) { $optionsSection[0].querySelector('we-title > span').textContent = val.data.string; } if (val['drop-near']) { @@ -800,17 +826,57 @@ var SnippetEditor = Widget.extend({ } var optionName = val.option; - var option = new (options.registry[optionName] || options.Class)( - this, - val.$el.children(), - val.base_target ? this.$target.find(val.base_target).eq(0) : this.$target, - this.$el, - Object.assign({ - optionName: optionName, - snippetName: this.getName(), - }, val.data), - this.options - ); + let option; + if (val.isOwl) { + // TODO: @owl-options remove when legacy is completely converted. + this._hasOwlOptions = true; + this.name = val.data.string || this.getName(); + option = Object.assign({}, registry.category("snippet_options").get(optionName)); + const optionClass = option.Class || SnippetOption; + option.instance = new (optionClass)({ + editor: this, + $target: val.base_target ? this.$target.find(val.base_target).eq(0) : this.$target, + $overlay: this.$el, + data: { + optionName, + snippetName: this.getName(), + ...val.data, + }, + options: this.options, + callbacks: { + requestUserValue: this._requestUserValue.bind(this), + cover: this.cover.bind(this), + coverUpdate: (overlayVisible) => this.trigger_up("cover_update", { overlayVisible }), + notifyOptions: (data) => this.trigger_up("option_update", data), + updateExtraTitle: (extra) => { this.extraTitle = extra }, + updateSnippetOptionVisibility: (show) => this.trigger_up("snippet_option_visibility_update", { show }), + } + }); + optionName = (optionClass).name + option.isOwl = true; + option.renderingComponent ??= (optionClass).defaultRenderingComponent; + option.renderingComponent.components = Object.fromEntries(registry.category("snippet_widgets").getEntries()); + option.forceNoDeleteButton = option.instance.forceNoDeleteButton ?? (optionClass).forceNoDeleteButton; + if (option.Class?.displayOverlayOptions) { + option.displayOverlayOptions = true; + } + if (option.Class?.isTopOption) { + option.isTopOption = true; + } + } else { + return; + option = new (options.registry[optionName] || options.Class)( + this, + val.$el.children(), + val.base_target ? this.$target.find(val.base_target).eq(0) : this.$target, + this.$el, + Object.assign({ + optionName: optionName, + snippetName: this.getName(), + }, val.data), + this.options + ); + } var key = optionName || uniqueId("option"); if (this.styles[key]) { // If two snippet options use the same option name (and so use @@ -818,7 +884,7 @@ var SnippetEditor = Widget.extend({ // ID (TODO improve) key = uniqueId(key); } - this.styles[key] = option; + this.styles[key] = option.isOwl ? option.instance : option; option.__order = i++; if (option.forceNoDeleteButton) { @@ -834,6 +900,11 @@ var SnippetEditor = Widget.extend({ this.forceDuplicateButton = true; } + if (val.isOwl) { + this.snippetOptions.push(markRaw(option)); + option.name = key; + return option.instance.willStart(); + } return option.appendTo(document.createDocumentFragment()); }); @@ -852,6 +923,10 @@ var SnippetEditor = Widget.extend({ const options = sortBy(Object.values(this.styles), "__order"); const firstOptions = []; options.forEach(option => { + // TODO: @owl-options Properly handle option position + if (option.isOwl) { + return; + } if (option.isTopOption) { if (option.isTopFirstOption) { firstOptions.push(option); @@ -1519,7 +1594,9 @@ var SnippetEditor = Widget.extend({ // another snippet, fill the gap left in the starting snippet. if (this.dragState.mobileOrder !== undefined && this.$target[0].parentNode !== this.dragState.startingParent) { - ColumnLayoutMixin._fillRemovedItemGap(this.dragState.startingParent, this.dragState.mobileOrder); + // TODO: @owl-options this is a bit ugly, maybe this method + // should be moved into an util. + ColumnLayoutUtils._fillRemovedItemGap(this.dragState.startingParent, this.dragState.mobileOrder); } this.$target.trigger('content_changed'); @@ -1580,6 +1657,13 @@ var SnippetEditor = Widget.extend({ * @param {OdooEvent} ev */ _onOptionUpdate: function (ev) { + // TODO: @owl-options: when removing trigger_up, adapt calls to this. + if (ev._stopped) { + return; + } + if (!ev.stopPropagation) { + ev.stopPropagation = () => ev._stopped = true; + } var self = this; // If multiple option names are given, we suppose it should not be @@ -1649,6 +1733,29 @@ var SnippetEditor = Widget.extend({ ev.stopPropagation(); } }, + /** + * Requests the UserValue of another option + * + * @param {string} name - the name of the userValue / Component + * @param {boolean} allowParentOption - if true, the request will be propagated to the parent + * @returns {import('./snippets.options.js').UserValue} the UserValue of the requested option + */ + _requestUserValue({ name, allowParentOption }) { + for (const key of Object.keys(this.styles)) { + const widget = this.styles[key].findWidget(name); + if (widget) { + return widget; + } + } + if (!allowParentOption) { + return null; + } + const parent = this.getParent(); + if (parent._requestUserValue) { + return parent._requestUserValue({ name, allowParentOption }); + } + return null; + }, /** * Called when the 'mouse wheel' is used when hovering over the overlay. * Disable the pointer events to prevent page scrolling from stopping. @@ -1790,6 +1897,59 @@ var SnippetEditor = Widget.extend({ }, }); +class SnippetOptionsManager extends Component { + static props = { + editors: { type: Object }, + enabledEditors: { type: Array }, + onOptionMounted: { type: Function }, + activateTab: { type: Function }, + execWithLoadingEffect: { type: Function }, + }; + static template = "web_editor.SnippetOptionsManager"; + + setup() { + useEffect( + () => { + this.props.execWithLoadingEffect(async () => { + const editorToEnable = this.props.enabledEditors[0]; + let enabledOptions; + if (editorToEnable) { + enabledOptions = await editorToEnable.toggleOptions(true); + if (!enabledOptions) { + // As some options can only be generated using JavaScript + // (e.g. 'SwitchableViews'), it may happen at this point + // that the overlay is activated even though there are no + // options. That's why we disable the overlay if there are + // no options to enable. + editorToEnable.toggleOverlay(false); + } + } + if (enabledOptions) { + this.props.activateTab(SnippetsMenu.tabs.OPTIONS); + } + }); + }, + () => { + // The compute dependencies can never return an empty array or + // the effect will not be reapplied, and therefore the tab will + // not change to blocks. + if (this.props.enabledEditors.length) { + return this.props.enabledEditors; + } else { + return [false]; + } + } + ); + } + + getEditors() { + return [...this.props.editors].sort((e1, e2) => { + return this.props.enabledEditors.indexOf(e1) < this.props.enabledEditors.indexOf(e2); + }); + } +} + + /** * Management of drag&drop menu and snippet related behaviors in the page. */ @@ -1856,11 +2016,13 @@ class SnippetsMenu extends Component { static template = "web_editor.SnippetsMenu"; - static components = { Toolbar, LinkTools }; + static components = { Toolbar, LinkTools, SnippetOptionsManager }; setup() { super.setup(...arguments); this.options = Object.assign({}, this.props.options); + this.options.snippetEditionRequest = this.snippetEditionRequest.bind(this); + this.options.optionUpdate = this._snippetOptionUpdate.bind(this); this.$body = $((this.options.document || document).body); this.customEvents = SnippetsMenu.custom_events; this.tabs = SnippetsMenu.tabs; @@ -1889,7 +2051,7 @@ class SnippetsMenu extends Component { this.snippetsAreaRef = useRef("snippets-area"); this.snippetEditors = []; - this._enabledEditorHierarchy = []; + this.state.enabledEditorHierarchy = []; this._mutex = this.options.mutex; @@ -1987,6 +2149,28 @@ class SnippetsMenu extends Component { useBus(this.props.bus, "CLEAN_FOR_SAVE", ({ detail }) => { detail.proms.push(this.cleanForSave()); }); + + useSubEnv({ + activateSnippet: this._activateSnippet.bind(this), + blockPreviewOverlays: this._onBlockPreviewOverlays.bind(this), + cloneSnippet: this._cloneSnippet.bind(this), + cleanUI: this._cleanUI.bind(this), + requestSave: (data) => this._onSaveRequest.call(this, { data }), + removeSnippet: this._onRemoveSnippet.bind(this), + isElementSelected: (data) => this._onIsElementSelected.call(this, { data }), + hideOverlay: this._onHideOverlay.bind(this), + unblockPreviewOverlays: this._onUnblockPreviewOverlays.bind(this), + updateInvisibleDOM: this._onUpdateInvisibleDom.bind(this), + userValueWidgetOpening: this._onUserValueWidgetOpening.bind(this), + userValueWidgetClosing: this._onUserValueWidgetClosing.bind(this), + snippetEditionRequest: this._execWithLoadingEffect.bind(this), + }); + this.options.env = this.env; + // If multiple SnippetOptionsComponent are mounted at the same time, + // only compute their state once per tick instead of once per option. + this.onOptionMounted = batched(() => { + this._onOptionMounted(); + }); } /** * By default, the SnippetCache is only invalidated when the browser is @@ -2281,6 +2465,7 @@ class SnippetsMenu extends Component { this.tooltips.dispose(); this.buttonTooltips.forEach(tooltip => tooltip.dispose()); options.clearServiceCache(); + clearServiceCache(); options.clearControlledSnippets(); if (this.$body[0].ownerDocument !== this.ownerDocument) { this.$body.off('.snippets_menu'); @@ -2882,16 +3067,16 @@ class SnippetsMenu extends Component { } resolve(null); }).then(async editorToEnable => { - if (!previewMode && this._enabledEditorHierarchy[0] === editorToEnable - || ifInactiveOptions && this._enabledEditorHierarchy.includes(editorToEnable)) { + if (!previewMode && this.state.enabledEditorHierarchy[0] === editorToEnable + || ifInactiveOptions && this.state.enabledEditorHierarchy.includes(editorToEnable)) { return editorToEnable; } if (!previewMode) { - this._enabledEditorHierarchy = []; + this.state.enabledEditorHierarchy = []; let current = editorToEnable; while (current && current.$target) { - this._enabledEditorHierarchy.push(current); + this.state.enabledEditorHierarchy.push(markRaw(current)); current = current.getParent(); } } @@ -2916,34 +3101,26 @@ class SnippetsMenu extends Component { if (editorToEnable) { editorToEnable.toggleOverlay(true, previewMode); if (!previewMode && !editorToEnable.displayOverlayOptions) { - const parentEditor = this._enabledEditorHierarchy.find(ed => ed.displayOverlayOptions); + const parentEditor = this.state.enabledEditorHierarchy.find(ed => ed.displayOverlayOptions); if (parentEditor) { parentEditor.toggleOverlay(true, previewMode); } } - customize$Elements = await editorToEnable.toggleOptions(true); + // TODO: this code is handled by SnippetOptionsManager now + // customize$Elements = await editorToEnable.toggleOptions(true); } else { for (const editor of this.snippetEditors) { if (editor.isSticky()) { editor.toggleOverlay(true, false); - customize$Elements = await editor.toggleOptions(true); + // customize$Elements = await editor.toggleOptions(true); + this.state.enabledEditorHierarchy.push(editor); } } } - - if (!previewMode) { - // As some options can only be generated using JavaScript - // (e.g. 'SwitchableViews'), it may happen at this point - // that the overlay is activated even though there are no - // options. That's why we disable the overlay if there are - // no options to enable. - if (editorToEnable && !customize$Elements) { - editorToEnable.toggleOverlay(false); - } + if (!editorToEnable || this.state.enabledEditorHierarchy.lenght === 0) { this._updateRightPanelContent({ - content: customize$Elements || [], - tab: customize$Elements ? this.tabs.OPTIONS : this.tabs.BLOCKS, - }); + tab: this.tabs.BLOCKS + }) } return editorToEnable; @@ -3187,7 +3364,7 @@ class SnippetsMenu extends Component { this.templateOptions = []; var selectors = []; var $styles = $html.find('[data-selector]'); - const snippetAdditionDropIn = $styles.filter('#so_snippet_addition').data('drop-in'); + let snippetAdditionDropIn = $styles.filter('#so_snippet_addition').data('drop-in'); $styles.each(function () { var $style = $(this); var selector = $style.data('selector'); @@ -3229,6 +3406,42 @@ class SnippetsMenu extends Component { }); $styles.addClass('d-none'); + // TODO: @owl-options Rename this property when all options have been converted. + const options = this.getOptions(); + snippetAdditionDropIn = options.find(([optionId]) => optionId === SNIPPET_ADDITION_OPTION_ID)[1].dropIn; + this.templateOptions.push(...options.map(([optionID, option]) => { + const selector = option.selector; + const exclude = option.exclude || ""; + const excludeParent = optionID === CONTENT_ADDITION_OPTION_ID ? snippetAdditionDropIn : ""; + const target = option.target; + const noCheck = option.noCheck; + const optionDef = { + "option": optionID, + "base_selector": selector, + "base_exclude": exclude, + "base_target": target, + "selector": this._computeSelectorFunctions({selector, exclude, target, noCheck}), + isOwl: true, + "drop-near": option.dropNear && this._computeSelectorFunctions({ + selector: option.dropNear, + noCheck, + isChildren: true, + excludeParent, + forDrop: true + }), + "drop-in": option.dropIn && this._computeSelectorFunctions({ + selector: option.dropIn, + noCheck, + forDrop: true, + }), + "drop-exclude-ancestor": option.dropExcludeAncestor, + "drop-lock-within": option.dropLockWithin, + "data": option.data || {}, + }; + selectors.push(optionDef.selector); + return optionDef; + })); + globalSelector.closest = function ($from) { var $temp; var $target; @@ -3346,6 +3559,15 @@ class SnippetsMenu extends Component { this._disableUndroppableSnippets(); } + /** + * Returns all the options from the registry. Allows modules to override + * this to filter out options that don't belong to them. + * + * @return {[string, Object][]} + */ + getOptions() { + return registry.category("snippet_options").getEntries(); + } /** * Creates a snippet editor to associated to the given snippet. If the given * snippet already has a linked snippet editor, the function only returns @@ -3427,6 +3649,7 @@ class SnippetsMenu extends Component { const canDrop = ($els) => [...$els].some((el) => checkSanitize(el) && isVisible(el)); var check = false; + // TODO: @owl-options Add Owl Options here. self.templateOptions.forEach((option, k) => { if (check || !($snippetBody.is(option.base_selector) && !$snippetBody.is(option.base_exclude))) { return; @@ -3846,7 +4069,7 @@ class SnippetsMenu extends Component { * the new content of the customizePanel * @param {this.tabs.VALUE} [tab='blocks'] - the tab to select */ - _updateRightPanelContent({content, tab, ...options}) { + _updateRightPanelContent({tab, ...options}) { this._hideTooltips(); this._closeWidgets(); @@ -3855,30 +4078,15 @@ class SnippetsMenu extends Component { tab = SnippetsMenu.tabs.OPTIONS; } - this.state.currentTab = tab || SnippetsMenu.tabs.BLOCKS; - - if (this._$toolbarContainer) { - this._$toolbarContainer[0].remove(); - } this.state.showToolbar = false; this._$toolbarContainer = null; - if (content) { - // The toolbar component will be hidden or shown by state.showToolbar - // as it is an OWL Component, OWL is in charge of the HTML for that - // component. So we do not want to remove it. - // TODO: This should be improved when SnippetEditor / SnippetOptions - // are converted to OWL. - while (this.customizePanel.firstChild?.id !== "o_we_editor_toolbar_container") { - this.customizePanel.removeChild(this.customizePanel.firstChild); - } - $(this.customizePanel).prepend(content); - if (this.state.currentTab === this.tabs.OPTIONS && !options.forceEmptyTab) { - this._addToolbar(); - } + if (this.state.currentTab === this.tabs.OPTIONS && !options.forceEmptyTab) { + this._addToolbar(); } if (options.forceEmptyTab) { this.state.showToolbar = false; } + this.state.currentTab = tab; } /** * Scrolls to given snippet. @@ -4051,19 +4259,40 @@ class SnippetsMenu extends Component { async _snippetOptionUpdate() { // Only update editors whose DOM target is still inside the document // as a top option may have removed currently-enabled child items. - const editors = this._enabledEditorHierarchy.filter(editor => !!editor.$target[0].closest('body')); + const editors = this.state.enabledEditorHierarchy.filter(editor => !!editor.$target[0].closest('body')); await Promise.all(editors.map(editor => editor.updateOptionsUI())); await Promise.all(editors.map(editor => editor.updateOptionsUIVisibility())); // Always enable the deepest editor whose DOM target is still inside // the document. - if (editors[0] !== this._enabledEditorHierarchy[0]) { + if (editors[0] !== this.state.enabledEditorHierarchy[0]) { // No awaiting this as the mutex is currently locked here. this._activateSnippet(editors[0].$target); } } /** + * Clones an existing snippet + * + * @param {jQuery} $snippet - the snippet to copy + */ + async _cloneSnippet($snippet) { + const editor = await this._createSnippetEditor($snippet); + await editor.clone(); + } + /** + * Asks options to clean their UI elements present in the DOM + * + * @param {HTMLElement} targetEl + * @returns {Promise} + */ + async _cleanUI(targetEl) { + const targetEditors = this.snippetEditors.filter(editor => { + return targetEl.contains(editor.$target[0]); + }); + await Promise.all(targetEditors.map(editor => editor.cleanUI())); + } + /** * @private */ _allowInTranslationMode($snippet) { @@ -4174,8 +4403,7 @@ class SnippetsMenu extends Component { */ async _onCloneSnippet(ev) { ev.stopPropagation(); - const editor = await this._createSnippetEditor(ev.data.$snippet); - await editor.clone(); + await this._cloneSnippet(ev.data.$snippet); if (ev.data.onSuccess) { ev.data.onSuccess(); } @@ -4187,12 +4415,7 @@ class SnippetsMenu extends Component { * @param {OdooEvent} ev */ _onCleanUIRequest(ev) { - const targetEditors = this.snippetEditors.filter(editor => { - return ev.data.targetEl.contains(editor.$target[0]); - }); - Promise.all(targetEditors.map(editor => editor.cleanUI())).then(() => { - ev.data.onSuccess(); - }); + this._cleanUI(ev.data.targetEl).then(ev.data.onSuccess); } /** * Called when a child editor asks to deactivate the current snippet @@ -4944,6 +5167,29 @@ class SnippetsMenu extends Component { return this.props.trigger_up(ev); } } + /** + * When a new option is mounted, update all existing editors to reflect the + * potential new values being computed/asked. + */ + async _onOptionMounted() { + // Delay the mutex until all post drop actions are done. It cannot be + // awaited inside the mutex because callPostSnippetDrop locks the mutex + // and it needs to be awaited because callPostSnippetDrop also creates + // editors, but it does so outside the mutex. However, calling + // updateOptionsUI while editors are being created creates race + // conditions + if (this.postSnippetDropPromise) { + await this.postSnippetDropPromise; + } + this.execWithLoadingEffect(async () => { + await Promise.all(this.state.enabledEditorHierarchy.map(editor => editor.updateOptionsUI())); + await Promise.all(this.state.enabledEditorHierarchy.map(editor => editor.updateOptionsUIVisibility())); + }); + } + + snippetEditionRequest({ exec, optionsLoader }) { + this._execWithLoadingEffect(exec, optionsLoader ? "both" : true); + } /** * @private * @param {OdooEvent} ev @@ -5207,6 +5453,9 @@ class SnippetsMenu extends Component { } snippet.renaming = false; } + activateTab(tab) { + this._updateRightPanelContent({tab}); + } } export default { diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js index 684db286e68c3..b128bc410e9ca 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -1,16 +1,22 @@ /** @odoo-module **/ import { attachComponent } from "@web/legacy/utils"; +import { uniqueId } from "@web/core/utils/functions"; +import { registry } from "@web/core/registry"; +import { user } from "@web/core/user"; import { MediaDialog } from "@web_editor/components/media_dialog/media_dialog"; import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; -import { throttleForAnimation, debounce } from "@web/core/utils/timing"; +import { KeepLast } from "@web/core/utils/concurrency"; +import { useSortable } from "@web/core/utils/sortable_owl"; +import { camelToKebab } from "@web/core/utils/strings"; +import { useThrottleForAnimation, debounce } from "@web/core/utils/timing"; import { clamp } from "@web/core/utils/numbers"; import { scrollTo } from "@web/core/utils/scrolling"; import Widget from "@web/legacy/js/core/widget"; import { ColorPalette } from "@web_editor/js/wysiwyg/widgets/color_palette"; import weUtils from "@web_editor/js/common/utils"; import * as gridUtils from "@web_editor/js/common/grid_layout_utils"; -import {ColumnLayoutMixin} from "@web_editor/js/common/column_layout_mixin"; +import { ColumnLayoutMixin, ColumnLayoutUtils } from "@web_editor/js/common/column_layout_mixin"; const { normalizeColor, getBgImageURL, @@ -41,6 +47,26 @@ import { } from '@web/core/utils/colors'; import { renderToElement } from "@web/core/utils/render"; import { rpc } from "@web/core/network/rpc"; +import snippetsOptionsLegacy from "./snippets.options.legacy"; +import { + Component, + markup, + onMounted, + onWillStart, + onWillUpdateProps, + onPatched, + onWillDestroy, + onWillUnmount, + reactive, + useEffect, + useChildSubEnv, + useRef, + useState, + useSubEnv, +} from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { registerOption } from "./snippets.registry"; const preserveCursor = OdooEditorLib.preserveCursor; const { DateTime } = luxon; @@ -49,13 +75,18 @@ let _serviceCache = { orm: {}, rpc: {}, }; -const clearServiceCache = () => { +export const clearServiceCache = () => { _serviceCache = { orm: {}, rpc: {}, }; }; +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +// TODO: This should be removed when all options are propperly migrated to OWL +const SnippetOptionWidget = snippetsOptionsLegacy.SnippetOptionWidget; +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + // Regex definitions to apply speed modification in SVG files // Note : These regex patterns are duplicated on the server side for // background images that are part of a CSS rule "background-image: ...". The @@ -70,31 +101,43 @@ const SVG_DUR_TIMECOUNT_VAL_REGEX = /(?<attribute_name>\sdur="\s*)(?<value>(?:\d+(?:\.\d+)?)|(?:\.\d+))(?<unit>h|min|ms|s)?\s*"/gm; const CSS_ANIMATION_RATIO_REGEX = /(--animation_ratio: (?<ratio>\d*(\.\d+)?));/m; /** - * Caches rpc/orm service - * @param {Function} service - * @param {...any} args - * @returns + * Caches the calls to a service. + * + * @param {Object} [env] - Object to find services in, if undefined, assume RPC + * @param {String} [serviceName] - service name, if empty, assume RPC + * @param {String[]} [exclude] - methodNames to exclude from the cache + * + * @returns {Object|Function} if RPC, a cached function, else a Proxy that + * caches the call to the services. */ -function serviceCached(service) { - const cache = _serviceCache; - return Object.assign(Object.create(service), { - call() { - // FIXME - const serviceName = Object.prototype.hasOwnProperty.call(service, "call") - ? "orm" - : "rpc"; - const cacheId = JSON.stringify(arguments); - if (!cache[serviceName][cacheId]) { - cache[serviceName][cacheId] = - serviceName == "rpc" ? service(...arguments) : service.call(...arguments); - } - return cache[serviceName][cacheId]; - }, - }); +export function serviceCached(env, serviceName, exclude = []) { + function cachedCall(func, args, cache) { + const cacheId = JSON.stringify(args); + if (!cache[cacheId]) { + cache[cacheId] = func(...args); + } + return cache[cacheId]; + } + + if (!env || !serviceName || serviceName === "rpc") { + return (...args) => cachedCall(rpc, args, _serviceCache["rpc"]); + } else { + const service = env.services[serviceName]; + if (!_serviceCache[serviceName]) { + _serviceCache[serviceName] = {}; + } + return new Proxy(service, { + get: (target, prop, receiver) => { + if (typeof target[prop] === "function" && !exclude.includes(prop)) { + return (...args) => cachedCall(target[prop].bind(target), args, _serviceCache[serviceName]); + } + } + }); + } } // Outdated snippets whose alert has been discarded. const controlledSnippets = new Set(); -const clearControlledSnippets = () => controlledSnippets.clear(); +export const clearControlledSnippets = () => controlledSnippets.clear(); /** * @param {HTMLElement} el * @param {string} [title] @@ -188,27 +231,77 @@ async function _buildImgElement(src) { const node = await _buildImgElementCache[src]; return node.cloneNode(true); } -/** - * Build the correct DOM for a we-row element. - * - * @param {string} [title] - @see _buildElement - * @param {Object} [options] - @see _buildElement - * @param {HTMLElement[]} [options.childNodes] - * @returns {HTMLElement} - */ -function _buildRowElement(title, options) { - const groupEl = _buildElement('we-row', title, options); - - const rowEl = document.createElement('div'); - groupEl.appendChild(rowEl); - if (options && options.childNodes) { - options.childNodes.forEach(node => rowEl.appendChild(node)); +const _buildSvgElementCache = {}; +async function buildSvgElement(src) { + if (!(src in _buildSvgElementCache)) { + _buildSvgElementCache[src] = (async () => { + let text; + try { + const response = await window.fetch(src); + text = await response.text(); + } catch { + // In some tours, the tour finishes before the fetch is done + // and when a tour is finished, the python side will ask the + // browser to stop loading resources. This causes the fetch + // to fail and throw an error which crashes the test even + // though it completed successfully. + // So return an empty SVG to ensure everything completes + // correctly. + text = "<svg></svg>"; + } + return markup(text); + })(); } + const svgMarkup = await _buildSvgElementCache[src]; + return svgMarkup; +} - return groupEl; +class WeTitle extends Component { + static template = "web_editor.WeTitle"; + static props = { + title: { type: String, optional: true }, + class: { type: String, optional: true }, + style: { type: String, optional: true }, + "*": { optional: true }, + }; +} + +class WeRow extends Component { + static template = "web_editor.WeRow"; + static props = { + class: { type: String, optional: true }, + fontFamily: { type: String, optional: true }, + slots: { type: Object, optional: true }, + title: { type: String, optional: true }, + tooltip: { type: String, optional: true }, + "*": { optional: true }, + }; + static components = { WeTitle }; + setup() { + /** @type {Object.<string, UserValue>} */ + this.userValues = {}; + this.state = useState({ + show: false, + }); + this.env.registerLayoutElement({ state: this.state, userValues: this.userValues }, true); + useChildSubEnv({ + registerUserValue: (userValue) => { + this.userValues[userValue.id] = userValue; + this.env.registerUserValue(userValue); + }, + unregisterUserValue: (userValue) => { + delete this.userValues[userValue.id]; + this.env.unregisterUserValue(userValue); + }, + }); + } } + +registry.category("snippet_widgets").add("WeRow", WeRow); + /** + * TODO: @owl-options remove when done. * Build the correct DOM for a we-collapse element. * * @param {string} [title] - @see _buildElement @@ -216,7 +309,7 @@ function _buildRowElement(title, options) { * @param {HTMLElement[]} [options.childNodes] * @returns {HTMLElement} */ -function _buildCollapseElement(title, options) { +export function _buildCollapseElement(title, options) { const groupEl = _buildElement('we-collapse', title, options); const titleEl = groupEl.querySelector('we-title'); @@ -269,20 +362,6 @@ function createPropertyProxy(obj, propertyName, value) { }, }); } -/** - * Creates and registers a UserValueWidget by tag-name - * - * @param {string} widgetName - * @param {SnippetOptionWidget|UserValueWidget|null} parent - * @param {string} title - * @param {Object} options - * @returns {UserValueWidget} - */ -function registerUserValueWidget(widgetName, parent, title, options, $target) { - const widget = new userValueWidgetsRegistry[widgetName](parent, title, options, $target); - parent.registerSubWidget(widget); - return widget; -} //:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: @@ -292,42 +371,86 @@ const NULL_ID = '__NULL__'; * Base class for components to be used in snippet options widgets to retrieve * user values. */ -const UserValueWidget = Widget.extend({ - className: 'o_we_user_value_widget', - custom_events: { +const UserValueWidget = Widget; + +export class UserValue { + static custom_events = { 'user_value_update': '_onUserValueNotification', - }, + }; /** * @constructor + * @param {string} id + * @param {Object} state + * @param {Object} env + * @param {SnippetOption} option + * @param {Object} data + */ + constructor(id, state, env, option, data) { + this.id = id; + this.option = option; + this.env = env; + // TODO: @owl-option maybe we should take the opportunity to remove this. + this.$target = env.$target; + /** @type {UserValue[]} */ + this._subValues = {}; + this.mounted = false; + this.textContent = ""; + this._state = state; + this._state.preview = false; + this._state.active = false; + this._state.show = true; + this._data = data; + } + /** + * Returns the value that is currently in the state. This allows components + * to react to state changes, and is different than what a SnippetOption + * would need. */ - init: function (parent, title, options, $target) { - this._super(...arguments); - this.title = title; - this.options = options; - this._userValueWidgets = []; - this._value = ''; - this.$target = $target; - }, + get value() { + return this._state.value; + } + + set value(value) { + this._state.value = value; + } /** - * @override + * Returns whether the component that posses this state should be visible + * or not. Cannot be set directly, use {@link toggleVisibility} instead. + */ + get show() { + return this._state.show; + } + /** + * Returns whether sub widgets should or shouldn't be shown. Typically in + * the case of a list, we do not want to show all buttons at all time, so + * instead they will only be shown when this value is true. + * + * @type {boolean} + */ + get opened() { + return this._state.opened; + } + /** + * Gives the opportunity for values to prepare asynchronous content. + * E.g. fetch external data. + */ + async start() { + this.started = true; + } + /** + * @TODO: @owl-options: This should be moved into the component */ async willStart() { await this._super(...arguments); - if (this.options.dataAttributes.img) { - this.illustrationEl = await _buildImgElement(this.options.dataAttributes.img); - } else if (this.options.dataAttributes.icon) { - this.illustrationEl = document.createElement('i'); - this.illustrationEl.classList.add('fa', this.options.dataAttributes.icon); - } if (this.options.dataAttributes.reload) { this.options.dataAttributes.noPreview = "true"; } - }, + } /** - * @override + * @TODO: @owl-options: This should be moved into the component */ - _makeDescriptive: function () { + _makeDescriptive() { const $el = this._super(...arguments); const el = $el[0]; _addTitleAndAllowedAttributes(el, this.title, this.options); @@ -339,38 +462,7 @@ const UserValueWidget = Widget.extend({ el.appendChild(this.containerEl); return $el; - }, - /** - * @override - */ - async start() { - await this._super(...arguments); - - if (this.el.classList.contains('o_we_img_animate')) { - const buildImgExtensionSwitcher = (from, to) => { - const regex = new RegExp(`${from}$`, 'i'); - return ev => { - const img = ev.currentTarget.getElementsByTagName("img")[0]; - img.src = img.src.replace(regex, to); - }; - }; - this.$el.on('mouseenter.img_animate', buildImgExtensionSwitcher('png', 'gif')); - this.$el.on('mouseleave.img_animate', buildImgExtensionSwitcher('gif', 'png')); - } - }, - /** - * @override - */ - destroy() { - // Check if $el exists in case the widget is destroyed before it has - // been fully initialized. - // TODO there is probably better to do. This case was found only in - // tours, where the editor is left before the widget icon is loaded. - if (this.$el) { - this.$el.off('.img_animate'); - } - this._super(...arguments); - }, + } //-------------------------------------------------------------------------- // Public @@ -379,15 +471,8 @@ const UserValueWidget = Widget.extend({ /** * Closes the widget (only meaningful for widgets that can be closed). */ - close: function () { - if (!this.el) { - // In case the method is called while the widget is not fully - // initialized yet. No need to prevent that case: asking a non - // initialized widget to close itself should just not be a problem - // and just be ignored. - return; - } - if (!this.el.classList.contains('o_we_widget_opened')) { + close() { + if (!this._state.opened) { // Small optimization: it would normally not matter asking to // remove a class of an element if it does not already have it but // in this case we do more: we trigger_up an event and ask to close @@ -397,22 +482,25 @@ const UserValueWidget = Widget.extend({ // instructions being done at each click in the editor. return; } - this.trigger_up('user_value_widget_closing'); - this.el.classList.remove('o_we_widget_opened'); - this._userValueWidgets.forEach(widget => widget.close()); - }, + this.env.userValueWidgetClosing(); + this._state.opened = false; + Object.values(this._subValues).forEach(widget => widget.close()); + } /** - * Simulates the correct event on the element to make it active. + * Triggers a value change in non preview mode. + * Should be overridden by Sub-classes to properly reflect what enabling + * their component means. */ enable() { - this.$el.click(); - }, + this.notifyValueChange(false); + } + // TODO: @owl-options: rename this to findUserValue /** * @param {string} name * @returns {UserValueWidget|null} */ - findWidget: function (name) { - for (const widget of this._userValueWidgets) { + findWidget(name) { + for (const widget of Object.values(this._subValues)) { if (widget.getName() === name) { return widget; } @@ -422,7 +510,7 @@ const UserValueWidget = Widget.extend({ } } return null; - }, + } /** * Focus the main focusable element of the widget. */ @@ -431,7 +519,7 @@ const UserValueWidget = Widget.extend({ if (el) { el.focus(); } - }, + } /** * Returns the value that the widget would hold if it was active, by default * the internal value it holds. @@ -439,9 +527,9 @@ const UserValueWidget = Widget.extend({ * @param {string} [methodName] * @returns {string} */ - getActiveValue: function (methodName) { - return this._value; - }, + getActiveValue(methodName) { + return this.value; + } /** * Returns the default value the widget holds when inactive, by default the * first "possible value". @@ -449,25 +537,25 @@ const UserValueWidget = Widget.extend({ * @param {string} [methodName] * @returns {string} */ - getDefaultValue: function (methodName) { + getDefaultValue(methodName) { const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; return possibleValues && possibleValues[0] || ''; - }, + } /** * @returns {string[]} */ - getDependencies: function () { + getDependencies() { return this._dependencies; - }, + } /** * Returns the names of the option methods associated to the widget. Those * are loaded with @see loadMethodsData. * * @returns {string[]} */ - getMethodsNames: function () { - return this._methodsNames; - }, + getMethodsNames() { + return [...this._methodsNames.values()]; + } /** * Returns the option parameters associated to the widget (for a given * method name or not). Most are loaded with @see loadMethodsData. @@ -475,7 +563,7 @@ const UserValueWidget = Widget.extend({ * @param {string} [methodName] * @returns {Object} */ - getMethodsParams: function (methodName) { + getMethodsParams(methodName) { const params = Object.assign({}, this._methodsParams); if (methodName) { params.possibleValues = params.optionsPossibleValues[methodName] || []; @@ -483,13 +571,13 @@ const UserValueWidget = Widget.extend({ params.defaultValue = this.getDefaultValue(methodName); } return params; - }, + } /** * @returns {string} empty string if no name is used by the widget */ - getName: function () { + getName() { return this._methodsParams.name || ''; - }, + } /** * Returns the user value that the widget currently holds. The value is a * string, this is the value that will be received in the option methods @@ -498,62 +586,69 @@ const UserValueWidget = Widget.extend({ * @param {string} [methodName] * @returns {string} */ - getValue: function (methodName) { + getValue(methodName) { const isActive = this.isActive(); - if (!methodName || !this._methodsNames.includes(methodName)) { + if (!methodName || !this._methodsNames.has(methodName)) { return isActive ? 'true' : ''; } if (isActive) { return this.getActiveValue(methodName); } return this.getDefaultValue(methodName); - }, + } /** * Returns whether or not the widget is active (holds a value). * * @returns {boolean} */ - isActive: function () { - return this._value && this._value !== NULL_ID; - }, + isActive() { + return this.value && this.value !== NULL_ID; + } /** * Indicates if the widget can contain sub user value widgets or not. * * @returns {boolean} */ - isContainer: function () { + isContainer() { return false; - }, + } /** * Indicates if the widget is being previewed or not: the user is * manipulating it. Base case: if an internal <input/> element is focused. * * @returns {boolean} */ - isPreviewed: function () { - const focusEl = document.activeElement; - if (focusEl && focusEl.tagName === 'INPUT' - && (this.el === focusEl || this.el.contains(focusEl))) { - return true; - } - return this.el.classList.contains('o_we_preview'); - }, + isPreviewed() { + // const focusEl = document.activeElement; + // TODO: @owl-options: implement this on input values + // if (focusEl && focusEl.tagName === 'INPUT' + // && (this.el === focusEl || this.el.contains(focusEl))) { + // return true; + // } + return this._state.preview; + } /** * Loads option method names and option method parameters. * * @param {string[]} validMethodNames * @param {Object} extraParams */ - loadMethodsData: function (validMethodNames, extraParams) { - this._methodsNames = []; + loadMethodsData(validMethodNames, extraParams) { + this._validMethodNames = validMethodNames; + this._methodsNames = new Set(); this._methodsParams = Object.assign({}, extraParams); this._methodsParams.optionsPossibleValues = {}; this._dependencies = []; this._triggerWidgetsNames = []; this._triggerWidgetsValues = []; - for (const key in this.el.dataset) { - const dataValue = this.el.dataset[key].trim(); + for (const key in this._data) { + // Ignore values set to false or undefined but not empty strings. + if (this._data[key] === false || this._data[key] === undefined) { + continue; + } + + const dataValue = this._data[key].trim(); if (key === 'dependencies') { this._dependencies.push(...dataValue.split(/\s*,\s*/g)); @@ -562,47 +657,26 @@ const UserValueWidget = Widget.extend({ } else if (key === 'triggerValue') { this._triggerWidgetsValues.push(...dataValue.split(/\s*,\s*/g)); } else if (validMethodNames.includes(key)) { - this._methodsNames.push(key); + this._methodsNames.add(key); this._methodsParams.optionsPossibleValues[key] = dataValue.split(/\s*\|\s*/g); } else { this._methodsParams[key] = dataValue; } } - this._userValueWidgets.forEach(widget => { - const inheritedParams = Object.assign({}, this._methodsParams); - inheritedParams.optionsPossibleValues = null; - widget.loadMethodsData(validMethodNames, inheritedParams); - const subMethodsNames = widget.getMethodsNames(); - const subMethodsParams = widget.getMethodsParams(); - - for (const methodName of subMethodsNames) { - if (!this._methodsNames.includes(methodName)) { - this._methodsNames.push(methodName); - this._methodsParams.optionsPossibleValues[methodName] = []; - } - for (const subPossibleValue of subMethodsParams.optionsPossibleValues[methodName]) { - this._methodsParams.optionsPossibleValues[methodName].push(subPossibleValue); - } - } - }); - for (const methodName of this._methodsNames) { - const arr = this._methodsParams.optionsPossibleValues[methodName]; - const uniqArr = arr.filter((v, i, arr) => i === arr.indexOf(v)); - this._methodsParams.optionsPossibleValues[methodName] = uniqArr; - } + // TODO: @owl-options Sort the method names or fix this. // Method names come from the widget's dataset whose keys' order cannot // be relied on. We explicitely sort them by alphabetical order allowing // consistent behavior, while relying on order for such methods should // not be done when possible (the methods should be independent from // each other when possible). - this._methodsNames.sort(); - }, + //this._methodsNames.sort(); + } /** * @param {boolean} [previewMode=false] * @param {boolean} [isSimulatedEvent=false] */ - notifyValueChange: function (previewMode, isSimulatedEvent) { + notifyValueChange(previewMode, isSimulatedEvent) { // In the case we notify a change update, force a preview update if it // was not already previewed const isPreviewed = this.isPreviewed(); @@ -614,6 +688,7 @@ const UserValueWidget = Widget.extend({ previewMode: previewMode || false, isSimulatedEvent: !!isSimulatedEvent, }; + // @owl-options: Could we change this? // TODO improve this. The preview state has to be updated only when the // actual option _select is gonna be called... but this is delayed by a // mutex. So, during test tours, we would notify both 'preview' and @@ -625,29 +700,54 @@ const UserValueWidget = Widget.extend({ // an inverted state)... but if, for example, a modal opens before // handling that non-preview, a 'reset' will be thrown thus removing // the preview class. So we force it in non-preview too. - data.prepare = () => this.el.classList.add('o_we_preview'); + data.prepare = () => this._state.preview = true; } else if (previewMode === 'reset') { - data.prepare = () => this.el.classList.remove('o_we_preview'); + data.prepare = () => this._state.preview = false; } - this.trigger_up('user_value_update', data); - }, + this.userValueNotification(data); + } /** * Opens the widget (only meaningful for widgets that can be opened). */ open() { - this.trigger_up('user_value_widget_opening'); - this.el.classList.add('o_we_widget_opened'); - }, + this.env.userValueWidgetOpening(this); + this._state.opened = true; + } /** - * Adds the given widget to the known list of user value sub-widgets (useful - * for container widgets). + * Adds the given userValue to the list of sub values (useful for container + * components). * - * @param {UserValueWidget} widget + * @param {UserValue} userValue */ - registerSubWidget: function (widget) { - this._userValueWidgets.push(widget); - }, + registerUserValue(userValue) { + this._subValues[userValue.id] = (userValue); + const inheritedParams = Object.assign({}, this._methodsParams); + inheritedParams.optionsPossibleValues = null; + userValue.loadMethodsData(this._validMethodNames, inheritedParams); + const subMethodsNames = userValue.getMethodsNames(); + const subMethodsParams = userValue.getMethodsParams(); + + for (const methodName of subMethodsNames) { + if (!this._methodsNames.has(methodName)) { + this._methodsNames.add(methodName); + this._methodsParams.optionsPossibleValues[methodName] = []; + } + for (const subPossibleValue of subMethodsParams.optionsPossibleValues[methodName]) { + if (!this._methodsParams.optionsPossibleValues[methodName].includes(subPossibleValue)) { + this._methodsParams.optionsPossibleValues[methodName].push(subPossibleValue); + } + } + } + } + /** + * Removes the given userValue from the list of sub values. + * + * @param {UserValue} userValue + */ + unregisterUserValue(userValue) { + delete this._subValues[userValue.id]; + } /** * Sets the user value that the widget should currently hold, for the * given method name. @@ -662,23 +762,24 @@ const UserValueWidget = Widget.extend({ * @param {string} [methodName] */ async setValue(value, methodName) { - this._value = value; - this.el.classList.remove('o_we_preview'); - }, + this._state.preview = false; + this.value = value; + } /** * @param {boolean} show */ - toggleVisibility: function (show) { - let doFocus = false; - if (show) { - const wasInvisible = this.el.classList.contains('d-none'); - doFocus = wasInvisible && this.el.dataset.requestFocus === "true"; - } - this.el.classList.toggle('d-none', !show); - if (doFocus) { - this.focus(); - } - }, + toggleVisibility(show) { + this._state.show = show; + // let doFocus = false; + // if (show) { + // const wasInvisible = this.el.classList.contains('d-none'); + // doFocus = wasInvisible && this.el.dataset.requestFocus === "true"; + // } + // this.el.classList.toggle('d-none', !show); + // if (doFocus) { + // this.focus(); + // } + } //-------------------------------------------------------------------------- // Private @@ -692,15 +793,191 @@ const UserValueWidget = Widget.extend({ * @private * @returns {HTMLElement} */ - _getFocusableElement: function () { + _getFocusableElement() { return null; - }, + } + /** + * Allows container widgets to add additional data if needed. + * + * @private + * @param {Object} params + */ + userValueNotification(params) { + params.widget = this; + + if (!params.triggerWidgetsNames) { + params.triggerWidgetsNames = []; + } + params.triggerWidgetsNames.push(...this._triggerWidgetsNames); + + if (!params.triggerWidgetsValues) { + params.triggerWidgetsValues = []; + } + params.triggerWidgetsValues.push(...this._triggerWidgetsValues); + this.env.userValueNotification(params); + } +} + +export class UserValueComponent extends Component { + + static props = { + name: { type: String, optional: true }, + slots: { type: Object, optional: true }, + animateImg: { type: String, optional: true }, + // Allow any prop as they will reference a method of SnippetOption + "*": {}, + }; + + static defaultProps = { + animateImg: false, + }; + + static StateModel = UserValue; + + // This is an abstract component, it should not be used alone. + static template = ""; + + setup() { + const id = uniqueId("userValue"); + const state = useState({}); + const stateParams = this.getStateParams(); + this.state = new this.constructor.StateModel( + id, + state, + this.env, + this.env.snippetOption, + stateParams + ); + if (this.props.multiSequence) { + this.state.multiSequence = this.props.multiSequence; + } + + this.env.registerUserValue(this.state); + + if (this.constructor.isContainer) { + // If this component contains other UserValueComponents, they should + // not register to the options, but to this Component's state instead, so + // that this one's UserValue can handle their state, as they are + // heavily dependent on the parent's state. + useChildSubEnv({ + registerUserValue: (userValue) => { + this.state.registerUserValue(userValue); + if (this.env.isContained) { + this.env.registerUserValue(userValue); + } + }, + unregisterUserValue: (userValue) => { + this.state.unregisterUserValue(userValue); + }, + userValueNotification: (params) => { + this.state.userValueNotification(params); + }, + isContained: true, + }); + } + + onWillStart(async () => { + if (this.props.img) { + const src = this.props.img; + if (src.split(".").pop() === "svg") { + this.svg = await buildSvgElement(src); + } else { + this.img = src; + + if (this.props.animateImg) { + this.previewImg = this.img.replace(/png$/i, "gif"); + } + } + } + }); + // Track the changes to the textContent slot so that parent widgets can + // use it (e.g. WeSelect use the active value's textContent for its + // toggler). + this.textContentRef = useRef("text-content"); + useEffect( + () => { + const textContent = this.textContentRef.el?.textContent || ""; + if (textContent && textContent !== this.state.textContent) { + this.state.textContent = textContent; + } else if (!textContent) { + const fakeImgEl = this.textContentRef.el?.querySelector('.o_we_fake_img_item'); + if (fakeImgEl) { + this.state.fakeImg = markup(fakeImgEl.outerHTML); + } + } + }, + () => [this.textContentRef.el] + ); + + onWillDestroy(() => { + this.env.unregisterUserValue(this.state); + }); + } + /** + * Returns an object containing the classes defined in the props + * + * @returns {Object} + */ + getPropsClass() { + if (typeof this.props.class === "string") { + return { [this.props.class]: true }; + } + if (Array.isArray(this.props.class)) { + const classes = this.props.class.join(" "); + return { [classes]: this.props.class.length > 0 }; + } + if (typeof this.props.class === "object") { + return this.props.class; + } + } + /** + * Returns all the classes that should be applied on the root of the widget. + * + * @returns {Object} + */ + getAllClasses() { + return { + "active": this.state.active, + "o_we_preview": this.state.isPreviewed(), + "d-none": !this.state.show, + "o_we_widget_opened": this.state.opened, + "o_we_icon_button": this.img || this.svg || this.props.icon, + "o_we_img_animate": this.props.animateImg, + ...this.getPropsClass(), + }; + } + /** + * Sets all props as data attributes to match with existing tours. + * @TODO this should be removed or only shown when tours are actually + * activated. However, some SCSS styles are using attributes to style some + * buttons (e.g. BackgroundShape). + */ + getAllDataAttributes() { + const dataAttributes = {}; + for (const key of Object.getOwnPropertyNames(this.state._data)) { + const kebabName = key.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); + dataAttributes[`data-${kebabName}`] = this.props[key]; + } + return dataAttributes; + } + /** + * Prepare the values that will be passed as parameters to the state. + * + * @private + * @return {Objectl} + */ + getStateParams() { + const propsCopy = { ...this.props }; + delete propsCopy.slots; + delete propsCopy.multiSequence; + return propsCopy; + } /** * @private * @param {OdooEvent|Event} * @returns {boolean} */ - _handleNotifierEvent: function (ev) { + _handleNotifierEvent(ev) { if (!ev) { return true; } @@ -712,7 +989,7 @@ const UserValueWidget = Widget.extend({ ev.preventDefault(); } return true; - }, + } //-------------------------------------------------------------------------- // Handlers @@ -725,30 +1002,11 @@ const UserValueWidget = Widget.extend({ * @private * @param {OdooEvent|Event} [ev] */ - _onUserValueChange: function (ev) { + _onUserValueChange(ev) { if (this._handleNotifierEvent(ev)) { - this.notifyValueChange(false); - } - }, - /** - * Allows container widgets to add additional data if needed. - * - * @private - * @param {OdooEvent} ev - */ - _onUserValueNotification: function (ev) { - ev.data.widget = this; - - if (!ev.data.triggerWidgetsNames) { - ev.data.triggerWidgetsNames = []; - } - ev.data.triggerWidgetsNames.push(...this._triggerWidgetsNames); - - if (!ev.data.triggerWidgetsValues) { - ev.data.triggerWidgetsValues = []; + this.state.notifyValueChange(false); } - ev.data.triggerWidgetsValues.push(...this._triggerWidgetsValues); - }, + } /** * Should be called when an user event on the widget indicates a value * preview. @@ -756,11 +1014,11 @@ const UserValueWidget = Widget.extend({ * @private * @param {OdooEvent|Event} [ev] */ - _onUserValuePreview: function (ev) { + _onUserValuePreview(ev) { if (this._handleNotifierEvent(ev)) { - this.notifyValueChange(true); + this.state.notifyValueChange(true); } - }, + } /** * Should be called when an user event on the widget indicates a value * reset. @@ -768,103 +1026,106 @@ const UserValueWidget = Widget.extend({ * @private * @param {OdooEvent|Event} [ev] */ - _onUserValueReset: function (ev) { + _onUserValueReset(ev) { if (this._handleNotifierEvent(ev)) { - this.notifyValueChange('reset'); + this.state.notifyValueChange('reset'); } - }, -}); + } +} -const ButtonUserValueWidget = UserValueWidget.extend({ - tagName: 'we-button', - events: { - 'click': '_onButtonClick', - 'click [role="button"]': '_onInnerButtonClick', - 'mouseenter': '_onUserValuePreview', - 'mouseleave': '_onUserValueReset', - }, +class ButtonUserValue extends UserValue { /** * @override */ - async willStart() { - await this._super(...arguments); - if (this.options.dataAttributes.activeImg) { - this.activeImgEl = await _buildImgElement(this.options.dataAttributes.activeImg); - } - }, - /** - * @override - */ - _makeDescriptive() { - const $el = this._super(...arguments); - if (this.illustrationEl) { - $el[0].classList.add('o_we_icon_button'); - } - if (this.activeImgEl) { - this.containerEl.appendChild(this.activeImgEl); - } - return $el; - }, - /** - * @override - */ - start: function (parent, title, options) { - if (this.options && this.options.childNodes) { - this.options.childNodes.forEach(node => this.containerEl.appendChild(node)); + async setValue(value, methodName) { + await super.setValue(...arguments); + let active = !!value; + if (methodName) { + if (!this._methodsNames.has(methodName)) { + return; + } + active = (this.getActiveValue(methodName) === value); } + this._state.active = active; + // TODO: @owl-options: Some of this code is no longer used, it should + // probably be removed in its own commit. + // if (this.illustrationEl && this.activeImgEl) { + // this.illustrationEl.classList.toggle('d-none', active); + // this.activeImgEl.classList.toggle('d-none', !active); + // } + // this.el.classList.toggle('active', active); + } - return this._super(...arguments); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - + get active() { + return this._state.active; + } /** * @override */ - getActiveValue: function (methodName) { + getActiveValue(methodName) { const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; return possibleValues && possibleValues[possibleValues.length - 1] || ''; - }, + } /** * @override */ - isActive: function () { - return (this.isPreviewed() !== this.el.classList.contains('active')); - }, + isActive() { + return (this.isPreviewed() !== this._state.active); + } /** * @override */ - loadMethodsData: function (validMethodNames) { - this._super.apply(this, arguments); - for (const methodName of this._methodsNames) { + loadMethodsData(validMethodNames) { + super.loadMethodsData(...arguments); + for (const methodName of this._methodsNames.values()) { const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; if (possibleValues.length <= 1) { possibleValues.unshift(''); } } - }, + } +} + +export class WeButton extends UserValueComponent { + static template = "web_editor.WeButton"; + static StateModel = ButtonUserValue; + setup() { + super.setup(); + } /** * @override */ - async setValue(value, methodName) { + async willStart() { await this._super(...arguments); - let active = !!value; - if (methodName) { - if (!this._methodsNames.includes(methodName)) { - return; - } - active = (this.getActiveValue(methodName) === value); + if (this.options.dataAttributes.activeImg) { + // TODO: @owl-options: this is no longer used. + this.activeImgEl = await _buildImgElement(this.options.dataAttributes.activeImg); } - if (this.illustrationEl && this.activeImgEl) { - this.illustrationEl.classList.toggle('d-none', active); - this.activeImgEl.classList.toggle('d-none', !active); + } + /** + * @override + */ + _makeDescriptive() { + const $el = this._super(...arguments); + if (this.illustrationEl) { + $el[0].classList.add('o_we_icon_button'); + } + if (this.activeImgEl) { + this.containerEl.appendChild(this.activeImgEl); + } + return $el; + } + /** + * @override + */ + start(parent, title, options) { + if (this.options && this.options.childNodes) { + this.options.childNodes.forEach(node => this.containerEl.appendChild(node)); } - this.el.classList.toggle('active', active); - }, + return this._super(...arguments); + } //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- @@ -872,33 +1133,28 @@ const ButtonUserValueWidget = UserValueWidget.extend({ /** * @private */ - _onButtonClick: function (ev) { + _onButtonClick(ev) { if (!ev._innerButtonClicked) { this._onUserValueChange(ev); } - }, + } /** * @private */ - _onInnerButtonClick: function (ev) { + _onInnerButtonClick(ev) { // Cannot just stop propagation as the click needs to be propagated to // potential parent widgets for event delegation on those inner buttons. ev._innerButtonClicked = true; - }, -}); - -const CheckboxUserValueWidget = ButtonUserValueWidget.extend({ - className: (ButtonUserValueWidget.prototype.className || '') + ' o_we_checkbox_wrapper', + } +} +registry.category("snippet_widgets").add("WeButton", WeButton); - /** - * @override - */ - start: function () { - const checkboxEl = document.createElement('we-checkbox'); - this.containerEl.appendChild(checkboxEl); +const ButtonUserValueWidget = Widget.extend({className: ''}); +const CheckboxUserValueWidget = ButtonUserValueWidget.extend({}); - return this._super(...arguments); - }, +class WeCheckbox extends WeButton { + static template = "web_editor.WeCheckbox"; + static components = { WeTitle }; //-------------------------------------------------------------------------- // Public @@ -908,8 +1164,9 @@ const CheckboxUserValueWidget = ButtonUserValueWidget.extend({ * @override */ enable() { + // TODO: @owl-options adapt this.$('we-checkbox').click(); - }, + } //-------------------------------------------------------------------------- // Handlers @@ -923,30 +1180,32 @@ const CheckboxUserValueWidget = ButtonUserValueWidget.extend({ // Only consider clicks on the label and the checkbox control itself return; } - return this._super(...arguments); - }, -}); + return super._onButtonClick(...arguments); + } +} +registry.category("snippet_widgets").add("WeCheckbox", WeCheckbox); -const BaseSelectionUserValueWidget = UserValueWidget.extend({ +const BaseSelectionUserValueWidget = UserValueWidget.extend({}); +class BaseSelectionUserValue extends UserValue { /** * @override */ async start() { - await this._super(...arguments); - - this.menuEl = document.createElement('we-selection-items'); - if (this.options && this.options.childNodes) { - this.options.childNodes.forEach(node => { - // Ensure to only put element nodes inside the selection menu - // as there could be an :empty CSS rule to handle the case when - // the menu is empty (so it should not contain any whitespace). - if (node.nodeType === Node.ELEMENT_NODE) { - this.menuEl.appendChild(node); - } - }); - } - this.containerEl.appendChild(this.menuEl); - }, + await super.start(...arguments); + + // this.menuEl = document.createElement('we-selection-items'); + // if (this.options && this.options.childNodes) { + // this.options.childNodes.forEach(node => { + // // Ensure to only put element nodes inside the selection menu + // // as there could be an :empty CSS rule to handle the case when + // // the menu is empty (so it should not contain any whitespace). + // if (node.nodeType === Node.ELEMENT_NODE) { + // this.menuEl.appendChild(node); + // } + // }); + // } + // this.containerEl.appendChild(this.menuEl); + } //-------------------------------------------------------------------------- // Public @@ -956,13 +1215,13 @@ const BaseSelectionUserValueWidget = UserValueWidget.extend({ * @override */ getMethodsParams(methodName) { - const params = this._super(...arguments); + const params = super.getMethodsParams(...arguments); const activeWidget = this._getActiveSubWidget(); if (!activeWidget) { return params; } return Object.assign(activeWidget.getMethodsParams(...arguments), params); - }, + } /** * @override */ @@ -971,23 +1230,23 @@ const BaseSelectionUserValueWidget = UserValueWidget.extend({ if (activeWidget) { return activeWidget.getActiveValue(methodName); } - return this._super(...arguments); - }, + return super.getValue(...arguments); + } /** * @override */ isContainer() { return true; - }, + } /** * @override */ async setValue(value, methodName) { - const _super = this._super.bind(this); - for (const widget of this._userValueWidgets) { + const subValues = Object.values(this._subValues); + for (const widget of subValues) { await widget.setValue(NULL_ID, methodName); } - for (const widget of [...this._userValueWidgets].reverse()) { + for (const widget of subValues.reverse()) { await widget.setValue(value, methodName); if (widget.isActive()) { // Only one select item can be true at a time, we consider the @@ -995,8 +1254,8 @@ const BaseSelectionUserValueWidget = UserValueWidget.extend({ break; } } - await _super(...arguments); - }, + await super.setValue(...arguments); + } //-------------------------------------------------------------------------- // Private @@ -1004,23 +1263,129 @@ const BaseSelectionUserValueWidget = UserValueWidget.extend({ /** * @private - * @returns {UserValueWidget|undefined} + * @returns {UserValue|undefined} */ _getActiveSubWidget() { - const previewedWidget = this._userValueWidgets.find(widget => widget.isPreviewed()); + const previewedWidget = Object.values(this._subValues).find(value => value.isPreviewed()); if (previewedWidget) { return previewedWidget; } - return this._userValueWidgets.find(widget => widget.isActive()); - }, -}); + return Object.values(this._subValues).find(value => value.isActive()); + } +} +export class SelectUserValue extends BaseSelectionUserValue { -const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({ - tagName: 'we-select', - events: { - 'click': '_onClick', - }, - PLACEHOLDER_TEXT: _t("None"), + static PLACEHOLDER_TEXT = _t("None"); + + constructor() { + super(...arguments); + this._state.toggler = { + faIcon: false, + imgSrc: false, + textContent: this.constructor.PLACEHOLDER_TEXT, + }; + } + /** + * Informations about the toggler element, to be used in the template. + * + * @type {Object} + */ + get toggler() { + return this._state.toggler; + } + /** + * @override + */ + async setValue(value, methodName) { + await super.setValue(...arguments); + // Re-initialising the toggler. + const newToggler = { + textContent: "", + faIcon: false, + imgSrc: false, + }; + const activeWidget = Object.values(this._subValues).find(value => !value.isPreviewed() && value.isActive()); + if (activeWidget) { + const params = activeWidget.getMethodsParams(methodName); + // Check the param for svg on buttons + const svgSrc = params.svgSrc; // useful to avoid searching text content in svg element + const text = (params.selectLabel || (!svgSrc && activeWidget.textContent.trim())); + const imgSrc = params.img; + const icon = params.icon; + if (text) { + newToggler.textContent = text; + } else if (icon) { + newToggler.faIcon = icon; + } else if (imgSrc) { + newToggler.imgSrc = imgSrc; + } else { + // TODO: @owl-options: check this? See the "border-style" option + // that appears when you add a border for an example. e.g.: on + // Images Wall. + if (activeWidget.fakeImg) { + newToggler.textContent = activeWidget.fakeImg; + } + } + } else { + newToggler.textContent = this.constructor.PLACEHOLDER_TEXT; + } + + this._state.toggler = newToggler; + } + /** + * @override + */ + isPreviewed() { + return super.isPreviewed() || this.opened; + } + /** + * @override + */ + enable() { + if (!this.opened) { + this.open(); + } + } +} +const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({}); +export class WeSelect extends UserValueComponent { + static isContainer = true; + static template = "web_editor.WeSelect"; + static components = { Dropdown, WeTitle }; + static StateModel = SelectUserValue; + static props = { + ...UserValueComponent.props, + title: { type: String, optional: true }, + } + + setup() { + // Use this ref to adjust the dropdown's position upwards if not enough + // space downwards. + this.menuRef = useRef("menu"); + + useEffect( + (opened) => { + if (opened) { + this._adjustDropdownPosition(); + } + return () => { + const containerEl = this.menuRef.el?.closest(".o_we_user_value_widget"); + containerEl.classList.remove("o_we_select_dropdown_up"); + }; + }, + () => [this.state.opened] + ); + + super.setup(); + } + /** + * @override + */ + getStateParams() { + const propsCopy = super.getStateParams(); + delete propsCopy.title; + return propsCopy; + } /** * @override @@ -1051,89 +1416,7 @@ const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({ const dropdownCaretEl = document.createElement('span'); dropdownCaretEl.classList.add('o_we_dropdown_caret'); this.containerEl.appendChild(dropdownCaretEl); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - close: function () { - this._super(...arguments); - this.el.classList.remove("o_we_select_dropdown_up"); - if (this.menuTogglerEl) { - this.menuTogglerEl.classList.remove('active'); - } - }, - /** - * @override - */ - isPreviewed: function () { - return this._super(...arguments) || this.menuTogglerEl.classList.contains('active'); - }, - /** - * @override - */ - open() { - this._super(...arguments); - this.menuTogglerEl.classList.add('active'); - this._adjustDropdownPosition(); - }, - /** - * @override - */ - async setValue() { - await this._super(...arguments); - - if (this.iconEl) { - return; - } - - if (this.menuTogglerItemEl) { - this.menuTogglerItemEl.remove(); - this.menuTogglerItemEl = null; - } - - let textContent = ''; - const activeWidget = this._userValueWidgets.find(widget => !widget.isPreviewed() && widget.isActive()); - if (activeWidget) { - const svgTag = activeWidget.el.querySelector('svg'); // useful to avoid searching text content in svg element - const value = (activeWidget.el.dataset.selectLabel || (!svgTag && activeWidget.el.textContent.trim())); - const imgSrc = activeWidget.el.dataset.img; - const icon = activeWidget.el.dataset.icon; - if (value) { - textContent = value; - } else if (icon) { - this.menuTogglerItemEl = document.createElement('i'); - this.menuTogglerItemEl.classList.add('fa', icon); - } else if (imgSrc) { - this.menuTogglerItemEl = document.createElement('img'); - this.menuTogglerItemEl.src = imgSrc; - } else { - const fakeImgEl = activeWidget.el.querySelector('.o_we_fake_img_item'); - if (fakeImgEl) { - this.menuTogglerItemEl = fakeImgEl.cloneNode(true); - } - } - } else { - textContent = this.PLACEHOLDER_TEXT; - } - - this.menuTogglerEl.textContent = textContent; - if (this.menuTogglerItemEl) { - this.menuTogglerEl.appendChild(this.menuTogglerItemEl); - } - }, - /** - * @override - */ - enable() { - if (!this.menuTogglerEl.classList.contains('active')) { - this.menuTogglerEl.click(); - } - }, + } //-------------------------------------------------------------------------- // Private @@ -1145,7 +1428,7 @@ const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({ */ _shouldIgnoreClick(ev) { return !!ev.target.closest('[role="button"]'); - }, + } /** * Decides whether the dropdown should be positioned below or above the * selector based on the available space. @@ -1153,14 +1436,22 @@ const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({ * @private */ _adjustDropdownPosition() { - const customizePanelEl = this.menuEl.closest(".o_we_customize_panel"); + if (!this.state.opened) { + return; + } + const customizePanelEl = this.menuRef.el?.closest(".o_we_customize_panel"); if (!customizePanelEl) { return; } - this.el.classList.remove("o_we_select_dropdown_up"); + const containerEl = this.menuRef.el.closest(".o_we_user_value_widget"); + if (!containerEl) { + return; + } + + containerEl.classList.remove("o_we_select_dropdown_up"); const customizePanelElCoords = customizePanelEl.getBoundingClientRect(); - let dropdownMenuElCoords = this.menuEl.getBoundingClientRect(); + let dropdownMenuElCoords = this.menuRef.el.getBoundingClientRect(); // Adds a margin to prevent the dropdown from sticking to the edge of // the customize panel. @@ -1168,15 +1459,15 @@ const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({ // If after opening, the dropdown list overflows the customization // panel at the bottom, opens the dropdown above the selector. if ((dropdownMenuElCoords.bottom + dropdownMenuMargin) > customizePanelElCoords.bottom) { - this.el.classList.add("o_we_select_dropdown_up"); - dropdownMenuElCoords = this.menuEl.getBoundingClientRect(); + containerEl.classList.add("o_we_select_dropdown_up"); + dropdownMenuElCoords = this.menuRef.el.getBoundingClientRect(); // If there is no available space above it either, then we open // it below the selector. if (dropdownMenuElCoords.top < customizePanelElCoords.top) { - this.el.classList.remove("o_we_select_dropdown_up"); + containerEl.classList.remove("o_we_select_dropdown_up"); } } - }, + } //-------------------------------------------------------------------------- // Handlers @@ -1187,40 +1478,39 @@ const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({ * * @private */ - _onClick: function (ev) { + _onClick(ev) { if (this._shouldIgnoreClick(ev)) { return; } - if (!this.menuTogglerEl.classList.contains('active')) { - this.open(); + if (!this.state.opened) { + this.state.open(); } else { - this.close(); + this.state.close(); } - const activeButton = this._userValueWidgets.find(widget => widget.isActive()); - if (activeButton) { - this.menuEl.scrollTop = activeButton.el.offsetTop - (this.menuEl.offsetHeight / 2); + const activeItem = this.menuRef.el?.querySelector(".active"); + if (activeItem) { + this.menuRef.el.scrollTop = activeItem.offsetTop - (this.menuRef.el.offsetHeight / 2); } - }, -}); + } +} +registry.category("snippet_widgets").add("WeSelect", WeSelect); + +class WeButtonGroup extends UserValueComponent { + static template = "web_editor.WeButtonGroup"; + static isContainer = true; + static components = { WeTitle }; + static StateModel = BaseSelectionUserValue; +} + +registry.category("snippet_widgets").add("WeButtonGroup", WeButtonGroup); const ButtonGroupUserValueWidget = BaseSelectionUserValueWidget.extend({ tagName: 'we-button-group', }); -const UnitUserValueWidget = UserValueWidget.extend({ - /** - * @override - */ - start: async function () { - const unit = this.el.dataset.unit || ''; - this.el.dataset.unit = unit; - if (this.el.dataset.saveUnit === undefined) { - this.el.dataset.saveUnit = unit; - } - - return this._super(...arguments); - }, +const UnitUserValueWidget = UserValueWidget.extend({}); +class UnitUserValue extends UserValue { //-------------------------------------------------------------------------- // Public @@ -1229,13 +1519,17 @@ const UnitUserValueWidget = UserValueWidget.extend({ /** * @override */ - getActiveValue: function (methodName) { - const activeValue = this._super(...arguments); + getActiveValue(methodName) { + const activeValue = this._nextValue || super.getActiveValue(...arguments) || ""; const params = this._methodsParams; if (!this._isNumeric()) { return activeValue; } + // TODO find correct way to apply this + if (params.saveUnit === undefined) { + params.saveUnit = params.unit; + } const defaultValue = this.getDefaultValue(methodName, false); @@ -1248,13 +1542,13 @@ const UnitUserValueWidget = UserValueWidget.extend({ return `${this._floatToStr(value)}${params.saveUnit}`; } }).join(' '); - }, + } /** * @override * @param {boolean} [useInputUnit=false] */ - getDefaultValue: function (methodName, useInputUnit) { - const defaultValue = this._super(...arguments); + getDefaultValue(methodName, useInputUnit) { + const defaultValue = super.getDefaultValue(...arguments); const params = this._methodsParams; if (!this._isNumeric()) { @@ -1267,37 +1561,48 @@ const UnitUserValueWidget = UserValueWidget.extend({ return defaultValue; } return `${this._floatToStr(numValue)}${unit}`; - }, + } /** * @override */ - isActive: function () { - const isSuperActive = this._super(...arguments); + isActive() { + const isSuperActive = super.isActive(...arguments); if (!this._isNumeric()) { return isSuperActive; } return isSuperActive && ( - this._floatToStr(parseFloat(this._value)) !== '0' + this._floatToStr(parseFloat(this.value)) !== '0' // Or is a composite value. - || !!this._value.match(/\d+\s+\d+/) + || !!this.value.match(/\d+\s+\d+/) ); - }, + } /** * @override */ - async setValue(value, methodName) { + loadMethodsData() { + super.loadMethodsData(...arguments); const params = this._methodsParams; - if (this._isNumeric()) { - value = value.split(' ').map(v => { - const numValue = weUtils.convertValueToUnit(v, params.unit, params.cssProperty, this.$target); + const unit = params.unit || ''; + if (params.saveUnit === undefined) { + params.saveUnit = unit; + } + } + /** + * @override + */ + async setValue(value, methodName) { + const params = this._methodsParams; + if (this._isNumeric()) { + value = value.split(' ').map(v => { + const numValue = weUtils.convertValueToUnit(v, params.unit, params.cssProperty, this.$target); if (isNaN(numValue)) { return ''; // Something not supported } return this._floatToStr(numValue); }).join(' '); } - return this._super(value, methodName); - }, + return super.setValue(value, methodName); + } //-------------------------------------------------------------------------- // Private @@ -1310,9 +1615,9 @@ const UnitUserValueWidget = UserValueWidget.extend({ * @param {number} value * @returns {string} */ - _floatToStr: function (value) { + _floatToStr(value) { return `${parseFloat(value.toFixed(5))}`; - }, + } /** * Checks whether the widget contains a numeric value. * @@ -1320,60 +1625,64 @@ const UnitUserValueWidget = UserValueWidget.extend({ * @returns {Boolean} true if the value is numeric, false otherwise. */ _isNumeric() { - const params = this._methodsParams || this.el.dataset; + const params = this._methodsParams; return !!params.unit; - }, -}); - -const InputUserValueWidget = UnitUserValueWidget.extend({ - tagName: 'we-input', - events: { - 'input input': '_onInputInput', - 'blur input': '_onInputBlur', - 'change input': '_onUserValueChange', - 'keydown input': '_onInputKeydown', - }, + } +} +const InputUserValueWidget = UnitUserValueWidget.extend({}); +class InputUserValue extends UnitUserValue { /** * @override */ - start: async function () { - await this._super(...arguments); + async setValue() { + await super.setValue(...arguments); + this._oldValue = this.value; + } + /** + * @override + */ + _isNumeric() { + const isNumeric = super._isNumeric(...arguments); + const params = this._methodsParams; + return isNumeric || !!params.fakeUnit || !!params.step; + } +} +export class WeInput extends UserValueComponent { + static template = 'web_editor.WeInput'; + static props = { ...UserValueComponent.props, + unit: { type: String, optional: true }, + step: { type: String, optional: true }, + saveUnit: { type: String, optional: true }, + // withUnit: { type: String, optional: true }, // ? boolean ? + fakeUnit: { type: String, optional: true }, // ? boolean ? + hideUnit: { type: String, optional: true }, // ? boolean ? + extraClass: { type: String, optional: true }, + placeholder: { type: String, optional: true }, + }; + static defaultProps = { + unit: "", + }; + static components = { WeTitle }; + static StateModel = InputUserValue; - const unit = this.el.dataset.unit; - this.inputEl = document.createElement('input'); - this.inputEl.setAttribute('type', 'text'); - this.inputEl.setAttribute('autocomplete', 'chrome-off'); - this.inputEl.setAttribute('placeholder', this.el.getAttribute('placeholder') || ''); - const useNumberAlignment = this._isNumeric() || !!this.el.dataset.hideUnit; - this.inputEl.classList.toggle('text-start', !useNumberAlignment); - this.inputEl.classList.toggle('text-end', useNumberAlignment); - this.containerEl.appendChild(this.inputEl); - - const showUnit = (!!unit || !!this.el.dataset.fakeUnit) && !this.el.dataset.hideUnit; - if (showUnit) { - var unitEl = document.createElement('span'); - const unitText = this.el.dataset.fakeUnit || unit; - unitEl.textContent = unitText; - this.containerEl.appendChild(unitEl); - if (unitText.length > 3) { - this.el.classList.add('o_we_large'); - } - } - }, + setup() { + super.setup(); + this.inputRef = useRef("input"); + } //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- - /** - * @override - */ - async setValue() { - await this._super(...arguments); - this.inputEl.value = this._value; - this._oldValue = this._value; - }, + getAllClasses() { + return { + ...super.getAllClasses(), + "o_we_large": ( + !this.props.unit && !this.props.fakeUnit + ) || this.props.hideUnit, + }; + } //-------------------------------------------------------------------------- // Private @@ -1383,16 +1692,8 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ * @override */ _getFocusableElement() { - return this.inputEl; - }, - /** - * @override - */ - _isNumeric() { - const isNumeric = this._super(...arguments); - const params = this._methodsParams || this.el.dataset; - return isNumeric || !!params.fakeUnit || !!params.step; - }, + return this.inputRef.el; + } //-------------------------------------------------------------------------- // Handlers @@ -1402,17 +1703,17 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ * @private * @param {Event} ev */ - _onInputInput: function (ev) { + _onInputInput(ev) { // First record the input value as the new current value and bound it if // necessary (min / max params). - this._value = this.inputEl.value; + this.state.value = this.inputRef.el?.value; - const params = this._methodsParams; + const params = this.props; const hasMin = ('min' in params); const hasMax = ('max' in params); if (hasMin || hasMax) { // Bounding the value in [min, max] if specified. - const boundedValue = this._value.split(/\s+/g).map(v => { + const boundedValue = this.state.value.split(/\s+/g).map(v => { let numValue = parseFloat(v); if (isNaN(numValue)) { return hasMin ? params.min : v; @@ -1425,14 +1726,14 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ // If the bounded version is different from the value, forget about // the old value so that we properly update the UI in any case. - this._oldValue = undefined; + this.state._oldValue = undefined; // Note: we do not change the input's value because we want the user // to be able to enter anything without it being auto-fixed. For // example, just emptying the input to enter new numbers: you don't // want the min value to pop up unexpectedly. The next UI update // will take care of showing the user that the value was bound. - this._value = boundedValue; + this.state.value = boundedValue; } // When the value changes as a result of a arrow up/down, the change @@ -1444,12 +1745,12 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ this.changeEventWillBeTriggered = true; } this._onUserValuePreview(ev); - }, + } /** * @private * @param {Event} ev */ - _onInputBlur: function (ev) { + _onInputBlur(ev) { if (this.notifyValueChangeOnBlur && !this.changeEventWillBeTriggered) { // In case the input value has been modified with arrow up/down, the // change event is not triggered (except if there has been a natural @@ -1459,14 +1760,14 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ this.notifyValueChangeOnBlur = false; } this.changeEventWillBeTriggered = false; - }, + } /** * @private * @param {Event} ev */ - _onInputKeydown: function (ev) { - const params = this._methodsParams; - if (!this._isNumeric()) { + _onInputKeydown(ev) { + const params = this.props; + if (!this.state._isNumeric()) { return; } switch (ev.key) { @@ -1475,6 +1776,7 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ break; case "ArrowUp": case "ArrowDown": { + ev.preventDefault(); // Do not let it be handled as an hotkey. const input = ev.currentTarget; let parts = (input.value || input.placeholder).match(/-?\d+\.\d+|-?\d+/g); if (!parts) { @@ -1512,7 +1814,7 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ value += (increasing ? step : -step); value = hasMin ? Math.max(params.min, value) : value; value = hasMax ? Math.min(value, params.max) : value; - return this._floatToStr(value); + return this.state._floatToStr(value); }).join(" "); if (newValue === (input.value || input.placeholder)) { return; @@ -1532,29 +1834,29 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ break; } } - }, + } /** * @override */ _onUserValueChange() { - if (this._oldValue !== this._value) { - this._super(...arguments); + if (this.state._oldValue !== this.state.value) { + super._onUserValueChange(...arguments); } } -}); +} +registry.category("snippet_widgets").add("WeInput", WeInput); -const MultiUserValueWidget = UserValueWidget.extend({ - tagName: 'we-multi', +class MultiUserValue extends UserValue { - /** - * @override - */ - start: function () { - if (this.options && this.options.childNodes) { - this.options.childNodes.forEach(node => this.containerEl.appendChild(node)); + get sortedValues() { + if (!this._sortedValues) { + this._sortedValues = Object.values(this._subValues); + this._sortedValues.sort((a, b) => { + return a.multiSequence - b.multiSequence; + }); } - return this._super(...arguments); - }, + return this._sortedValues; + } //-------------------------------------------------------------------------- // Public @@ -1563,148 +1865,94 @@ const MultiUserValueWidget = UserValueWidget.extend({ /** * @override */ - getValue: function (methodName) { - const value = this._userValueWidgets.map(widget => { - return widget.getValue(methodName); + getValue(methodName) { + const value = this.sortedValues.map(subValue => { + return subValue.getValue(methodName); }).join(' ').trim(); - return value || this._super(...arguments); - }, - /** - * @override - */ - isContainer: function () { - return true; - }, + return value || super.getValue(...arguments); + } /** * @override */ async setValue(value, methodName) { + // TODO: @owl-options avoid null + value ||= ""; let values = value.split(/\s*\|\s*/g); if (values.length === 1) { values = value.split(/\s+/g); } - for (let i = 0; i < this._userValueWidgets.length - 1; i++) { - await this._userValueWidgets[i].setValue(values.shift() || '', methodName); + for (let i = 0; i < this.sortedValues.length - 1; i++) { + await this.sortedValues[i].setValue(values.shift() || '', methodName); } - await this._userValueWidgets[this._userValueWidgets.length - 1].setValue(values.join(' '), methodName); - }, -}); - -const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ - className: (SelectUserValueWidget.prototype.className || '') + ' o_we_so_color_palette', - - /** - * @override - */ - start: async function () { - const _super = this._super.bind(this); - const args = arguments; - - this.resetTabCount = 0; - - // Build the select element with a custom span to hold the color preview - this.colorPreviewEl = document.createElement('span'); - this.colorPreviewEl.classList.add('o_we_color_preview'); - // todo: This div should be removed whenever possible (like when - // converting the uservaluewidget to owl). - this.colorPaletteEl = document.createElement('div'); - this.colorPaletteEl.classList.add('o_we_color_palette_wrapper'); - this.colorPaletteEl.style.display = 'contents'; - this.colorPaletteColorNames = []; - this.options.childNodes = [this.colorPaletteEl]; - this.options.valueEl = this.colorPreviewEl; - // TODO: find a better way to do this. - // The colorpicker widget is started before the ColorPalette component - // is attached to the DOM (which only happens once the user opens the - // picker). However, the colorNames are only set after the ColorPalette - // has been mounted. Initializing the colorNames through a direct call - // to the `getColorPickerTemplateService` so that the widget starts - // with possible default values is thus necessary to avoid bugs on - // `_computeWidgetState()`. - const wysiwyg = this.getParent().options.wysiwyg; - if (wysiwyg) { - const colorpickerTemplate = await wysiwyg.getColorpickerTemplate.call(wysiwyg); - this.colorPaletteColorNames = this._getColorNames(colorpickerTemplate); - } - return _super(...args); - }, + await this.sortedValues[this.sortedValues.length - 1].setValue(values.join(' '), methodName); + } +} - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- +const MultiUserValueWidget = UserValueWidget.extend({}); +class WeMulti extends UserValueComponent { + static template = "web_editor.WeMulti"; + static isContainer = true; + static StateModel = MultiUserValue; + static components = { WeTitle }; +} +registry.category("snippet_widgets").add("WeMulti", WeMulti); - /** - * @override +export class ColorpickerUserValue extends SelectUserValue { + /** + * The Color Combination value, which is a string ranging from 1 to 5 + * + * @type {string} */ - open: function () { - if (this.colorPaletteWrapper) { - this.colorPaletteWrapper?.update({ - selectedCC: this._ccValue, - selectedColor: this._value, - resetTabCount: ++this.resetTabCount, - }); - this._super(...arguments); - } else { - // TODO review in master, this does async stuff. Maybe the open - // method should now be async. This is not really robust as the - // colorPalette can be used without it to be fully rendered but - // the use of the saved promise where we can should mitigate that - // issue. - this._colorPaletteRenderPromise = this._renderColorPalette(); - this._super(...arguments); - this._colorPaletteRenderPromise.then(() => { - // Re-adjust the position of the colorpicker once the - // colorpalette is completely rendered (once that the - // colorpicker has its final height. - // TODO should not be needed once everything will be converted - // to owl. - this._adjustDropdownPosition(); - }); - } - }, + get ccValue() { + return this._state.ccValue; + } + set ccValue(value) { + this._state.ccValue = value; + } /** - * @override + * A custom color value, selected through the color picker of the color + * palette + * + * @type {string} */ - close: function () { - this._super(...arguments); - if (this._customColorValue && this._customColorValue !== this._value) { - this._value = this._customColorValue; - this._customColorValue = false; - this._onUserValueChange(); - } - }, + get customColorValue() { + return this._state.cutomColorValue; + } + set customColorValue(value) { + this._state.customColorValue = value; + } /** * @override */ - getMethodsParams: function () { - return Object.assign(this._super(...arguments), { + getMethodsParams() { + return Object.assign(super.getMethodsParams(...arguments), { colorNames: this.colorPaletteColorNames, }); - }, + } /** * @override */ - getValue: function (methodName) { + getValue(methodName) { const isCCMethod = (this._methodsParams.withCombinations === methodName); - let value = this._super(...arguments); + let value = super.getValue(...arguments); if (isCCMethod) { - value = this._ccValue; - } else if (typeof this._customColorValue === 'string') { - value = this._customColorValue; + value = this.ccValue; + } else if (typeof this.customColorValue === 'string') { + value = this.customColorValue; } // TODO strange there is some processing below for the normal value but // not for the preview value? To check in older stable versions as well. - if (typeof this._previewColor === 'string') { - return isCCMethod ? this._previewCC : this._previewColor; + if (typeof this.previewColor === 'string') { + return isCCMethod ? this.previewCC : this.previewColor; } if (value) { // TODO probably something to be done to handle gradients properly // in this code. - const useCssColor = this.options.dataAttributes.hasOwnProperty('useCssColor'); - const cssCompatible = this.options.dataAttributes.hasOwnProperty('cssCompatible'); + const useCssColor = this._data.useCssColor; + const cssCompatible = this._data.cssCompatible; if ((useCssColor || cssCompatible) && !isCSSColor(value)) { if (useCssColor) { value = weUtils.getCSSVariableValue(value); @@ -1714,20 +1962,7 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ } } return value; - }, - /** - * @override - */ - isContainer: function () { - return false; - }, - /** - * @override - */ - isActive: function () { - return !!this._ccValue - || !weUtils.areCssValuesEqual(this._value, 'rgba(0, 0, 0, 0)'); - }, + } /** * Updates the color preview + re-render the whole color palette widget. * @@ -1740,30 +1975,134 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ // available in `_ccValue`. const isCCMethod = (this._methodsParams.withCombinations === methodName); // Always call _super but don't change _value if meant for the CC value. - await this._super(isCCMethod ? this._value : color, methodName, ...rest); + await super.setValue(isCCMethod ? this.value : color, methodName, ...rest); if (isCCMethod) { - this._ccValue = color; + this.ccValue = color; + } + } + /** + * @override + */ + isActive() { + return !!this.ccValue + || !weUtils.areCssValuesEqual(this.value, 'rgba(0, 0, 0, 0)'); + } + /** + * @override + */ + close() { + super.close(); + if (this._state.customColorValue && this._state.customColorValue !== this._state.value) { + this._state.value = this._state.customColorValue; + this._state.customColorValue = false; + this.notifyValueChange(false); } + } +} - await this._colorPaletteRenderPromise; +export class WeColorpicker extends WeSelect { + static StateModel = ColorpickerUserValue; + static template = "web_editor.WeColorpicker"; + static components = { ...WeSelect.components, ColorPalette }; + static isContainer = false; + + setup() { + super.setup(); + this.getColorPickerTemplateService = useService("get_color_picker_template"); + onWillStart(async () => { + // TODO: find a better way to do this. + // The colorpicker widget is started before the ColorPalette component + // is attached to the DOM (which only happens once the user opens the + // picker). However, the colorNames are only set after the ColorPalette + // has been mounted. Initializing the colorNames through a direct call + // to the `getColorPickerTemplateService` so that the widget starts + // with possible default values is thus necessary to avoid bugs on + // `_computeWidgetState()`. + const colorpickerTemplate = await this.getColorPickerTemplateService(); + this.state.colorPaletteColorNames = this._getColorNames(colorpickerTemplate); + }); + this.colorPaletteColorNames = []; + // Colorpalette Props. + this.resetTabCount = 0; + const options = { + getCustomColors: () => { + let result = []; + if (this.env.getCustomColors) { + result = this.env.getCustomColors(); + } + return result; + }, + onCustomColorPicked: this._onCustomColorPicked.bind(this), + onColorPicked: this._onColorPicked.bind(this), + onColorHover: this._onColorHovered.bind(this), + onColorLeave: this._onColorLeft.bind(this), + onInputEnter: this._onEnterKey.bind(this), + }; + if (this.props.excluded) { + options.excluded = this.props.excluded.replace(/ /g, '').split(','); + } + if (this.props.opacity) { + options.opacity = parseFloat(this.props.opacity); + } + if (this.props.withCombinations) { + options.withCombinations = !!this.props.withCombinations; + } + if (this.props.withGradients) { + options.withGradients = !!this.props.withGradients; + } + if (this.props.noTransparency) { + options.noTransparency = !!this.props.noTransparency; + options.excluded = [...(options.excluded || []), 'transparent_grayscale']; + } + if (this.props.selectedTab) { + options.selectedTab = this.props.selectedTab; + } + options.getTemplate = this.getColorPickerTemplateService; + this.options = options; + } + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** + * @override + */ + isContainer() { + return false; + } + /** + * @override + */ + isActive() { + return !!this._ccValue + || !weUtils.areCssValuesEqual(this._value, 'rgba(0, 0, 0, 0)'); + } + getPreviewAttributes() { const classes = weUtils.computeColorClasses(this.colorPaletteColorNames); - 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.style.backgroundColor = `var(--we-cp-o-cc${this._ccValue}-${prefix.replace(/-/, '')})`; - this.colorPreviewEl.style.backgroundImage = `var(--we-cp-o-cc${this._ccValue}-${prefix.replace(/-/, '')}-gradient)`; - } - if (this._value) { - this.colorPreviewEl.style.backgroundImage = 'none'; - if (isCSSColor(this._value)) { - this.colorPreviewEl.style.backgroundColor = this._value; - } else if (weUtils.isColorGradient(this._value)) { - this.colorPreviewEl.style.backgroundImage = this._value; - } else if (weUtils.EDITOR_COLOR_CSS_VARIABLES.includes(this._value)) { - this.colorPreviewEl.style.backgroundColor = `var(--we-cp-${this._value}`; + // this.colorPreviewEl.classList.remove(...classes); + // this.colorPreviewEl.style.removeProperty('background-color'); + // this.colorPreviewEl.style.removeProperty('background-image'); + const prefix = this.props.colorPrefix || 'bg'; + const style = { + "background-color": "", + "background-image": "", + }; + const attributes = { + class: "", + style: "", + }; + if (this.state.ccValue) { + style["background-color"] = `var(--we-cp-o-cc${this.state.ccValue}-${prefix.replace(/-/, '')})`; + style["background-image"] = `var(--we-cp-o-cc${this.state.ccValue}-${prefix.replace(/-/, '')}-gradient)`; + } + if (this.state.value) { + style["background-image"] = "none"; + if (isCSSColor(this.state.value)) { + style["background-color"] = this.state.value; + } else if (weUtils.isColorGradient(this.state.value)) { + style["background-image"] = this.state.value; + } else if (weUtils.EDITOR_COLOR_CSS_VARIABLES.includes(this.state.value)) { + style["background-color"] = `var(--we-cp-${this.state.value}`; } else { // Checking if the className actually exists seems overkill but // it is actually needed to prevent a crash. As an example, if a @@ -1778,90 +2117,26 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ // space inside). In that case, we simply do not show any color. // We could choose to handle this split-value case specifically // but it was decided that this is enough for the moment. - const className = `bg-${this._value}`; + const className = `bg-${this.state.value}`; if (classes.includes(className)) { - this.colorPreviewEl.classList.add(className); + attributes.class = className; } } } + attributes.style = Object.entries(style).map(([k, v]) => `${k}: ${v}`).join("; "); // If the palette was already opened (e.g. modifying a gradient), the new DOM state must be // reflected in the palette, but the tab selection must not be impacted. - this.colorPaletteWrapper?.update({ - selectedCC: this._ccValue, - selectedColor: this._value, - }); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - * @returns {Promise} - */ - _renderColorPalette: async function () { - this.resetTabCount = 0; - const options = { - resetTabCount: this.resetTabCount, - selectedCC: this._ccValue, - selectedColor: this._value, - getCustomColors: () => { - let result = []; - this.trigger_up('get_custom_colors', { - onSuccess: (colors) => result = colors, - }); - return result; - }, - onCustomColorPicked: this._onCustomColorPicked.bind(this), - onColorPicked: this._onColorPicked.bind(this), - onColorHover: this._onColorHovered.bind(this), - onColorLeave: this._onColorLeft.bind(this), - onInputEnter: this._onEnterKey.bind(this), - }; - if (this.options.dataAttributes.excluded) { - options.excluded = this.options.dataAttributes.excluded.replace(/ /g, '').split(','); - } - if (this.options.dataAttributes.opacity) { - options.opacity = parseFloat(this.options.dataAttributes.opacity); - } - if (this.options.dataAttributes.withCombinations) { - options.withCombinations = !!this.options.dataAttributes.withCombinations; - } - if (this.options.dataAttributes.withGradients) { - options.withGradients = !!this.options.dataAttributes.withGradients; - } - if (this.options.dataAttributes.noTransparency) { - options.noTransparency = !!this.options.dataAttributes.noTransparency; - options.excluded = [...(options.excluded || []), 'transparent_grayscale']; - } - if (this.options.dataAttributes.selectedTab) { - options.selectedTab = this.options.dataAttributes.selectedTab; - } - const wysiwyg = this.getParent().options.wysiwyg; - if (wysiwyg) { - options.document = this.$target[0].ownerDocument; - options.getTemplate = wysiwyg.getColorpickerTemplate.bind(wysiwyg); - } - this.colorPaletteWrapper?.destroy(); - const sidebarDocument = this.colorPaletteEl.ownerDocument; - if (!(this.colorPaletteEl instanceof sidebarDocument.defaultView.HTMLElement)) { - // When inside an iframe, the element for mounting a component must - // be an instance of the iframe's HTMLElement, or else target - // validation for attachComponent fails. - const newEl = sidebarDocument.importNode(this.colorPaletteEl, true); - this.colorPaletteEl.before(newEl); - this.colorPaletteEl.remove(); - this.colorPaletteEl = newEl; - } - this.colorPaletteWrapper = await attachComponent(this, this.colorPaletteEl, ColorPalette, options); - }, + // TODO: @owl-options this used to be part of setValue, now it isn't. + // this.options.selectedCC = this.state.ccValue; + // this.options.selectedColor = this.state.value; + return attributes; + } /** * @override */ _shouldIgnoreClick(ev) { - return ev.originalEvent.__isColorpickerClick || this._super(...arguments); - }, + return ev.__isColorpickerClick || super._shouldIgnoreClick(...arguments); + } /** * Browses the colorpicker XML template to return all possible values of * [data-color]. @@ -1881,7 +2156,7 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ } }); return colorNames; - }, + } //-------------------------------------------------------------------------- // Handlers @@ -1894,75 +2169,67 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ * @private * @param {Object} params */ - _onCustomColorPicked: function (params) { - this._customColorValue = params.color; - }, + _onCustomColorPicked(params) { + this.state.customColorValue = params.color; + } /** * Called when a color button is clicked -> confirms the preview. * * @private * @param {Object} params */ - _onColorPicked: function (params) { - this._previewCC = false; - this._previewColor = false; - this._customColorValue = false; + _onColorPicked(params) { + this.state.previewCC = false; + this.state.previewColor = false; + this.state.customColorValue = false; - this._ccValue = params.ccValue; - this._value = params.color; + this.state.ccValue = params.ccValue; + this.state.value = params.color; this._onUserValueChange(); - }, + } /** * Called when a color button is entered -> previews the background color. * * @private * @param {Object} params */ - _onColorHovered: function (params) { - this._previewCC = params.ccValue; - this._previewColor = params.color; + _onColorHovered(params) { + this.state.previewCC = params.ccValue; + this.state.previewColor = params.color; this._onUserValuePreview(); - }, + } /** * Called when a color button is left -> cancels the preview. * * @private */ - _onColorLeft: function () { - this._previewCC = false; - this._previewColor = false; + _onColorLeft() { + this.state.previewCC = false; + this.state.previewColor = false; this._onUserValueReset(); - }, + } /** * @private */ - _onEnterKey: function () { - this.close(); - }, -}); - -const MediapickerUserValueWidget = UserValueWidget.extend({ - tagName: 'we-button', - events: { - 'click': '_onEditMedia', - }, - + _onEnterKey() { + this.state.close(); + } /** * @override */ - async start() { - await this._super(...arguments); - if (this.options.dataAttributes.buttonStyle) { - const iconEl = document.createElement('i'); - iconEl.classList.add('fa', 'fa-fw', 'fa-camera'); - $(this.containerEl).prepend(iconEl); - } else { - this.el.classList.add('o_we_no_toggle', 'o_we_bg_success'); - this.containerEl.textContent = _t("Replace"); + _onClick() { + super._onClick(...arguments); + if (this.state.opened) { + this.options.resetTabCount++; } - }, + } +} +registry.category("snippet_widgets").add("WeColorpicker", WeColorpicker); + +const MediapickerUserValueWidget = UserValueWidget.extend({}); +class MediapickerUserValue extends UserValue { //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -1978,9 +2245,10 @@ const MediapickerUserValueWidget = UserValueWidget.extend({ * default: false */ _openDialog(el, {images = false, videos = false, save}) { - el.src = this._value; - const $editable = this.$target.closest('.o_editable'); - this.call("dialog", "add", MediaDialog, { + el.src = this.value || ""; + // @TODO: @owl-options: review this line. Put it in the env maybe? + const editableEl = this.option.$target[0].closest('.o_editable'); + this.env.services.dialog.add(MediaDialog, { noImages: !images, noVideos: !videos, noIcons: true, @@ -1988,12 +2256,28 @@ const MediapickerUserValueWidget = UserValueWidget.extend({ isForBgVideo: true, vimeoPreviewIds: ['528686125', '430330731', '509869821', '397142251', '763851966', '486931161', '499761556', '392935303', '728584384', '865314310', '511727912', '466830211'], - 'res_model': $editable.data('oe-model'), - 'res_id': $editable.data('oe-id'), + 'res_model': editableEl.dataset.oeModel, + 'res_id': editableEl.dataset.oeId, save, media: el, }); - }, + } + /** + * Opens a dialog to select a new image and sets the new src + */ + _editMedia() { + // Need a dummy element for the media dialog to modify. + const tagName = this._data.mediaType === "images" ? "img" : "iframe"; + const dummyEl = document.createElement(tagName); + this._openDialog(dummyEl, { + [this._data.mediaType]: true, + save: (media) => { + this.value = this._data.mediaType === "images" + ? media.getAttribute('src') + : media.querySelector(tagName).src; + this.notifyValueChange(false); + }}); + } //-------------------------------------------------------------------------- // Public @@ -2003,103 +2287,104 @@ const MediapickerUserValueWidget = UserValueWidget.extend({ * @override */ async setValue() { - await this._super(...arguments); - this.el.classList.toggle('active', this.isActive()); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - + await super.setValue(...arguments); + this._state.active = this.isActive(); + } /** - * Called when the edit button is clicked. - * - * @private - * @param {Event} ev + * @override */ - _onEditMedia: function (ev) {}, -}); + enable() { + this._editMedia(); + } +} + +class WeMediapicker extends UserValueComponent { + static template = "web_editor.WeMediapicker"; + static StateModel = MediapickerUserValue; + static props = { + ...UserValueComponent.props, + mediaType: { type: String }, // One of "images", "videos" + buttonStyle: { type: Boolean, optional: true }, + }; + static components = { WeTitle }; -const ImagepickerUserValueWidget = MediapickerUserValueWidget.extend({ //-------------------------------------------------------------------------- - // Handlers + // Public //-------------------------------------------------------------------------- /** * @override */ - _onEditMedia(ev) { - // Need a dummy element for the media dialog to modify. - const dummyEl = document.createElement('img'); - 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(); - } - }); - }, -}); + getAllClasses() { + return { + ...super.getAllClasses(), + "o_we_no_toggle o_we_bg_success": !this.props.buttonStyle, + }; + } -const VideopickerUserValueWidget = MediapickerUserValueWidget.extend({ //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** - * @override - */ + * Called when the edit button is clicked. + * + * @private + * @param {Event} ev + */ _onEditMedia(ev) { - // Need a dummy element for the media dialog to modify. - const dummyEl = document.createElement('iframe'); - this._openDialog(dummyEl, { - videos: true, - save: (media) => { - this._value = media.querySelector('iframe').src; - this._onUserValueChange(); - }}); - }, -}); + this.state._editMedia(); + } +} +registry.category("snippet_widgets").add("WeMediapicker", WeMediapicker); -const DatetimePickerUserValueWidget = InputUserValueWidget.extend({ - events: { // Explicitely not consider all InputUserValueWidget events - 'blur input': '_onInputBlur', - 'input input': '_onDateInputInput', - }, - pickerType: 'datetime', +const ImagepickerUserValueWidget = MediapickerUserValueWidget.extend({}); +const VideopickerUserValueWidget = MediapickerUserValueWidget.extend({}); +const DatetimePickerUserValueWidget = InputUserValueWidget.extend({}); +class WeDatetime extends WeInput { + static template = "web_editor.WeDatetime"; + static props = { ...WeInput.props, + pickerType: { type: String, optional: true }, + }; + static defaultProps = { + pickerType: "datetime", + }; /** * @override */ - init: function () { - this._super(...arguments); - this._value = DateTime.now().toUnixInteger().toString(); - }, - /** - * @override - */ - start: async function () { - await this._super(...arguments); + setup() { + super.setup(); + this.datetimePicker = useService("datetime_picker"); + onMounted(() => { + this.picker = this.datetimePicker.create({ + target: this.inputRef.el, + onChange: this._onDateTimePickerChange.bind(this), + pickerProps: { + type: this.props.pickerType, + minDate: DateTime.fromObject({ year: 1000 }), + maxDate: DateTime.now().plus({ year: 200 }), + value: this.state.value, + rounding: 0, + }, + }); + this.picker.enable(); + }); - this.el.classList.add('o_we_large'); - this.inputEl.classList.add('datetimepicker-input', 'mx-0', 'text-start'); - - this.picker = this.call("datetime_picker", "create", { - target: this.inputEl, - onChange: this._onDateTimePickerChange.bind(this), - pickerProps: { - type: this.pickerType, - minDate: DateTime.fromObject({ year: 1000 }), - maxDate: DateTime.now().plus({ year: 200 }), - value: DateTime.fromSeconds(parseInt(this._value)), - rounding: 0, + useEffect( + (value) => { + let dateTime = null; + if (value) { + dateTime = DateTime.fromSeconds(parseInt(value)); + if (!dateTime.isValid) { + dateTime = DateTime.now(); + } + } + this.picker.state.value = dateTime; }, - }); - this.picker.enable(); - }, + () => [this.state.value] + ); + } //-------------------------------------------------------------------------- // Public @@ -2108,31 +2393,17 @@ const DatetimePickerUserValueWidget = InputUserValueWidget.extend({ /** * @override */ - getMethodsParams: function () { + getMethodsParams() { return Object.assign(this._super(...arguments), { format: this.defaultFormat, }); - }, + } /** * @override */ - isPreviewed: function () { + isPreviewed() { return this._super(...arguments) || this.picker.isOpen; - }, - /** - * @override - */ - async setValue() { - await this._super(...arguments); - let dateTime = null; - if (this._value) { - dateTime = DateTime.fromSeconds(parseInt(this._value)) - if (!dateTime.isValid) { - dateTime = DateTime.now(); - } - } - this.picker.state.value = dateTime; - }, + } //-------------------------------------------------------------------------- // Handlers @@ -2142,14 +2413,14 @@ const DatetimePickerUserValueWidget = InputUserValueWidget.extend({ * @private * @param {Event} ev */ - _onDateTimePickerChange: function (newDateTime) { + _onDateTimePickerChange(newDateTime) { if (!newDateTime || !newDateTime.isValid) { - this._value = ''; + this.state.value = ""; } else { - this._value = newDateTime.toUnixInteger().toString(); + this.state.value = newDateTime.toUnixInteger().toString(); } this._onUserValuePreview(); - }, + } /** * Handles the clear button of the datepicker. * @@ -2157,74 +2428,51 @@ const DatetimePickerUserValueWidget = InputUserValueWidget.extend({ * @param {Event} ev */ _onDateInputInput(ev) { - if (!this.inputEl.value) { - this._value = ''; + if (!this.inputRef.el.value) { + this.state.value = ""; this._onUserValuePreview(ev); } - }, -}); + } +} +registry.category("snippet_widgets").add("WeDatetime", WeDatetime); -const DatePickerUserValueWidget = DatetimePickerUserValueWidget.extend({ - pickerType: 'date', -}); +const DatePickerUserValueWidget = DatetimePickerUserValueWidget.extend({}); -const ListUserValueWidget = UserValueWidget.extend({ - tagName: 'we-list', - events: { - 'click we-button.o_we_select_remove_option': '_onRemoveItemClick', - 'click we-button.o_we_list_add_optional': '_onAddCustomItemClick', - 'click we-button.o_we_list_add_existing': '_onAddExistingItemClick', - 'click we-select.o_we_user_value_widget.o_we_add_list_item': '_onAddItemSelectClick', - 'click we-button.o_we_checkbox_wrapper': '_onAddItemCheckboxClick', - 'input table input': '_onListItemBlurInput', - 'blur table input': '_onListItemBlurInput', - 'mousedown': '_onWeListMousedown', - }, +const ListUserValueWidget = UserValueWidget.extend({}); +class ListUserValue extends UserValue { + constructor() { + super(...arguments); + this._state.listRecords = []; + this._state.selected = []; - /** - * @override - */ - willStart() { - if (this.options.createWidget) { - this.createWidget = this.options.createWidget; - this.createWidget.setParent(this); - this.registerSubWidget(this.createWidget); + if (this._data.availableRecords) { + this.existingRecords = JSON.parse(this._data.availableRecords); + } else { + this.isCustom = this._data.notEditable !== "true"; } - return this._super(...arguments); - }, + if (this._data.defaults || this._data.hasDefault) { + this.hasDefault = this._data.hasDefault || 'unique'; + this._state.selected = this._data.defaults ? JSON.parse(this._data.defaults) : []; + } + } /** - * @override + * @type {Object[]} */ - start() { - this.addItemTitle = this.el.dataset.addItemTitle || _t("Add"); - if (this.el.dataset.availableRecords) { - this.records = JSON.parse(this.el.dataset.availableRecords); - } else { - this.isCustom = !this.el.dataset.notEditable; - } - if (this.el.dataset.defaults || this.el.dataset.hasDefault) { - this.hasDefault = this.el.dataset.hasDefault || 'unique'; - this.selected = this.el.dataset.defaults ? JSON.parse(this.el.dataset.defaults) : []; - } - this.listTable = document.createElement('table'); - const tableWrapper = document.createElement('div'); - tableWrapper.classList.add('o_we_table_wrapper'); - tableWrapper.appendChild(this.listTable); - this.containerEl.appendChild(tableWrapper); - this.el.classList.add('o_we_fw'); - this._makeListItemsSortable(); - if (this.createWidget) { - return this.createWidget.appendTo(this.containerEl); - } - }, - + get listRecords() { + return this._state.listRecords; + } + set listRecords(value) { + this._state.listRecords = value; + } /** - * @override + * @type {integer|string[]} */ - destroy() { - this.bindedSortable?.cleanup(); - this._super(...arguments); - }, + get selected() { + return this._state.selected; + } + set selected(value) { + this._state.selected = value; + } //-------------------------------------------------------------------------- // Public @@ -2234,75 +2482,84 @@ const ListUserValueWidget = UserValueWidget.extend({ * @override */ getMethodsParams() { - return Object.assign(this._super(...arguments), { - records: this.records, + return Object.assign(super.getMethodsParams(...arguments), { + records: this.existingRecords, }); - }, + } /** * @override */ setValue() { - this._super(...arguments); - const currentValues = this._value ? JSON.parse(this._value) : []; - this.listTable.innerHTML = ''; - if (this.addItemButton) { - this.addItemButton.remove(); - } + super.setValue(...arguments); + const currentValues = this._state.value ? JSON.parse(this._state.value) : []; - if (this.createWidget) { + if (this.createUserValue) { const selectedIds = currentValues.map(({ id }) => id) .filter(id => typeof id === 'number'); // Note: it's important to simplify the domain at its maximum as the // rpc using it are cached. Similar domain components should be // written the same way for the cache to work. - this.createWidget.options.domainComponents.selected = selectedIds.length ? ['id', 'not in', selectedIds] : null; - this.createWidget.setValue(''); - this.createWidget.inputEl.value = ''; - $(this.createWidget.inputEl).trigger('input'); - } else { - if (this.isCustom) { - this.addItemButton = document.createElement('we-button'); - this.addItemButton.textContent = this.addItemTitle; - this.addItemButton.classList.add('o_we_list_add_optional'); - } else { - // TODO use a real select widget ? - this.addItemButton = document.createElement('we-select'); - this.addItemButton.classList.add('o_we_user_value_widget', 'o_we_add_list_item'); - const divEl = document.createElement('div'); - this.addItemButton.appendChild(divEl); - const togglerEl = document.createElement('we-toggler'); - togglerEl.textContent = this.addItemTitle; - divEl.appendChild(togglerEl); - this.selectMenuEl = document.createElement('we-selection-items'); - divEl.appendChild(this.selectMenuEl); - } - this.containerEl.appendChild(this.addItemButton); - } - currentValues.forEach(value => { - if (typeof value === 'object') { - const recordData = value; - const { id, display_name } = recordData; - delete recordData.id; - delete recordData.display_name; - this._addItemToTable(id, display_name, recordData); + this.createUserValue.options.domainComponents.selected = selectedIds.length ? ['id', 'not in', selectedIds] : null; + this.createUserValue.setValue(""); + // Reset the search with an empty needle and the proper selected ids + this.createUserValue._search(""); + // Reset the input value after creating a new record. The actual + // input element's value is reset `_onClick` of the WeMany2one. + this.createUserValue.createInputValue = ""; + } + this.listRecords = []; + this.selected = []; + currentValues.forEach((value) => { + if (typeof value === "object") { + const record = this._updateListRecords(value); + if (value.selected) { + this.selected.push(record.id); + } } else { - this._addItemToTable(value, value); + this.listRecords.push({ id: value, display_name: value }); + this.selected.push(value); } - }); - if (!this.createWidget && !this.isCustom) { - this._reloadSelectDropdown(currentValues); - } - this._makeListItemsSortable(); - }, + }); + } /** * @override */ getValue(methodName) { - if (this.createWidget && this.createWidget.getMethodsNames().includes(methodName)) { - return this.createWidget.getValue(methodName); + if (this.createUserValue?.getMethodsNames().includes(methodName)) { + return this.createUserValue.getValue(methodName); } - return this._value; - }, + return this._state.value; + } + /** + * @override + */ + registerUserValue(userValue) { + if (userValue instanceof Many2oneUserValue && userValue._data.createUserValue) { + this.createUserValue = userValue; + } + super.registerUserValue(userValue); + } + /** + * @override + */ + userValueNotification(params) { + const { widget, previewMode, prepare } = params; + if (widget && widget === this.createUserValue) { + if (widget.options.createMethod && widget.getValue(widget.options.createMethod)) { + return super.userValueNotification(...arguments); + } + if (previewMode) { + return; + } + prepare(); + const recordData = JSON.parse(widget.getMethodsParams('addRecord').recordData); + if (!this.listRecords.find((record) => record.id === recordData.id)) { + this._updateListRecords(recordData); + this._notifyCurrentState(); + } + } + return super.userValueNotification(...arguments); + } //-------------------------------------------------------------------------- // Private @@ -2310,220 +2567,250 @@ const ListUserValueWidget = UserValueWidget.extend({ /** * @private - * @param {string || integer} id - * @param {string} [value] - * @param {Object} [recordData] key, values that will be added to the - * element's dataset + * @param {Object} newRecord */ - _addItemToTable(id, value = this.el.dataset.defaultValue || _t("Item"), recordData) { - const trEl = document.createElement('tr'); - if (!this.el.dataset.unsortable) { - const draggableEl = document.createElement('we-button'); - draggableEl.classList.add('o_we_drag_handle', 'o_we_link', 'fa', 'fa-fw', 'fa-arrows'); - draggableEl.dataset.noPreview = 'true'; - const draggableTdEl = document.createElement('td'); - draggableTdEl.appendChild(draggableEl); - trEl.appendChild(draggableTdEl); + _updateListRecords(newRecord) { + let id = newRecord.id; + // Create an ID if there is none, and avoid duplicate IDs. + if ( + !newRecord.id + || this.listRecords.find((record) => record.id === this._toComparableId(newRecord.id)) + ) { + id = uniqueId("list_record_"); } - let recordDataSelected = false; - const inputEl = document.createElement('input'); - inputEl.type = this.el.dataset.inputType || 'text'; - if (value) { - inputEl.value = value; - } - if (id) { - inputEl.name = id; - } - if (recordData) { - recordDataSelected = recordData.selected; - if (recordData.placeholder) { - inputEl.placeholder = recordData.placeholder; - } - for (const key of Object.keys(recordData)) { - inputEl.dataset[key] = recordData[key]; - } - } - inputEl.disabled = !this.isCustom; - const inputTdEl = document.createElement('td'); - inputTdEl.classList.add('o_we_list_record_name'); - inputTdEl.appendChild(inputEl); - trEl.appendChild(inputTdEl); - if (this.hasDefault) { - const checkboxEl = document.createElement('we-button'); - checkboxEl.classList.add('o_we_user_value_widget', 'o_we_checkbox_wrapper'); - if (this.selected.includes(id) || recordDataSelected) { - checkboxEl.classList.add('active'); - } - if (!recordData || !recordData.notToggleable) { - const div = document.createElement('div'); - const checkbox = document.createElement('we-checkbox'); - div.appendChild(checkbox); - checkboxEl.appendChild(div); - checkboxEl.appendChild(checkbox); - const checkboxTdEl = document.createElement('td'); - checkboxTdEl.appendChild(checkboxEl); - trEl.appendChild(checkboxTdEl); - } - } - if (!recordData || !recordData.undeletable) { - const buttonTdEl = document.createElement('td'); - const buttonEl = document.createElement('we-button'); - buttonEl.classList.add('o_we_select_remove_option', 'o_we_link', 'o_we_text_danger', 'fa', 'fa-fw', 'fa-minus'); - buttonEl.dataset.removeOption = id; - buttonTdEl.appendChild(buttonEl); - trEl.appendChild(buttonTdEl); - } - this.listTable.appendChild(trEl); - }, + const record = Object.assign({}, newRecord, { + id: this._toComparableId(id), + name: this._trimmed(newRecord.name), + display_name: this._trimmed(newRecord.display_name) || this._data.defaultValue, + }); + this.listRecords.push(record); + return record; + } + /** + * @param {string} str + * @returns {string} + */ + _trimmed(str) { + if (typeof str !== "string") { + return str; + } + return str.trim().replace(/\s+/g, " "); + } + /** + * Cast ids to the right type (number or string) to compare them between + * record / state / DOM. + * + * @private + * @param {string|number} id + * @returns {string|number} + */ + _toComparableId(id) { + if (typeof id === "string") { + id = this._trimmed(id); + return /^-?[0-9]{1,15}$/.test(id) ? parseInt(id) : id; + } + return id; + } +} + +class WeList extends UserValueComponent { + static template = "web_editor.WeList"; + static StateModel = ListUserValue; + static props = { + ...UserValueComponent.props, + availableRecords: { type: String, optional: true }, + hasDefault: { type: String, optional: true }, + inputType: { type: String, optional: true }, + defaults: { type: String, optional: true }, + defaultValue: { type: String, optional: true }, + addItemTitle: { type: String, optional: true }, + unsortable: { type: String, optional: true }, + notEditable: { type: String, optional: true }, + allowEmpty: { type: String, optional: true }, + newElementsNotToggleable: { type: String, optional: true }, + renderOnInputBlur: { type: String, optional: true }, + doubleInput: { type: String, optional: true }, + }; + static defaultProps = { + inputType: "text", + defaultValue: _t("Item"), + addItemTitle: _t("Add"), + }; + static isContainer = true; + static components = { WeTitle }; + + setup() { + super.setup(); + this.tableRef = useRef("table"); + + this._makeListItemsSortable(); + this.state._notifyCurrentState = this._notifyCurrentState.bind(this); + + useEffect( + () => { + if (this.scrollToLast) { + // Scroll to the new list element. + this.tableRef.el.querySelector('tr:last-child') + .scrollIntoView({behavior: 'smooth', block: 'nearest'}); + this.scrollToLast = false; + } + }, + () => [this.state.listRecords.length] + ); + } + + //------------------------------------------------------------------ + // PRIVATE + //------------------------------------------------------------------ + /** * @override */ _getFocusableElement() { - return this.listTable.querySelector('input'); - }, + return this.tableRef.el.querySelector("input"); + } /** * @private */ _makeListItemsSortable() { - if (this.el.dataset.unsortable) { + if (this.props.unsortable === "true") { return; } - this.bindedSortable = this.call( - "sortable", - "create", - { - ref: { el: this.listTable }, - elements: "tr", - followingElementClasses: ["opacity-50"], - handle: ".o_we_drag_handle", - onDrop: () => this._notifyCurrentState(), - applyChangeOnDrop: true, + useSortable({ + ref: this.tableRef, + elements: "tr", + followingElementClasses: ["opacity-50"], + handle: ".o_we_drag_handle", + onDrop: ({ element, next }) => { + const id = this.state._toComparableId(element.querySelector("input").name); + const recordIdx = this.state.listRecords.findIndex((rec) => rec.id === id); + const record = this.state.listRecords.splice(recordIdx, 1)[0]; + const insertIdx = next + ? this.state.listRecords.findIndex((rec) => + rec.id === this.state._toComparableId(next.querySelector("input").name) + ) : this.state.listRecords.length; + this.state.listRecords.splice(insertIdx, 0, record); + this._notifyCurrentState(); }, - ).enable(); - }, + applyChangeOnDrop: true, + }); + } /** * @private * @param {Boolean} [preview] */ _notifyCurrentState(preview = false) { - const isIdModeName = this.el.dataset.idMode === "name" || !this.isCustom; - const trimmed = (str) => str.trim().replace(/\s+/g, " "); - const values = [...this.listTable.querySelectorAll('.o_we_list_record_name input')].map(el => { - const id = trimmed(isIdModeName ? el.name : el.value); - return Object.assign({ - id: /^-?[0-9]{1,15}$/.test(id) ? parseInt(id) : id, - name: trimmed(el.value), - display_name: trimmed(el.value), - }, el.dataset); - }); - if (this.hasDefault) { - const checkboxes = [...this.listTable.querySelectorAll('we-button.o_we_checkbox_wrapper.active')]; - this.selected = checkboxes.map(el => { - const input = el.parentElement.previousSibling.firstChild; - const id = trimmed(isIdModeName ? input.name : input.value); - return /^-?[0-9]{1,15}$/.test(id) ? parseInt(id) : id; - }); - values.forEach(v => { + if (this.state.hasDefault) { + this.state.listRecords.forEach((v) => { + const id = this.state._toComparableId(v.id); // Elements not toggleable are considered as always selected. - // We have to check that it is equal to the string 'true' - // because this information comes from the dataset. - v.selected = this.selected.includes(v.id) || v.notToggleable === 'true'; + v.selected = this.state.selected.includes(id) || v.notToggleable; }); } - this._value = JSON.stringify(values); + + this.state.value = JSON.stringify(this.state.listRecords); if (preview) { this._onUserValuePreview(); } else { this._onUserValueChange(); } - if (!this.createWidget && !this.isCustom) { - this._reloadSelectDropdown(values); - } - }, + } /** + * Maps listRecord properties to data-attributes. + * * @private - * @param {Array} currentValues + * @param {Object} listRecord + * @returns {Object} */ - _reloadSelectDropdown(currentValues) { - this.selectMenuEl.innerHTML = ''; - this.records.forEach(el => { - if (!currentValues.find(v => v.id === el.id)) { - const option = document.createElement('we-button'); - option.classList.add('o_we_list_add_existing'); - option.dataset.addOption = el.id; - option.dataset.noPreview = 'true'; - const divEl = document.createElement('div'); - divEl.textContent = el.display_name; - option.appendChild(divEl); - this.selectMenuEl.appendChild(option); + getInputDataAtts(listRecord) { + return Object.entries(listRecord).reduce((obj, entry) => { + if (["display_name", "id", "placeholder"].includes(entry[0])) { + return obj; } - }); - if (!this.selectMenuEl.children.length) { - const title = document.createElement('we-title'); - title.textContent = _t("No more records"); - this.selectMenuEl.appendChild(title); - } - }, + obj[`data-${camelToKebab(entry[0])}`] = entry[1]; + return obj; + }, {}); + } //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- + /** + * @private + */ + _onClick(ev) { + if (!this.state.opened) { + this.state.open(); + } else { + this.state.close(); + } + } /** * @private */ _onAddCustomItemClick() { const recordData = {}; - if (this.el.dataset.newElementsNotToggleable) { + if (this.props.newElementsNotToggleable === "true") { recordData.notToggleable = true; } - this._addItemToTable(undefined, this.el.dataset.defaultValue, recordData); + if (this.props.newRecordId) { + recordData.id = this.props.newRecordId; + } + this.state._updateListRecords(recordData); this._notifyCurrentState(); - // Scroll to the new list element. - this.el.querySelector('tr:last-child') - .scrollIntoView({behavior: 'smooth', block: 'nearest'}); - }, + this.scrollToLast = true; + } /** * @private * @param {Event} ev */ _onAddExistingItemClick(ev) { - const value = ev.currentTarget.dataset.addOption; - this._addItemToTable(value, ev.currentTarget.textContent); + const record = this.state.existingRecords.find((record) => { + return record.id === this.state._toComparableId(ev.currentTarget.dataset.addOption); + }); + this.state._updateListRecords(record); this._notifyCurrentState(); - }, - /** - * @private - * @param {Event} ev - */ - _onAddItemSelectClick(ev) { - ev.currentTarget.querySelector('we-toggler').classList.toggle('active'); - }, + this.scrollToLast = true; + } /** * @private * @param {Event} ev */ - _onAddItemCheckboxClick: function (ev) { - const isActive = ev.currentTarget.classList.contains('active'); - if (this.hasDefault === 'unique') { - this.listTable.querySelectorAll('we-button.o_we_checkbox_wrapper.active').forEach(el => el.classList.remove('active')); + _onAddItemCheckboxClick(ev) { + const recordId = this.state._toComparableId(ev.currentTarget.dataset.select); + const isActive = this.state.selected.includes(recordId); + if (this.props.hasDefault === 'unique') { + this.state.selected = []; + } + if (isActive) { + this.state.selected.splice(this.state.selected.indexOf(recordId), 1); + } else { + this.state.selected.push(recordId); } - ev.currentTarget.classList.toggle('active', !isActive); this._notifyCurrentState(); - }, + } /** * @private * @param {Event} ev */ _onListItemBlurInput(ev) { const preview = ev.type === 'input'; - if (preview || !this.el.contains(ev.relatedTarget) || this.el.dataset.renderOnInputBlur) { + if (preview || !this.tableRef.el.contains(ev.relatedTarget) || this.props.renderOnInputBlur === "true") { // We call the function below only if the element that recovers the // focus after this blur is not an element of the we-list or if it // is an input event (preview). This allows to use the TAB key to go // from one input to another in the list. This behavior can be // cancelled if the widget has reloadOnInputBlur = "true" in its - // dataset. + // props. + const recordToUpdate = this.state.listRecords.find((rec) => + rec.id === this.state._toComparableId(ev.currentTarget.name) + ); + if (ev.currentTarget.closest(".o_we_list_record_name")) { + recordToUpdate.display_name = ev.currentTarget.value; + } else { + recordToUpdate.secondInputText = ev.currentTarget.value; + } const timeSinceMousedown = ev.timeStamp - this.mousedownTime; if (timeSinceMousedown < 500) { // Without this "setTimeOut", "click" events are not triggered when @@ -2536,81 +2823,61 @@ const ListUserValueWidget = UserValueWidget.extend({ this._notifyCurrentState(preview); } } - }, + } /** * @private */ _onWeListMousedown(ev) { this.mousedownTime = ev.timeStamp; - }, + } /** * @private * @param {Event} ev */ _onRemoveItemClick(ev) { - const minElements = this.el.dataset.allowEmpty ? 0 : 1; - if (ev.target.closest('table').querySelectorAll('tr').length > minElements) { - ev.target.closest('tr').remove(); - this._notifyCurrentState(); - } - }, - /** - * @override - */ - _onUserValueNotification(ev) { - const { widget, previewMode, prepare } = ev.data; - if (widget && widget === this.createWidget) { - if (widget.options.createMethod && widget.getValue(widget.options.createMethod)) { - return this._super(ev); - } - ev.stopPropagation(); - if (previewMode) { - return; + const minElements = this.props.allowEmpty ? 0 : 1; + if (this.state.listRecords.length > minElements) { + const removeId = this.state._toComparableId(ev.currentTarget.dataset.removeOption); + const recordIdx = this.state.listRecords.findIndex((record) => record.id === removeId); + if (recordIdx >= 0) { + this.state.listRecords.splice(recordIdx, 1); + } + if (this.state.hasDefault) { + const selectedIdx = this.state.selected.indexOf(removeId); + if (selectedIdx >= 0) { + this.state.selected.splice(selectedIdx, 1); + } } - prepare(); - const recordData = JSON.parse(widget.getMethodsParams('addRecord').recordData); - const { id, display_name } = recordData; - delete recordData.id; - delete recordData.display_name; - this._addItemToTable(id, display_name, recordData); this._notifyCurrentState(); } - return this._super(ev); - }, -}); - -const RangeUserValueWidget = UnitUserValueWidget.extend({ - tagName: 'we-range', - events: { - 'change input': '_onInputChange', - 'input input': '_onInputInput', - }, + } +} +registry.category("snippet_widgets").add("WeList", WeList); +class RangeUserValue extends UnitUserValue { + constructor() { + super(...arguments); + this._state.max = undefined; + this._state.displayValue = false; + } /** - * @override + * @type {number} */ - async start() { - await this._super(...arguments); - this.input = document.createElement('input'); - this.input.type = "range"; - let min = this.el.dataset.min && parseFloat(this.el.dataset.min) || 0; - let max = this.el.dataset.max && parseFloat(this.el.dataset.max) || 100; - const step = this.el.dataset.step && parseFloat(this.el.dataset.step) || 1; - this.displayValue = this.el.dataset.displayRangeValue; - if (min > max) { - [min, max] = [max, min]; - this.input.classList.add('o_we_inverted_range'); - } - this._setInputAttributes(min, max, step); - this.containerEl.appendChild(this.input); - if (this.displayValue) { - this.outputEl = document.createElement('output'); - this.outputEl.classList.add('ms-2'); - this.containerEl.appendChild(this.outputEl); - } - - this._onInputChange = debounce(this._onInputChange, 100); - }, + get max() { + return this._state.max; + } + set max(value) { + this._state.max = value; + } + /** + * @type {string|number} + */ + get displayValue() { + return this._state.displayValue; + } + set displayValue(value) { + this._state.displayValue = value; + } //-------------------------------------------------------------------------- // Public @@ -2620,35 +2887,85 @@ const RangeUserValueWidget = UnitUserValueWidget.extend({ * @override */ loadMethodsData(validMethodNames) { - this._super(...arguments); + super.loadMethodsData(...arguments); for (const methodName of this._methodsNames) { const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; if (possibleValues.length > 1) { - this._setInputAttributes(0, possibleValues.length - 1, 1); + this.max = possibleValues.length - 1; break; } } - }, + } /** * @override */ async setValue(value, methodName) { - await this._super(...arguments); + await super.setValue(value, methodName); const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; - const inputValue = possibleValues.length > 1 ? possibleValues.indexOf(value) : this._value; - this.input.value = inputValue; - if (this.displayValue) { + const inputValue = possibleValues.length > 1 ? possibleValues.indexOf(value) : this.value; + if (this._data.displayRangeValue) { this._computeDisplayValue(inputValue); } - }, + } /** * @override */ getValue(methodName) { - const value = this._super(...arguments); + const value = super.getValue(...arguments); const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; return possibleValues.length > 1 ? possibleValues[+value] : value; - }, + } + /** + * @private + * @param {string} inputValue + */ + _computeDisplayValue(inputValue) { + if (this.toRatio) { + const inputValueAsNumber = Number(inputValue); + const ratio = inputValueAsNumber >= 0 ? 1 + inputValueAsNumber : 1 / (1 - inputValueAsNumber); + this.displayValue = `${ratio.toFixed(2)}x`; + } else if (this._data.displayRangeValueUnit) { + this.displayValue = inputValue + this._data.displayRangeValueUnit; + } else { + this.displayValue = inputValue; + } + } +} + +const RangeUserValueWidget = UnitUserValueWidget.extend({}); +class WeRange extends WeInput { + static template = "web_editor.WeRange"; + static StateModel = RangeUserValue; + static props = { + ...WeInput.props, + min: { type: String, optional: true }, + max: { type: String, optional: true }, + step: { type: String, optional: true }, + toRatio: { type: String, optional: true }, + displayRangeValue: { type: String, optional: true }, + }; + static defaultProps = { + min: "0", + max: "100", + step: "1", + toRatio: "", + displayRangeValue: "", + unit: "", + }; + setup() { + this._onInputChange = debounce(this._onInputChange, 100); + super.setup(); + this.state.toRatio = this.props.toRatio; + if (Number(this.props.min) > Number(this.props.max)) { + this.state.max = this.props.min; + this.min = this.props.max; + this.inverted = true; + } else { + this.min = this.props.min; + this.state.max ||= this.props.max; + this.inverted = false; + } + } //-------------------------------------------------------------------------- // Handlers @@ -2658,72 +2975,33 @@ const RangeUserValueWidget = UnitUserValueWidget.extend({ * @private */ _onInputChange(ev) { - this._value = ev.target.value; + this.state.value = ev.target.value; this._onUserValueChange(ev); - }, - /** - * @private - * @param {string} inputValue - */ - _computeDisplayValue(inputValue) { - if (this.el.dataset.toRatio) { - const inputValueAsNumber = Number(inputValue); - const ratio = inputValueAsNumber >= 0 ? 1 + inputValueAsNumber : 1 / (1 - inputValueAsNumber); - this.outputEl.value = `${ratio.toFixed(2)}x`; - } else if (this.el.dataset.displayRangeValueUnit) { - this.outputEl.value = inputValue + this.el.dataset.displayRangeValueUnit; - } else { - this.outputEl.value = inputValue; - } - }, + } /** * @private * @param {Event} ev */ _onInputInput(ev) { - this._value = ev.target.value; - if (this.displayValue) { - this._computeDisplayValue(this._value); + this.state.value = ev.target.value; + if (this.props.displayRangeValue) { + this.state._computeDisplayValue(this.state.value); } this._onUserValuePreview(ev); - }, - /** - * @private - */ - _setInputAttributes(min, max, step) { - this.input.setAttribute('min', min); - this.input.setAttribute('max', max); - this.input.setAttribute('step', step); - }, -}); - -const SelectPagerUserValueWidget = SelectUserValueWidget.extend({ - className: (SelectUserValueWidget.prototype.className || '') + ' o_we_select_pager', - events: Object.assign({}, SelectUserValueWidget.prototype.events, { - 'click .o_pager_nav_btn': '_onClickScrollPage', - 'click .o_pager_nav_angle': '_onClickCloseMenu', - }), - /** - * @override - */ - async start() { - const _super = this._super.bind(this); + } +} +registry.category("snippet_widgets").add("WeRange", WeRange); - await _super(...arguments); - this.menuEl.classList.add('o_we_has_pager', 'position-fixed', 'top-0', 'end-0', 'z-1', 'rounded-0'); - this.menuTogglerEl.classList.add('o_we_toggler_pager'); +class WeSelectPager extends WeSelect { - this.pagerContainerEl = this.el.querySelector('.o_pager_container'); - this.__onScroll = throttleForAnimation(this._onScroll.bind(this)); - this.pagerContainerEl.addEventListener('scroll', this.__onScroll); - }, + static template = "web_editor.WeSelectPager"; /** * @override */ - destroy() { - this._super(...arguments); - this.pagerContainerEl.removeEventListener('scroll', this.__onScroll); - }, + setup() { + super.setup(); + this.__onScroll = useThrottleForAnimation(this._onScroll); + } //-------------------------------------------------------------------------- // Private @@ -2737,13 +3015,13 @@ const SelectPagerUserValueWidget = SelectUserValueWidget.extend({ */ _adjustDropdownPosition() { return; - }, + } /** * @override */ _shouldIgnoreClick(ev) { - return !!ev.target.closest('.o_pager_nav') || this._super(...arguments); - }, + return !!ev.target.closest('.o_pager_nav') || super._shouldIgnoreClick(...arguments); + } //-------------------------------------------------------------------------- // Handlers @@ -2755,181 +3033,144 @@ const SelectPagerUserValueWidget = SelectUserValueWidget.extend({ * @private */ _onClickScrollPage(ev) { - const navButtonEl = ev.currentTarget; + const navButtonEl = ev.target.closest("[data-scroll-to]"); + if (!navButtonEl) { + return; + } const attribute = navButtonEl.dataset.scrollTo; - const destinationOffset = this.menuEl.querySelector('.' + attribute).offsetTop; + const destinationOffset = this.menuRef.el.querySelector('.' + attribute).offsetTop; - const pagerNavEl = this.menuEl.querySelector('.o_pager_nav'); - this.pagerContainerEl.scrollTop = destinationOffset - pagerNavEl.offsetHeight; - }, + const pagerNavEl = this.menuRef.el.querySelector(".o_pager_nav"); + const pagerContainerEl = this.menuRef.el.querySelector(".o_pager_container"); + pagerContainerEl.scrollTop = destinationOffset - pagerNavEl.offsetHeight; + } /** * @private */ _onClickCloseMenu(ev) { - this.close(); - }, + this.state.close(); + } /** * @private */ _onScroll(ev) { - const pagerContainerHeight = this.pagerContainerEl.getBoundingClientRect().height; + const pagerContainerEl = this.menuRef.el.querySelector(".o_pager_container"); + const pagerContainerHeight = pagerContainerEl.getBoundingClientRect().height; // The threshold for when a menu element is defined as 'active' is half // of the container's height. This has a drawback as if a section // is too small it might never get `active` if it's the last section. - const threshold = this.pagerContainerEl.scrollTop + (pagerContainerHeight / 2); - const anchorElements = this.menuEl.querySelectorAll('[data-scroll-to]'); + const threshold = pagerContainerEl.scrollTop + (pagerContainerHeight / 2); + const anchorElements = this.menuRef.el.querySelectorAll('[data-scroll-to]'); for (const anchorEl of anchorElements) { const destination = anchorEl.getAttribute('data-scroll-to'); - const sectionEl = this.menuEl.querySelector(`.${destination}`); + const sectionEl = this.menuRef.el.querySelector(`.${destination}`); const nextSectionEl = sectionEl.nextElementSibling; anchorEl.classList.toggle('active', sectionEl.offsetTop < threshold && (!nextSectionEl || nextSectionEl.offsetTop > threshold)); } } -}); +} +registry.category("snippet_widgets").add("WeSelectPager", WeSelectPager); -const Many2oneUserValueWidget = SelectUserValueWidget.extend({ - className: (SelectUserValueWidget.prototype.className || '') + ' o_we_many2one', - events: Object.assign({}, SelectUserValueWidget.prototype.events, { - 'input .o_we_m2o_search input': '_onSearchInput', - 'keydown .o_we_m2o_search input': '_onSearchKeydown', - 'click .o_we_m2o_search_more': '_onSearchMoreClick', - }), - // Data-attributes that will be read into `this.options` on init and not +const Many2oneUserValueWidget = SelectUserValueWidget.extend({}); + +export class Many2oneUserValue extends SelectUserValue { + // Props that will be read into `this.options` on setup and not // transfered to inner buttons. // `domain` is the static part of the domain used in searches, not // depending on already selected ids and other filters. - configAttributes: [ + static configProps = [ "model", "fields", "limit", "domain", "callWith", "createMethod", "filterInModel", "filterInField", "nullText", "defaultMessage", - ], + ]; + + constructor() { + super(...arguments); + this.orm = serviceCached(this.env, "orm"); + this._state.records = []; + this._state.hasMore = false; - /** - * @override - */ - init(parent, title, options, $target) { - this.afterSearch = []; this.displayNameCache = {}; - const {dataAttributes} = options; - Object.assign(options, { + this.options = Object.assign({}, this._data, { limit: '5', fields: '[]', domain: '[]', callWith: 'id', + dataAttributes: {}, }); - this.configAttributes.forEach(attr => { - if (dataAttributes.hasOwnProperty(attr)) { - options[attr] = dataAttributes[attr]; - delete dataAttributes[attr]; - } - }); - options.limit = parseInt(options.limit); - options.fields = JSON.parse(options.fields); - if (!options.fields.includes('display_name')) { - options.fields.push('display_name'); - } - options.domain = JSON.parse(options.domain); - options.domainComponents = {}; - options.nullText = $target[0].dataset.nullText || - JSON.parse($target[0].dataset.oeContactOptions || '{}')['null_text']; - - this.orm = serviceCached(this.bindService("orm")); + this._updateOptions(this._data); + this.options.domainComponents = {}; - return this._super(...arguments); - }, + this.constructorPatch(); + } + constructorPatch() {} /** - * @override + * @type {Object[]} */ - async start() { - await this._super(...arguments); - - this.inputEl = document.createElement('input'); - this.inputEl.setAttribute('placeholder', _t("Search for records...")); - const searchEl = document.createElement('div'); - searchEl.classList.add('o_we_m2o_search'); - searchEl.appendChild(this.inputEl); - this.menuEl.appendChild(searchEl); - - this.searchMore = document.createElement('div'); - this.searchMore.classList.add('o_we_m2o_search_more'); - this.searchMore.textContent = _t("Search more..."); - this.searchMore.title = _t("Search to show more records"); - - if (this.options.createMethod) { - this.createInput = new InputUserValueWidget(this, undefined, { - classes: ['o_we_large'], - dataAttributes: { noPreview: 'true' }, - }, this.$target); - this.createButton = new ButtonUserValueWidget(this, undefined, { - classes: ['flex-grow-0'], - dataAttributes: { - noPreview: 'true', - [this.options.createMethod]: '', // Value through getValue. - }, - childNodes: [document.createTextNode(_t("Create"))], - }, this.$target); - // Override isActive so it doesn't show up in toggler - this.createButton.isActive = () => false; - - await Promise.all([ - this.createInput.appendTo(document.createDocumentFragment()), - this.createButton.appendTo(document.createDocumentFragment()), - ]); - this.registerSubWidget(this.createInput); - this.registerSubWidget(this.createButton); - this.createWidget = _buildRowElement('', { - classes: ['o_we_full_row', 'o_we_m2o_create', 'p-1'], - childNodes: [this.createInput.el, this.createButton.el], - }); - } + get records() { + return this._state.records; + } + set records(value) { + this._state.records = value; + } + /** + * @type {boolean} + */ + get hasMore() { + return this._state.hasMore; + } - return this._search(''); - }, /** * @override */ async setValue(value, methodName) { - await this._super(...arguments); - if (this.menuTogglerEl.textContent === this.PLACEHOLDER_TEXT.toString()) { + await super.setValue(...arguments); + if (this._state.toggler.textContent === this.constructor.PLACEHOLDER_TEXT.toString()) { // The currently selected value is not present in the search, need to read // its display name. if (value !== '') { // FIXME: value may not be an id if callWith is specified! - this.menuTogglerEl.textContent = await this._getDisplayName(parseInt(value)); + this._state.toggler.textContent = await this._getDisplayName(parseInt(value)); } else { - this.menuTogglerEl.textContent = this.options.defaultMessage || _t("Choose a record..."); + this._state.toggler.textContent = this.options.defaultMessage || _t("Choose a record..."); } } - }, + } /** * @override */ getValue(methodName) { - if (methodName === this.options.createMethod && this.createInput) { - return this.createInput._value; + if (methodName === this.options.createMethod && this.createInputValue) { + return this.createInputValue; } - return this._super(...arguments); - }, + return super.getValue(...arguments); + } /** - * Prevents double widget instanciation for we-buttons that have been - * created manually by _search (container widgets will have their innner - * html searched for userValueWidgets to instanciate during option startup) - * * @override */ - isContainer() { - return false; - }, + registerUserValue(userValue) { + // Get a reference to the createButton and make sure it is always + // inactive. + if (this._data.createMethod) { + if ( + !this.createButton + && userValue instanceof ButtonUserValue + && userValue._data[this._data.createMethod] !== undefined + ) { + this.createButton = userValue; + this.createButton.isActive = () => false; + } + } + super.registerUserValue(userValue); + } /** * @override */ - open() { - if (this.createInput) { - this.createInput.setValue(''); - } - return this._super(...arguments); - }, + close() { + super.close(...arguments); + this._search(""); + } /** * Updates the domain with defined inclusive filter to make sure that only * records that are linked to specific records are retrieved. @@ -2956,12 +3197,37 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ if (allowedIds.size) { this.options.domainComponents.filterInModel = ['id', 'in', [...allowedIds]]; } - }, + } //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- + _updateOptions(props) { + for (const prop in props) { + // TODO: @owl-options: remove undefined props in UserValue directly? + if (props[prop] === undefined) { + continue; + } + if (this.constructor.configProps.includes(prop)) { + this.options[prop] = props[prop]; + } else { + this.options.dataAttributes[prop] = props[prop]; + } + } + this.options.limit = parseInt(this.options.limit); + if (typeof this.options.fields === "string") { + this.options.fields = JSON.parse(this.options.fields); + } + if (!this.options.fields.includes('display_name')) { + this.options.fields.push('display_name'); + } + if (typeof this.options.domain === "string") { + this.options.domain = JSON.parse(this.options.domain); + } + this.options.nullText = this.$target[0].dataset.nullText || + JSON.parse(this.$target[0].dataset.oeContactOptions || '{}')['null_text']; + } /** * Searches the database for corresponding records and updates the dropdown * @@ -2981,18 +3247,7 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ recTuples.map(([id, _name]) => id), this.options.fields ); - // Remove select options. - this._userValueWidgets.filter(widget => { - return widget instanceof ButtonUserValueWidget && - !widget.isDestroyed() && - widget.el.parentElement.matches('we-selection-items'); - }).forEach(button => { - if (button.isPreviewed()) { - button.notifyValueChange('reset'); - } - button.destroy(); - }); - this._userValueWidgets = this._userValueWidgets.filter(widget => !widget.isDestroyed()); + if (this.options.nullText && this.options.nullText.toLowerCase().includes(needle.toLowerCase())) { // Beware of RPC cache. @@ -3004,55 +3259,13 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ this.displayNameCache[record.id] = record.display_name; }); - await Promise.all(records.slice(0, this.options.limit).map(async record => { - // Copy over the data-attributes from the main element, and default the value - // to the callWith field of the record so that if it's a method, it will - // be called with that value - const buttonDataAttributes = Object.assign({}, this.options.dataAttributes); - Object.keys(buttonDataAttributes).forEach(key => { - buttonDataAttributes[key] = buttonDataAttributes[key] || record[this.options.callWith]; - }); - // REMARK: this syntax is very similar to React.createComponent, maybe we could - // write a transformer like there is for JSX? - const buttonWidget = new ButtonUserValueWidget(this, undefined, { - dataAttributes: Object.assign({recordData: JSON.stringify(record)}, buttonDataAttributes), - childNodes: [document.createTextNode(record.display_name)], - }, this.$target); - this.registerSubWidget(buttonWidget); - await buttonWidget.appendTo(this.menuEl); - if (this._methodsNames) { - buttonWidget.loadMethodsData(this._methodsNames); - } - })); - // Load methodsData for new buttons if possible. It will not be possible - // when the widget is first created (as this._methodsNames will be undefined) - // but the snippetOption lifecycle will load the methods data explicitely - // just after creating the widget - if (this._methodsNames) { - this._methodsNames.forEach(methodName => { - this.setValue(this._value, methodName); - }); - } - - const hasMore = records.length > this.options.limit; - if (hasMore) { - this.menuEl.appendChild(this.searchMore); - this.searchMore.classList.remove('d-none'); - } else { - this.searchMore.classList.add('d-none'); - } - - if (this.createWidget) { - this.menuEl.appendChild(this.createWidget); - } + this.records = records.slice(0, this.options.limit); + this._state.hasMore = records.length > this.options.limit; - this.waitingForSearch = false; - this.afterSearch.forEach(cb => cb()); - this.afterSearch = []; if (this.options.nullText && !this.getValue()) { this.setValue(0); } - }, + } /** * Returns the domain to use for the search. * @@ -3060,7 +3273,7 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ */ async _getSearchDomain() { return this.options.domain; - }, + } /** * Returns the display name for a given record. * @@ -3071,7 +3284,136 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ this.displayNameCache[recordId] = (await this.orm.read(this.options.model, [recordId], ['display_name']))[0].display_name; } return this.displayNameCache[recordId]; - }, + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + userValueNotification(params) { + const { widget } = params; + if (widget && widget === this.createButton && !params.previewMode) { + // When the create button is clicked, make sure the state value + // matches the value of the input element because it might have been + // removed after closing the dropdown. + if (this.displayedValue && !this.createInputValue) { + this.createInputValue = this.displayedValue; + delete this.displayedValue; + } + // Prevent creating a record if the input is empty. + if (!this.createInputValue) { + return; + } + } + if (widget !== this.createButton && this.createInputValue) { + // Remove the createInputValue if something else than the + // createButton was clicked or hovered, so that it doesn't create a + // record. We reset it after the value handling. + this.displayedValue = this.createInputValue; + this.createInputValue = ""; + } + super.userValueNotification(...arguments); + // Reset the createInputValue after value handling, only with + // previewMode === true or 'reset'. + if (this.displayedValue && params.previewMode) { + this.createInputValue = this.displayedValue; + delete this.displayedValue; + } + } +} + +class WeMany2one extends WeSelect { + static template = "web_editor.WeMany2one"; + static StateModel = Many2oneUserValue; + static components = { ...WeSelect.components, WeRow, WeInput, WeButton }; + static configProps = { + model: { type: String }, + domain: { type: String, optional: true }, + fields: { type: String, optional: true }, + limit: { type: String, optional: true }, + callWith: { type: String, optional: true }, + createMethod: { type: String, optional: true }, + filterInModel: { type: String, optional: true }, + filterInField: { type: String, optional: true }, + nullText: { type: String, optional: true }, + defaultMessage: { type: String, optional: true }, + }; + static props = { + ...WeSelect.props, + ...this.configProps, + }; + + setup() { + super.setup(); + const keepLast = new KeepLast(); + this.afterSearch = []; + + onWillStart(async () => { + await this.state._search(""); + }); + + onWillUpdateProps(async (nextProps) => { + this.state._updateOptions(nextProps); + // Make sure the update is reflected in the prefetched records. + await this.state._search(""); + }); + + useEffect( + () => { + // Load methodsData for WeButtons generated from + // this.state.records. + if (this.state._methodsNames) { + Object.values(this.state._subValues).forEach(widget => { + if (widget instanceof ButtonUserValue && widget._data.recordData) { + widget.loadMethodsData([...this.state._methodsNames]); + } + }); + if (this.state.value !== undefined) { + this.state._methodsNames.forEach(async (methodName) => + await keepLast.add(this.state.setValue(this.state.value, methodName)) + ); + } + } + this.waitingForSearch = false; + this.afterSearch.forEach(cb => cb()); + this.afterSearch = []; + }, + () => [this.state.records] + ); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getAllClasses() { + return { + ...super.getAllClasses(), + "position-static": this.props.createUserValue, + }; + } + /** + * Copy over the props from the main element, and default the value + * to the callWith field of the record so that if it's a method, it will + * be called with that value + * + * @param {Object} record + * @returns {Object} + */ + getButtonProps(record) { + const buttonProps = Object.assign({}, this.state.options.dataAttributes); + Object.keys(buttonProps).forEach((key) => { + buttonProps[key] = buttonProps[key] || record[this.state.options.callWith].toString(); + }); + buttonProps.recordData = JSON.stringify(record); + return buttonProps; + } //-------------------------------------------------------------------------- // Handlers @@ -3087,8 +3429,16 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ ev.stopPropagation(); return; } - return this._super(...arguments); - }, + if (ev.target.closest("we-button")) { + this.menuRef.el.querySelector(".o_we_m2o_search input").value = ""; + } + // Reset the input value when creating a new record. The state value is + // reset on `setValue` of the ListUserValue. + if (ev.target.closest(".o_we_m2o_create") && ev.target.closest("we-button")) { + this.menuRef.el.querySelector(".o_we_m2o_create input").value = ""; + } + return super._onClick(...arguments); + } /** * Handles changes to the search bar. * @@ -3100,10 +3450,10 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ // is one that is ongoing (ie currently waiting for the debounce or RPC) clearTimeout(this.searchIntent); this.waitingForSearch = true; - this.searchIntent = setTimeout(() => { - this._search(ev.target.value); + this.searchIntent = setTimeout(async () => { + await this.state._search(ev.target.value); }, 500); - }, + } /** * Selects the first option when pressing enter in the search input. * @@ -3114,7 +3464,7 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ return; } const action = () => { - const firstButton = this.menuEl.querySelector(':scope > we-button'); + const firstButton = this.menuRef.el.querySelector(':scope > we-button'); if (firstButton) { firstButton.click(); } @@ -3124,162 +3474,107 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ } else { action(); } - }, + } /** * Focuses the search input when clicking on the "Search more..." button. * * @private */ _onSearchMoreClick(ev) { - this.inputEl.focus(); - }, + this.menuRef.el.querySelector(".o_we_m2o_search input").focus(); + } /** - * @override + * @private */ - _onUserValueNotification(ev) { - const { widget } = ev.data; - if (widget && widget === this.createInput) { - ev.stopPropagation(); - return; - } - if (widget && widget === this.createButton) { - // When the create button is clicked, make sure the text - // value is restored from the actual input element because - // it might have been removed when hovering existing tags. - // TODO review this, there is probably better to do - this.createInput._value = this.createInput.el.querySelector('input').value; - if (!this.createInput._value) { - ev.stopPropagation(); - } - return; - } - if (widget !== this.createButton && this.createInput) { - this.createInput._value = ''; - } - return this._super(ev); - }, -}); + _onCreateInputInput(ev) { + this.state.createInputValue = ev.currentTarget.value; + } +} +registry.category("snippet_widgets").add("WeMany2one", WeMany2one); -const Many2manyUserValueWidget = UserValueWidget.extend({ - configAttributes: ['model', 'recordId', 'm2oField', 'createMethod', 'fakem2m', 'filterIn'], - /** - * @override - */ - init(parent, title, options, $target) { - const { dataAttributes } = options; - this.configAttributes.forEach(attr => { - if (dataAttributes.hasOwnProperty(attr)) { - options[attr] = dataAttributes[attr]; - delete dataAttributes[attr]; +const Many2manyUserValueWidget = UserValueWidget.extend({}); + +class Many2manyUserValue extends UserValue { + static configProps = ['model', 'recordId', 'm2oField', 'createMethod', 'fakem2m', 'filterIn']; + + constructor() { + super(...arguments); + this._state.m2oProps = {}; + + for (const prop in this._data) { + if ( + !this.constructor.configProps.includes(prop) + && Many2oneUserValue.configProps.includes(prop) + ) { + this.m2oProps[prop] = this._data[prop]; } - }); - this.filterIn = options.filterIn !== undefined; + } + + this.filterIn = this._data.filterIn !== undefined; if (this.filterIn) { // Transfer filter-in values to child m2o. - dataAttributes.filterInModel = options.model; - dataAttributes.filterInField = options.m2oField; - } - this.orm = this.bindService("orm"); - this.fields = this.bindService("field"); - return this._super(...arguments); - }, - /** - * @override - */ - async willStart() { - await this._super(...arguments); - // If the widget does not have a real m2m field in the database - // We do not need to fetch anything from the DB - if (this.options.fakem2m) { - this.m2oModel = this.options.model; - return; + this.m2oProps.filterInModel = this._data.model; + this.m2oProps.filterInField = this._data.m2oField; } - const { model, recordId, m2oField } = this.options; - const [record] = await this.orm.read(model, [parseInt(recordId)], [m2oField]); - const selectedRecordIds = record[m2oField]; - // TODO: handle no record - const modelData = await this.fields.loadFields(model, { fieldNames: [m2oField] }); - // TODO: simultaneously fly both RPCs - this.m2oModel = modelData[m2oField].relation; - this.m2oName = modelData[m2oField].field_description; // Use as string attr? - - const selectedRecords = await this.orm.read(this.m2oModel, selectedRecordIds, ['display_name']); - // TODO: reconcile the fact that this widget sets its own initial value - // instead of it coming through setValue(_computeWidgetState) - this._value = JSON.stringify(selectedRecords); - }, - /** - * @override - */ - async start() { - this.el.classList.add('o_we_m2m'); - const m2oDataAttributes = Object.entries(this.options.dataAttributes).filter(([attrName]) => { - return Many2oneUserValueWidget.prototype.configAttributes.includes(attrName); - }); - m2oDataAttributes.push( - ['model', this.m2oModel], - ['addRecord', ''], - ['createMethod', this.options.createMethod], - ); - // Don't register this one as a subWidget because it will be a subWidget - // of the listWidget - this.createWidget = new Many2oneUserValueWidget(null, undefined, { - dataAttributes: Object.fromEntries(m2oDataAttributes), - }, this.$target); - - this.listWidget = registerUserValueWidget('we-list', this, undefined, { - dataAttributes: { unsortable: 'true', notEditable: 'true', allowEmpty: 'true' }, - createWidget: this.createWidget, - }, this.$target); - await this.listWidget.appendTo(this.containerEl); - - // Make this.el the select's offsetParent so the we-selection-items has - // the correct width - this.listWidget.el.querySelector('we-select').style.position = 'static'; - this.el.style.position = 'relative'; - }, + } + get m2oProps() { + return this._state.m2oProps; + } + set m2oProps(value) { + this._state.m2oProps = value; + } + /** * Only allow to fetch/select records which are linked (via `m2oField`) to the * specified records. * * @param {integer[]} linkedRecordsIds * @returns {Promise} - * @see Many2oneUserValueWidget.setFilterInDomainIds + * @see Many2oneUserValue.setFilterInDomainIds */ async setFilterInDomainIds(linkedRecordsIds) { if (this.filterIn) { - return this.listWidget.createWidget.setFilterInDomainIds(linkedRecordsIds); + return this.listUserValue.createUserValue.setFilterInDomainIds(linkedRecordsIds); } - }, + } /** * @override */ loadMethodsData(validMethodNames, ...rest) { // TODO: check that addRecord is still needed. - this._super(['addRecord', ...validMethodNames], ...rest); - this._methodsNames = this._methodsNames.filter(name => name !== 'addRecord'); - }, + super.loadMethodsData(['addRecord', ...validMethodNames], ...rest); + } /** * @override */ setValue(value, methodName) { - if (methodName === this.options.createMethod) { - return this.createWidget.setValue(value, methodName); + if (methodName === this._data.createMethod) { + return this.listUserValue?.createUserValue.setValue(value, methodName); } if (!value) { // TODO: why do we need this. - value = this._value; + value = this.value; } - this._super(value, methodName); - this.listWidget.setValue(this._value); - }, + super.setValue(value, methodName); + this.listUserValue?.setValue(this.value); + } /** * @override */ getValue(methodName) { - return this.listWidget.getValue(methodName); - }, + return this.listUserValue.getValue(methodName); + } + /** + * @override + */ + registerUserValue(userValue) { + if (!this.listUserValue && userValue instanceof ListUserValue) { + this.listUserValue = userValue; + } + super.registerUserValue(userValue); + this._methodsNames.delete("addRecord"); + } //-------------------------------------------------------------------------- // Private @@ -3288,85 +3583,198 @@ const Many2manyUserValueWidget = UserValueWidget.extend({ /** * @override */ - _onUserValueNotification(ev) { - const { widget, previewMode } = ev.data; + userValueNotification(params) { + const { widget, previewMode } = params; if (!widget) { - return this._super(ev); + return super.userValueNotification(...arguments); } - if (widget === this.listWidget) { - ev.stopPropagation(); - this._value = widget._value; + if (widget === this.listUserValue) { + this.value = widget.value; this.notifyValueChange(previewMode); } - }, -}); + } +} + +class WeMany2many extends UserValueComponent { + static isContainer = true; + static template = "web_editor.WeMany2many"; + static StateModel = Many2manyUserValue; + static components = { WeList, WeMany2one, WeTitle }; + static configProps = { + // "model" and "createMethod" are already part of the WeMany2one props. + fakem2m: { type: String, optional: true }, + filterIn: { type: String, optional: true }, + m2oField: { type: String, optional: true }, + recordId: { type: String, optional: true }, + }; + static props = { + ...UserValueComponent.props, + ...WeMany2one.configProps, + ...this.configProps, + }; + + setup() { + super.setup(...arguments); + this.orm = useService("orm"); + this.field = useService("field"); + + onWillStart(async () => { + let m2oModel; + // If the widget does not have a real m2m field in the database + // We do not need to fetch anything from the DB + if (this.props.fakem2m) { + m2oModel = this.props.model; + } else { + const { model, recordId, m2oField } = this.props; + const [record] = await this.orm.read(model, [parseInt(recordId)], [m2oField]); + const selectedRecordIds = record[m2oField]; + // TODO: handle no record + const modelData = await this.field.loadFields(model, { fieldNames: [m2oField] }); + m2oModel = modelData[m2oField].relation; + + const selectedRecords = await this.orm.read(m2oModel, selectedRecordIds, ['display_name']); + this.state.value = JSON.stringify(selectedRecords); + } + + Object.assign(this.state.m2oProps, { + model: m2oModel, + addRecord: '', + createMethod: this.props.createMethod, + }); + }); + } +} +registry.category("snippet_widgets").add("WeMany2many", WeMany2many); -const userValueWidgetsRegistry = { +// TODO: @owl-options properly remove this registry +export const userValueWidgetsRegistry = { 'we-button': ButtonUserValueWidget, 'we-checkbox': CheckboxUserValueWidget, 'we-select': SelectUserValueWidget, 'we-button-group': ButtonGroupUserValueWidget, 'we-input': InputUserValueWidget, 'we-multi': MultiUserValueWidget, - 'we-colorpicker': ColorpickerUserValueWidget, 'we-datetimepicker': DatetimePickerUserValueWidget, 'we-datepicker': DatePickerUserValueWidget, 'we-list': ListUserValueWidget, 'we-imagepicker': ImagepickerUserValueWidget, 'we-videopicker': VideopickerUserValueWidget, 'we-range': RangeUserValueWidget, - 'we-select-pager': SelectPagerUserValueWidget, 'we-many2one': Many2oneUserValueWidget, 'we-many2many': Many2manyUserValueWidget, }; +/** + * This component is responsible for rendering the widgets. Once it's mounted + * it means all "UserValue" states have been created, and the templates are + * accessible. So it notifies the SnippetsMenu that the initial "updateUI" is + * ready to be done. + * @TODO @owl-options: If an option renders a widget **after** the initial + * onMounted, the SnippetsMenu will not have a computed state for its widgets. + * It should therefore ask for a new updateUI. This should probably be improved. + * To discuss. + */ +export class SnippetOptionComponent extends Component { + + static template = "web_editor.SnippetOptionComponent"; + + static props = { + snippetOption: Object, + onOptionMounted: Function, + }; + + setup() { + this.$overlay = this.props.snippetOption.instance.$overlay; + this.renderContext = useState(this.props.snippetOption.instance.renderContext); + // When a component is mounted or unmounted, the state of other + // components might be impacted. (i.e. dependencies behaving + // differently when a component is in the DOM or when it isn't) + this.updateUI = false; + + useSubEnv({ + snippetOption: this.props.snippetOption.instance, + userValueNotification: this.props.snippetOption.instance.onUserValueUpdate, + registerUserValue: (userValue) => { + this.updateUI = true; + return this.props.snippetOption.instance.registerUserValue(userValue); + }, + unregisterUserValue: (userValue) => { + this.updateUI = true; + return this.props.snippetOption.instance.unregisterUserValue(userValue); + }, + renderContext: this.renderContext, + registerLayoutElement: (layoutElement) => { + return this.props.snippetOption.instance.registerLayoutElement(layoutElement); + }, + $target: this.props.snippetOption.instance.$target, + }); + + onMounted(() => { + this.props.onOptionMounted(); + this.updateUI = false; + }); + + onPatched(() => { + if (this.updateUI) { + this.updateUI = false; + this.props.onOptionMounted(); + } + }); + } +} /** * Handles a set of options for one snippet. The registry returned by this * module contains the names of the specialized SnippetOptionWidget which can be * referenced thanks to the data-js key in the web_editor options template. */ -const SnippetOptionWidget = Widget.extend({ - tagName: 'we-customizeblock-option', - events: { +export class SnippetOption { + static events = { 'click .o_we_collapse_toggler': '_onCollapseTogglerClick', - }, - custom_events: { + }; + static custom_events = { 'user_value_update': '_onUserValueUpdate', 'user_value_widget_critical': '_onUserValueWidgetCritical', - }, + }; /** * Indicates if the option should be displayed in the button group at the * top of the options panel, next to the clone/remove button. * * @type {boolean} */ - isTopOption: false, + static isTopOption = false; /** * Indicates if the option should be the first one displayed in the button * group at the top of the options panel, next to the clone/remove button. * * @type {boolean} */ - isTopFirstOption: false, + static isTopFirstOption = false; /** * Forces the target to not be possible to remove. It will also hide the * clone button. * * @type {boolean} */ - forceNoDeleteButton: false, + static forceNoDeleteButton = false; /** * The option needs the handles overlay to be displayed on the snippet. * * @type {boolean} */ - displayOverlayOptions: false, + static displayOverlayOptions = false; /** * Forces the target to be duplicable. * * @type {boolean} */ - forceDuplicateButton: false, + static forceDuplicateButton = false; + /** + * Default component to use for rendering. + * Can be overridden when registering a snippet option in snippet_options registry. + * + * @type {Class<SnippetOptionComponent>} + */ + static defaultRenderingComponent = SnippetOptionComponent; /** * The option `$el` is supposed to be the associated DOM UI element. @@ -3376,42 +3784,59 @@ const SnippetOptionWidget = Widget.extend({ * * @constructor */ - init: function (parent, $uiElements, $target, $overlay, data, options) { - this._super.apply(this, arguments); - - this.$originalUIElements = $uiElements; + constructor({ editor, $target, $overlay, data, options, callbacks }) { + this.env = options.env; this.$target = $target; this.$overlay = $overlay; this.data = data; this.options = options; + // TODO: @owl-options better name or do it differently? It's to replace + // what was previously done by trigger_up's. + this.callbacks = callbacks; this.className = 'snippet-option-' + this.data.optionName; this.ownerDocument = this.$target[0].ownerDocument; + /** @type {UserValue[]} */ + this._userValues = {}; + this._layoutElements = []; this._userValueWidgets = []; this._actionQueues = new Map(); - this.dialog = this.bindService("dialog"); - }, + this.dialog = this.env.services.dialog; + // Passed as props to components so bind it here, that way + // it will always have the same reference. + this.onUserValueUpdate = this._onUserValueUpdate.bind(this); + + this.constructorPatch(); + } /** - * @override + * To be patched. */ - willStart: async function () { - await this._super(...arguments); - return this._renderOriginalXML().then(uiFragment => { - this.uiFragment = uiFragment; - }); - }, + constructorPatch() {} + /** + * Called after the constructor. Allows the option to do async stuff + * before the widgets are mounted. + */ + async willStart() { + const context = await this._getRenderContext(); + this.renderContext = reactive({...context}); + if (this.data.groups) { + for (const group of this.data.groups) { + this.isRestrictedGroup ||= !(await user.hasGroup(group)); + } + } + } + destroy() {} /** * @override */ - renderElement: function () { - this._super(...arguments); + renderElement() { this.el.appendChild(this.uiFragment); this.uiFragment = null; - }, + } /** * Called when the parent edition overlay is covering the associated snippet * (the first time, this follows the call to the @see start method). @@ -3419,7 +3844,7 @@ const SnippetOptionWidget = Widget.extend({ * @abstract * @returns {Promise|undefined} */ - async onFocus() {}, + async onFocus() {} /** * Called when the parent edition overlay is covering the associated snippet * for the first time, when it is a new snippet dropped from the d&d snippet @@ -3432,7 +3857,7 @@ const SnippetOptionWidget = Widget.extend({ * the main element has been built). * @returns {Promise|undefined} */ - async onBuilt(options) {}, + async onBuilt(options) {} /** * Called when the parent edition overlay is removed from the associated * snippet (another snippet enters edition for example). @@ -3440,7 +3865,7 @@ const SnippetOptionWidget = Widget.extend({ * @abstract * @returns {Promise|undefined} */ - async onBlur() {}, + async onBlur() {} /** * Called when the associated snippet is the result of the cloning of * another snippet (so `this.$target` is a cloned element). @@ -3452,20 +3877,20 @@ const SnippetOptionWidget = Widget.extend({ * was cloned (so not a clone of a child of this main element that * was cloned) */ - onClone: function (options) {}, + onClone(options) {} /** * Called when the associated snippet is moved to another DOM location. * * @abstract */ - onMove: function () {}, + onMove() {} /** * Called when the associated snippet is about to be removed from the DOM. * * @abstract * @returns {Promise|undefined} */ - onRemove: async function () {}, + async onRemove() {} /** * Called when the target is shown, only meaningful if the target was hidden * at some point (typically used for 'invisible' snippets). @@ -3473,7 +3898,7 @@ const SnippetOptionWidget = Widget.extend({ * @abstract * @returns {Promise|undefined} */ - onTargetShow: async function () {}, + async onTargetShow() {} /** * Called when the target is hidden (typically used for 'invisible' * snippets). @@ -3481,7 +3906,7 @@ const SnippetOptionWidget = Widget.extend({ * @abstract * @returns {Promise|undefined} */ - onTargetHide: async function () {}, + async onTargetHide() {} /** * Called when the template which contains the associated snippet is about * to be saved. @@ -3489,7 +3914,7 @@ const SnippetOptionWidget = Widget.extend({ * @abstract * @return {Promise|undefined} */ - cleanForSave: async function () {}, + async cleanForSave() {} /** * Called when the associated snippet UI needs to be cleaned (e.g. from * visual effects like previews). @@ -3498,15 +3923,32 @@ const SnippetOptionWidget = Widget.extend({ * @abstract * @return {Promise|undefined} */ - cleanUI: async function () {}, + async cleanUI() {} /** * Adds the given widget to the known list of user value widgets * - * @param {UserValueWidget} widget + * @param {UserValue} userValue */ - registerSubWidget(widget) { - this._userValueWidgets.push(widget); - }, + registerUserValue(userValue) { + this._userValues[userValue.id] = (userValue); + userValue.loadMethodsData(this.getMethodsNames()); + } + /** + * Removes the give user value to the known list of user values. + * + * @param {UserValue} userValue + */ + unregisterUserValue(userValue) { + delete this._userValues[userValue.id]; + } + /** + * Register a layout element to hide them if no component is visible + * within it. + * @params {Object} layoutElement + */ + registerLayoutElement(layoutElement) { + this._layoutElements.push(layoutElement); + } //-------------------------------------------------------------------------- // Options @@ -3527,7 +3969,7 @@ const SnippetOptionWidget = Widget.extend({ * @param {Object} params * @returns {Promise|undefined} */ - selectClass: function (previewMode, widgetValue, params) { + selectClass(previewMode, widgetValue, params) { for (const classNames of params.possibleValues) { if (classNames) { this.$target[0].classList.remove(...classNames.trim().split(/\s+/g)); @@ -3536,7 +3978,7 @@ const SnippetOptionWidget = Widget.extend({ if (widgetValue) { this.$target[0].classList.add(...widgetValue.trim().split(/\s+/g)); } - }, + } /** * Default option method which allows to select a value and set it on the * associated snippet as a data attribute. The name of the data attribute is @@ -3547,10 +3989,10 @@ const SnippetOptionWidget = Widget.extend({ * @param {Object} params * @returns {Promise|undefined} */ - selectDataAttribute: function (previewMode, widgetValue, params) { + selectDataAttribute(previewMode, widgetValue, params) { const value = this._selectAttributeHelper(widgetValue, params); this.$target[0].dataset[params.attributeName] = value; - }, + } /** * Default option method which allows to select a value and set it on the * associated snippet as an attribute. The name of the attribute is @@ -3561,14 +4003,14 @@ const SnippetOptionWidget = Widget.extend({ * @param {Object} params * @returns {Promise|undefined} */ - selectAttribute: function (previewMode, widgetValue, params) { + selectAttribute(previewMode, widgetValue, params) { const value = this._selectAttributeHelper(widgetValue, params); if (value) { this.$target[0].setAttribute(params.attributeName, value); } else { this.$target[0].removeAttribute(params.attributeName); } - }, + } /** * Default option method which allows to select a value and set it on the * associated snippet as a property. The name of the property is @@ -3578,13 +4020,13 @@ const SnippetOptionWidget = Widget.extend({ * @param {string} widgetValue * @param {Object} params */ - selectProperty: function (previewMode, widgetValue, params) { + selectProperty(previewMode, widgetValue, params) { if (!params.propertyName) { throw new Error('Property name missing'); } const value = this._selectValueHelper(widgetValue, params); this.$target[0][params.propertyName] = value; - }, + } /** * Default option method which allows to select a value and set it on the * associated snippet as a css style. The name of the css property is @@ -3603,7 +4045,7 @@ const SnippetOptionWidget = Widget.extend({ * an effect. * @returns {Promise|undefined} */ - selectStyle: async function (previewMode, widgetValue, params) { + async selectStyle(previewMode, widgetValue, params) { // Disable all transitions for the duration of the method as many // comparisons will be done on the element to know if applying a // property has an effect or not. Also, changing a css property via the @@ -3761,7 +4203,7 @@ const SnippetOptionWidget = Widget.extend({ } _restoreTransitions(); - }, + } /** * Sets a color combination. * @@ -3777,7 +4219,7 @@ const SnippetOptionWidget = Widget.extend({ this.$target[0].classList.add('o_cc', `o_cc${widgetValue}`); } } - }, + } //-------------------------------------------------------------------------- // Public @@ -3789,21 +4231,21 @@ const SnippetOptionWidget = Widget.extend({ * * @override */ - $: function () { + $() { return this.$target.find.apply(this.$target, arguments); - }, + } /** * Closes all user value widgets. */ - closeWidgets: function () { - this._userValueWidgets.forEach(widget => widget.close()); - }, + closeWidgets() { + Object.values(this._userValues).forEach(widget => widget.close()); + } /** * @param {string} name * @returns {UserValueWidget|null} */ - findWidget: function (name) { - for (const widget of this._userValueWidgets) { + findWidget(name) { + for (const widget of Object.values(this._userValues)) { if (widget.getName() === name) { return widget; } @@ -3813,7 +4255,7 @@ const SnippetOptionWidget = Widget.extend({ } } return null; - }, + } /** * Sometimes, options may need to notify other options, even in parent * editors. This can be done thanks to the 'option_update' event, which @@ -3822,13 +4264,13 @@ const SnippetOptionWidget = Widget.extend({ * @param {string} name - an identifier for a type of update * @param {*} data */ - notify: function (name, data) { + notify(name, data) { // We prefer to avoid refactoring this notify mechanism to make it // asynchronous because the upcoming conversion to owl might remove it. if (name === 'target') { this.setTarget(data); } - }, + } /** * Sometimes, an option is binded on an element but should in fact apply on * another one. For example, elements which contain slides: we want all the @@ -3840,9 +4282,21 @@ const SnippetOptionWidget = Widget.extend({ * @param {jQuery} $target - the new target element * @returns {Promise} */ - setTarget: function ($target) { + setTarget($target) { this.$target = $target; - }, + } + getMethodsNames() { + // TODO: @owl-options either add all possible method or find a way to compute it. + function getMethods(obj) { + if (!obj || obj === Object) { + return []; + } + const properties = Object.getOwnPropertyNames(obj); + const methods = properties.filter(name => typeof obj[name] === "function"); + return [...methods, ...getMethods(Object.getPrototypeOf(obj))]; + } + return getMethods(this); + } /** * Updates the UI. For widget update, @see _computeWidgetState. * @@ -3858,11 +4312,15 @@ const SnippetOptionWidget = Widget.extend({ // For each widget, for each of their option method, notify to the // widget the current value they should hold according to the $target's // current state, related for that method. - const proms = this._userValueWidgets.map(async widget => { + const useValueStates = Object.values(this._userValues); + const proms = useValueStates.map(async (userValue) => { + if (!userValue.started) { + await userValue.start(); + } // Update widget value (for each method) - const methodsNames = widget.getMethodsNames(); + const methodsNames = userValue.getMethodsNames(); for (const methodName of methodsNames) { - const params = widget.getMethodsParams(methodName); + const params = userValue.getMethodsParams(methodName); let obj = this; if (params.applyTo) { @@ -3878,7 +4336,7 @@ const SnippetOptionWidget = Widget.extend({ continue; } const normalizedValue = this._normalizeWidgetValue(value); - await widget.setValue(normalizedValue, methodName); + await userValue.setValue(normalizedValue, methodName); } }); await Promise.all(proms); @@ -3886,22 +4344,22 @@ const SnippetOptionWidget = Widget.extend({ if (!noVisibility) { await this.updateUIVisibility(); } - }, + } /** * Updates the UI visibility - @see _computeVisibility. For widget update, * @see _computeWidgetVisibility. * * @returns {Promise} */ - updateUIVisibility: async function () { - const proms = this._userValueWidgets.map(async widget => { - const params = widget.getMethodsParams(); + async updateUIVisibility() { + const proms = Object.values(this._userValues).map(async userValue => { + const params = userValue.getMethodsParams(); let obj = this; if (params.applyTo) { const $firstSubTarget = this.$(params.applyTo).eq(0); if (!$firstSubTarget.length) { - widget.toggleVisibility(false); + userValue.toggleVisibility(false); return; } obj = createPropertyProxy(this, '$target', $firstSubTarget); @@ -3910,23 +4368,23 @@ const SnippetOptionWidget = Widget.extend({ // Make sure to check the visibility of all sub-widgets. For // simplicity and efficiency, those will be checked with main // widgets params. - const allSubWidgets = [widget]; + const allSubValues = [userValue]; let i = 0; - while (i < allSubWidgets.length) { - allSubWidgets.push(...allSubWidgets[i]._userValueWidgets); + while (i < allSubValues.length) { + allSubValues.push(...Object.values(allSubValues[i]._subValues)); i++; } - const proms = allSubWidgets.map(async widget => { - const show = await this._computeWidgetVisibility.call(obj, widget.getName(), params); + const proms = allSubValues.map(async userValue => { + const show = await this._computeWidgetVisibility.call(obj, userValue.getName(), params); if (!show) { - widget.toggleVisibility(false); + userValue.toggleVisibility(false); return; } - const dependencies = widget.getDependencies(); + const dependencies = userValue.getDependencies(); if (dependencies.length === 1 && dependencies[0] === 'fake') { - widget.toggleVisibility(false); + userValue.toggleVisibility(false); return; } @@ -3937,47 +4395,40 @@ const SnippetOptionWidget = Widget.extend({ depName = depName.substr(1); } - const widget = this._requestUserValueWidgets(depName, true)[0]; - if (widget) { + const userValue = this._requestUserValueWidgets(depName, true)[0]; + if (userValue) { dependenciesData.push({ - widget: widget, + userValue: userValue, toBeActive: toBeActive, }); } }); const dependenciesOK = !dependenciesData.length || dependenciesData.some(depData => { - return (depData.widget.isActive() === depData.toBeActive); + return (depData.userValue.isActive() === depData.toBeActive); }); - widget.toggleVisibility(dependenciesOK); + userValue.toggleVisibility(dependenciesOK); }); return Promise.all(proms); }); - const showUI = await this._computeVisibility(); - this.el.classList.toggle('d-none', !showUI); + this.renderContext.showUI = await this._computeVisibility(); await Promise.all(proms); + for (const layout of this._layoutElements) { + const shouldShow = Object.values(layout.userValues) + .some(uv => uv.show); + layout.state.show = shouldShow; + } + + // TODO: @owl-options, layout should probably be hidden in template. // Hide layouting elements which contains only hidden widgets // TODO improve this, this is hackish to rely on DOM structure here. // Layouting elements should be handled as widgets or other. - for (const el of this.$el.find('we-row')) { - const $userValueWidget = $(el).find('> div > .o_we_user_value_widget'); - el.classList.toggle('d-none', $userValueWidget.length && !$userValueWidget.not('.d-none').length); - } - for (const el of this.$el.find('we-collapse')) { - const $el = $(el); - el.classList.toggle('d-none', $el.children().first().hasClass('d-none')); - const hasNoVisibleElInCollapseMenu = !$el.children().last().children().not('.d-none').length; - if (hasNoVisibleElInCollapseMenu) { - this._toggleCollapseEl(el, false); - } - el.querySelector('.o_we_collapse_toggler').classList.toggle('d-none', hasNoVisibleElInCollapseMenu); - } - return !this.displayOverlayOptions && showUI; - }, + return !this.displayOverlayOptions && this.renderContext.showUI; + } //-------------------------------------------------------------------------- // Private @@ -3997,7 +4448,7 @@ const SnippetOptionWidget = Widget.extend({ } } return messages.join(' '); - }, + } /** * @private * @param {UserValueWidget[]} widgets @@ -4005,14 +4456,14 @@ const SnippetOptionWidget = Widget.extend({ */ async _checkIfWidgetsUpdateNeedReload(widgets) { return false; - }, + } /** * @private * @returns {Promise<boolean>|boolean} */ - _computeVisibility: async function () { + async _computeVisibility() { return true; - }, + } /** * Returns the string value that should be hold by the widget which is * related to the given method name. @@ -4024,7 +4475,7 @@ const SnippetOptionWidget = Widget.extend({ * @param {Object} params * @returns {Promise<string|undefined>|string|undefined} */ - _computeWidgetState: async function (methodName, params) { + async _computeWidgetState(methodName, params) { switch (methodName) { case 'selectClass': { let maxNbClasses = 0; @@ -4154,7 +4605,6 @@ const SnippetOptionWidget = Widget.extend({ if (value === "currentColor") { return styles.color; } - return value; } case 'selectColorCombination': { @@ -4172,22 +4622,22 @@ const SnippetOptionWidget = Widget.extend({ return ''; } } - }, + } /** * @private * @param {string} widgetName * @param {Object} params * @returns {Promise<boolean>|boolean} */ - _computeWidgetVisibility: async function (widgetName, params) { + async _computeWidgetVisibility(widgetName, params) { return true; - }, + } /** * @private * @param {HTMLElement} el * @returns {Object} */ - _extraInfoFromDescriptionElement: function (el) { + _extraInfoFromDescriptionElement(el) { return { title: el.getAttribute('string'), options: { @@ -4198,110 +4648,33 @@ const SnippetOptionWidget = Widget.extend({ childNodes: [...el.childNodes], }, }; - }, + } /** * @private * @param {*} * @returns {string} */ - _normalizeWidgetValue: function (value) { + _normalizeWidgetValue(value) { value = `${value}`.trim(); // Force to a trimmed string value = normalizeCSSColor(value); // If is a css color, normalize it return value; - }, - /** - * @private - * @param {HTMLElement} uiFragment - * @returns {Promise} - */ - _renderCustomWidgets: function (uiFragment) { - return Promise.resolve(); - }, - /** - * @private - * @param {HTMLElement} uiFragment - * @returns {Promise} - */ - _renderCustomXML: function (uiFragment) { - return Promise.resolve(); - }, - /** - * @private - * @param {jQuery} [$xml] - default to original xml content - * @returns {Promise} - */ - _renderOriginalXML: async function ($xml) { - const uiFragment = document.createDocumentFragment(); - ($xml || this.$originalUIElements).clone(true).appendTo(uiFragment); - - await this._renderCustomXML(uiFragment); - - // Build layouting components first - for (const [itemName, build] of [['we-row', _buildRowElement], ['we-collapse', _buildCollapseElement]]) { - uiFragment.querySelectorAll(itemName).forEach(el => { - const infos = this._extraInfoFromDescriptionElement(el); - const groupEl = build(infos.title, infos.options); - el.parentNode.insertBefore(groupEl, el); - el.parentNode.removeChild(el); - }); - } - - // Load widgets - await this._renderXMLWidgets(uiFragment); - await this._renderCustomWidgets(uiFragment); - - if (this.isDestroyed()) { - // TODO there is probably better to do. This case was found only in - // tours, where the editor is left before the widget are fully - // loaded (loadMethodsData doesn't work if the widget is destroyed). - return uiFragment; - } - - const validMethodNames = []; - for (const key in this) { - validMethodNames.push(key); - } - this._userValueWidgets.forEach(widget => { - widget.loadMethodsData(validMethodNames); - }); - - return uiFragment; - }, + } /** + * Allows options to share a context to their template / components. + * * @private - * @param {HTMLElement} parentEl - * @param {SnippetOptionWidget|UserValueWidget} parentWidget * @returns {Promise} */ - _renderXMLWidgets: function (parentEl, parentWidget) { - const proms = [...parentEl.children].map(el => { - const widgetName = el.tagName.toLowerCase(); - if (!userValueWidgetsRegistry.hasOwnProperty(widgetName)) { - return this._renderXMLWidgets(el, parentWidget); - } - - const infos = this._extraInfoFromDescriptionElement(el); - const widget = registerUserValueWidget(widgetName, parentWidget || this, infos.title, infos.options, this.$target); - return widget.insertAfter(el).then(() => { - // Remove the original element afterwards as the insertion - // operation may move some of its inner content during - // widget start. - parentEl.removeChild(el); - - if (widget.isContainer() && !widget.isDestroyed()) { - return this._renderXMLWidgets(widget.el, widget); - } - }); - }); - return Promise.all(proms); - }, + async _getRenderContext() { + return {}; + } /** * @private * @param {...string} widgetNames * @param {boolean} [allowParentOption=false] * @returns {UserValueWidget[]} */ - _requestUserValueWidgets: function (...args) { + _requestUserValueWidgets(...args) { const widgetNames = args; let allowParentOption = false; const lastArg = args[args.length - 1]; @@ -4313,9 +4686,8 @@ const SnippetOptionWidget = Widget.extend({ const widgets = []; for (const widgetName of widgetNames) { let widget = null; - this.trigger_up('user_value_widget_request', { + widget = this.callbacks.requestUserValue({ name: widgetName, - onSuccess: _widget => widget = _widget, allowParentOption: allowParentOption, }); if (widget) { @@ -4323,13 +4695,13 @@ const SnippetOptionWidget = Widget.extend({ } } return widgets; - }, + } /** * @private * @param {function<Promise<jQuery>>} [callback] * @returns {Promise} */ - _rerenderXML: async function (callback) { + async _rerenderXML(callback) { this._userValueWidgets.forEach(widget => widget.destroy()); this._userValueWidgets = []; this.$el.empty(); @@ -4343,7 +4715,7 @@ const SnippetOptionWidget = Widget.extend({ this.$el.append(uiFragment); return this.updateUI(); }); - }, + } /** * Activates the option associated to the given DOM element. * @@ -4352,10 +4724,10 @@ const SnippetOptionWidget = Widget.extend({ * - truthy if the option is enabled for preview or if leaving it (in * that second case, the value is 'reset') * - false if the option should be activated for good - * @param {UserValueWidget} widget - the widget which triggered the option change + * @param {UserValue} widget - the widget which triggered the option change * @returns {Promise} */ - _select: async function (previewMode, widget) { + async _select(previewMode, widget) { let $applyTo = null; if (previewMode === true) { @@ -4389,7 +4761,7 @@ const SnippetOptionWidget = Widget.extend({ // this.$target could not be in an editable element while the elements // targeted by apply-to are. ($applyTo || this.$target).trigger('content_changed'); - }, + } /** * Used to handle attribute or data attribute value change * @@ -4400,7 +4772,7 @@ const SnippetOptionWidget = Widget.extend({ throw new Error('Attribute name missing'); } return this._selectValueHelper(value, params); - }, + } /** * Used to handle value of a select * @@ -4418,7 +4790,7 @@ const SnippetOptionWidget = Widget.extend({ this.$target.toggleClass(params.extraClass, params.defaultValue !== value); } return value; - }, + } /** * @private * @param {HTMLElement} collapseEl @@ -4427,7 +4799,7 @@ const SnippetOptionWidget = Widget.extend({ _toggleCollapseEl(collapseEl, show) { collapseEl.classList.toggle('active', show); collapseEl.querySelector('we-toggler.o_we_collapse_toggler').classList.toggle('active', show); - }, + } //-------------------------------------------------------------------------- // Handlers @@ -4443,26 +4815,25 @@ const SnippetOptionWidget = Widget.extend({ for (const collapseEl of currentCollapseEl.querySelectorAll('we-collapse')) { this._toggleCollapseEl(collapseEl, false); } - }, + } /** * Called when a widget notifies a preview/change/reset. * * @private * @param {Event} ev */ - _onUserValueUpdate: async function (ev) { - ev.stopPropagation(); - const widget = ev.data.widget; - const previewMode = ev.data.previewMode; + async _onUserValueUpdate(params) { + const widget = params.widget; + const previewMode = params.previewMode; // First check if the updated widget or any of the widgets it triggers // will require a reload or a confirmation choice by the user. If it is // the case, warn the user and potentially ask if he agrees to save its // current changes. If not, just do nothing. let requiresReload = false; - if (!ev.data.previewMode && !ev.data.isSimulatedEvent) { - const linkedWidgets = this._requestUserValueWidgets(...ev.data.triggerWidgetsNames); - const widgets = [ev.data.widget].concat(linkedWidgets); + if (!params.previewMode && !params.isSimulatedEvent) { + const linkedWidgets = this._requestUserValueWidgets(...params.triggerWidgetsNames); + const widgets = [params.widget].concat(linkedWidgets); const warnMessage = await this._checkIfWidgetsUpdateNeedWarning(widgets); if (warnMessage) { @@ -4489,18 +4860,20 @@ const SnippetOptionWidget = Widget.extend({ this._actionQueues.get(widget).push(currentAction); // Ask a mutexed snippet update according to the widget value change - const shouldRecordUndo = (!previewMode && !ev.data.isSimulatedEvent); + const shouldRecordUndo = (!previewMode && !params.isSimulatedEvent); if (shouldRecordUndo) { this.options.wysiwyg.odooEditor.unbreakableStepUnactive(); } - const useLoaderOnOptionPanel = ev.target.el.dataset.loaderOnOptionPanel; - this.trigger_up('snippet_edition_request', {exec: async () => { + // TODO: @owl-options make it go through props + params. + // const useLoaderOnOptionPanel = ev.target.el.dataset.loaderOnOptionPanel; + const useLoaderOnOptionPanel = false; + this.options.snippetEditionRequest({exec: async () => { // If some previous snippet edition in the mutex removed the target from // the DOM, the widget can be destroyed, in that case the edition request // is now useless and can be discarded. - if (this.isDestroyed()) { - return; - } + // if (this.isDestroyed()) { + // return; + // } // Filter actions that are counterbalanced by earlier/later actions const actionQueue = this._actionQueues.get(widget).filter(({previewMode}, i, actions) => { const prev = actions[i - 1]; @@ -4519,11 +4892,12 @@ const SnippetOptionWidget = Widget.extend({ } this._actionQueues.set(widget, actionQueue.filter(action => action !== currentAction)); - if (ev.data.prepare) { - ev.data.prepare(); + if (params.prepare) { + // Why must this be done before checking noPreview ? + params.prepare(); } - if (previewMode && (widget.$el.closest('[data-no-preview="true"]').length)) { + if (previewMode && (widget.getMethodsParams().noPreview)) { // TODO the flag should be fetched through widget params somehow return; } @@ -4544,15 +4918,14 @@ const SnippetOptionWidget = Widget.extend({ await new Promise(resolve => setTimeout(() => { // Will update the UI of the correct widgets for all options // related to the same $target/editor - this.trigger_up('snippet_option_update', { - onSuccess: () => resolve(), - }); + this.options.optionUpdate().then(resolve); // Set timeout needed so that the user event which triggered the // option can bubble first. })); }, optionsLoader: useLoaderOnOptionPanel}); - if (ev.data.isSimulatedEvent) { + // TODO: @owl-options is this still needed? + if (params.isSimulatedEvent) { // If the user value update was simulated through a trigger, we // prevent triggering further widgets. This could be allowed at some // point but does not work correctly in complex website cases (see @@ -4560,13 +4933,14 @@ const SnippetOptionWidget = Widget.extend({ return; } + // TODO: @owl-options support triggerWidgets. // Check linked widgets: force their value and simulate a notification // It is possible that we don't have the widget, we continue because a // reload might be needed. For example, change template header without // being on a website.page (e.g: /shop). - const linkedWidgets = this._requestUserValueWidgets(...ev.data.triggerWidgetsNames); + const linkedWidgets = this._requestUserValueWidgets(...params.triggerWidgetsNames); let i = 0; - const triggerWidgetsValues = ev.data.triggerWidgetsValues; + const triggerWidgetsValues = params.triggerWidgetsValues; for (const linkedWidget of linkedWidgets) { const widgetValue = triggerWidgetsValues[i]; if (widgetValue !== undefined) { @@ -4589,13 +4963,13 @@ const SnippetOptionWidget = Widget.extend({ } if (requiresReload) { - this.trigger_up('request_save', { + this.env.requestSave({ reloadEditor: true, optionSelector: this.data.selector, url: this.data.reload, }); } - }, + } /** * @private */ @@ -4603,13 +4977,102 @@ const SnippetOptionWidget = Widget.extend({ this.trigger_up('remove_snippet', { $snippet: this.$target, }); - }, -}); -const registry = {}; + } +} + + +class TestOption extends SnippetOption { + constructor() { + super(...arguments); + this.orm = serviceCached(this.env, "orm"); + this.listItems = ["Test1", "Test2", "Test3"]; + // M2O + this.pageId = ""; + this.newPage = {}; + // M2m + this.allGroupsByID = {}; + this.groupIDs = []; + + this.fetchGroups(); + } + async fetchGroups() { + const groups = await this.orm.searchRead( + "res.groups", + [], + ["id", "name", "display_name"] + ); + for (const group of groups) { + this.allGroupsByID[group.id] = group; + } + } + + renderListItems(previewMode, widgetValue, params) { + this.listItems = JSON.parse(widgetValue); + } + + setPageTemplate(previewMode, widgetValue, params) { + this.pageId = widgetValue; + } + createPage(previewMode, widgetValue, params) { + if (!widgetValue) { + return; + } + this.newPage = { + 'id': Math.floor(Math.random() * 8) + 1, + 'name': widgetValue, + 'display_name': widgetValue, + }; + this.pageId = this.newPage.id.toString(); + } + + setGroups(previewMode, widgetValue, params) { + this.groupIDs = JSON.parse(widgetValue).map(group => group.id); + } + createGroup(previewMode, widgetValue, params) { + if (!widgetValue) { + return; + } + const existing = Object.values(this.allGroupsByID).some(group => { + // A group is already existing only if it was already defined (i.e. + // id is a number) or if it appears in the current list of groups. + return group.name.toLowerCase() === widgetValue.toLowerCase() + && (typeof(group.id) === 'number' || this.groupIDs.includes(group.id)); + }); + if (existing) { + return; + } + const newGroupID = uniqueId("new_group_"); + this.allGroupsByID[newGroupID] = { + 'id': newGroupID, + 'name': widgetValue, + 'display_name': widgetValue, + }; + this.groupIDs.push(newGroupID); + } + + _computeWidgetState(methodName, params) { + if (methodName === "renderListItems") { + return JSON.stringify(this.listItems); + } else if (methodName === "setPageTemplate") { + return this.pageId; + } else if (methodName === "setGroups") { + return JSON.stringify(this.groupIDs.map(id => this.allGroupsByID[id])); + } + return super._computeWidgetState(...arguments); + } +} +// TODO: @owl-options remove this or include it in some tests? +//registerOption("test_option", { +// Class: TestOption, +// template: "web_editor.TestSnippetOption", +// selector: "section", +//}); + +const legacyRegistry = {}; //:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -registry.sizing = SnippetOptionWidget.extend({ +legacyRegistry.sizing = SnippetOptionWidget.extend({ displayOverlayOptions: true, /** @@ -4980,7 +5443,7 @@ registry.sizing = SnippetOptionWidget.extend({ /** * Handles the edition of padding-top and padding-bottom. */ -registry['sizing_y'] = registry.sizing.extend({ +legacyRegistry['sizing_y'] = legacyRegistry.sizing.extend({ //-------------------------------------------------------------------------- // Private @@ -5013,7 +5476,7 @@ registry['sizing_y'] = registry.sizing.extend({ return this.grid; }, }); -registry['sizing_x'] = registry.sizing.extend({ +legacyRegistry['sizing_x'] = legacyRegistry.sizing.extend({ /** * @override */ @@ -5125,7 +5588,7 @@ registry['sizing_x'] = registry.sizing.extend({ /** * Handles the sizing in grid mode: edition of grid-{column|row}-{start|end}. */ -registry['sizing_grid'] = registry.sizing.extend({ +legacyRegistry['sizing_grid'] = legacyRegistry.sizing.extend({ /** * @override */ @@ -5227,7 +5690,7 @@ registry['sizing_grid'] = registry.sizing.extend({ /** * Controls box properties. */ -registry.Box = SnippetOptionWidget.extend({ +export class Box extends SnippetOption { //-------------------------------------------------------------------------- // Options @@ -5283,7 +5746,7 @@ registry.Box = SnippetOptionWidget.extend({ } } await this.selectStyle(previewMode, shadow, Object.assign({cssProperty: 'box-shadow'}, params)); - }, + } //-------------------------------------------------------------------------- // Private @@ -5300,8 +5763,8 @@ registry.Box = SnippetOptionWidget.extend({ } return this.$target.css('box-shadow').includes('inset') ? 'inset' : 'outset'; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ @@ -5309,8 +5772,8 @@ registry.Box = SnippetOptionWidget.extend({ if (widgetName === 'fake_inset_shadow_opt') { return false; } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * @private * @param {string} type @@ -5328,18 +5791,18 @@ registry.Box = SnippetOptionWidget.extend({ const shadow = `${$(el).css('box-shadow')}${type === 'inset' ? ' inset' : ''}`; el.remove(); return shadow; - }, -}); + } +} -registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { +export class LayoutColumn extends ColumnLayoutMixin(SnippetOption) { /** * @override */ cleanUI() { this._removeGridPreview(); - }, + } //-------------------------------------------------------------------------- // Options @@ -5350,7 +5813,7 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { * * @see this.selectClass for parameters */ - selectCount: async function (previewMode, widgetValue, params) { + async selectCount(previewMode, widgetValue, params) { // Make sure the "Custom" option is read-only. if (widgetValue === "custom") { return; @@ -5375,15 +5838,15 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { resetOuids($row[0]); $row.contents().unwrap().contents().unwrap(); restoreCursor(); - this.trigger_up('activate_snippet', {$snippet: this.$target}); + this.env.activateSnippet(this.$target); } else if (previousNbColumns === 0) { - this.trigger_up('activate_snippet', {$snippet: this.$('> .row').children().first()}); + this.env.activateSnippet(this.$('> .row').children().first()); } - this.trigger_up('option_update', { - optionName: 'StepsConnector', - name: 'change_columns', + this.callbacks.notifyOptions({ + optionNames: ["StepsConnector"], + name: "change_columns", }); - }, + } /** * Changes the layout (columns or grid). * @@ -5394,7 +5857,7 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { const rowEl = this.$target[0].querySelector('.row'); if (!rowEl || !rowEl.classList.contains('o_grid_mode')) { // Prevent toggling grid mode twice. gridUtils._toggleGridMode(this.$target[0]); - this.trigger_up('activate_snippet', {$snippet: this.$target}); + this.env.activateSnippet(this.$target); } } else { // Toggle normal mode only if grid mode was activated (as it's in @@ -5402,14 +5865,10 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { const rowEl = this.$target[0].querySelector('.row'); if (rowEl && rowEl.classList.contains('o_grid_mode')) { this._toggleNormalMode(rowEl); - this.trigger_up('activate_snippet', {$snippet: this.$target}); + this.env.activateSnippet(this.$target); } } - this.trigger_up('option_update', { - optionName: 'StepsConnector', - name: 'change_columns', - }); - }, + } /** * Adds an image, some text or a button in the grid. * @@ -5445,7 +5904,7 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { // Choose an image with the media dialog. let isImageSaved = false; await new Promise(resolve => { - this.call("dialog", "add", MediaDialog, { + this.env.services.dialog.add(MediaDialog, { onlyImages: true, save: imageEl => { isImageSaved = true; @@ -5518,13 +5977,13 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { if (!sameCoordinatesEl || !newColumnEl.contains(sameCoordinatesEl)) { newColumnEl.scrollIntoView({behavior: "smooth", block: "center"}); } - this.trigger_up('activate_snippet', {$snippet: $(newColumnEl)}); - }, + this.env.activateSnippet($(newColumnEl)); + } /** * @override */ async selectStyle(previewMode, widgetValue, params) { - await this._super(previewMode, widgetValue, params); + await super.selectStyle(previewMode, widgetValue, params); const rowEl = this.$target[0]; const isMobileView = weUtils.isMobileView(rowEl); @@ -5542,7 +6001,7 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { this.removeGridPreview = this._removeGridPreview.bind(this); rowEl.addEventListener("animationend", this.removeGridPreview); } - }, + } //-------------------------------------------------------------------------- // Private @@ -5551,7 +6010,7 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { if (methodName === 'selectCount') { const isMobile = this._isMobile(); const columnEls = this.$target[0].querySelector(":scope > .row")?.children; @@ -5564,8 +6023,8 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { return 'normal'; } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ @@ -5590,8 +6049,8 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { this._areColsCustomized(this.$target[0].querySelector(":scope > .row").children, isMobile); } - return this._super(...arguments); - }, + return super._computeVisibility(...arguments); + } /** * If the number of columns requested is greater than the number of items, * adds new columns which are clones of the last one. If there are less @@ -5618,14 +6077,14 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { for (let i = 0; i < itemsDelta; i++) { const lastEl = rowEl.lastElementChild; newItems.push(new Promise(resolve => { - this.trigger_up("clone_snippet", {$snippet: $(lastEl), onSuccess: resolve}); + this.env.cloneSnippet($(lastEl)).then(resolve); })); } await Promise.all(newItems); } - this.trigger_up('cover_update'); - }, + this.callbacks.cover(); + } /** * Resizes the columns for the mobile or desktop view. * @@ -5655,7 +6114,7 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { const hasDesktopOffset = columnEl.className.match(/(^|\s+)offset-lg-[1-9][0-1]?(?!\S)/); columnEl.classList.toggle("offset-lg-0", hasMobileOffset && !hasDesktopOffset); } - }, + } /** * Toggles the normal mode. * @@ -5667,12 +6126,7 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { rowEl.classList.remove('o_grid_mode'); const columnEls = rowEl.children; // Removing the grid previews (if any). - await new Promise(resolve => { - this.trigger_up("clean_ui_request", { - targetEl: this.$target[0].closest("section"), - onSuccess: resolve, - }); - }); + await this.env.cleanUI(this.$target[0].closest("section")) for (const columnEl of columnEls) { // Reloading the images. @@ -5686,7 +6140,7 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { rowEl.style.removeProperty('--grid-item-padding-x'); rowEl.style.removeProperty('--grid-item-padding-y'); rowEl.style.removeProperty("gap"); - }, + } /** * Removes the grid preview that was added when changing the grid gaps. * @@ -5701,16 +6155,16 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { } delete this.removeGridPreview; this.options.wysiwyg.odooEditor.observerActive("removeGridPreview"); - }, + } /** * @returns {boolean} */ _isMobile() { return weUtils.isMobileView(this.$target[0]); - }, -}); + } +} -registry.GridColumns = SnippetOptionWidget.extend({ +legacyRegistry.GridColumns = SnippetOptionWidget.extend({ /** * @override */ @@ -5771,7 +6225,7 @@ registry.GridColumns = SnippetOptionWidget.extend({ }, }); -registry.vAlignment = SnippetOptionWidget.extend({ +export class vAlignment extends SnippetOption { /** * @override */ @@ -5784,25 +6238,50 @@ registry.vAlignment = SnippetOptionWidget.extend({ return 'align-items-stretch'; } return value; - }, -}); + } +} + /** - * Allows snippets to be moved before the preceding element or after the following. + * Portal component that target the overlay */ -registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, { - displayOverlayOptions: true, +export class Overlay extends Component { + static template = "__portal__"; + /* + static props = { + targetEl: { + type: HTMLElement, + }, + slots: true, + }; + */ + setup() { + const node = this.__owl__; + onMounted(() => { + const portal = node.bdom; + portal.content.moveBeforeDOMNode(this.props.targetEl.firstChild, this.props.targetEl); + }); + onWillUnmount(() => { + const portal = node.bdom; + portal.remove(); + }); + } +} +registry.category("snippet_widgets").add("Overlay", Overlay); + + + +/** + * Allows snippets to be moved before the preceding element or after the following. + */ +export class SnippetMove extends ColumnLayoutMixin(SnippetOption) { + static displayOverlayOptions = true; + /** * @override */ - start: function () { - var $buttons = this.$el.find('we-button'); - var $overlayArea = this.$overlay.find('.o_overlay_move_options'); - // Putting the arrows side by side. - $overlayArea.prepend($buttons[1]); - $overlayArea.prepend($buttons[0]); - + willStart() { // Needed for compatibility (with already dropped snippets). // If the target is a column, check if all the columns are either mobile // ordered or not. If they are not consistent, then we remove the mobile @@ -5816,13 +6295,13 @@ registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, { } } - return this._super(...arguments); - }, + return super.willStart(...arguments); + } /** * @override */ onClone(options) { - this._super.apply(this, arguments); + super.onClone(arguments); const mobileOrder = this.$target[0].style.order; // If the order has been adapted on mobile, it must be different // for each clone. @@ -5839,20 +6318,20 @@ registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, { } }); } - }, + } /** * @override */ onMove() { - this._super.apply(this, arguments); + super.onMove(arguments); // Remove all the mobile order classes after a drag and drop. this._removeMobileOrders(this.$target[0].parentElement.children); - }, + } /** * @override */ onRemove() { - this._super.apply(this, arguments); + super.onRemove(arguments); const targetMobileOrder = this.$target[0].style.order; // If the order has been adapted on mobile, the gap created by the // removed snippet must be filled in. @@ -5860,7 +6339,7 @@ registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, { const targetOrder = parseInt(targetMobileOrder); this._fillRemovedItemGap(this.$target[0].parentElement, targetOrder); } - }, + } //-------------------------------------------------------------------------- // Options @@ -5871,7 +6350,7 @@ registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, { * * @see this.selectClass for parameters */ - moveSnippet: function (previewMode, widgetValue, params) { + moveSnippet(previewMode, widgetValue, params) { const isMobile = this._isMobile(); const isNavItem = this.$target[0].classList.contains('nav-item'); const $tabPane = isNavItem ? $(this.$target.find('.nav-link')[0].hash) : null; @@ -5934,14 +6413,20 @@ registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, { }); } } + // TODO: @owl-options were parameters even used in there ? + this.options.optionUpdate("StepsConnector", "move_snippet"); + /* this.trigger_up('option_update', { optionName: 'StepsConnector', name: 'move_snippet', }); + */ // Update the "Invisible Elements" panel as the order of invisible // snippets could have changed on the page. - this.trigger_up("update_invisible_dom"); - }, + // TODO: @owl-options ? + // this.options._updateInvisibleDom(); + // this.trigger_up("update_invisible_dom"); + } //-------------------------------------------------------------------------- // Private @@ -5993,8 +6478,8 @@ registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, { } return !!siblingEl; } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * Swaps the mobile orders. * @@ -6016,19 +6501,35 @@ registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, { comparedEl.style.order = targetMobileOrder; break; } - }, + } /** * @returns {Boolean} */ _isMobile() { return false; - }, + } +} + +registerOption("SnippetMove (Vertical)", { + Class: SnippetMove, + template: "web_editor.snippet_move_option_vertical", + selector: "section, .accordion > .card, .s_showcase .row:not(.s_col_no_resize) > div, .s_hr", + noScroll: ".accordion > .card", }); +registerOption("SnippetMove (Horizontal)", { + Class: SnippetMove, + template: "web_editor.snippet_move_option_horizontal", + selector: ".row:not(.s_col_no_resize) > div, .nav-item", + exclude: ".s_showcase .row > div", + name: "move_horizontally_opt", +}); + + /** * Allows for media to be replaced. */ -registry.ReplaceMedia = SnippetOptionWidget.extend({ +legacyRegistry.ReplaceMedia = SnippetOptionWidget.extend({ init: function () { this._super(...arguments); this._activateLinkTool = this._activateLinkTool.bind(this); @@ -6203,30 +6704,27 @@ registry.ReplaceMedia = SnippetOptionWidget.extend({ * Abstract option to be extended by the ImageTools and BackgroundOptimize * options that handles all the common parts. */ -const ImageHandlerOption = SnippetOptionWidget.extend({ +export class ImageHandlerOption extends SnippetOption { /** * @override */ async willStart() { - const _super = this._super.bind(this); await this._initializeImage(); - return _super(...arguments); - }, + return super.willStart(...arguments); + } /** * @override + * */ async start() { + // TODO: @owl-options This is currently not called await this._super(...arguments); - const weightEl = document.createElement('span'); - weightEl.classList.add('o_we_image_weight', 'o_we_tag', 'd-none'); - weightEl.title = _t("Size"); - this.$weight = $(weightEl); // Perform the loading of the image info synchronously in order to // avoid an intermediate rendering of the Blocks tab during the // loadImageInfo RPC that obtains the file size. // This does not update the target. await this._applyOptions(false); - }, + } //-------------------------------------------------------------------------- // Public @@ -6236,18 +6734,14 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ * @override */ async updateUI() { - await this._super(...arguments); + await super.updateUI(...arguments); if (this._filesize === undefined) { - this.$weight.addClass('d-none'); await this._applyOptions(false); } - if (this._filesize !== undefined) { - this.$weight.text(`${this._filesize.toFixed(1)} kb`); - this.$weight.removeClass('d-none'); - this._relocateWeightEl(); - } - }, + + this._relocateWeightEl(); + } //-------------------------------------------------------------------------- // Options @@ -6269,7 +6763,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ image.dataset.mimetype = values[1]; } return this._applyOptions(); - }, + } /** * @see this.selectClass for parameters */ @@ -6279,7 +6773,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ } this._getImg().dataset.quality = widgetValue; return this._applyOptions(); - }, + } /** * @see this.selectClass for parameters */ @@ -6291,7 +6785,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ delete dataset.glFilter; } return this._applyOptions(); - }, + } /** * @see this.selectClass for parameters */ @@ -6305,7 +6799,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ const newOptions = Object.assign(JSON.parse(filterOptions || "{}"), {[filterProperty]: widgetValue}); img.dataset.filterOptions = JSON.stringify(newOptions); return this._applyOptions(); - }, + } //-------------------------------------------------------------------------- // Private @@ -6317,13 +6811,12 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ _computeVisibility() { const src = this._getImg().getAttribute('src'); return src && src !== '/'; - }, + } /** * @override */ async _computeWidgetState(methodName, params) { const img = this._getImg(); - const _super = this._super.bind(this); // Make sure image is loaded because we need its naturalWidth await new Promise((resolve, reject) => { @@ -6351,32 +6844,26 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ return options[filterProperty] || defaultValue; } } - return _super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @abstract */ - _relocateWeightEl() {}, + _relocateWeightEl() {} /** * @override */ - async _renderCustomXML(uiFragment) { + async _getRenderContext() { + const context = await super._getRenderContext(); const img = this._getImg(); if (!this.originalSrc || !this._isImageSupportedForProcessing(img)) { - return; + return context; } - const $select = $(uiFragment).find('we-select[data-name=format_select_opt]'); - (await this._computeAvailableFormats()).forEach(([value, [label, targetFormat]]) => { - $select.append(`<we-button data-select-format="${Math.round(value)} ${targetFormat}" class="o_we_badge_at_end">${label} <span class="badge rounded-pill text-bg-dark">${targetFormat.split('/')[1]}</span></we-button>`); - }); + context.availableFormats = await this._computeAvailableFormats(); + context.noQuality = !['image/jpeg', 'image/webp'].includes(this._getImageMimetype(img)); - if (!['image/jpeg', 'image/webp'].includes(this._getImageMimetype(img))) { - const optQuality = uiFragment.querySelector('we-range[data-set-quality]'); - if (optQuality) { - optQuality.remove(); - } - } - }, + return context; + } /** * Returns a list of valid formats for a given image or an empty list if * there is no mimetypeBeforeConversion data attribute on the image. @@ -6411,7 +6898,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ return Object.entries(widths) .filter(([width]) => width <= maxWidth) .sort(([v1], [v2]) => v1 - v2); - }, + } /** * Applies all selected options on the original image. * @@ -6448,7 +6935,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ return loadedImg; } return img; - }, + } /** * Loads the image's attachment info. * @@ -6465,7 +6952,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ this.originalId = img.dataset.originalId; this.originalSrc = img.dataset.originalSrc; this.mimetypeBeforeConversion = img.dataset.mimetypeBeforeConversion; - }, + } /** * Sets the image's width to its suggested size. * @@ -6473,7 +6960,8 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ */ async _autoOptimizeImage() { await this._loadImageInfo(); - await this._rerenderXML(); + const newContext = await this._getRenderContext(); + Object.assign(this.renderContext, newContext); const img = this._getImg(); if (!['image/gif', 'image/svg+xml'].includes(img.dataset.mimetype)) { // Convert to recommended format and width. @@ -6485,7 +6973,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ } await this._applyOptions(); await this.updateUI(); - }, + } /** * Returns the image that is currently being modified. * @@ -6493,7 +6981,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ * @abstract * @returns {HTMLImageElement} the image to use for modifications */ - _getImg() {}, + _getImg() {} /** * Computes the image's maximum display width. * @@ -6501,7 +6989,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ * @abstract * @returns {Int} the maximum width at which the image can be displayed */ - _computeMaxDisplayWidth() {}, + _computeMaxDisplayWidth() {} /** * Use the processed image when it's needed in the DOM. * @@ -6509,7 +6997,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ * @abstract * @param {HTMLImageElement} img */ - _applyImage(img) {}, + _applyImage(img) {} /** * @private * @param {HTMLImageElement} img @@ -6517,13 +7005,13 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ */ _getImageMimetype(img) { return img.dataset.mimetype; - }, + } /** * @private */ async _initializeImage() { return this._loadImageInfo(); - }, + } /** * @private * @param {HTMLImageElement} img @@ -6532,7 +7020,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ */ _isImageSupportedForProcessing(img, strict = false) { return isImageSupportedForProcessing(this._getImageMimetype(img), strict); - }, + } /** * @override */ @@ -6545,7 +7033,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ return this._isImageSupportedForProcessing(img, true); } return isImageSupportedForStyle(this._getImg()); - }, + } /** * Indicates if an option should be applied only on supported mimetypes. * @@ -6558,8 +7046,8 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ || 'customFilter' in params.optionsPossibleValues || params.optionsPossibleValues.setQuality || widgetName === 'format_select_opt'; - }, -}); + } +} /** * @param {Element} containerEl @@ -6590,7 +7078,8 @@ const _addAnimatedShapeLabel = function addAnimatedShapeLabel(containerEl, label /** * Controls image width and quality. */ -registry.ImageTools = ImageHandlerOption.extend({ + +legacyRegistry.ImageTools = { MAX_SUGGESTED_WIDTH: 1920, /** @@ -7916,26 +8405,26 @@ registry.ImageTools = ImageHandlerOption.extend({ async _onImageCropped(ev) { await this._rerenderXML(); }, -}); +}; /** * Controls background image width and quality. */ -registry.BackgroundOptimize = ImageHandlerOption.extend({ +export class BackgroundOptimize extends ImageHandlerOption { /** * @override */ - start() { + async willStart() { this.$target.on('background_changed.BackgroundOptimize', this._onBackgroundChanged.bind(this)); - return this._super(...arguments); - }, + return super.willStart(...arguments); + } /** * @override */ - destroy() { + cleanForSave() { this.$target.off('.BackgroundOptimize'); - return this._super(...arguments); - }, + return super.cleanForSave(...arguments); + } //-------------------------------------------------------------------------- // Private @@ -7946,13 +8435,13 @@ registry.BackgroundOptimize = ImageHandlerOption.extend({ */ _getImg() { return this.img; - }, + } /** * @override */ _computeMaxDisplayWidth() { return 1920; - }, + } /** * Initializes this.img to an image with the background image url as src. * @@ -7977,18 +8466,18 @@ registry.BackgroundOptimize = ImageHandlerOption.extend({ // modified) this.img.src = src.startsWith("/") ? src : ""; } - return await this._super(...arguments); - }, + return await super._loadImageInfo(...arguments); + } /** * @override */ _relocateWeightEl() { - this.trigger_up('option_update', { - optionNames: ['BackgroundImage'], - name: 'add_size_indicator', - data: this.$weight, - }); - }, + this.callbacks.notifyOptions({ + optionNames: ['BackgroundImage'], + name: 'add_size_indicator', + data: this._filesize, + }); + } /** * @override */ @@ -8012,7 +8501,7 @@ registry.BackgroundOptimize = ImageHandlerOption.extend({ this.$target[0].dataset[key] = value; }); this.$target[0].dataset.bgSrc = img.getAttribute("src"); - }, + } //-------------------------------------------------------------------------- // Handlers @@ -8026,14 +8515,14 @@ registry.BackgroundOptimize = ImageHandlerOption.extend({ async _onBackgroundChanged(ev, previewMode) { ev.stopPropagation(); if (!previewMode) { - this.trigger_up('snippet_edition_request', {exec: async () => { + this.env.snippetEditionRequest(async () => { await this._autoOptimizeImage(); - }}); + }); } - }, -}); + } +} -registry.BackgroundToggler = SnippetOptionWidget.extend({ +export class BackgroundToggler extends SnippetOption { //-------------------------------------------------------------------------- // Options @@ -8049,13 +8538,13 @@ registry.BackgroundToggler = SnippetOptionWidget.extend({ this.$target.find('> .o_we_bg_filter').remove(); // TODO: use setWidgetValue instead of calling background directly when possible const [bgImageWidget] = this._requestUserValueWidgets('bg_image_opt'); - const bgImageOpt = bgImageWidget.getParent(); + const bgImageOpt = bgImageWidget.option; return bgImageOpt.background(false, '', bgImageWidget.getMethodsParams('background')); } else { // TODO: use trigger instead of el.click when possible - this._requestUserValueWidgets('bg_image_opt')[0].el.click(); + this._requestUserValueWidgets('bg_image_opt')[0].enable(); } - }, + } /** * Toggles background shape on or off. * @@ -8063,11 +8552,11 @@ registry.BackgroundToggler = SnippetOptionWidget.extend({ */ toggleBgShape(previewMode, widgetValue, params) { const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt'); - const shapeOption = shapeWidget.getParent(); + const shapeOption = shapeWidget.option; // TODO: open select after shape was selected? // TODO: use setWidgetValue instead of calling shapeOption method directly when possible return shapeOption._toggleShape(); - }, + } /** * Sets a color filter. * @@ -8102,7 +8591,7 @@ registry.BackgroundToggler = SnippetOptionWidget.extend({ const obj = createPropertyProxy(this, '$target', $(filterEl)); params.cssProperty = 'background-color'; return this.selectStyle.call(obj, previewMode, widgetValue, params); - }, + } //-------------------------------------------------------------------------- // Private @@ -8115,12 +8604,12 @@ registry.BackgroundToggler = SnippetOptionWidget.extend({ switch (methodName) { case 'toggleBgImage': { const [bgImageWidget] = this._requestUserValueWidgets('bg_image_opt'); - const bgImageOpt = bgImageWidget.getParent(); + const bgImageOpt = bgImageWidget.option; return !!bgImageOpt._computeWidgetState('background', bgImageWidget.getMethodsParams('background')); } case 'toggleBgShape': { const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt'); - const shapeOption = shapeWidget.getParent(); + const shapeOption = shapeWidget.option; return !!shapeOption._computeWidgetState('shape', shapeWidget.getMethodsParams('shape')); } case 'selectFilterColor': { @@ -8133,27 +8622,27 @@ registry.BackgroundToggler = SnippetOptionWidget.extend({ return this._computeWidgetState.call(obj, 'selectStyle', params); } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @private */ _getLastPreFilterLayerElement() { return null; - }, -}); + } +} /** * Handles the edition of snippet's background image. */ -registry.BackgroundImage = SnippetOptionWidget.extend({ +export class BackgroundImage extends SnippetOption { /** * @override */ - start: function () { + willStart() { this.__customImageSrc = getBgImageURL(this.$target[0]); - return this._super(...arguments); - }, + return super.willStart(...arguments); + } //-------------------------------------------------------------------------- // Options @@ -8164,7 +8653,7 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ * * @see this.selectClass for parameters */ - background: async function (previewMode, widgetValue, params) { + async background(previewMode, widgetValue, params) { if (previewMode === true) { this.__customImageSrc = getBgImageURL(this.$target[0]); } else if (previewMode === 'reset') { @@ -8179,7 +8668,7 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ removeOnImageChangeAttrs.forEach(attr => delete this.$target[0].dataset[attr]); this.$target.trigger('background_changed', [previewMode]); } - }, + } /** * Changes the main color of dynamic SVGs. * @@ -8203,7 +8692,7 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ if (!previewMode) { this.previousSrc = src; } - }, + } //-------------------------------------------------------------------------- // Public @@ -8214,15 +8703,15 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ */ notify(name, data) { if (name === 'add_size_indicator') { - this._requestUserValueWidgets('bg_image_opt')[0].$el.after(data); + this.renderContext.filesize = data && `${data.toFixed(1)} kb` || undefined; } else { - this._super(...arguments); + super.notify(...arguments); } - }, + } /** * @override */ - setTarget: function () { + setTarget() { // When we change the target of this option we need to transfer the // background-image and the dataset information relative to this image // from the old target to the new one. @@ -8240,7 +8729,7 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ // target as its image source will be deleted. this.$target[0].classList.remove("o_modified_image_to_save"); this._setBackground(''); - this._super(...arguments); + super.setTarget(...arguments); if (oldBgURL) { this._setBackground(oldBgURL); filteredOldDataset.forEach(([key, value]) => { @@ -8251,7 +8740,7 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ // TODO should be automatic for all options as equal to the start method this.__customImageSrc = getBgImageURL(this.$target[0]); - }, + } //-------------------------------------------------------------------------- // Private @@ -8260,15 +8749,15 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { switch (methodName) { case 'background': return getBgImageURL(this.$target[0]); case 'dynamicColor': return new URL(getBgImageURL(this.$target[0]), window.location.origin).searchParams.get(params.colorName); } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ @@ -8280,8 +8769,8 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ const src = new URL(getBgImageURL(this.$target[0]), window.location.origin); return src.origin === window.location.origin && src.pathname.startsWith('/web_editor/shape/'); } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * @private * @param {string} backgroundURL @@ -8306,23 +8795,28 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ this.selectStyle(false, combined, { cssProperty: 'background-image', }); - }, -}); + } +} /** * Handles background shapes. */ -registry.BackgroundShape = SnippetOptionWidget.extend({ +export class BackgroundShape extends SnippetOption { + constructor() { + super(...arguments); + this.debugRow = true; + } /** * @override */ - updateUI({assetsChanged} = {}) { + async updateUI({assetsChanged} = {}) { if (this.rerender || assetsChanged) { this.rerender = false; - return this._rerenderXML(); + const newContext = await this._getRenderContext(); + Object.assign(this.renderContext, newContext); } - return this._super.apply(this, arguments); - }, + return super.updateUI(this, arguments); + } /** * @override */ @@ -8334,7 +8828,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ return {flip: this._getShapeData().flip}; }); } - }, + } //-------------------------------------------------------------------------- // Options @@ -8355,7 +8849,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ shapeAnimationSpeed: this._getShapeData().shapeAnimationSpeed, }; }); - }, + } /** * Sets the current background shape's colors. * @@ -8369,7 +8863,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ const newColors = Object.assign(previousColors, {[colorName]: newColor}); return {colors: newColors}; }); - }, + } /** * Flips the shape on its x axis. * @@ -8377,7 +8871,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ */ flipX(previewMode, widgetValue, params) { this._flipShape(previewMode, 'x'); - }, + } /** * Flips the shape on its y axis. * @@ -8385,7 +8879,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ */ flipY(previewMode, widgetValue, params) { this._flipShape(previewMode, 'y'); - }, + } /** * Shows/Hides the shape on mobile. * @@ -8395,7 +8889,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ this._handlePreviewState(previewMode, () => { return {showOnMobile: !this._getShapeData().showOnMobile}; }); - }, + } /** * Sets the speed of the animation of a background shape. * @@ -8405,7 +8899,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ this._handlePreviewState(previewMode, () => { return { shapeAnimationSpeed: widgetValue }; }); - }, + } //-------------------------------------------------------------------------- // Private @@ -8442,8 +8936,8 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ return this._getShapeData().shapeAnimationSpeed; } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ @@ -8452,16 +8946,14 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ const bgShapeWidget = this._requestUserValueWidgets("bg_shape_opt")[0]; return bgShapeWidget.getMethodsParams().animated === "true"; } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * @override */ - _renderCustomXML(uiFragment) { - Object.keys(this._getDefaultColors()).map(colorName => { - uiFragment.querySelector('[data-name="colors"]') - .prepend($(`<we-colorpicker data-color="true" data-color-name="${colorName}"></we-colorpicker>`)[0]); - }); + async _getRenderContext() { + const context = await super._getRenderContext(); + const colorPickers = Object.keys(this._getDefaultColors()); // Inventory shape URLs per class. const style = window.getComputedStyle(this.$target[0]); @@ -8481,30 +8973,11 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ } } } + context._shapeBackgroundImagePerClass = this._shapeBackgroundImagePerClass; } - - uiFragment.querySelectorAll('we-select-pager we-button[data-shape]').forEach(btn => { - const btnContent = document.createElement('div'); - btnContent.classList.add('o_we_shape_btn_content', 'position-relative', 'border-dark'); - const btnContentInnerDiv = document.createElement('div'); - btnContentInnerDiv.classList.add('o_we_shape'); - btnContent.appendChild(btnContentInnerDiv); - - if (btn.dataset.animated) { - _addAnimatedShapeLabel(btnContent); - } - - const {shape} = btn.dataset; - const shapeEl = btnContent.querySelector('.o_we_shape'); - const shapeClassName = `o_${shape.replace(/\//g, '_')}`; - shapeEl.classList.add(shapeClassName); - // Match current palette. - const shapeBackgroundImage = this._shapeBackgroundImagePerClass[`.o_we_shape.${shapeClassName}`]; - shapeEl.style.setProperty("background-image", shapeBackgroundImage); - btn.append(btnContent); - }); - return uiFragment; - }, + context.colorPickers = colorPickers; + return context; + } /** * Flips the shape on its x/y axis. * @@ -8521,7 +8994,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ } return {flip: [...flip]}; }); - }, + } /** * Inserts or removes the given container at the right position in the * document. @@ -8544,7 +9017,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ } } return newContainer; - }, + } /** * Creates and inserts a container for the shape with the right classes. * @@ -8555,7 +9028,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ this.$target[0].style.position = 'relative'; shapeContainer.className = `o_we_shape o_${shape.replace(/\//g, '_')}`; return shapeContainer; - }, + } /** * Handles everything related to saving state before preview and restoring * it after a preview or locking in the changes when not in preview. @@ -8632,14 +9105,14 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ this.prevShapeContainer = shapeContainer.cloneNode(true); this.prevShape = target.dataset.oeShapeData; } - }, + } /** * @private * @param {HTMLElement} shapeEl */ _removeShapeEl(shapeEl) { shapeEl.remove(); - }, + } /** * Overwrites shape properties with the specified data. * @@ -8660,7 +9133,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ } else { this.$target[0].dataset.oeShapeData = JSON.stringify(shapeData); } - }, + } /** * @private */ @@ -8670,7 +9143,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ return $filterEl[0]; } return null; - }, + } /** * Returns the src of the shape corresponding to the current parameters. * @@ -8693,7 +9166,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ searchParams.push(`shapeAnimationSpeed=${encodeURIComponent(shapeAnimationSpeed)}`); } return `/web_editor/shape/${encodeURIComponent(shape)}.svg?${searchParams.join('&')}`; - }, + } /** * Retrieves current shape data from the target's dataset. * @@ -8711,7 +9184,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ }; const json = target.dataset.oeShapeData; return json ? Object.assign(defaultData, JSON.parse(json.replace(/'/g, '"'))) : defaultData; - }, + } /** * Returns the default colors for the currently selected shape. * @@ -8734,7 +9207,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ } const url = new URL(shapeSrc, window.location.origin); return Object.fromEntries(url.searchParams.entries()); - }, + } /** * Returns the default colors for the a shape in the selector. * @@ -8742,12 +9215,11 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ * @param {String} shapeId identifier of the shape */ _getShapeDefaultColors(shapeId) { - const $shapeContainer = this.$el.find(".o_we_bg_shape_menu we-button[data-shape='" + shapeId + "'] div.o_we_shape"); - const shapeContainer = $shapeContainer[0]; - const shapeSrc = shapeContainer && getBgImageURL(shapeContainer); + const shapeClassName = `o_we_shape.${shapeId.replace(new RegExp('/', 'g'), '_')}`; + const shapeSrc = getBgImageURL(this.renderContext._shapeBackgroundImagePerClass[shapeClassName]); const url = new URL(shapeSrc, window.location.origin); return Object.fromEntries(url.searchParams.entries()); - }, + } /** * Returns the implicit colors for the currently selected shape. * @@ -8772,7 +9244,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ const defaultKeys = Object.keys(defaultColors); colors = Object.assign(defaultColors, colors); return pick(colors, ...defaultKeys); - }, + } /** * Toggles whether there is a shape or not, to be called from bg toggler. * @@ -8785,7 +9257,8 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ const target = this.$target[0]; const previousSibling = target.previousElementSibling; const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt'); - const possibleShapes = shapeWidget.getMethodsParams('shape').possibleValues; + const params = shapeWidget.getMethodsParams("shape"); + const possibleShapes = params.possibleValues; let shapeToSelect; if (previousSibling) { const previousShape = this._getShapeData(previousSibling).shape; @@ -8802,9 +9275,9 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ } // Only show on mobile by default if toggled from mobile view const showOnMobile = weUtils.isMobileView(this.$target[0]); - this.trigger_up('snippet_edition_request', {exec: () => { + this.options.snippetEditionRequest({exec: () => { // options for shape will only be available after _toggleShape() returned - this._requestUserValueWidgets('bg_shape_opt')[0].enable(); + this._requestUserValueWidgets('bg_shape_opt')[0].open(); }}); this._createShapeContainer(shapeToSelect); return this._handlePreviewState(false, () => ( @@ -8815,55 +9288,68 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ } )); } - }, -}); + } +} + +export class WeShapeBtn extends Component { + static template = "web_editor.WeShapeBtn"; + static components = { WeButton }; + static props = { + shape: String, + selectLabel: String, + animated: { type: Boolean, optional: true }, + }; + setup() { + this.renderContext = useState(this.env.renderContext); + } +} + +registry.category("snippet_widgets").add("WeShapeBtn", WeShapeBtn); + +export class BackgroundShapePosition extends SnippetOptionComponent { + static template = "web_editor.BackgroundShape"; +} /** * Handles the edition of snippets' background image position. */ -registry.BackgroundPosition = SnippetOptionWidget.extend({ +class BackgroundPosition extends SnippetOption { /** * @override */ - start: function () { - this._super.apply(this, arguments); - + willStart() { this._initOverlay(); - - // Resize overlay content on window resize because background images - // change size, and on carousel slide because they sometimes take up - // more space and move elements around them. $(window).on('resize.bgposition', () => this._dimensionOverlay()); - }, + return super.willStart(); + } /** * @override */ - destroy: function () { + cleanUI() { this._toggleBgOverlay(false); $(window).off('.bgposition'); - this._super.apply(this, arguments); - }, + } //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- - /** + /* * Sets the background type (cover/repeat pattern). * * @see this.selectClass for params */ - backgroundType: function (previewMode, widgetValue, params) { + backgroundType(previewMode, widgetValue, params) { this.$target.toggleClass('o_bg_img_opt_repeat', widgetValue === 'repeat-pattern'); this.$target.css('background-position', ''); this.$target.css('background-size', widgetValue !== 'repeat-pattern' ? '' : '100px'); - }, + } /** * Saves current background position and enables overlay. * * @see this.selectClass for params */ - backgroundPositionOverlay: async function (previewMode, widgetValue, params) { + async backgroundPositionOverlay(previewMode, widgetValue, params) { // Updates the internal image await new Promise(resolve => { this.img = document.createElement('img'); @@ -8896,11 +9382,11 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ await scrollTo(this.$target[0], { behavior: "smooth", offset: 50 }); } this._toggleBgOverlay(true); - }, + } /** * @override */ - selectStyle: function (previewMode, widgetValue, params) { + selectStyle(previewMode, widgetValue, params) { if (params.cssProperty === 'background-size' && !this.$target.hasClass('o_bg_img_opt_repeat')) { // Disable the option when the image is in cover mode, otherwise @@ -8908,7 +9394,7 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ return; } this._super(...arguments); - }, + } //-------------------------------------------------------------------------- // Private @@ -8917,25 +9403,25 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ /** * @override */ - _computeVisibility: function () { - return this._super(...arguments) && !!getBgImageURL(this.$target[0]); - }, + _computeVisibility() { + return super._computeVisibility && !!getBgImageURL(this.$target[0]); + } /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { if (methodName === 'backgroundType') { return this.$target.css('background-repeat') === 'repeat' ? 'repeat-pattern' : 'cover'; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * Initializes the overlay, binds events to the buttons, inserts it in * the DOM. * * @private */ - _initOverlay: function () { + _initOverlay() { this.$backgroundOverlay = $(renderToElement('web_editor.background_position_overlay')); this.$overlayContent = this.$backgroundOverlay.find('.o_we_overlay_content'); this.$overlayBackground = this.$overlayContent.find('.o_overlay_background'); @@ -8949,14 +9435,14 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ }); this.$backgroundOverlay.insertAfter(this.$overlay); - }, + } /** * Sets the overlay in the right place so that the draggable background * renders over the target, and size the background item like the target. * * @private */ - _dimensionOverlay: function () { + _dimensionOverlay() { if (!this.$backgroundOverlay.is('.oe_active')) { return; } @@ -8978,22 +9464,22 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ const topPos = Math.max(0, $(window).scrollTop() - this.$target.offset().top); this.$overlayContent.find('.o_we_overlay_buttons').css('top', `${topPos}px`); - }, + } /** * Toggles the overlay's display and renders a background clone inside of it. * * @private * @param {boolean} activate toggle the overlay on (true) or off (false) */ - _toggleBgOverlay: function (activate) { + _toggleBgOverlay(activate) { if (!this.$backgroundOverlay || this.$backgroundOverlay.is('.oe_active') === activate) { return; } if (!activate) { this.$backgroundOverlay.removeClass('oe_active'); - this.trigger_up('unblock_preview_overlays'); - this.trigger_up('activate_snippet', {$snippet: this.$target}); + this.env.unblockPreviewOverlays(); + this.env.activateSnippet(this.$target); $(document).off('click.bgposition'); if (this.$bgDragger) { @@ -9002,12 +9488,9 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ return; } - this.trigger_up('hide_overlay'); - this.trigger_up('activate_snippet', { - $snippet: this.$target, - previewMode: true, - }); - this.trigger_up('block_preview_overlays'); + this.env.hideOverlay(); + this.env.activateSnippet(this.$target, true); + this.env.blockPreviewOverlays(); // Create empty clone of $target with same display size, make it draggable and give it a tooltip. this.$bgDragger = this.$target.clone().empty(); @@ -9035,14 +9518,14 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ // Needs to be deferred or the click event that activated the overlay deactivates it as well. // This is caused by the click event which we are currently handling bubbling up to the document. window.setTimeout(() => $(document).on('click.bgposition', this._onDocumentClicked.bind(this)), 0); - }, + } /** * Returns the difference between the target's size and the background's * rendered size. Background position values in % are a percentage of this. * * @private */ - _getBackgroundDelta: function () { + _getBackgroundDelta() { const bgSize = this.$target.css('background-size'); if (bgSize !== 'cover') { let [width, height] = bgSize.split(' '); @@ -9069,7 +9552,7 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ x: this.$target.outerWidth() - Math.round(renderRatio * this.img.naturalWidth), y: this.$target.outerHeight() - Math.round(renderRatio * this.img.naturalHeight), }; - }, + } //-------------------------------------------------------------------------- // Handlers @@ -9080,7 +9563,7 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ * * @private */ - _onDragBackgroundStart: function (ev) { + _onDragBackgroundStart(ev) { ev.preventDefault(); this.$bgDragger.addClass('o_we_grabbing'); const $document = $(this.$target[0].ownerDocument); @@ -9089,13 +9572,13 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ this.$bgDragger.removeClass('o_we_grabbing'); $document.off('mousemove.bgposition'); }); - }, + } /** * Drags the overlay's background image, copied to target on "Apply". * * @private */ - _onDragBackgroundMove: function (ev) { + _onDragBackgroundMove(ev) { ev.preventDefault(); const delta = this._getBackgroundDelta(); @@ -9119,18 +9602,18 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ bounds = bounds.sort(); return Math.max(bounds[0], Math.min(val, bounds[1])); } - }, + } /** * Deactivates the overlay if the user clicks outside of it. * * @private */ - _onDocumentClicked: function (ev) { + _onDocumentClicked(ev) { if (!$(ev.target).closest('.o_we_background_position_overlay').length) { this._toggleBgOverlay(false); } - }, -}); + } +} /** * Marks color levels of any element that may get or has a color classes. This @@ -9138,20 +9621,20 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ * snippet drop (so that base snippet definition do not need to care about that) * and on first focus (for compatibility). */ -registry.ColoredLevelBackground = registry.BackgroundToggler.extend({ +export class ColoredLevelBackground extends BackgroundToggler { /** * @override */ - start: function () { + start() { this._markColorLevel(); return this._super(...arguments); - }, + } /** * @override */ - onBuilt: function () { + onBuilt() { this._markColorLevel(); - }, + } //-------------------------------------------------------------------------- // Private @@ -9165,20 +9648,20 @@ registry.ColoredLevelBackground = registry.BackgroundToggler.extend({ * * @private */ - _markColorLevel: function () { + _markColorLevel() { this.options.wysiwyg.odooEditor.observerUnactive('_markColorLevel'); this.$target.addClass('o_colored_level'); this.options.wysiwyg.odooEditor.observerActive('_markColorLevel'); - }, -}); + } +} -registry.ContainerWidth = SnippetOptionWidget.extend({ +class ContainerWidth extends SnippetOption { /** * @override */ - cleanForSave: function () { + cleanForSave() { this.$target.removeClass('o_container_preview'); - }, + } //-------------------------------------------------------------------------- // Options @@ -9187,25 +9670,33 @@ registry.ContainerWidth = SnippetOptionWidget.extend({ /** * @override */ - selectClass: async function (previewMode, widgetValue, params) { - await this._super(...arguments); + async selectClass(previewMode, widgetValue, params) { + await super.selectClass(...arguments); if (previewMode === 'reset') { this.$target.removeClass('o_container_preview'); } else if (previewMode) { this.$target.addClass('o_container_preview'); } - this.trigger_up('option_update', { + this.callbacks.notifyOptions({ optionName: 'StepsConnector', name: 'change_container_width', }); - }, + } +} + +registerOption("container_width", { + Class: ContainerWidth, + template: "web_editor.container_width", + selector: "section, .s_carousel .carousel-item, s_quotes_carousel .carousel-item", + exclude: "[data-snippet] :not(.oe_structure) > [data-snippet]", + target: "> .container, > .container-fluid, > .o_container_small", }); /** * Allows to replace a text value with the name of a database record. * @todo replace this mechanism with real backend m2o field ? */ -registry.many2one = SnippetOptionWidget.extend({ +legacyRegistry.many2one = SnippetOptionWidget.extend({ init() { this._super(...arguments); this.orm = this.bindService("orm"); @@ -9331,7 +9822,7 @@ registry.many2one = SnippetOptionWidget.extend({ /** * Allows to display a warning message on outdated snippets. */ -registry.VersionControl = SnippetOptionWidget.extend({ +legacyRegistry.VersionControl = SnippetOptionWidget.extend({ //-------------------------------------------------------------------------- // Options @@ -9416,7 +9907,7 @@ registry.VersionControl = SnippetOptionWidget.extend({ /** * Handle the save of a snippet as a template that can be reused later */ -registry.SnippetSave = SnippetOptionWidget.extend({ +legacyRegistry.SnippetSave = SnippetOptionWidget.extend({ isTopOption: true, //-------------------------------------------------------------------------- @@ -9506,7 +9997,7 @@ registry.SnippetSave = SnippetOptionWidget.extend({ /** * Handles the dynamic colors for dynamic SVGs. */ -registry.DynamicSvg = SnippetOptionWidget.extend({ +legacyRegistry.DynamicSvg = SnippetOptionWidget.extend({ /** * @override */ @@ -9596,7 +10087,7 @@ registry.DynamicSvg = SnippetOptionWidget.extend({ /** * Allows to handle snippets with a list of items. */ -registry.MultipleItems = SnippetOptionWidget.extend({ +export class MultipleItems extends SnippetOption { //-------------------------------------------------------------------------- // Options @@ -9610,22 +10101,17 @@ registry.MultipleItems = SnippetOptionWidget.extend({ const addBeforeItem = params.addBefore === 'true'; if ($target.length) { await new Promise(resolve => { - this.trigger_up('clone_snippet', { - $snippet: $target, - onSuccess: resolve, - }); + this.env.cloneSnippet($target).then(resolve); }); if (addBeforeItem) { $target.before($target.next()); } if (params.selectItem !== 'false') { - this.trigger_up('activate_snippet', { - $snippet: addBeforeItem ? $target.prev() : $target.next(), - }); + this.env.activateSnippet(addBeforeItem ? $target.prev() : $target.next()); } this._addItemCallback($target); } - }, + } /** * @see this.selectClass for parameters */ @@ -9633,14 +10119,17 @@ registry.MultipleItems = SnippetOptionWidget.extend({ const $target = this.$(params.item); if ($target.length) { await new Promise(resolve => { - this.trigger_up('remove_snippet', { - $snippet: $target, - onSuccess: resolve, + this.env.removeSnippet({ + data: { + $snippet: $target, + onSuccess: resolve, + }, + stopPropagation: (ev) => {}, }); }); this._removeItemCallback($target); } - }, + } //-------------------------------------------------------------------------- // Private @@ -9653,37 +10142,45 @@ registry.MultipleItems = SnippetOptionWidget.extend({ * @abstract * @param {jQueryElement} $target */ - _addItemCallback($target) {}, + _addItemCallback($target) {} /** * @private * @abstract * @param {jQueryElement} $target */ - _removeItemCallback($target) {}, -}); + _removeItemCallback($target) {} +} -registry.SelectTemplate = SnippetOptionWidget.extend({ - custom_events: Object.assign({}, SnippetOptionWidget.prototype.custom_events, { - 'user_value_widget_opening': '_onWidgetOpening', - }), +export class SelectTemplateComponent extends SnippetOptionComponent { + setup() { + super.setup(); + useChildSubEnv({ + userValueWidgetOpening: (userValue) => { + this.env.userValueWidgetOpening(userValue); + this.props.snippetOption.instance.onUserValueWidgetOpening(userValue); + }, + }); + } +} +export class SelectTemplate extends SnippetOption { /** - * @constructor + * @override */ - init() { - this._super(...arguments); + static defaultRenderingComponent = SelectTemplateComponent; + + constructor() { + super(...arguments); this.containerSelector = ''; this.selectTemplateWidgetName = ''; - this.orm = this.bindService("orm"); - }, - /** - * @constructor - */ - async start() { + this.orm = this.env.services.orm; + } + + async willStart() { + await super.willStart(...arguments); this.containerEl = this.containerSelector ? this.$target.find(this.containerSelector)[0] : this.$target[0]; this._templates = {}; - return this._super(...arguments); - }, + } //-------------------------------------------------------------------------- // Options @@ -9732,7 +10229,7 @@ registry.SelectTemplate = SnippetOptionWidget.extend({ // added by other options or custo). this.beforePreviewNodes = null; } - }, + } //-------------------------------------------------------------------------- // Private @@ -9743,7 +10240,7 @@ registry.SelectTemplate = SnippetOptionWidget.extend({ * * @private * @param {string} xmlid - * @returns {string} + * @returns {Promise<string>} */ async _getTemplate(xmlid) { if (!this._templates[xmlid]) { @@ -9755,21 +10252,20 @@ registry.SelectTemplate = SnippetOptionWidget.extend({ ); } return this._templates[xmlid]; - }, + } //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** - * @private - * @param {OdooEvent} ev + * @override */ - _onWidgetOpening(ev) { - if (this._templatesLoading || ev.target.getName() !== this.selectTemplateWidgetName) { + onUserValueWidgetOpening(userValue) { + if (this._templatesLoading || userValue.getName() !== this.selectTemplateWidgetName) { return; } - const templateParams = ev.target.getMethodsParams('selectTemplate'); + const templateParams = userValue.getMethodsParams('selectTemplate'); const proms = templateParams.possibleValues.map(async xmlid => { if (!xmlid) { return; @@ -9780,16 +10276,15 @@ registry.SelectTemplate = SnippetOptionWidget.extend({ await this._getTemplate(xmlid); }); this._templatesLoading = Promise.all(proms); - }, -}); + } +} /* * Abstract option to be extended by the Carousel and gallery options (through * the "CarouselHandler" option) that handles all the common parts (reordering * of elements). */ -registry.GalleryHandler = SnippetOptionWidget.extend({ - +export class GalleryHandler extends SnippetOption { //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- @@ -9800,7 +10295,7 @@ registry.GalleryHandler = SnippetOptionWidget.extend({ * @override */ notify(name, data) { - this._super(...arguments); + super.notify(...arguments); if (name === "reorder_items") { const itemsEls = this._getItemsGallery(); const oldPosition = itemsEls.indexOf(data.itemEl); @@ -9826,7 +10321,7 @@ registry.GalleryHandler = SnippetOptionWidget.extend({ } this._reorderItems(itemsEls, itemsEls.indexOf(data.itemEl)); } - }, + } //-------------------------------------------------------------------------- // Private @@ -9839,7 +10334,7 @@ registry.GalleryHandler = SnippetOptionWidget.extend({ * @abstract * @returns {HTMLElement[]} */ - _getItemsGallery() {}, + _getItemsGallery() {} /** * Called to reorder the items of the gallery. * @@ -9847,15 +10342,14 @@ registry.GalleryHandler = SnippetOptionWidget.extend({ * @param {HTMLElement[]} itemsEls - the items to reorder. * @param {integer} newItemPosition - the new position of the moved items. */ - _reorderItems(itemsEls, newItemPosition) {}, -}); + _reorderItems(itemsEls, newItemPosition) {} +} /* * Abstract option to be extended by the Carousel and gallery options that * handles the update of the carousel indicator. */ -registry.CarouselHandler = registry.GalleryHandler.extend({ - +export class CarouselHandler extends GalleryHandler { //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -9877,36 +10371,48 @@ registry.CarouselHandler = registry.GalleryHandler.extend({ } this.$target[0].querySelector(`.carousel-indicators li[data-bs-slide-to="${position}"]`) .classList.add("active"); - this.trigger_up("activate_snippet", { - $snippet: $(this.$target[0].querySelector(".carousel-item.active img")), - ifInactiveOptions: true, - }); + this.env.activateSnippet($(this.$target[0].querySelector(".carousel-item.active img")), false, true); carouselEl.classList.add("slide"); - }, -}); - - -export default { - SnippetOptionWidget: SnippetOptionWidget, - snippetOptionRegistry: registry, - - NULL_ID: NULL_ID, - UserValueWidget: UserValueWidget, - userValueWidgetsRegistry: userValueWidgetsRegistry, - UnitUserValueWidget: UnitUserValueWidget, - - addTitleAndAllowedAttributes: _addTitleAndAllowedAttributes, - buildElement: _buildElement, - buildTitleElement: _buildTitleElement, - buildRowElement: _buildRowElement, - buildCollapseElement: _buildCollapseElement, + } +} - addAnimatedShapeLabel: _addAnimatedShapeLabel, +export function registerBackgroundOptions(name, options, getTemplateName = () => null) { + registerOption(`${name}-bgToggler`, { + Class: ( + options.withColors + && options.withColorCombinations + && ColoredLevelBackground + ) || BackgroundToggler, + template: getTemplateName("toggler") || "web_editor.snippet_options_background_options", + ...options + }, { sequence: 25 }); + if (options.withImages) { + registerOption(`${name}-bgImg`, { + Class: BackgroundImage, + template: getTemplateName("img") || "web_editor.BackgroundImage", + ...options + }, { sequence: 27 }); + registerOption(`${name}-bgPosition`, { + Class: BackgroundPosition, + template: getTemplateName("position") || "web_editor.BackgroundPosition", + ...options, + }, { sequence: 29 }); + registerOption(`${name}-bgOptimize`, { + Class: BackgroundOptimize, + template: getTemplateName("optimize") || "web_editor.snippet_options_image_optimization_widgets", + indent: true, + ...options, + }, { sequence: 31 }); + registerOption(`${name}-filter`, { + Class: options.withColors && options.withColorCombinations && ColoredLevelBackground || BackgroundToggler, + template: getTemplateName("filter") || "web_editor.BackgroundFilter", + ...options, + }, { sequence: 33 }); + registerOption(`${name}-shape`, { + Class: BackgroundShape, + template: getTemplateName("shape") || "web_editor.BackgroundShapes", + ...options, + }, { sequence: 35 }); + } +} - // Other names for convenience - Class: SnippetOptionWidget, - registry: registry, - serviceCached, - clearServiceCache, - clearControlledSnippets, -}; diff --git a/addons/web_editor/static/src/js/editor/snippets.options.legacy.js b/addons/web_editor/static/src/js/editor/snippets.options.legacy.js new file mode 100644 index 0000000000000..1feb705c10f4f --- /dev/null +++ b/addons/web_editor/static/src/js/editor/snippets.options.legacy.js @@ -0,0 +1,9789 @@ +/** @odoo-module **/ + +import { attachComponent } from "@web/legacy/utils"; +import { MediaDialog } from "@web_editor/components/media_dialog/media_dialog"; +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import dom from "@web/legacy/js/core/dom"; +import { throttleForAnimation, debounce } from "@web/core/utils/timing"; +import { clamp } from "@web/core/utils/numbers"; +import Widget from "@web/legacy/js/core/widget"; +import { ColorPalette } from "@web_editor/js/wysiwyg/widgets/color_palette"; +import weUtils from "@web_editor/js/common/utils"; +import * as gridUtils from "@web_editor/js/common/grid_layout_utils"; +import {ColumnLayoutUtils} from "@web_editor/js/common/column_layout_mixin"; +const { + normalizeColor, + getBgImageURL, + backgroundImageCssToParts, + backgroundImagePartsToCss, + DEFAULT_PALETTE, + isBackgroundImageAttribute, +} = weUtils; +import { ImageCrop } from '@web_editor/js/wysiwyg/widgets/image_crop'; +import { + loadImage, + loadImageInfo, + applyModifications, + removeOnImageChangeAttrs, + isImageSupportedForProcessing, + isImageSupportedForStyle, + createDataURL, + isGif, + getDataURLBinarySize, +} from "@web_editor/js/editor/image_processing"; +import * as OdooEditorLib from "@web_editor/js/editor/odoo-editor/src/OdooEditor"; +import { pick } from "@web/core/utils/objects"; +import { _t } from "@web/core/l10n/translation"; +import { + isCSSColor, + convertCSSColorToRgba, + normalizeCSSColor, + } from '@web/core/utils/colors'; +import { renderToElement } from "@web/core/utils/render"; +import { rpc } from "@web/core/network/rpc"; + +const preserveCursor = OdooEditorLib.preserveCursor; +const { DateTime } = luxon; +const resetOuids = OdooEditorLib.resetOuids; +let _serviceCache = { + orm: {}, + rpc: {}, +}; +const clearServiceCache = () => { + _serviceCache = { + orm: {}, + rpc: {}, + }; +}; + +// Regex definitions to apply speed modification in SVG files +// Note : These regex patterns are duplicated on the server side for +// background images that are part of a CSS rule "background-image: ...". The +// client-side regex patterns are used for images that are part of an +// "src" attribute with a base64 encoded svg in the <img> tag. Perhaps we should +// consider finding a solution to define them only once? The issue is that the +// regex patterns in Python are slightly different from those in JavaScript. +// See : controllers/main.py +const CSS_ANIMATION_RULE_REGEX = + /(?<declaration>animation(?:-duration)?: .*?)(?<value>(?:\d+(?:\.\d+)?)|(?:\.\d+))(?<unit>ms|s)(?<separator>\s|;|"|$)/gm; +const SVG_DUR_TIMECOUNT_VAL_REGEX = + /(?<attribute_name>\sdur="\s*)(?<value>(?:\d+(?:\.\d+)?)|(?:\.\d+))(?<unit>h|min|ms|s)?\s*"/gm; +const CSS_ANIMATION_RATIO_REGEX = /(--animation_ratio: (?<ratio>\d*(\.\d+)?));/m; +/** + * Caches rpc/orm service + * @param {Function} service + * @param {...any} args + * @returns + */ +function serviceCached(service) { + const cache = _serviceCache; + return Object.assign(Object.create(service), { + call() { + // FIXME + const serviceName = Object.prototype.hasOwnProperty.call(service, "call") + ? "orm" + : "rpc"; + const cacheId = JSON.stringify(arguments); + if (!cache[serviceName][cacheId]) { + cache[serviceName][cacheId] = + serviceName == "rpc" ? service(...arguments) : service.call(...arguments); + } + return cache[serviceName][cacheId]; + }, + }); +} +// Outdated snippets whose alert has been discarded. +const controlledSnippets = new Set(); +const clearControlledSnippets = () => controlledSnippets.clear(); +/** + * @param {HTMLElement} el + * @param {string} [title] + * @param {Object} [options] + * @param {string[]} [options.classes] + * @param {string} [options.tooltip] + * @param {string} [options.placeholder] + * @param {Object} [options.dataAttributes] + * @returns {HTMLElement} - the original 'el' argument + */ +function _addTitleAndAllowedAttributes(el, title, options) { + let tooltipEl = el; + if (title) { + const titleEl = _buildTitleElement(title); + tooltipEl = titleEl; + el.appendChild(titleEl); + if (options && options.dataAttributes && options.dataAttributes.fontFamily) { + titleEl.style.fontFamily = options.dataAttributes.fontFamily; + } + } + + if (options && options.classes) { + el.classList.add(...options.classes); + } + if (options && options.tooltip) { + tooltipEl.title = options.tooltip; + } + if (options && options.placeholder) { + el.setAttribute('placeholder', options.placeholder); + } + if (options && options.dataAttributes) { + for (const key in options.dataAttributes) { + el.dataset[key] = options.dataAttributes[key]; + } + } + + return el; +} +/** + * @param {string} tagName + * @param {string} title - @see _addTitleAndAllowedAttributes + * @param {Object} options - @see _addTitleAndAllowedAttributes + * @returns {HTMLElement} + */ +function _buildElement(tagName, title, options) { + const el = document.createElement(tagName); + return _addTitleAndAllowedAttributes(el, title, options); +} +/** + * @param {string} title + * @returns {HTMLElement} + */ +function _buildTitleElement(title) { + const titleEl = document.createElement('we-title'); + titleEl.textContent = title; + return titleEl; +} +/** + * @param {string} src + * @returns {HTMLElement} + */ +const _buildImgElementCache = {}; +async function _buildImgElement(src) { + if (!(src in _buildImgElementCache)) { + _buildImgElementCache[src] = (async () => { + let text; + if (src.split('.').pop() === 'svg') { + try { + const response = await window.fetch(src); + text = await response.text(); + } catch { + // In some tours, the tour finishes before the fetch is done + // and when a tour is finished, the python side will ask the + // browser to stop loading resources. This causes the fetch + // to fail and throw an error which crashes the test even + // though it completed successfully. + // So return an empty SVG to ensure everything completes + // correctly. + text = "<svg></svg>"; + } + const parser = new window.DOMParser(); + const xmlDoc = parser.parseFromString(text, 'text/xml'); + return xmlDoc.getElementsByTagName('svg')[0]; + } else { + const imgEl = document.createElement('img'); + imgEl.src = src; + return imgEl; + } + })(); + } + const node = await _buildImgElementCache[src]; + return node.cloneNode(true); +} +/** + * Build the correct DOM for a we-row element. + * + * @param {string} [title] - @see _buildElement + * @param {Object} [options] - @see _buildElement + * @param {HTMLElement[]} [options.childNodes] + * @returns {HTMLElement} + */ +function _buildRowElement(title, options) { + const groupEl = _buildElement('we-row', title, options); + + const rowEl = document.createElement('div'); + groupEl.appendChild(rowEl); + + if (options && options.childNodes) { + options.childNodes.forEach(node => rowEl.appendChild(node)); + } + + return groupEl; +} +/** + * Build the correct DOM for a we-collapse element. + * + * @param {string} [title] - @see _buildElement + * @param {Object} [options] - @see _buildElement + * @param {HTMLElement[]} [options.childNodes] + * @returns {HTMLElement} + */ +function _buildCollapseElement(title, options) { + const groupEl = _buildElement('we-collapse', title, options); + const titleEl = groupEl.querySelector('we-title'); + + const children = options && options.childNodes || []; + if (titleEl) { + titleEl.remove(); + titleEl.classList.add('o_we_collapse_toggler'); + children.unshift(titleEl); + } + let i = 0; + for (i = 0; i < children.length; i++) { + groupEl.appendChild(children[i]); + if (children[i].nodeType === Node.ELEMENT_NODE) { + break; + } + } + + const togglerEl = document.createElement('we-toggler'); + togglerEl.classList.add('o_we_collapse_toggler'); + groupEl.appendChild(togglerEl); + + const containerEl = document.createElement('div'); + children.slice(i + 1).forEach(node => containerEl.appendChild(node)); + groupEl.appendChild(containerEl); + + return groupEl; +} +/** + * Creates a proxy for an object where one property is replaced by a different + * value. This value is captured in the closure and can be read and written to. + * + * @param {Object} obj - the object for which to create a proxy + * @param {string} propertyName - the name/key of the property to replace + * @param {*} value - the initial value to give to the property's copy + * @returns {Proxy} a proxy of the object with the property replaced + */ +function createPropertyProxy(obj, propertyName, value) { + return new Proxy(obj, { + get: function (obj, prop) { + if (prop === propertyName) { + return value; + } + return obj[prop]; + }, + set: function (obj, prop, val) { + if (prop === propertyName) { + return (value = val); + } + return Reflect.set(...arguments); + }, + }); +} +/** + * Creates and registers a UserValueWidget by tag-name + * + * @param {string} widgetName + * @param {SnippetOptionWidget|UserValueWidget|null} parent + * @param {string} title + * @param {Object} options + * @returns {UserValueWidget} + */ +function registerUserValueWidget(widgetName, parent, title, options, $target) { + const widget = new userValueWidgetsRegistry[widgetName](parent, title, options, $target); + parent.registerSubWidget(widget); + return widget; +} + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +const NULL_ID = '__NULL__'; + +/** + * Base class for components to be used in snippet options widgets to retrieve + * user values. + */ +const UserValueWidget = Widget.extend({ + className: 'o_we_user_value_widget', + custom_events: { + 'user_value_update': '_onUserValueNotification', + }, + + /** + * @constructor + */ + init: function (parent, title, options, $target) { + this._super(...arguments); + this.title = title; + this.options = options; + this._userValueWidgets = []; + this._value = ''; + this.$target = $target; + }, + /** + * @override + */ + async willStart() { + await this._super(...arguments); + if (this.options.dataAttributes.img) { + this.illustrationEl = await _buildImgElement(this.options.dataAttributes.img); + } else if (this.options.dataAttributes.icon) { + this.illustrationEl = document.createElement('i'); + this.illustrationEl.classList.add('fa', this.options.dataAttributes.icon); + } + if (this.options.dataAttributes.reload) { + this.options.dataAttributes.noPreview = "true"; + } + }, + /** + * @override + */ + _makeDescriptive: function () { + const $el = this._super(...arguments); + const el = $el[0]; + _addTitleAndAllowedAttributes(el, this.title, this.options); + this.containerEl = document.createElement('div'); + + if (this.illustrationEl) { + this.containerEl.appendChild(this.illustrationEl); + } + + el.appendChild(this.containerEl); + return $el; + }, + /** + * @override + */ + async start() { + await this._super(...arguments); + + if (this.el.classList.contains('o_we_img_animate')) { + const buildImgExtensionSwitcher = (from, to) => { + const regex = new RegExp(`${from}$`, 'i'); + return ev => { + const img = ev.currentTarget.getElementsByTagName("img")[0]; + img.src = img.src.replace(regex, to); + }; + }; + this.$el.on('mouseenter.img_animate', buildImgExtensionSwitcher('png', 'gif')); + this.$el.on('mouseleave.img_animate', buildImgExtensionSwitcher('gif', 'png')); + } + }, + /** + * @override + */ + destroy() { + // Check if $el exists in case the widget is destroyed before it has + // been fully initialized. + // TODO there is probably better to do. This case was found only in + // tours, where the editor is left before the widget icon is loaded. + if (this.$el) { + this.$el.off('.img_animate'); + } + this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Closes the widget (only meaningful for widgets that can be closed). + */ + close: function () { + if (!this.el) { + // In case the method is called while the widget is not fully + // initialized yet. No need to prevent that case: asking a non + // initialized widget to close itself should just not be a problem + // and just be ignored. + return; + } + if (!this.el.classList.contains('o_we_widget_opened')) { + // Small optimization: it would normally not matter asking to + // remove a class of an element if it does not already have it but + // in this case we do more: we trigger_up an event and ask to close + // all sub widgets. When we ask the editor to close all widgets... + // it makes sense not letting every sub button of every select + // trigger_up an event. This allows to avoid tens of thousands of + // instructions being done at each click in the editor. + return; + } + this.trigger_up('user_value_widget_closing'); + this.el.classList.remove('o_we_widget_opened'); + this._userValueWidgets.forEach(widget => widget.close()); + }, + /** + * Simulates the correct event on the element to make it active. + */ + enable() { + this.$el.click(); + }, + /** + * @param {string} name + * @returns {UserValueWidget|null} + */ + findWidget: function (name) { + for (const widget of this._userValueWidgets) { + if (widget.getName() === name) { + return widget; + } + const depWidget = widget.findWidget(name); + if (depWidget) { + return depWidget; + } + } + return null; + }, + /** + * Focus the main focusable element of the widget. + */ + focus() { + const el = this._getFocusableElement(); + if (el) { + el.focus(); + } + }, + /** + * Returns the value that the widget would hold if it was active, by default + * the internal value it holds. + * + * @param {string} [methodName] + * @returns {string} + */ + getActiveValue: function (methodName) { + return this._value; + }, + /** + * Returns the default value the widget holds when inactive, by default the + * first "possible value". + * + * @param {string} [methodName] + * @returns {string} + */ + getDefaultValue: function (methodName) { + const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; + return possibleValues && possibleValues[0] || ''; + }, + /** + * @returns {string[]} + */ + getDependencies: function () { + return this._dependencies; + }, + /** + * Returns the names of the option methods associated to the widget. Those + * are loaded with @see loadMethodsData. + * + * @returns {string[]} + */ + getMethodsNames: function () { + return this._methodsNames; + }, + /** + * Returns the option parameters associated to the widget (for a given + * method name or not). Most are loaded with @see loadMethodsData. + * + * @param {string} [methodName] + * @returns {Object} + */ + getMethodsParams: function (methodName) { + const params = Object.assign({}, this._methodsParams); + if (methodName) { + params.possibleValues = params.optionsPossibleValues[methodName] || []; + params.activeValue = this.getActiveValue(methodName); + params.defaultValue = this.getDefaultValue(methodName); + } + return params; + }, + /** + * @returns {string} empty string if no name is used by the widget + */ + getName: function () { + return this._methodsParams.name || ''; + }, + /** + * Returns the user value that the widget currently holds. The value is a + * string, this is the value that will be received in the option methods + * of SnippetOptionWidget instances. + * + * @param {string} [methodName] + * @returns {string} + */ + getValue: function (methodName) { + const isActive = this.isActive(); + if (!methodName || !this._methodsNames.includes(methodName)) { + return isActive ? 'true' : ''; + } + if (isActive) { + return this.getActiveValue(methodName); + } + return this.getDefaultValue(methodName); + }, + /** + * Returns whether or not the widget is active (holds a value). + * + * @returns {boolean} + */ + isActive: function () { + return this._value && this._value !== NULL_ID; + }, + /** + * Indicates if the widget can contain sub user value widgets or not. + * + * @returns {boolean} + */ + isContainer: function () { + return false; + }, + /** + * Indicates if the widget is being previewed or not: the user is + * manipulating it. Base case: if an internal <input/> element is focused. + * + * @returns {boolean} + */ + isPreviewed: function () { + const focusEl = document.activeElement; + if (focusEl && focusEl.tagName === 'INPUT' + && (this.el === focusEl || this.el.contains(focusEl))) { + return true; + } + return this.el.classList.contains('o_we_preview'); + }, + /** + * Loads option method names and option method parameters. + * + * @param {string[]} validMethodNames + * @param {Object} extraParams + */ + loadMethodsData: function (validMethodNames, extraParams) { + this._methodsNames = []; + this._methodsParams = Object.assign({}, extraParams); + this._methodsParams.optionsPossibleValues = {}; + this._dependencies = []; + this._triggerWidgetsNames = []; + this._triggerWidgetsValues = []; + + for (const key in this.el.dataset) { + const dataValue = this.el.dataset[key].trim(); + + if (key === 'dependencies') { + this._dependencies.push(...dataValue.split(/\s*,\s*/g)); + } else if (key === 'trigger') { + this._triggerWidgetsNames.push(...dataValue.split(/\s*,\s*/g)); + } else if (key === 'triggerValue') { + this._triggerWidgetsValues.push(...dataValue.split(/\s*,\s*/g)); + } else if (validMethodNames.includes(key)) { + this._methodsNames.push(key); + this._methodsParams.optionsPossibleValues[key] = dataValue.split(/\s*\|\s*/g); + } else { + this._methodsParams[key] = dataValue; + } + } + this._userValueWidgets.forEach(widget => { + const inheritedParams = Object.assign({}, this._methodsParams); + inheritedParams.optionsPossibleValues = null; + widget.loadMethodsData(validMethodNames, inheritedParams); + const subMethodsNames = widget.getMethodsNames(); + const subMethodsParams = widget.getMethodsParams(); + + for (const methodName of subMethodsNames) { + if (!this._methodsNames.includes(methodName)) { + this._methodsNames.push(methodName); + this._methodsParams.optionsPossibleValues[methodName] = []; + } + for (const subPossibleValue of subMethodsParams.optionsPossibleValues[methodName]) { + this._methodsParams.optionsPossibleValues[methodName].push(subPossibleValue); + } + } + }); + for (const methodName of this._methodsNames) { + const arr = this._methodsParams.optionsPossibleValues[methodName]; + const uniqArr = arr.filter((v, i, arr) => i === arr.indexOf(v)); + this._methodsParams.optionsPossibleValues[methodName] = uniqArr; + } + + // Method names come from the widget's dataset whose keys' order cannot + // be relied on. We explicitely sort them by alphabetical order allowing + // consistent behavior, while relying on order for such methods should + // not be done when possible (the methods should be independent from + // each other when possible). + this._methodsNames.sort(); + }, + /** + * @param {boolean} [previewMode=false] + * @param {boolean} [isSimulatedEvent=false] + */ + notifyValueChange: function (previewMode, isSimulatedEvent) { + // In the case we notify a change update, force a preview update if it + // was not already previewed + const isPreviewed = this.isPreviewed(); + if (!previewMode && !isPreviewed) { + this.notifyValueChange(true); + } + + const data = { + previewMode: previewMode || false, + isSimulatedEvent: !!isSimulatedEvent, + }; + // TODO improve this. The preview state has to be updated only when the + // actual option _select is gonna be called... but this is delayed by a + // mutex. So, during test tours, we would notify both 'preview' and + // 'reset' before the 'preview' handling is done: and so the widget + // would not be considered in preview during that 'preview' handling. + if (previewMode === true || previewMode === false) { + // Note: the widgets need to be considered in preview mode during + // non-preview handling (a previewed checkbox is considered having + // an inverted state)... but if, for example, a modal opens before + // handling that non-preview, a 'reset' will be thrown thus removing + // the preview class. So we force it in non-preview too. + data.prepare = () => this.el.classList.add('o_we_preview'); + } else if (previewMode === 'reset') { + data.prepare = () => this.el.classList.remove('o_we_preview'); + } + + this.trigger_up('user_value_update', data); + }, + /** + * Opens the widget (only meaningful for widgets that can be opened). + */ + open() { + this.trigger_up('user_value_widget_opening'); + this.el.classList.add('o_we_widget_opened'); + }, + /** + * Adds the given widget to the known list of user value sub-widgets (useful + * for container widgets). + * + * @param {UserValueWidget} widget + */ + registerSubWidget: function (widget) { + this._userValueWidgets.push(widget); + }, + /** + * Sets the user value that the widget should currently hold, for the + * given method name. + * + * Note: a widget typically only holds one value for the only method it + * supports. However, widgets can have several methods; in that case, the + * value is typically received for a first method and receiving the value + * for other ones should not affect the widget (otherwise, it means the + * methods are conflicting with each other). + * + * @param {string} value + * @param {string} [methodName] + */ + async setValue(value, methodName) { + this._value = value; + this.el.classList.remove('o_we_preview'); + }, + /** + * @param {boolean} show + */ + toggleVisibility: function (show) { + let doFocus = false; + if (show) { + const wasInvisible = this.el.classList.contains('d-none'); + doFocus = wasInvisible && this.el.dataset.requestFocus === "true"; + } + this.el.classList.toggle('d-none', !show); + if (doFocus) { + this.focus(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns the main focusable element of the widget. By default supposes + * nothing is focusable. + * + * @todo review all specific widget's method + * @private + * @returns {HTMLElement} + */ + _getFocusableElement: function () { + return null; + }, + /** + * @private + * @param {OdooEvent|Event} + * @returns {boolean} + */ + _handleNotifierEvent: function (ev) { + if (!ev) { + return true; + } + if (ev._seen) { + return false; + } + ev._seen = true; + if (ev.preventDefault) { + ev.preventDefault(); + } + return true; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Should be called when an user event on the widget indicates a value + * change. + * + * @private + * @param {OdooEvent|Event} [ev] + */ + _onUserValueChange: function (ev) { + if (this._handleNotifierEvent(ev)) { + this.notifyValueChange(false); + } + }, + /** + * Allows container widgets to add additional data if needed. + * + * @private + * @param {OdooEvent} ev + */ + _onUserValueNotification: function (ev) { + ev.data.widget = this; + + if (!ev.data.triggerWidgetsNames) { + ev.data.triggerWidgetsNames = []; + } + ev.data.triggerWidgetsNames.push(...this._triggerWidgetsNames); + + if (!ev.data.triggerWidgetsValues) { + ev.data.triggerWidgetsValues = []; + } + ev.data.triggerWidgetsValues.push(...this._triggerWidgetsValues); + }, + /** + * Should be called when an user event on the widget indicates a value + * preview. + * + * @private + * @param {OdooEvent|Event} [ev] + */ + _onUserValuePreview: function (ev) { + if (this._handleNotifierEvent(ev)) { + this.notifyValueChange(true); + } + }, + /** + * Should be called when an user event on the widget indicates a value + * reset. + * + * @private + * @param {OdooEvent|Event} [ev] + */ + _onUserValueReset: function (ev) { + if (this._handleNotifierEvent(ev)) { + this.notifyValueChange('reset'); + } + }, +}); + +const ButtonUserValueWidget = UserValueWidget.extend({ + tagName: 'we-button', + events: { + 'click': '_onButtonClick', + 'click [role="button"]': '_onInnerButtonClick', + 'mouseenter': '_onUserValuePreview', + 'mouseleave': '_onUserValueReset', + }, + + /** + * @override + */ + async willStart() { + await this._super(...arguments); + if (this.options.dataAttributes.activeImg) { + this.activeImgEl = await _buildImgElement(this.options.dataAttributes.activeImg); + } + }, + /** + * @override + */ + _makeDescriptive() { + const $el = this._super(...arguments); + if (this.illustrationEl) { + $el[0].classList.add('o_we_icon_button'); + } + if (this.activeImgEl) { + this.containerEl.appendChild(this.activeImgEl); + } + return $el; + }, + /** + * @override + */ + start: function (parent, title, options) { + if (this.options && this.options.childNodes) { + this.options.childNodes.forEach(node => this.containerEl.appendChild(node)); + } + + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getActiveValue: function (methodName) { + const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; + return possibleValues && possibleValues[possibleValues.length - 1] || ''; + }, + /** + * @override + */ + isActive: function () { + return (this.isPreviewed() !== this.el.classList.contains('active')); + }, + /** + * @override + */ + loadMethodsData: function (validMethodNames) { + this._super.apply(this, arguments); + for (const methodName of this._methodsNames) { + const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; + if (possibleValues.length <= 1) { + possibleValues.unshift(''); + } + } + }, + /** + * @override + */ + async setValue(value, methodName) { + await this._super(...arguments); + let active = !!value; + if (methodName) { + if (!this._methodsNames.includes(methodName)) { + return; + } + active = (this.getActiveValue(methodName) === value); + } + if (this.illustrationEl && this.activeImgEl) { + this.illustrationEl.classList.toggle('d-none', active); + this.activeImgEl.classList.toggle('d-none', !active); + } + this.el.classList.toggle('active', active); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onButtonClick: function (ev) { + if (!ev._innerButtonClicked) { + this._onUserValueChange(ev); + } + }, + /** + * @private + */ + _onInnerButtonClick: function (ev) { + // Cannot just stop propagation as the click needs to be propagated to + // potential parent widgets for event delegation on those inner buttons. + ev._innerButtonClicked = true; + }, +}); + +const CheckboxUserValueWidget = ButtonUserValueWidget.extend({ + className: (ButtonUserValueWidget.prototype.className || '') + ' o_we_checkbox_wrapper', + + /** + * @override + */ + start: function () { + const checkboxEl = document.createElement('we-checkbox'); + this.containerEl.appendChild(checkboxEl); + + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + enable() { + this.$('we-checkbox').click(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onButtonClick(ev) { + if (!ev.target.closest('we-title, we-checkbox')) { + // Only consider clicks on the label and the checkbox control itself + return; + } + return this._super(...arguments); + }, +}); + +const BaseSelectionUserValueWidget = UserValueWidget.extend({ + /** + * @override + */ + async start() { + await this._super(...arguments); + + this.menuEl = document.createElement('we-selection-items'); + if (this.options && this.options.childNodes) { + this.options.childNodes.forEach(node => { + // Ensure to only put element nodes inside the selection menu + // as there could be an :empty CSS rule to handle the case when + // the menu is empty (so it should not contain any whitespace). + if (node.nodeType === Node.ELEMENT_NODE) { + this.menuEl.appendChild(node); + } + }); + } + this.containerEl.appendChild(this.menuEl); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getMethodsParams(methodName) { + const params = this._super(...arguments); + const activeWidget = this._getActiveSubWidget(); + if (!activeWidget) { + return params; + } + return Object.assign(activeWidget.getMethodsParams(...arguments), params); + }, + /** + * @override + */ + getValue(methodName) { + const activeWidget = this._getActiveSubWidget(); + if (activeWidget) { + return activeWidget.getActiveValue(methodName); + } + return this._super(...arguments); + }, + /** + * @override + */ + isContainer() { + return true; + }, + /** + * @override + */ + async setValue(value, methodName) { + const _super = this._super.bind(this); + for (const widget of this._userValueWidgets) { + await widget.setValue(NULL_ID, methodName); + } + for (const widget of [...this._userValueWidgets].reverse()) { + await widget.setValue(value, methodName); + if (widget.isActive()) { + // Only one select item can be true at a time, we consider the + // last one if multiple would be active. + break; + } + } + await _super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @returns {UserValueWidget|undefined} + */ + _getActiveSubWidget() { + const previewedWidget = this._userValueWidgets.find(widget => widget.isPreviewed()); + if (previewedWidget) { + return previewedWidget; + } + return this._userValueWidgets.find(widget => widget.isActive()); + }, +}); + +const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({ + tagName: 'we-select', + events: { + 'click': '_onClick', + }, + PLACEHOLDER_TEXT: _t("None"), + + /** + * @override + */ + async start() { + await this._super(...arguments); + + if (this.options && this.options.valueEl) { + this.containerEl.insertBefore(this.options.valueEl, this.menuEl); + } + + this.menuEl.dataset.placeholderText = this.PLACEHOLDER_TEXT; + + this.menuTogglerEl = document.createElement('we-toggler'); + this.menuTogglerEl.dataset.placeholderText = this.PLACEHOLDER_TEXT; + this.iconEl = this.illustrationEl || null; + const icon = this.el.dataset.icon; + if (icon) { + this.iconEl = document.createElement('i'); + this.iconEl.classList.add('fa', 'fa-fw', icon); + } + if (this.iconEl) { + this.el.classList.add('o_we_icon_select'); + this.menuTogglerEl.appendChild(this.iconEl); + } + this.containerEl.insertBefore(this.menuTogglerEl, this.menuEl); + + const dropdownCaretEl = document.createElement('span'); + dropdownCaretEl.classList.add('o_we_dropdown_caret'); + this.containerEl.appendChild(dropdownCaretEl); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + close: function () { + this._super(...arguments); + this.el.classList.remove("o_we_select_dropdown_up"); + if (this.menuTogglerEl) { + this.menuTogglerEl.classList.remove('active'); + } + }, + /** + * @override + */ + isPreviewed: function () { + return this._super(...arguments) || this.menuTogglerEl.classList.contains('active'); + }, + /** + * @override + */ + open() { + this._super(...arguments); + this.menuTogglerEl.classList.add('active'); + this._adjustDropdownPosition(); + }, + /** + * @override + */ + async setValue() { + await this._super(...arguments); + + if (this.iconEl) { + return; + } + + if (this.menuTogglerItemEl) { + this.menuTogglerItemEl.remove(); + this.menuTogglerItemEl = null; + } + + let textContent = ''; + const activeWidget = this._userValueWidgets.find(widget => !widget.isPreviewed() && widget.isActive()); + if (activeWidget) { + const svgTag = activeWidget.el.querySelector('svg'); // useful to avoid searching text content in svg element + const value = (activeWidget.el.dataset.selectLabel || (!svgTag && activeWidget.el.textContent.trim())); + const imgSrc = activeWidget.el.dataset.img; + const icon = activeWidget.el.dataset.icon; + if (value) { + textContent = value; + } else if (icon) { + this.menuTogglerItemEl = document.createElement('i'); + this.menuTogglerItemEl.classList.add('fa', icon); + } else if (imgSrc) { + this.menuTogglerItemEl = document.createElement('img'); + this.menuTogglerItemEl.src = imgSrc; + } else { + const fakeImgEl = activeWidget.el.querySelector('.o_we_fake_img_item'); + if (fakeImgEl) { + this.menuTogglerItemEl = fakeImgEl.cloneNode(true); + } + } + } else { + textContent = this.PLACEHOLDER_TEXT; + } + + this.menuTogglerEl.textContent = textContent; + if (this.menuTogglerItemEl) { + this.menuTogglerEl.appendChild(this.menuTogglerItemEl); + } + }, + /** + * @override + */ + enable() { + if (!this.menuTogglerEl.classList.contains('active')) { + this.menuTogglerEl.click(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _shouldIgnoreClick(ev) { + return !!ev.target.closest('[role="button"]'); + }, + /** + * Decides whether the dropdown should be positioned below or above the + * selector based on the available space. + * + * @private + */ + _adjustDropdownPosition() { + const customizePanelEl = this.menuEl.closest(".o_we_customize_panel"); + if (!customizePanelEl) { + return; + } + + this.el.classList.remove("o_we_select_dropdown_up"); + const customizePanelElCoords = customizePanelEl.getBoundingClientRect(); + let dropdownMenuElCoords = this.menuEl.getBoundingClientRect(); + + // Adds a margin to prevent the dropdown from sticking to the edge of + // the customize panel. + const dropdownMenuMargin = 5; + // If after opening, the dropdown list overflows the customization + // panel at the bottom, opens the dropdown above the selector. + if ((dropdownMenuElCoords.bottom + dropdownMenuMargin) > customizePanelElCoords.bottom) { + this.el.classList.add("o_we_select_dropdown_up"); + dropdownMenuElCoords = this.menuEl.getBoundingClientRect(); + // If there is no available space above it either, then we open + // it below the selector. + if (dropdownMenuElCoords.top < customizePanelElCoords.top) { + this.el.classList.remove("o_we_select_dropdown_up"); + } + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the select is clicked anywhere -> open/close it. + * + * @private + */ + _onClick: function (ev) { + if (this._shouldIgnoreClick(ev)) { + return; + } + + if (!this.menuTogglerEl.classList.contains('active')) { + this.open(); + } else { + this.close(); + } + const activeButton = this._userValueWidgets.find(widget => widget.isActive()); + if (activeButton) { + this.menuEl.scrollTop = activeButton.el.offsetTop - (this.menuEl.offsetHeight / 2); + } + }, +}); + +const ButtonGroupUserValueWidget = BaseSelectionUserValueWidget.extend({ + tagName: 'we-button-group', +}); + +const UnitUserValueWidget = UserValueWidget.extend({ + /** + * @override + */ + start: async function () { + const unit = this.el.dataset.unit || ''; + this.el.dataset.unit = unit; + if (this.el.dataset.saveUnit === undefined) { + this.el.dataset.saveUnit = unit; + } + + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getActiveValue: function (methodName) { + const activeValue = this._super(...arguments); + + const params = this._methodsParams; + if (!this._isNumeric()) { + return activeValue; + } + + const defaultValue = this.getDefaultValue(methodName, false); + + return activeValue.split(/\s+/g).map(v => { + const numValue = parseFloat(v); + if (isNaN(numValue)) { + return defaultValue; + } else { + const value = weUtils.convertNumericToUnit(numValue, params.unit, params.saveUnit, params.cssProperty, this.$target); + return `${this._floatToStr(value)}${params.saveUnit}`; + } + }).join(' '); + }, + /** + * @override + * @param {boolean} [useInputUnit=false] + */ + getDefaultValue: function (methodName, useInputUnit) { + const defaultValue = this._super(...arguments); + + const params = this._methodsParams; + if (!this._isNumeric()) { + return defaultValue; + } + + const unit = useInputUnit ? params.unit : params.saveUnit; + const numValue = weUtils.convertValueToUnit(defaultValue || '0', unit, params.cssProperty, this.$target); + if (isNaN(numValue)) { + return defaultValue; + } + return `${this._floatToStr(numValue)}${unit}`; + }, + /** + * @override + */ + isActive: function () { + const isSuperActive = this._super(...arguments); + if (!this._isNumeric()) { + return isSuperActive; + } + return isSuperActive && ( + this._floatToStr(parseFloat(this._value)) !== '0' + // Or is a composite value. + || !!this._value.match(/\d+\s+\d+/) + ); + }, + /** + * @override + */ + async setValue(value, methodName) { + const params = this._methodsParams; + if (this._isNumeric()) { + value = value.split(' ').map(v => { + const numValue = weUtils.convertValueToUnit(v, params.unit, params.cssProperty, this.$target); + if (isNaN(numValue)) { + return ''; // Something not supported + } + return this._floatToStr(numValue); + }).join(' '); + } + return this._super(value, methodName); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Converts a floating value to a string, rounded to 5 digits without zeros. + * + * @private + * @param {number} value + * @returns {string} + */ + _floatToStr: function (value) { + return `${parseFloat(value.toFixed(5))}`; + }, + /** + * Checks whether the widget contains a numeric value. + * + * @private + * @returns {Boolean} true if the value is numeric, false otherwise. + */ + _isNumeric() { + const params = this._methodsParams || this.el.dataset; + return !!params.unit; + }, +}); + +const InputUserValueWidget = UnitUserValueWidget.extend({ + tagName: 'we-input', + events: { + 'input input': '_onInputInput', + 'blur input': '_onInputBlur', + 'change input': '_onUserValueChange', + 'keydown input': '_onInputKeydown', + }, + + /** + * @override + */ + start: async function () { + await this._super(...arguments); + + const unit = this.el.dataset.unit; + this.inputEl = document.createElement('input'); + this.inputEl.setAttribute('type', 'text'); + this.inputEl.setAttribute('autocomplete', 'chrome-off'); + this.inputEl.setAttribute('placeholder', this.el.getAttribute('placeholder') || ''); + const useNumberAlignment = this._isNumeric() || !!this.el.dataset.hideUnit; + this.inputEl.classList.toggle('text-start', !useNumberAlignment); + this.inputEl.classList.toggle('text-end', useNumberAlignment); + this.containerEl.appendChild(this.inputEl); + + const showUnit = (!!unit || !!this.el.dataset.fakeUnit) && !this.el.dataset.hideUnit; + if (showUnit) { + var unitEl = document.createElement('span'); + const unitText = this.el.dataset.fakeUnit || unit; + unitEl.textContent = unitText; + this.containerEl.appendChild(unitEl); + if (unitText.length > 3) { + this.el.classList.add('o_we_large'); + } + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + async setValue() { + await this._super(...arguments); + this.inputEl.value = this._value; + this._oldValue = this._value; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getFocusableElement() { + return this.inputEl; + }, + /** + * @override + */ + _isNumeric() { + const isNumeric = this._super(...arguments); + const params = this._methodsParams || this.el.dataset; + return isNumeric || !!params.fakeUnit || !!params.step; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onInputInput: function (ev) { + // First record the input value as the new current value and bound it if + // necessary (min / max params). + this._value = this.inputEl.value; + + const params = this._methodsParams; + const hasMin = ('min' in params); + const hasMax = ('max' in params); + if (hasMin || hasMax) { + // Bounding the value in [min, max] if specified. + const boundedValue = this._value.split(/\s+/g).map(v => { + let numValue = parseFloat(v); + if (isNaN(numValue)) { + return hasMin ? params.min : v; + } else { + numValue = hasMin ? Math.max(params.min, numValue) : numValue; + numValue = hasMax ? Math.min(numValue, params.max) : numValue; + return numValue; + } + }).join(" "); + + // If the bounded version is different from the value, forget about + // the old value so that we properly update the UI in any case. + this._oldValue = undefined; + + // Note: we do not change the input's value because we want the user + // to be able to enter anything without it being auto-fixed. For + // example, just emptying the input to enter new numbers: you don't + // want the min value to pop up unexpectedly. The next UI update + // will take care of showing the user that the value was bound. + this._value = boundedValue; + } + + // When the value changes as a result of a arrow up/down, the change + // event is not called, unless a real user input has been triggered. + // This event handler holds a variable for this in order to not call + // `_onUserValueChange` two times. If the users only uses arrow up/down + // it will be trigger on blur otherwise it will be triggered on change. + if (!ev.detail || !ev.detail.keyUpOrDown) { + this.changeEventWillBeTriggered = true; + } + this._onUserValuePreview(ev); + }, + /** + * @private + * @param {Event} ev + */ + _onInputBlur: function (ev) { + if (this.notifyValueChangeOnBlur && !this.changeEventWillBeTriggered) { + // In case the input value has been modified with arrow up/down, the + // change event is not triggered (except if there has been a natural + // input event), so if the element doesn't trigger a preview, we + // have to notify that the value changes now. + this._onUserValueChange(ev); + this.notifyValueChangeOnBlur = false; + } + this.changeEventWillBeTriggered = false; + }, + /** + * @private + * @param {Event} ev + */ + _onInputKeydown: function (ev) { + const params = this._methodsParams; + if (!this._isNumeric()) { + return; + } + switch (ev.key) { + case "Enter": + this._onUserValueChange(ev); + break; + case "ArrowUp": + case "ArrowDown": { + const input = ev.currentTarget; + let parts = (input.value || input.placeholder).match(/-?\d+\.\d+|-?\d+/g); + if (!parts) { + parts = [input.value || input.placeholder]; + } + if (parts.length > 1 && !('min' in params)) { + // No negative for composite values. + params['min'] = 0; + } + const newValue = parts.map(part => { + let value = parseFloat(part); + if (isNaN(value)) { + value = 0.0; + } + let step = parseFloat(params.step); + if (isNaN(step)) { + step = 1.0; + } + + const increasing = ev.key === "ArrowUp"; + const hasMin = ('min' in params); + const hasMax = ('max' in params); + + // If value already at min and trying to decrease, do nothing + if (!increasing && hasMin && Math.abs(value - params.min) < 0.001) { + return value; + } + // If value already at max and trying to increase, do nothing + if (increasing && hasMax && Math.abs(value - params.max) < 0.001) { + return value; + } + + // If trying to decrease/increase near min/max, we still need to + // bound the produced value and immediately show the user. + value += (increasing ? step : -step); + value = hasMin ? Math.max(params.min, value) : value; + value = hasMax ? Math.min(value, params.max) : value; + return this._floatToStr(value); + }).join(" "); + if (newValue === (input.value || input.placeholder)) { + return; + } + input.value = newValue; + + // We need to know if the change event will be triggered or not. + // Change is triggered if there has been a "natural" input event + // from the user. Since we are triggering a "fake" input event, + // we specify that the original event is a key up/down. + input.dispatchEvent(new CustomEvent('input', { + bubbles: true, + cancelable: true, + detail: {keyUpOrDown: true} + })); + this.notifyValueChangeOnBlur = true; + break; + } + } + }, + /** + * @override + */ + _onUserValueChange() { + if (this._oldValue !== this._value) { + this._super(...arguments); + } + } +}); + +const MultiUserValueWidget = UserValueWidget.extend({ + tagName: 'we-multi', + + /** + * @override + */ + start: function () { + if (this.options && this.options.childNodes) { + this.options.childNodes.forEach(node => this.containerEl.appendChild(node)); + } + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getValue: function (methodName) { + const value = this._userValueWidgets.map(widget => { + return widget.getValue(methodName); + }).join(' ').trim(); + + return value || this._super(...arguments); + }, + /** + * @override + */ + isContainer: function () { + return true; + }, + /** + * @override + */ + async setValue(value, methodName) { + let values = value.split(/\s*\|\s*/g); + if (values.length === 1) { + values = value.split(/\s+/g); + } + for (let i = 0; i < this._userValueWidgets.length - 1; i++) { + await this._userValueWidgets[i].setValue(values.shift() || '', methodName); + } + await this._userValueWidgets[this._userValueWidgets.length - 1].setValue(values.join(' '), methodName); + }, +}); + +const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ + className: (SelectUserValueWidget.prototype.className || '') + ' o_we_so_color_palette', + + /** + * @override + */ + start: async function () { + const _super = this._super.bind(this); + const args = arguments; + + this.resetTabCount = 0; + + // Build the select element with a custom span to hold the color preview + this.colorPreviewEl = document.createElement('span'); + this.colorPreviewEl.classList.add('o_we_color_preview'); + // todo: This div should be removed whenever possible (like when + // converting the uservaluewidget to owl). + this.colorPaletteEl = document.createElement('div'); + this.colorPaletteEl.classList.add('o_we_color_palette_wrapper'); + this.colorPaletteEl.style.display = 'contents'; + this.colorPaletteColorNames = []; + this.options.childNodes = [this.colorPaletteEl]; + this.options.valueEl = this.colorPreviewEl; + // TODO: find a better way to do this. + // The colorpicker widget is started before the ColorPalette component + // is attached to the DOM (which only happens once the user opens the + // picker). However, the colorNames are only set after the ColorPalette + // has been mounted. Initializing the colorNames through a direct call + // to the `getColorPickerTemplateService` so that the widget starts + // with possible default values is thus necessary to avoid bugs on + // `_computeWidgetState()`. + const wysiwyg = this.getParent().options.wysiwyg; + if (wysiwyg) { + const colorpickerTemplate = await wysiwyg.getColorpickerTemplate.call(wysiwyg); + this.colorPaletteColorNames = this._getColorNames(colorpickerTemplate); + } + return _super(...args); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + open: function () { + if (this.colorPaletteWrapper) { + this.colorPaletteWrapper?.update({ + selectedCC: this._ccValue, + selectedColor: this._value, + resetTabCount: ++this.resetTabCount, + }); + this._super(...arguments); + } else { + // TODO review in master, this does async stuff. Maybe the open + // method should now be async. This is not really robust as the + // colorPalette can be used without it to be fully rendered but + // the use of the saved promise where we can should mitigate that + // issue. + this._colorPaletteRenderPromise = this._renderColorPalette(); + this._super(...arguments); + this._colorPaletteRenderPromise.then(() => { + // Re-adjust the position of the colorpicker once the + // colorpalette is completely rendered (once that the + // colorpicker has its final height. + // TODO should not be needed once everything will be converted + // to owl. + this._adjustDropdownPosition(); + }); + } + }, + /** + * @override + */ + close: function () { + this._super(...arguments); + if (this._customColorValue && this._customColorValue !== this._value) { + this._value = this._customColorValue; + this._customColorValue = false; + this._onUserValueChange(); + } + }, + /** + * @override + */ + getMethodsParams: function () { + return Object.assign(this._super(...arguments), { + colorNames: this.colorPaletteColorNames, + }); + }, + /** + * @override + */ + getValue: function (methodName) { + const isCCMethod = (this._methodsParams.withCombinations === methodName); + let value = this._super(...arguments); + if (isCCMethod) { + value = this._ccValue; + } else if (typeof this._customColorValue === 'string') { + value = this._customColorValue; + } + + // TODO strange there is some processing below for the normal value but + // not for the preview value? To check in older stable versions as well. + if (typeof this._previewColor === 'string') { + return isCCMethod ? this._previewCC : this._previewColor; + } + + if (value) { + // TODO probably something to be done to handle gradients properly + // in this code. + const useCssColor = this.options.dataAttributes.hasOwnProperty('useCssColor'); + const cssCompatible = this.options.dataAttributes.hasOwnProperty('cssCompatible'); + if ((useCssColor || cssCompatible) && !isCSSColor(value)) { + if (useCssColor) { + value = weUtils.getCSSVariableValue(value); + } else { + value = `var(--${value})`; + } + } + } + return value; + }, + /** + * @override + */ + isContainer: function () { + return false; + }, + /** + * @override + */ + isActive: function () { + return !!this._ccValue + || !weUtils.areCssValuesEqual(this._value, 'rgba(0, 0, 0, 0)'); + }, + /** + * Updates the color preview + re-render the whole color palette widget. + * + * @override + */ + async setValue(color, methodName, ...rest) { + // The colorpicker widget can hold two values: a color combination and + // a normal color or a gradient. The base `_value` will hold the normal + // color or the gradient value. The color combination one will be + // available in `_ccValue`. + const isCCMethod = (this._methodsParams.withCombinations === methodName); + // Always call _super but don't change _value if meant for the CC value. + await this._super(isCCMethod ? this._value : color, methodName, ...rest); + if (isCCMethod) { + this._ccValue = color; + } + + await this._colorPaletteRenderPromise; + + const classes = weUtils.computeColorClasses(this.colorPaletteColorNames); + 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.style.backgroundColor = `var(--we-cp-o-cc${this._ccValue}-${prefix.replace(/-/, '')})`; + this.colorPreviewEl.style.backgroundImage = `var(--we-cp-o-cc${this._ccValue}-${prefix.replace(/-/, '')}-gradient)`; + } + if (this._value) { + this.colorPreviewEl.style.backgroundImage = 'none'; + if (isCSSColor(this._value)) { + this.colorPreviewEl.style.backgroundColor = this._value; + } else if (weUtils.isColorGradient(this._value)) { + this.colorPreviewEl.style.backgroundImage = this._value; + } else if (weUtils.EDITOR_COLOR_CSS_VARIABLES.includes(this._value)) { + this.colorPreviewEl.style.backgroundColor = `var(--we-cp-${this._value}`; + } else { + // Checking if the className actually exists seems overkill but + // it is actually needed to prevent a crash. As an example, if a + // colorpicker widget is linked to a SnippetOption instance's + // `selectStyle` method designed to handle the "border-color" + // property of an element, the value received can be split if + // the item uses different colors for its top/right/bottom/left + // borders. For instance, you could receive "red blue" if the + // item as red top and bottom borders and blue left and right + // borders, in which case you would reach this `else` and try to + // add the class "bg-red blue" which would crash because of the + // space inside). In that case, we simply do not show any color. + // We could choose to handle this split-value case specifically + // but it was decided that this is enough for the moment. + const className = `bg-${this._value}`; + if (classes.includes(className)) { + this.colorPreviewEl.classList.add(className); + } + } + } + // If the palette was already opened (e.g. modifying a gradient), the new DOM state must be + // reflected in the palette, but the tab selection must not be impacted. + this.colorPaletteWrapper?.update({ + selectedCC: this._ccValue, + selectedColor: this._value, + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @returns {Promise} + */ + _renderColorPalette: async function () { + this.resetTabCount = 0; + const options = { + resetTabCount: this.resetTabCount, + selectedCC: this._ccValue, + selectedColor: this._value, + getCustomColors: () => { + let result = []; + this.trigger_up('get_custom_colors', { + onSuccess: (colors) => result = colors, + }); + return result; + }, + onCustomColorPicked: this._onCustomColorPicked.bind(this), + onColorPicked: this._onColorPicked.bind(this), + onColorHover: this._onColorHovered.bind(this), + onColorLeave: this._onColorLeft.bind(this), + onInputEnter: this._onEnterKey.bind(this), + }; + if (this.options.dataAttributes.excluded) { + options.excluded = this.options.dataAttributes.excluded.replace(/ /g, '').split(','); + } + if (this.options.dataAttributes.opacity) { + options.opacity = parseFloat(this.options.dataAttributes.opacity); + } + if (this.options.dataAttributes.withCombinations) { + options.withCombinations = !!this.options.dataAttributes.withCombinations; + } + if (this.options.dataAttributes.withGradients) { + options.withGradients = !!this.options.dataAttributes.withGradients; + } + if (this.options.dataAttributes.noTransparency) { + options.noTransparency = !!this.options.dataAttributes.noTransparency; + options.excluded = [...(options.excluded || []), 'transparent_grayscale']; + } + if (this.options.dataAttributes.selectedTab) { + options.selectedTab = this.options.dataAttributes.selectedTab; + } + const wysiwyg = this.getParent().options.wysiwyg; + if (wysiwyg) { + options.document = this.$target[0].ownerDocument; + options.getTemplate = wysiwyg.getColorpickerTemplate.bind(wysiwyg); + } + this.colorPaletteWrapper?.destroy(); + const sidebarDocument = this.colorPaletteEl.ownerDocument; + if (!(this.colorPaletteEl instanceof sidebarDocument.defaultView.HTMLElement)) { + // When inside an iframe, the element for mounting a component must + // be an instance of the iframe's HTMLElement, or else target + // validation for attachComponent fails. + const newEl = sidebarDocument.importNode(this.colorPaletteEl, true); + this.colorPaletteEl.before(newEl); + this.colorPaletteEl.remove(); + this.colorPaletteEl = newEl; + } + this.colorPaletteWrapper = await attachComponent(this, this.colorPaletteEl, ColorPalette, options); + }, + /** + * @override + */ + _shouldIgnoreClick(ev) { + return ev.originalEvent.__isColorpickerClick || this._super(...arguments); + }, + /** + * Browses the colorpicker XML template to return all possible values of + * [data-color]. + * + * @param {string} colorpickerTemplate + * @returns {string[]} + */ + _getColorNames(colorpickerTemplate) { + // Init with the color combinations presets as these don't appear in + // the template. + const colorNames = ["1", "2", "3", "4", "5"]; + const template = new DOMParser().parseFromString(colorpickerTemplate, "text/html"); + template.querySelectorAll("button[data-color]:not(.o_custom_gradient_btn)").forEach(el => { + const colorName = el.dataset.color; + if (!weUtils.isColorGradient(colorName)) { + colorNames.push(colorName); + } + }); + return colorNames; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when a custom color is selected -> preview the color + * and set the current value. Update of this value on close + * + * @private + * @param {Object} params + */ + _onCustomColorPicked: function (params) { + this._customColorValue = params.color; + }, + /** + * Called when a color button is clicked -> confirms the preview. + * + * @private + * @param {Object} params + */ + _onColorPicked: function (params) { + this._previewCC = false; + this._previewColor = false; + this._customColorValue = false; + + this._ccValue = params.ccValue; + this._value = params.color; + + this._onUserValueChange(); + }, + /** + * Called when a color button is entered -> previews the background color. + * + * @private + * @param {Object} params + */ + _onColorHovered: function (params) { + this._previewCC = params.ccValue; + this._previewColor = params.color; + this._onUserValuePreview(); + }, + /** + * Called when a color button is left -> cancels the preview. + * + * @private + */ + _onColorLeft: function () { + this._previewCC = false; + this._previewColor = false; + this._onUserValueReset(); + }, + /** + * @private + */ + _onEnterKey: function () { + this.close(); + }, +}); + +const MediapickerUserValueWidget = UserValueWidget.extend({ + tagName: 'we-button', + events: { + 'click': '_onEditMedia', + }, + + /** + * @override + */ + async start() { + await this._super(...arguments); + if (this.options.dataAttributes.buttonStyle) { + const iconEl = document.createElement('i'); + iconEl.classList.add('fa', 'fa-fw', 'fa-camera'); + $(this.containerEl).prepend(iconEl); + } else { + this.el.classList.add('o_we_no_toggle', 'o_we_bg_success'); + this.containerEl.textContent = _t("Replace"); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Creates and opens a media dialog to edit a given element's media. + * + * @private + * @param {HTMLElement} el the element whose media should be edited + * @param {boolean} [images] whether images should be available + * default: false + * @param {boolean} [videos] whether videos should be available + * default: false + */ + _openDialog(el, {images = false, videos = false, save}) { + el.src = this._value; + const $editable = this.$target.closest('.o_editable'); + this.call("dialog", "add", MediaDialog, { + noImages: !images, + noVideos: !videos, + noIcons: true, + noDocuments: true, + isForBgVideo: true, + vimeoPreviewIds: ['528686125', '430330731', '509869821', '397142251', '763851966', '486931161', + '499761556', '392935303', '728584384', '865314310', '511727912', '466830211'], + 'res_model': $editable.data('oe-model'), + 'res_id': $editable.data('oe-id'), + save, + media: el, + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + async setValue() { + await this._super(...arguments); + this.el.classList.toggle('active', this.isActive()); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the edit button is clicked. + * + * @private + * @param {Event} ev + */ + _onEditMedia: function (ev) {}, +}); + +const ImagepickerUserValueWidget = MediapickerUserValueWidget.extend({ + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onEditMedia(ev) { + // Need a dummy element for the media dialog to modify. + const dummyEl = document.createElement('img'); + 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(); + } + }); + }, +}); + +const VideopickerUserValueWidget = MediapickerUserValueWidget.extend({ + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onEditMedia(ev) { + // Need a dummy element for the media dialog to modify. + const dummyEl = document.createElement('iframe'); + this._openDialog(dummyEl, { + videos: true, + save: (media) => { + this._value = media.querySelector('iframe').src; + this._onUserValueChange(); + }}); + }, +}); + +const DatetimePickerUserValueWidget = InputUserValueWidget.extend({ + events: { // Explicitely not consider all InputUserValueWidget events + 'blur input': '_onInputBlur', + 'input input': '_onDateInputInput', + }, + pickerType: 'datetime', + + /** + * @override + */ + init: function () { + this._super(...arguments); + this._value = DateTime.now().toUnixInteger().toString(); + }, + /** + * @override + */ + start: async function () { + await this._super(...arguments); + + this.el.classList.add('o_we_large'); + this.inputEl.classList.add('datetimepicker-input', 'mx-0', 'text-start'); + + this.picker = this.call("datetime_picker", "create", { + target: this.inputEl, + onChange: this._onDateTimePickerChange.bind(this), + pickerProps: { + type: this.pickerType, + minDate: DateTime.fromObject({ year: 1000 }), + maxDate: DateTime.now().plus({ year: 200 }), + value: DateTime.fromSeconds(parseInt(this._value)), + rounding: 0, + }, + }); + this.picker.enable(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getMethodsParams: function () { + return Object.assign(this._super(...arguments), { + format: this.defaultFormat, + }); + }, + /** + * @override + */ + isPreviewed: function () { + return this._super(...arguments) || this.picker.isOpen; + }, + /** + * @override + */ + async setValue() { + await this._super(...arguments); + let dateTime = null; + if (this._value) { + dateTime = DateTime.fromSeconds(parseInt(this._value)) + if (!dateTime.isValid) { + dateTime = DateTime.now(); + } + } + this.picker.state.value = dateTime; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onDateTimePickerChange: function (newDateTime) { + if (!newDateTime || !newDateTime.isValid) { + this._value = ''; + } else { + this._value = newDateTime.toUnixInteger().toString(); + } + this._onUserValuePreview(); + }, + /** + * Handles the clear button of the datepicker. + * + * @private + * @param {Event} ev + */ + _onDateInputInput(ev) { + if (!this.inputEl.value) { + this._value = ''; + this._onUserValuePreview(ev); + } + }, +}); + +const DatePickerUserValueWidget = DatetimePickerUserValueWidget.extend({ + pickerType: 'date', +}); + +const ListUserValueWidget = UserValueWidget.extend({ + tagName: 'we-list', + events: { + 'click we-button.o_we_select_remove_option': '_onRemoveItemClick', + 'click we-button.o_we_list_add_optional': '_onAddCustomItemClick', + 'click we-button.o_we_list_add_existing': '_onAddExistingItemClick', + 'click we-select.o_we_user_value_widget.o_we_add_list_item': '_onAddItemSelectClick', + 'click we-button.o_we_checkbox_wrapper': '_onAddItemCheckboxClick', + 'input table input': '_onListItemBlurInput', + 'blur table input': '_onListItemBlurInput', + 'mousedown': '_onWeListMousedown', + }, + + /** + * @override + */ + willStart() { + if (this.options.createWidget) { + this.createWidget = this.options.createWidget; + this.createWidget.setParent(this); + this.registerSubWidget(this.createWidget); + } + return this._super(...arguments); + }, + /** + * @override + */ + start() { + this.addItemTitle = this.el.dataset.addItemTitle || _t("Add"); + if (this.el.dataset.availableRecords) { + this.records = JSON.parse(this.el.dataset.availableRecords); + } else { + this.isCustom = !this.el.dataset.notEditable; + } + if (this.el.dataset.defaults || this.el.dataset.hasDefault) { + this.hasDefault = this.el.dataset.hasDefault || 'unique'; + this.selected = this.el.dataset.defaults ? JSON.parse(this.el.dataset.defaults) : []; + } + this.listTable = document.createElement('table'); + const tableWrapper = document.createElement('div'); + tableWrapper.classList.add('o_we_table_wrapper'); + tableWrapper.appendChild(this.listTable); + this.containerEl.appendChild(tableWrapper); + this.el.classList.add('o_we_fw'); + this._makeListItemsSortable(); + if (this.createWidget) { + return this.createWidget.appendTo(this.containerEl); + } + }, + + /** + * @override + */ + destroy() { + this.bindedSortable?.cleanup(); + this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getMethodsParams() { + return Object.assign(this._super(...arguments), { + records: this.records, + }); + }, + /** + * @override + */ + setValue() { + this._super(...arguments); + const currentValues = this._value ? JSON.parse(this._value) : []; + this.listTable.innerHTML = ''; + if (this.addItemButton) { + this.addItemButton.remove(); + } + + if (this.createWidget) { + const selectedIds = currentValues.map(({ id }) => id) + .filter(id => typeof id === 'number'); + // Note: it's important to simplify the domain at its maximum as the + // rpc using it are cached. Similar domain components should be + // written the same way for the cache to work. + this.createWidget.options.domainComponents.selected = selectedIds.length ? ['id', 'not in', selectedIds] : null; + this.createWidget.setValue(''); + this.createWidget.inputEl.value = ''; + $(this.createWidget.inputEl).trigger('input'); + } else { + if (this.isCustom) { + this.addItemButton = document.createElement('we-button'); + this.addItemButton.textContent = this.addItemTitle; + this.addItemButton.classList.add('o_we_list_add_optional'); + } else { + // TODO use a real select widget ? + this.addItemButton = document.createElement('we-select'); + this.addItemButton.classList.add('o_we_user_value_widget', 'o_we_add_list_item'); + const divEl = document.createElement('div'); + this.addItemButton.appendChild(divEl); + const togglerEl = document.createElement('we-toggler'); + togglerEl.textContent = this.addItemTitle; + divEl.appendChild(togglerEl); + this.selectMenuEl = document.createElement('we-selection-items'); + divEl.appendChild(this.selectMenuEl); + } + this.containerEl.appendChild(this.addItemButton); + } + currentValues.forEach(value => { + if (typeof value === 'object') { + const recordData = value; + const { id, display_name } = recordData; + delete recordData.id; + delete recordData.display_name; + this._addItemToTable(id, display_name, recordData); + } else { + this._addItemToTable(value, value); + } + }); + if (!this.createWidget && !this.isCustom) { + this._reloadSelectDropdown(currentValues); + } + this._makeListItemsSortable(); + }, + /** + * @override + */ + getValue(methodName) { + if (this.createWidget && this.createWidget.getMethodsNames().includes(methodName)) { + return this.createWidget.getValue(methodName); + } + return this._value; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {string || integer} id + * @param {string} [value] + * @param {Object} [recordData] key, values that will be added to the + * element's dataset + */ + _addItemToTable(id, value = this.el.dataset.defaultValue || _t("Item"), recordData) { + const trEl = document.createElement('tr'); + if (!this.el.dataset.unsortable) { + const draggableEl = document.createElement('we-button'); + draggableEl.classList.add('o_we_drag_handle', 'o_we_link', 'fa', 'fa-fw', 'fa-arrows'); + draggableEl.dataset.noPreview = 'true'; + const draggableTdEl = document.createElement('td'); + draggableTdEl.appendChild(draggableEl); + trEl.appendChild(draggableTdEl); + } + let recordDataSelected = false; + const inputEl = document.createElement('input'); + inputEl.type = this.el.dataset.inputType || 'text'; + if (value) { + inputEl.value = value; + } + if (id) { + inputEl.name = id; + } + if (recordData) { + recordDataSelected = recordData.selected; + if (recordData.placeholder) { + inputEl.placeholder = recordData.placeholder; + } + for (const key of Object.keys(recordData)) { + inputEl.dataset[key] = recordData[key]; + } + } + inputEl.disabled = !this.isCustom; + const inputTdEl = document.createElement('td'); + inputTdEl.classList.add('o_we_list_record_name'); + inputTdEl.appendChild(inputEl); + trEl.appendChild(inputTdEl); + if (this.hasDefault) { + const checkboxEl = document.createElement('we-button'); + checkboxEl.classList.add('o_we_user_value_widget', 'o_we_checkbox_wrapper'); + if (this.selected.includes(id) || recordDataSelected) { + checkboxEl.classList.add('active'); + } + if (!recordData || !recordData.notToggleable) { + const div = document.createElement('div'); + const checkbox = document.createElement('we-checkbox'); + div.appendChild(checkbox); + checkboxEl.appendChild(div); + checkboxEl.appendChild(checkbox); + const checkboxTdEl = document.createElement('td'); + checkboxTdEl.appendChild(checkboxEl); + trEl.appendChild(checkboxTdEl); + } + } + if (!recordData || !recordData.undeletable) { + const buttonTdEl = document.createElement('td'); + const buttonEl = document.createElement('we-button'); + buttonEl.classList.add('o_we_select_remove_option', 'o_we_link', 'o_we_text_danger', 'fa', 'fa-fw', 'fa-minus'); + buttonEl.dataset.removeOption = id; + buttonTdEl.appendChild(buttonEl); + trEl.appendChild(buttonTdEl); + } + this.listTable.appendChild(trEl); + }, + /** + * @override + */ + _getFocusableElement() { + return this.listTable.querySelector('input'); + }, + /** + * @private + */ + _makeListItemsSortable() { + if (this.el.dataset.unsortable) { + return; + } + this.bindedSortable = this.call( + "sortable", + "create", + { + ref: { el: this.listTable }, + elements: "tr", + followingElementClasses: ["opacity-50"], + handle: ".o_we_drag_handle", + onDrop: () => this._notifyCurrentState(), + applyChangeOnDrop: true, + }, + ).enable(); + }, + /** + * @private + * @param {Boolean} [preview] + */ + _notifyCurrentState(preview = false) { + const isIdModeName = this.el.dataset.idMode === "name" || !this.isCustom; + const trimmed = (str) => str.trim().replace(/\s+/g, " "); + const values = [...this.listTable.querySelectorAll('.o_we_list_record_name input')].map(el => { + const id = trimmed(isIdModeName ? el.name : el.value); + return Object.assign({ + id: /^-?[0-9]{1,15}$/.test(id) ? parseInt(id) : id, + name: trimmed(el.value), + display_name: trimmed(el.value), + }, el.dataset); + }); + if (this.hasDefault) { + const checkboxes = [...this.listTable.querySelectorAll('we-button.o_we_checkbox_wrapper.active')]; + this.selected = checkboxes.map(el => { + const input = el.parentElement.previousSibling.firstChild; + const id = trimmed(isIdModeName ? input.name : input.value); + return /^-?[0-9]{1,15}$/.test(id) ? parseInt(id) : id; + }); + values.forEach(v => { + // Elements not toggleable are considered as always selected. + // We have to check that it is equal to the string 'true' + // because this information comes from the dataset. + v.selected = this.selected.includes(v.id) || v.notToggleable === 'true'; + }); + } + this._value = JSON.stringify(values); + if (preview) { + this._onUserValuePreview(); + } else { + this._onUserValueChange(); + } + if (!this.createWidget && !this.isCustom) { + this._reloadSelectDropdown(values); + } + }, + /** + * @private + * @param {Array} currentValues + */ + _reloadSelectDropdown(currentValues) { + this.selectMenuEl.innerHTML = ''; + this.records.forEach(el => { + if (!currentValues.find(v => v.id === el.id)) { + const option = document.createElement('we-button'); + option.classList.add('o_we_list_add_existing'); + option.dataset.addOption = el.id; + option.dataset.noPreview = 'true'; + const divEl = document.createElement('div'); + divEl.textContent = el.display_name; + option.appendChild(divEl); + this.selectMenuEl.appendChild(option); + } + }); + if (!this.selectMenuEl.children.length) { + const title = document.createElement('we-title'); + title.textContent = _t("No more records"); + this.selectMenuEl.appendChild(title); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onAddCustomItemClick() { + const recordData = {}; + if (this.el.dataset.newElementsNotToggleable) { + recordData.notToggleable = true; + } + this._addItemToTable(undefined, this.el.dataset.defaultValue, recordData); + this._notifyCurrentState(); + // Scroll to the new list element. + this.el.querySelector('tr:last-child') + .scrollIntoView({behavior: 'smooth', block: 'nearest'}); + }, + /** + * @private + * @param {Event} ev + */ + _onAddExistingItemClick(ev) { + const value = ev.currentTarget.dataset.addOption; + this._addItemToTable(value, ev.currentTarget.textContent); + this._notifyCurrentState(); + }, + /** + * @private + * @param {Event} ev + */ + _onAddItemSelectClick(ev) { + ev.currentTarget.querySelector('we-toggler').classList.toggle('active'); + }, + /** + * @private + * @param {Event} ev + */ + _onAddItemCheckboxClick: function (ev) { + const isActive = ev.currentTarget.classList.contains('active'); + if (this.hasDefault === 'unique') { + this.listTable.querySelectorAll('we-button.o_we_checkbox_wrapper.active').forEach(el => el.classList.remove('active')); + } + ev.currentTarget.classList.toggle('active', !isActive); + this._notifyCurrentState(); + }, + /** + * @private + * @param {Event} ev + */ + _onListItemBlurInput(ev) { + const preview = ev.type === 'input'; + if (preview || !this.el.contains(ev.relatedTarget) || this.el.dataset.renderOnInputBlur) { + // We call the function below only if the element that recovers the + // focus after this blur is not an element of the we-list or if it + // is an input event (preview). This allows to use the TAB key to go + // from one input to another in the list. This behavior can be + // cancelled if the widget has reloadOnInputBlur = "true" in its + // dataset. + const timeSinceMousedown = ev.timeStamp - this.mousedownTime; + if (timeSinceMousedown < 500) { + // Without this "setTimeOut", "click" events are not triggered when + // clicking directly on a "we-button" of the "we-list" without first + // focusing out the input. + setTimeout(() => { + this._notifyCurrentState(preview); + }, 500); + } else { + this._notifyCurrentState(preview); + } + } + }, + /** + * @private + */ + _onWeListMousedown(ev) { + this.mousedownTime = ev.timeStamp; + }, + /** + * @private + * @param {Event} ev + */ + _onRemoveItemClick(ev) { + const minElements = this.el.dataset.allowEmpty ? 0 : 1; + if (ev.target.closest('table').querySelectorAll('tr').length > minElements) { + ev.target.closest('tr').remove(); + this._notifyCurrentState(); + } + }, + /** + * @override + */ + _onUserValueNotification(ev) { + const { widget, previewMode, prepare } = ev.data; + if (widget && widget === this.createWidget) { + if (widget.options.createMethod && widget.getValue(widget.options.createMethod)) { + return this._super(ev); + } + ev.stopPropagation(); + if (previewMode) { + return; + } + prepare(); + const recordData = JSON.parse(widget.getMethodsParams('addRecord').recordData); + const { id, display_name } = recordData; + delete recordData.id; + delete recordData.display_name; + this._addItemToTable(id, display_name, recordData); + this._notifyCurrentState(); + } + return this._super(ev); + }, +}); + +const RangeUserValueWidget = UnitUserValueWidget.extend({ + tagName: 'we-range', + events: { + 'change input': '_onInputChange', + 'input input': '_onInputInput', + }, + + /** + * @override + */ + async start() { + await this._super(...arguments); + this.input = document.createElement('input'); + this.input.type = "range"; + let min = this.el.dataset.min && parseFloat(this.el.dataset.min) || 0; + let max = this.el.dataset.max && parseFloat(this.el.dataset.max) || 100; + const step = this.el.dataset.step && parseFloat(this.el.dataset.step) || 1; + this.displayValue = this.el.dataset.displayRangeValue; + if (min > max) { + [min, max] = [max, min]; + this.input.classList.add('o_we_inverted_range'); + } + this._setInputAttributes(min, max, step); + this.containerEl.appendChild(this.input); + if (this.displayValue) { + this.outputEl = document.createElement('output'); + this.outputEl.classList.add('ms-2'); + this.containerEl.appendChild(this.outputEl); + } + + this._onInputChange = debounce(this._onInputChange, 100); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + loadMethodsData(validMethodNames) { + this._super(...arguments); + for (const methodName of this._methodsNames) { + const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; + if (possibleValues.length > 1) { + this._setInputAttributes(0, possibleValues.length - 1, 1); + break; + } + } + }, + /** + * @override + */ + async setValue(value, methodName) { + await this._super(...arguments); + const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; + const inputValue = possibleValues.length > 1 ? possibleValues.indexOf(value) : this._value; + this.input.value = inputValue; + if (this.displayValue) { + this._computeDisplayValue(inputValue); + } + }, + /** + * @override + */ + getValue(methodName) { + const value = this._super(...arguments); + const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; + return possibleValues.length > 1 ? possibleValues[+value] : value; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onInputChange(ev) { + this._value = ev.target.value; + this._onUserValueChange(ev); + }, + /** + * @private + * @param {string} inputValue + */ + _computeDisplayValue(inputValue) { + if (this.el.dataset.toRatio) { + const inputValueAsNumber = Number(inputValue); + const ratio = inputValueAsNumber >= 0 ? 1 + inputValueAsNumber : 1 / (1 - inputValueAsNumber); + this.outputEl.value = `${ratio.toFixed(2)}x`; + } else { + this.outputEl.value = inputValue; + } + }, + /** + * @private + * @param {Event} ev + */ + _onInputInput(ev) { + this._value = ev.target.value; + if (this.displayValue) { + this._computeDisplayValue(this._value); + } + this._onUserValuePreview(ev); + }, + /** + * @private + */ + _setInputAttributes(min, max, step) { + this.input.setAttribute('min', min); + this.input.setAttribute('max', max); + this.input.setAttribute('step', step); + }, +}); + +const SelectPagerUserValueWidget = SelectUserValueWidget.extend({ + className: (SelectUserValueWidget.prototype.className || '') + ' o_we_select_pager', + events: Object.assign({}, SelectUserValueWidget.prototype.events, { + 'click .o_pager_nav_btn': '_onClickScrollPage', + 'click .o_pager_nav_angle': '_onClickCloseMenu', + }), + /** + * @override + */ + async start() { + const _super = this._super.bind(this); + + await _super(...arguments); + this.menuEl.classList.add('o_we_has_pager', 'position-fixed', 'top-0', 'end-0', 'z-1', 'rounded-0'); + this.menuTogglerEl.classList.add('o_we_toggler_pager'); + + this.pagerContainerEl = this.el.querySelector('.o_pager_container'); + this.__onScroll = throttleForAnimation(this._onScroll.bind(this)); + this.pagerContainerEl.addEventListener('scroll', this.__onScroll); + }, + /** + * @override + */ + destroy() { + this._super(...arguments); + this.pagerContainerEl.removeEventListener('scroll', this.__onScroll); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * We never try to adjust the position for selection with pagers as they + * are fullscreen. + * + * @override + */ + _adjustDropdownPosition() { + return; + }, + /** + * @override + */ + _shouldIgnoreClick(ev) { + return !!ev.target.closest('.o_pager_nav') || this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Scrolls to the requested section. + * + * @private + */ + _onClickScrollPage(ev) { + const navButtonEl = ev.currentTarget; + const attribute = navButtonEl.dataset.scrollTo; + const destinationOffset = this.menuEl.querySelector('.' + attribute).offsetTop; + + const pagerNavEl = this.menuEl.querySelector('.o_pager_nav'); + this.pagerContainerEl.scrollTop = destinationOffset - pagerNavEl.offsetHeight; + }, + /** + * @private + */ + _onClickCloseMenu(ev) { + this.close(); + }, + /** + * @private + */ + _onScroll(ev) { + const pagerContainerHeight = this.pagerContainerEl.getBoundingClientRect().height; + // The threshold for when a menu element is defined as 'active' is half + // of the container's height. This has a drawback as if a section + // is too small it might never get `active` if it's the last section. + const threshold = this.pagerContainerEl.scrollTop + (pagerContainerHeight / 2); + const anchorElements = this.menuEl.querySelectorAll('[data-scroll-to]'); + for (const anchorEl of anchorElements) { + const destination = anchorEl.getAttribute('data-scroll-to'); + const sectionEl = this.menuEl.querySelector(`.${destination}`); + const nextSectionEl = sectionEl.nextElementSibling; + anchorEl.classList.toggle('active', sectionEl.offsetTop < threshold && + (!nextSectionEl || nextSectionEl.offsetTop > threshold)); + } + } +}); + +const Many2oneUserValueWidget = SelectUserValueWidget.extend({ + className: (SelectUserValueWidget.prototype.className || '') + ' o_we_many2one', + events: Object.assign({}, SelectUserValueWidget.prototype.events, { + 'input .o_we_m2o_search input': '_onSearchInput', + 'keydown .o_we_m2o_search input': '_onSearchKeydown', + 'click .o_we_m2o_search_more': '_onSearchMoreClick', + }), + // Data-attributes that will be read into `this.options` on init and not + // transfered to inner buttons. + // `domain` is the static part of the domain used in searches, not + // depending on already selected ids and other filters. + configAttributes: [ + "model", "fields", "limit", "domain", + "callWith", "createMethod", "filterInModel", "filterInField", "nullText", + "defaultMessage", + ], + + /** + * @override + */ + init(parent, title, options, $target) { + this.afterSearch = []; + this.displayNameCache = {}; + const {dataAttributes} = options; + Object.assign(options, { + limit: '5', + fields: '[]', + domain: '[]', + callWith: 'id', + }); + this.configAttributes.forEach(attr => { + if (dataAttributes.hasOwnProperty(attr)) { + options[attr] = dataAttributes[attr]; + delete dataAttributes[attr]; + } + }); + options.limit = parseInt(options.limit); + options.fields = JSON.parse(options.fields); + if (!options.fields.includes('display_name')) { + options.fields.push('display_name'); + } + options.domain = JSON.parse(options.domain); + options.domainComponents = {}; + options.nullText = $target[0].dataset.nullText || + JSON.parse($target[0].dataset.oeContactOptions || '{}')['null_text']; + + this.orm = serviceCached(this.bindService("orm")); + + return this._super(...arguments); + }, + /** + * @override + */ + async start() { + await this._super(...arguments); + + this.inputEl = document.createElement('input'); + this.inputEl.setAttribute('placeholder', _t("Search for records...")); + const searchEl = document.createElement('div'); + searchEl.classList.add('o_we_m2o_search'); + searchEl.appendChild(this.inputEl); + this.menuEl.appendChild(searchEl); + + this.searchMore = document.createElement('div'); + this.searchMore.classList.add('o_we_m2o_search_more'); + this.searchMore.textContent = _t("Search more..."); + this.searchMore.title = _t("Search to show more records"); + + if (this.options.createMethod) { + this.createInput = new InputUserValueWidget(this, undefined, { + classes: ['o_we_large'], + dataAttributes: { noPreview: 'true' }, + }, this.$target); + this.createButton = new ButtonUserValueWidget(this, undefined, { + classes: ['flex-grow-0'], + dataAttributes: { + noPreview: 'true', + [this.options.createMethod]: '', // Value through getValue. + }, + childNodes: [document.createTextNode(_t("Create"))], + }, this.$target); + // Override isActive so it doesn't show up in toggler + this.createButton.isActive = () => false; + + await Promise.all([ + this.createInput.appendTo(document.createDocumentFragment()), + this.createButton.appendTo(document.createDocumentFragment()), + ]); + this.registerSubWidget(this.createInput); + this.registerSubWidget(this.createButton); + this.createWidget = _buildRowElement('', { + classes: ['o_we_full_row', 'o_we_m2o_create', 'p-1'], + childNodes: [this.createInput.el, this.createButton.el], + }); + } + + return this._search(''); + }, + /** + * @override + */ + async setValue(value, methodName) { + await this._super(...arguments); + if (this.menuTogglerEl.textContent === this.PLACEHOLDER_TEXT.toString()) { + // The currently selected value is not present in the search, need to read + // its display name. + if (value !== '') { + // FIXME: value may not be an id if callWith is specified! + this.menuTogglerEl.textContent = await this._getDisplayName(parseInt(value)); + } else { + this.menuTogglerEl.textContent = this.options.defaultMessage || _t("Choose a record..."); + } + } + }, + /** + * @override + */ + getValue(methodName) { + if (methodName === this.options.createMethod && this.createInput) { + return this.createInput._value; + } + return this._super(...arguments); + }, + /** + * Prevents double widget instanciation for we-buttons that have been + * created manually by _search (container widgets will have their innner + * html searched for userValueWidgets to instanciate during option startup) + * + * @override + */ + isContainer() { + return false; + }, + /** + * @override + */ + open() { + if (this.createInput) { + this.createInput.setValue(''); + } + return this._super(...arguments); + }, + /** + * Updates the domain with defined inclusive filter to make sure that only + * records that are linked to specific records are retrieved. + * Filtering-in is configured with + * * a `filterInModel` attribute, the linked model + * * a `filterInField` attribute, field of the linked model holding + * allowed values for this widget + * + * @param {integer[]} linkedRecordsIds + * @returns {Promise} + */ + async setFilterInDomainIds(linkedRecordsIds) { + const allowedIds = new Set(); + if (linkedRecordsIds) { + const parentRecordsData = await this.orm.searchRead( + this.options.filterInModel, + [['id', 'in', linkedRecordsIds]], + [this.options.filterInField] + ); + parentRecordsData.forEach(record => { + record[this.options.filterInField].forEach(item => allowedIds.add(item)); + }); + } + if (allowedIds.size) { + this.options.domainComponents.filterInModel = ['id', 'in', [...allowedIds]]; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Searches the database for corresponding records and updates the dropdown + * + * @private + */ + async _search(needle) { + const recTuples = await this.orm.call(this.options.model, "name_search", [], { + name: needle, + args: (await this._getSearchDomain()).concat( + Object.values(this.options.domainComponents).filter(item => item !== null) + ), + operator: "ilike", + limit: this.options.limit + 1, + }); + const records = await this.orm.read( + this.options.model, + recTuples.map(([id, _name]) => id), + this.options.fields + ); + // Remove select options. + this._userValueWidgets.filter(widget => { + return widget instanceof ButtonUserValueWidget && + !widget.isDestroyed() && + widget.el.parentElement.matches('we-selection-items'); + }).forEach(button => { + if (button.isPreviewed()) { + button.notifyValueChange('reset'); + } + button.destroy(); + }); + this._userValueWidgets = this._userValueWidgets.filter(widget => !widget.isDestroyed()); + if (this.options.nullText && + this.options.nullText.toLowerCase().includes(needle.toLowerCase())) { + // Beware of RPC cache. + if (!records.length || records[0].id) { + records.unshift({id: 0, name: this.options.nullText, display_name: this.options.nullText}); + } + } + records.forEach(record => { + this.displayNameCache[record.id] = record.display_name; + }); + + await Promise.all(records.slice(0, this.options.limit).map(async record => { + // Copy over the data-attributes from the main element, and default the value + // to the callWith field of the record so that if it's a method, it will + // be called with that value + const buttonDataAttributes = Object.assign({}, this.options.dataAttributes); + Object.keys(buttonDataAttributes).forEach(key => { + buttonDataAttributes[key] = buttonDataAttributes[key] || record[this.options.callWith]; + }); + // REMARK: this syntax is very similar to React.createComponent, maybe we could + // write a transformer like there is for JSX? + const buttonWidget = new ButtonUserValueWidget(this, undefined, { + dataAttributes: Object.assign({recordData: JSON.stringify(record)}, buttonDataAttributes), + childNodes: [document.createTextNode(record.display_name)], + }, this.$target); + this.registerSubWidget(buttonWidget); + await buttonWidget.appendTo(this.menuEl); + if (this._methodsNames) { + buttonWidget.loadMethodsData(this._methodsNames); + } + })); + // Load methodsData for new buttons if possible. It will not be possible + // when the widget is first created (as this._methodsNames will be undefined) + // but the snippetOption lifecycle will load the methods data explicitely + // just after creating the widget + if (this._methodsNames) { + this._methodsNames.forEach(methodName => { + this.setValue(this._value, methodName); + }); + } + + const hasMore = records.length > this.options.limit; + if (hasMore) { + this.menuEl.appendChild(this.searchMore); + this.searchMore.classList.remove('d-none'); + } else { + this.searchMore.classList.add('d-none'); + } + + if (this.createWidget) { + this.menuEl.appendChild(this.createWidget); + } + + this.waitingForSearch = false; + this.afterSearch.forEach(cb => cb()); + this.afterSearch = []; + if (this.options.nullText && !this.getValue()) { + this.setValue(0); + } + }, + /** + * Returns the domain to use for the search. + * + * @private + */ + async _getSearchDomain() { + return this.options.domain; + }, + /** + * Returns the display name for a given record. + * + * @private + */ + async _getDisplayName(recordId) { + if (!this.displayNameCache.hasOwnProperty(recordId)) { + this.displayNameCache[recordId] = (await this.orm.read(this.options.model, [recordId], ['display_name']))[0].display_name; + } + return this.displayNameCache[recordId]; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onClick(ev) { + // Prevent dropdown from closing if you click on the search or has_more + if (ev.target.closest('.o_we_m2o_search_more, .o_we_m2o_search, .o_we_m2o_create') && + !ev.target.closest('we-button')) { + ev.stopPropagation(); + return; + } + return this._super(...arguments); + }, + /** + * Handles changes to the search bar. + * + * @private + */ + _onSearchInput(ev) { + // maybe there is a concurrency primitive we can use instead of manual record-keeping? + // Basically we want to queue the enter action to go after the current search if there + // is one that is ongoing (ie currently waiting for the debounce or RPC) + clearTimeout(this.searchIntent); + this.waitingForSearch = true; + this.searchIntent = setTimeout(() => { + this._search(ev.target.value); + }, 500); + }, + /** + * Selects the first option when pressing enter in the search input. + * + * @private + */ + _onSearchKeydown(ev) { + if (ev.key !== "Enter") { + return; + } + const action = () => { + const firstButton = this.menuEl.querySelector(':scope > we-button'); + if (firstButton) { + firstButton.click(); + } + }; + if (this.waitingForSearch) { + this.afterSearch.push(action); + } else { + action(); + } + }, + /** + * Focuses the search input when clicking on the "Search more..." button. + * + * @private + */ + _onSearchMoreClick(ev) { + this.inputEl.focus(); + }, + /** + * @override + */ + _onUserValueNotification(ev) { + const { widget } = ev.data; + if (widget && widget === this.createInput) { + ev.stopPropagation(); + return; + } + if (widget && widget === this.createButton) { + // When the create button is clicked, make sure the text + // value is restored from the actual input element because + // it might have been removed when hovering existing tags. + // TODO review this, there is probably better to do + this.createInput._value = this.createInput.el.querySelector('input').value; + if (!this.createInput._value) { + ev.stopPropagation(); + } + return; + } + if (widget !== this.createButton && this.createInput) { + this.createInput._value = ''; + } + return this._super(ev); + }, +}); + +const Many2manyUserValueWidget = UserValueWidget.extend({ + configAttributes: ['model', 'recordId', 'm2oField', 'createMethod', 'fakem2m', 'filterIn'], + + /** + * @override + */ + init(parent, title, options, $target) { + const { dataAttributes } = options; + this.configAttributes.forEach(attr => { + if (dataAttributes.hasOwnProperty(attr)) { + options[attr] = dataAttributes[attr]; + delete dataAttributes[attr]; + } + }); + this.filterIn = options.filterIn !== undefined; + if (this.filterIn) { + // Transfer filter-in values to child m2o. + dataAttributes.filterInModel = options.model; + dataAttributes.filterInField = options.m2oField; + } + this.orm = this.bindService("orm"); + this.fields = this.bindService("field"); + return this._super(...arguments); + }, + /** + * @override + */ + async willStart() { + await this._super(...arguments); + // If the widget does not have a real m2m field in the database + // We do not need to fetch anything from the DB + if (this.options.fakem2m) { + this.m2oModel = this.options.model; + return; + } + const { model, recordId, m2oField } = this.options; + const [record] = await this.orm.read(model, [parseInt(recordId)], [m2oField]); + const selectedRecordIds = record[m2oField]; + // TODO: handle no record + const modelData = await this.fields.loadFields(model, { fieldNames: [m2oField] }); + // TODO: simultaneously fly both RPCs + this.m2oModel = modelData[m2oField].relation; + this.m2oName = modelData[m2oField].field_description; // Use as string attr? + + const selectedRecords = await this.orm.read(this.m2oModel, selectedRecordIds, ['display_name']); + // TODO: reconcile the fact that this widget sets its own initial value + // instead of it coming through setValue(_computeWidgetState) + this._value = JSON.stringify(selectedRecords); + }, + /** + * @override + */ + async start() { + this.el.classList.add('o_we_m2m'); + const m2oDataAttributes = Object.entries(this.options.dataAttributes).filter(([attrName]) => { + return Many2oneUserValueWidget.prototype.configAttributes.includes(attrName); + }); + m2oDataAttributes.push( + ['model', this.m2oModel], + ['addRecord', ''], + ['createMethod', this.options.createMethod], + ); + // Don't register this one as a subWidget because it will be a subWidget + // of the listWidget + this.createWidget = new Many2oneUserValueWidget(null, undefined, { + dataAttributes: Object.fromEntries(m2oDataAttributes), + }, this.$target); + + this.listWidget = registerUserValueWidget('we-list', this, undefined, { + dataAttributes: { unsortable: 'true', notEditable: 'true', allowEmpty: 'true' }, + createWidget: this.createWidget, + }, this.$target); + await this.listWidget.appendTo(this.containerEl); + + // Make this.el the select's offsetParent so the we-selection-items has + // the correct width + this.listWidget.el.querySelector('we-select').style.position = 'static'; + this.el.style.position = 'relative'; + }, + /** + * Only allow to fetch/select records which are linked (via `m2oField`) to the + * specified records. + * + * @param {integer[]} linkedRecordsIds + * @returns {Promise} + * @see Many2oneUserValueWidget.setFilterInDomainIds + */ + async setFilterInDomainIds(linkedRecordsIds) { + if (this.filterIn) { + return this.listWidget.createWidget.setFilterInDomainIds(linkedRecordsIds); + } + }, + /** + * @override + */ + loadMethodsData(validMethodNames, ...rest) { + // TODO: check that addRecord is still needed. + this._super(['addRecord', ...validMethodNames], ...rest); + this._methodsNames = this._methodsNames.filter(name => name !== 'addRecord'); + }, + /** + * @override + */ + setValue(value, methodName) { + if (methodName === this.options.createMethod) { + return this.createWidget.setValue(value, methodName); + } + if (!value) { + // TODO: why do we need this. + value = this._value; + } + this._super(value, methodName); + this.listWidget.setValue(this._value); + }, + /** + * @override + */ + getValue(methodName) { + return this.listWidget.getValue(methodName); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onUserValueNotification(ev) { + const { widget, previewMode } = ev.data; + if (!widget) { + return this._super(ev); + } + if (widget === this.listWidget) { + ev.stopPropagation(); + this._value = widget._value; + this.notifyValueChange(previewMode); + } + }, +}); + +const userValueWidgetsRegistry = { + 'we-button': ButtonUserValueWidget, + 'we-checkbox': CheckboxUserValueWidget, + 'we-select': SelectUserValueWidget, + 'we-button-group': ButtonGroupUserValueWidget, + 'we-input': InputUserValueWidget, + 'we-multi': MultiUserValueWidget, + 'we-colorpicker': ColorpickerUserValueWidget, + 'we-datetimepicker': DatetimePickerUserValueWidget, + 'we-datepicker': DatePickerUserValueWidget, + 'we-list': ListUserValueWidget, + 'we-imagepicker': ImagepickerUserValueWidget, + 'we-videopicker': VideopickerUserValueWidget, + 'we-range': RangeUserValueWidget, + 'we-select-pager': SelectPagerUserValueWidget, + 'we-many2one': Many2oneUserValueWidget, + 'we-many2many': Many2manyUserValueWidget, +}; + +/** + * Handles a set of options for one snippet. The registry returned by this + * module contains the names of the specialized SnippetOptionWidget which can be + * referenced thanks to the data-js key in the web_editor options template. + */ +const SnippetOptionWidget = Widget.extend({ + tagName: 'we-customizeblock-option', + events: { + 'click .o_we_collapse_toggler': '_onCollapseTogglerClick', + }, + custom_events: { + 'user_value_update': '_onUserValueUpdate', + 'user_value_widget_critical': '_onUserValueWidgetCritical', + }, + /** + * Indicates if the option should be displayed in the button group at the + * top of the options panel, next to the clone/remove button. + * + * @type {boolean} + */ + isTopOption: false, + /** + * Indicates if the option should be the first one displayed in the button + * group at the top of the options panel, next to the clone/remove button. + * + * @type {boolean} + */ + isTopFirstOption: false, + /** + * Forces the target to not be possible to remove. It will also hide the + * clone button. + * + * @type {boolean} + */ + forceNoDeleteButton: false, + /** + * The option needs the handles overlay to be displayed on the snippet. + * + * @type {boolean} + */ + displayOverlayOptions: false, + /** + * Forces the target to be duplicable. + * + * @type {boolean} + */ + forceDuplicateButton: false, + + /** + * The option `$el` is supposed to be the associated DOM UI element. + * The option controls another DOM element: the snippet it + * customizes, which can be found at `$target`. Access to the whole edition + * overlay is possible with `$overlay` (this is not recommended though). + * + * @constructor + */ + init: function (parent, $uiElements, $target, $overlay, data, options) { + this._super.apply(this, arguments); + + this.$originalUIElements = $uiElements; + + this.$target = $target; + this.$overlay = $overlay; + this.data = data; + this.options = options; + + this.className = 'snippet-option-' + this.data.optionName; + + this.ownerDocument = this.$target[0].ownerDocument; + + this._userValueWidgets = []; + this._actionQueues = new Map(); + + this.dialog = this.bindService("dialog"); + }, + /** + * @override + */ + willStart: async function () { + await this._super(...arguments); + return this._renderOriginalXML().then(uiFragment => { + this.uiFragment = uiFragment; + }); + }, + /** + * @override + */ + renderElement: function () { + this._super(...arguments); + this.el.appendChild(this.uiFragment); + this.uiFragment = null; + }, + /** + * Called when the parent edition overlay is covering the associated snippet + * (the first time, this follows the call to the @see start method). + * + * @abstract + * @returns {Promise|undefined} + */ + async onFocus() {}, + /** + * Called when the parent edition overlay is covering the associated snippet + * for the first time, when it is a new snippet dropped from the d&d snippet + * menu. Note: this is called after the start and onFocus methods. + * + * @abstract + * @param {Object} options + * @param {boolean} options.isCurrent + * true if the main element has been built (so not when a child of + * the main element has been built). + * @returns {Promise|undefined} + */ + async onBuilt(options) {}, + /** + * Called when the parent edition overlay is removed from the associated + * snippet (another snippet enters edition for example). + * + * @abstract + * @returns {Promise|undefined} + */ + async onBlur() {}, + /** + * Called when the associated snippet is the result of the cloning of + * another snippet (so `this.$target` is a cloned element). + * + * @abstract + * @param {Object} options + * @param {boolean} options.isCurrent + * true if the associated snippet is a clone of the main element that + * was cloned (so not a clone of a child of this main element that + * was cloned) + */ + onClone: function (options) {}, + /** + * Called when the associated snippet is moved to another DOM location. + * + * @abstract + */ + onMove: function () {}, + /** + * Called when the associated snippet is about to be removed from the DOM. + * + * @abstract + * @returns {Promise|undefined} + */ + onRemove: async function () {}, + /** + * Called when the target is shown, only meaningful if the target was hidden + * at some point (typically used for 'invisible' snippets). + * + * @abstract + * @returns {Promise|undefined} + */ + onTargetShow: async function () {}, + /** + * Called when the target is hidden (typically used for 'invisible' + * snippets). + * + * @abstract + * @returns {Promise|undefined} + */ + onTargetHide: async function () {}, + /** + * Called when the template which contains the associated snippet is about + * to be saved. + * + * @abstract + * @return {Promise|undefined} + */ + cleanForSave: async function () {}, + /** + * Called when the associated snippet UI needs to be cleaned (e.g. from + * visual effects like previews). + * TODO this function will replace `cleanForSave` in the future. + * + * @abstract + * @return {Promise|undefined} + */ + cleanUI: async function () {}, + /** + * Adds the given widget to the known list of user value widgets + * + * @param {UserValueWidget} widget + */ + registerSubWidget(widget) { + this._userValueWidgets.push(widget); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Default option method which allows to select one and only one class in + * the option classes set and set it on the associated snippet. The common + * case is having a select with each item having a `data-select-class` + * value allowing to choose the associated class, or simply an unique + * checkbox to allow toggling a unique class. + * + * @param {boolean|string} previewMode + * - truthy if the option is enabled for preview or if leaving it (in + * that second case, the value is 'reset') + * - false if the option should be activated for good + * @param {string} widgetValue + * @param {Object} params + * @returns {Promise|undefined} + */ + selectClass: function (previewMode, widgetValue, params) { + for (const classNames of params.possibleValues) { + if (classNames) { + this.$target[0].classList.remove(...classNames.trim().split(/\s+/g)); + } + } + if (widgetValue) { + this.$target[0].classList.add(...widgetValue.trim().split(/\s+/g)); + } + }, + /** + * Default option method which allows to select a value and set it on the + * associated snippet as a data attribute. The name of the data attribute is + * given by the attributeName parameter. + * + * @param {boolean} previewMode - @see this.selectClass + * @param {string} widgetValue + * @param {Object} params + * @returns {Promise|undefined} + */ + selectDataAttribute: function (previewMode, widgetValue, params) { + const value = this._selectAttributeHelper(widgetValue, params); + this.$target[0].dataset[params.attributeName] = value; + }, + /** + * Default option method which allows to select a value and set it on the + * associated snippet as an attribute. The name of the attribute is + * given by the attributeName parameter. + * + * @param {boolean} previewMode - @see this.selectClass + * @param {string} widgetValue + * @param {Object} params + * @returns {Promise|undefined} + */ + selectAttribute: function (previewMode, widgetValue, params) { + const value = this._selectAttributeHelper(widgetValue, params); + if (value) { + this.$target[0].setAttribute(params.attributeName, value); + } else { + this.$target[0].removeAttribute(params.attributeName); + } + }, + /** + * Default option method which allows to select a value and set it on the + * associated snippet as a property. The name of the property is + * given by the propertyName parameter. + * + * @param {boolean} previewMode - @see this.selectClass + * @param {string} widgetValue + * @param {Object} params + */ + selectProperty: function (previewMode, widgetValue, params) { + if (!params.propertyName) { + throw new Error('Property name missing'); + } + const value = this._selectValueHelper(widgetValue, params); + this.$target[0][params.propertyName] = value; + }, + /** + * Default option method which allows to select a value and set it on the + * associated snippet as a css style. The name of the css property is + * given by the cssProperty parameter. + * + * @param {boolean} previewMode - @see this.selectClass + * @param {string} widgetValue + * @param {Object} params + * @param {string} [params.forceStyle] if undefined, the method will not + * set the inline style (and thus even remove it) if the item would + * already have the given style without it (thanks to a CSS rule for + * example). If defined (as a string), it acts as the "priority" param + * of @see CSSStyleDeclaration.setProperty: it should be 'important' to + * set the style as important or '' otherwise. Note that if forceStyle + * is undefined, the style is set as important only if required to have + * an effect. + * @returns {Promise|undefined} + */ + selectStyle: async function (previewMode, widgetValue, params) { + // Disable all transitions for the duration of the method as many + // comparisons will be done on the element to know if applying a + // property has an effect or not. Also, changing a css property via the + // editor should not show any transition as previews would not be done + // immediately, which is not good for the user experience. + this.$target[0].classList.add('o_we_force_no_transition'); + const _restoreTransitions = () => this.$target[0].classList.remove('o_we_force_no_transition'); + + if (params.cssProperty === 'background-color') { + this.$target.trigger('background-color-event', previewMode); + } + + // Always reset the inline style first to not put inline style on an + // element which already have this style through css stylesheets. + let cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty]; + for (const cssProp of cssProps) { + this.$target[0].style.setProperty(cssProp, ''); + } + if (params.extraClass) { + this.$target.removeClass(params.extraClass); + } + // Plain color and gradient are mutually exclusive as background so in + // case we edit a background-color we also have to reset the gradient + // part of the background-image property (the opposite is handled by the + // fact that editing a gradient as background is done by calling this + // method with background-color as property too, so it is automatically + // reset anyway). + let bgImageParts = undefined; + if (params.withGradients && params.cssProperty === 'background-color') { + const styles = getComputedStyle(this.$target[0]); + bgImageParts = backgroundImageCssToParts(styles['background-image']); + delete bgImageParts.gradient; + this.$target[0].style.setProperty('background-image', ''); + if (!widgetValue || widgetValue === 'false') { + // If no background-color is being set and there is an image, + // combine it with the current color combination's gradient. + const styleBgImageParts = backgroundImageCssToParts(styles['background-image']); + bgImageParts.gradient = styleBgImageParts.gradient; + } + const combined = backgroundImagePartsToCss(bgImageParts); + applyCSS.call(this, 'background-image', combined, styles); + } + + // Only allow to use a color name as a className if we know about the + // other potential color names (to remove) and if we know about a prefix + // (otherwise we suppose that we should use the actual related color). + // Note: color combinations classes are handled by a dedicated method, + // as they can be combined with normal classes. + if (params.colorNames && params.colorPrefix) { + const colorNames = params.colorNames.filter(name => !weUtils.isColorCombinationName(name)); + const classes = weUtils.computeColorClasses(colorNames, params.colorPrefix); + this.$target[0].classList.remove(...classes); + + if (colorNames.includes(widgetValue)) { + const originalCSSValue = window.getComputedStyle(this.$target[0])[cssProps[0]]; + const className = params.colorPrefix + widgetValue; + this.$target[0].classList.add(className); + if (originalCSSValue !== window.getComputedStyle(this.$target[0])[cssProps[0]]) { + // If applying the class did indeed changed the css + // property we are editing, nothing more has to be done. + // (except adding the extra class) + this.$target.addClass(params.extraClass); + _restoreTransitions(); + return; + } + // Otherwise, it means that class probably does not exist, + // we remove it and continue. Especially useful for some + // prefixes which only work with some color names but not all. + this.$target[0].classList.remove(className); + } + } + + const styles = window.getComputedStyle(this.$target[0]); + + // At this point, the widget value is either a property/color name or + // an actual css property value. If it is a property/color name, we will + // apply a css variable as style value. + const htmlPropValue = weUtils.getCSSVariableValue(widgetValue); + if (htmlPropValue) { + widgetValue = `var(--${widgetValue})`; + } + + // In case of background-color edition, we could receive a gradient, in + // which case the value has to be combined with the potential background + // image (real image). + if (params.withGradients && params.cssProperty === 'background-color' && weUtils.isColorGradient(widgetValue)) { + cssProps = ['background-image']; + bgImageParts.gradient = widgetValue; + widgetValue = backgroundImagePartsToCss(bgImageParts); + + // Also force the background-color to transparent as otherwise it + // won't act as a "gradient replacing the color combination + // background" but be applied over it (which would be the opposite + // of what happens when editing the background color). + applyCSS.call(this, 'background-color', 'rgba(0, 0, 0, 0)', styles); + } + + // replacing ', ' by ',' to prevent attributes with internal space separators from being split: + // eg: "rgba(55, 12, 47, 1.9) 47px" should be split as ["rgba(55,12,47,1.9)", "47px"] + const values = widgetValue.replace(/,\s/g, ',').split(/\s+/g); + while (values.length < cssProps.length) { + switch (values.length) { + case 1: + case 2: { + values.push(values[0]); + break; + } + case 3: { + values.push(values[1]); + break; + } + default: { + values.push(values[values.length - 1]); + } + } + } + + let hasUserValue = false; + const applyAllCSS = (values) => { + for (let i = cssProps.length - 1; i > 0; i--) { + hasUserValue = applyCSS.call(this, cssProps[i], values.pop(), styles) || hasUserValue; + } + hasUserValue = applyCSS.call(this, cssProps[0], values.join(' '), styles) || hasUserValue; + } + + applyAllCSS([...values]); + + function applyCSS(cssProp, cssValue, styles) { + if (typeof params.forceStyle !== 'undefined') { + this.$target[0].style.setProperty(cssProp, cssValue, params.forceStyle); + return true; + } + + if (!weUtils.areCssValuesEqual(styles.getPropertyValue(cssProp), cssValue, cssProp, this.$target[0])) { + this.$target[0].style.setProperty(cssProp, cssValue); + // If change had no effect then make it important. + if (!params.preventImportant && !weUtils.areCssValuesEqual( + styles.getPropertyValue(cssProp), cssValue, cssProp, this.$target[0])) { + this.$target[0].style.setProperty(cssProp, cssValue, 'important'); + } + return true; + } + return false; + } + + if (params.extraClass) { + this.$target.toggleClass(params.extraClass, hasUserValue); + if (hasUserValue) { + // Might have changed because of the class. + for (const cssProp of cssProps) { + this.$target[0].style.removeProperty(cssProp); + } + applyAllCSS(values); + } + } + + _restoreTransitions(); + }, + /** + * Sets a color combination. + * + * @see this.selectClass for parameters + */ + async selectColorCombination(previewMode, widgetValue, params) { + if (params.colorNames) { + const names = params.colorNames.filter(weUtils.isColorCombinationName); + const classes = weUtils.computeColorClasses(names); + this.$target[0].classList.remove(...classes); + + if (widgetValue) { + this.$target[0].classList.add('o_cc', `o_cc${widgetValue}`); + } + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Override the helper method to search inside the $target element instead + * of the UI item element. + * + * @override + */ + $: function () { + return this.$target.find.apply(this.$target, arguments); + }, + /** + * Closes all user value widgets. + */ + closeWidgets: function () { + this._userValueWidgets.forEach(widget => widget.close()); + }, + /** + * @param {string} name + * @returns {UserValueWidget|null} + */ + findWidget: function (name) { + for (const widget of this._userValueWidgets) { + if (widget.getName() === name) { + return widget; + } + const depWidget = widget.findWidget(name); + if (depWidget) { + return depWidget; + } + } + return null; + }, + /** + * Sometimes, options may need to notify other options, even in parent + * editors. This can be done thanks to the 'option_update' event, which + * will then be handled by this function. + * + * @param {string} name - an identifier for a type of update + * @param {*} data + */ + notify: function (name, data) { + // We prefer to avoid refactoring this notify mechanism to make it + // asynchronous because the upcoming conversion to owl might remove it. + if (name === 'target') { + this.setTarget(data); + } + }, + /** + * Sometimes, an option is binded on an element but should in fact apply on + * another one. For example, elements which contain slides: we want all the + * per-slide options to be in the main menu of the whole snippet. This + * function allows to set the option's target. + * + * Note: the UI is not updated accordindly automatically. + * + * @param {jQuery} $target - the new target element + * @returns {Promise} + */ + setTarget: function ($target) { + this.$target = $target; + }, + /** + * Updates the UI. For widget update, @see _computeWidgetState. + * + * @param {boolean} [noVisibility=false] + * If true, only update widget values and their UI, not their visibility + * -> @see updateUIVisibility for toggling visibility only + * @param {boolean} [assetsChanged=false] + * If true, widgets might prefer to _rerenderXML instead of calling + * this super implementation + * @returns {Promise} + */ + async updateUI({noVisibility, assetsChanged} = {}) { + // For each widget, for each of their option method, notify to the + // widget the current value they should hold according to the $target's + // current state, related for that method. + const proms = this._userValueWidgets.map(async widget => { + // Update widget value (for each method) + const methodsNames = widget.getMethodsNames(); + for (const methodName of methodsNames) { + const params = widget.getMethodsParams(methodName); + + let obj = this; + if (params.applyTo) { + const $firstSubTarget = this.$(params.applyTo).eq(0); + if (!$firstSubTarget.length) { + continue; + } + obj = createPropertyProxy(this, '$target', $firstSubTarget); + } + + const value = await this._computeWidgetState.call(obj, methodName, params); + if (value === undefined) { + continue; + } + const normalizedValue = this._normalizeWidgetValue(value); + await widget.setValue(normalizedValue, methodName); + } + }); + await Promise.all(proms); + + if (!noVisibility) { + await this.updateUIVisibility(); + } + }, + /** + * Updates the UI visibility - @see _computeVisibility. For widget update, + * @see _computeWidgetVisibility. + * + * @returns {Promise} + */ + updateUIVisibility: async function () { + const proms = this._userValueWidgets.map(async widget => { + const params = widget.getMethodsParams(); + + let obj = this; + if (params.applyTo) { + const $firstSubTarget = this.$(params.applyTo).eq(0); + if (!$firstSubTarget.length) { + widget.toggleVisibility(false); + return; + } + obj = createPropertyProxy(this, '$target', $firstSubTarget); + } + + // Make sure to check the visibility of all sub-widgets. For + // simplicity and efficiency, those will be checked with main + // widgets params. + const allSubWidgets = [widget]; + let i = 0; + while (i < allSubWidgets.length) { + allSubWidgets.push(...allSubWidgets[i]._userValueWidgets); + i++; + } + const proms = allSubWidgets.map(async widget => { + const show = await this._computeWidgetVisibility.call(obj, widget.getName(), params); + if (!show) { + widget.toggleVisibility(false); + return; + } + + const dependencies = widget.getDependencies(); + + if (dependencies.length === 1 && dependencies[0] === 'fake') { + widget.toggleVisibility(false); + return; + } + + const dependenciesData = []; + dependencies.forEach(depName => { + const toBeActive = (depName[0] !== '!'); + if (!toBeActive) { + depName = depName.substr(1); + } + + const widget = this._requestUserValueWidgets(depName, true)[0]; + if (widget) { + dependenciesData.push({ + widget: widget, + toBeActive: toBeActive, + }); + } + }); + const dependenciesOK = !dependenciesData.length || dependenciesData.some(depData => { + return (depData.widget.isActive() === depData.toBeActive); + }); + + widget.toggleVisibility(dependenciesOK); + }); + return Promise.all(proms); + }); + + const showUI = await this._computeVisibility(); + this.el.classList.toggle('d-none', !showUI); + + await Promise.all(proms); + + // Hide layouting elements which contains only hidden widgets + // TODO improve this, this is hackish to rely on DOM structure here. + // Layouting elements should be handled as widgets or other. + for (const el of this.$el.find('we-row')) { + const $userValueWidget = $(el).find('> div > .o_we_user_value_widget'); + el.classList.toggle('d-none', $userValueWidget.length && !$userValueWidget.not('.d-none').length); + } + for (const el of this.$el.find('we-collapse')) { + const $el = $(el); + el.classList.toggle('d-none', $el.children().first().hasClass('d-none')); + const hasNoVisibleElInCollapseMenu = !$el.children().last().children().not('.d-none').length; + if (hasNoVisibleElInCollapseMenu) { + this._toggleCollapseEl(el, false); + } + el.querySelector('.o_we_collapse_toggler').classList.toggle('d-none', hasNoVisibleElInCollapseMenu); + } + + return !this.displayOverlayOptions && showUI; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {UserValueWidget[]} widgets + * @returns {Promise<string>} + */ + async _checkIfWidgetsUpdateNeedWarning(widgets) { + const messages = []; + for (const widget of widgets) { + const message = widget.getMethodsParams().warnMessage; + if (message) { + messages.push(message); + } + } + return messages.join(' '); + }, + /** + * @private + * @param {UserValueWidget[]} widgets + * @returns {Promise<boolean|string>} + */ + async _checkIfWidgetsUpdateNeedReload(widgets) { + return false; + }, + /** + * @private + * @returns {Promise<boolean>|boolean} + */ + _computeVisibility: async function () { + return true; + }, + /** + * Returns the string value that should be hold by the widget which is + * related to the given method name. + * + * If the value is irrelevant for a method, it must return undefined. + * + * @private + * @param {string} methodName + * @param {Object} params + * @returns {Promise<string|undefined>|string|undefined} + */ + _computeWidgetState: async function (methodName, params) { + switch (methodName) { + case 'selectClass': { + let maxNbClasses = 0; + let activeClassNames = ''; + for (const classNames of params.possibleValues) { + if (!classNames) { + continue; + } + const classes = classNames.split(/\s+/g); + if (params.stateToFirstClass) { + if (this.$target[0].classList.contains(classes[0])) { + return classNames; + } else { + continue; + } + } + + if (classes.length >= maxNbClasses + && classes.every(className => this.$target[0].classList.contains(className))) { + maxNbClasses = classes.length; + activeClassNames = classNames; + } + } + return activeClassNames; + } + case 'selectAttribute': + case 'selectDataAttribute': { + const attrName = params.attributeName; + let attrValue; + if (methodName === 'selectAttribute') { + attrValue = this.$target[0].getAttribute(attrName); + } else if (methodName === 'selectDataAttribute') { + attrValue = this.$target[0].dataset[attrName]; + } + attrValue = (attrValue || '').trim(); + if (params.saveUnit && !params.withUnit) { + attrValue = attrValue.split(/\s+/g).map(v => v + params.saveUnit).join(' '); + } + return attrValue || params.attributeDefaultValue || ''; + } + case 'selectStyle': { + let usedCC = undefined; + if (params.colorPrefix && params.colorNames) { + for (const c of params.colorNames) { + const className = weUtils.computeColorClasses([c], params.colorPrefix)[0]; + if (this.$target[0].classList.contains(className)) { + if (weUtils.isColorCombinationName(c)) { + usedCC = c; + continue; + } + return c; + } + } + } + + // Disable all transitions for the duration of the style check + // as we want to know the final value of a property to properly + // update the UI. + this.$target[0].classList.add('o_we_force_no_transition'); + + const styles = window.getComputedStyle(this.$target[0]); + const cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty]; + const borderWidthCssProps = weUtils.CSS_SHORTHANDS['border-width']; + const cssValues = cssProps.map(cssProp => { + let value = styles.getPropertyValue(cssProp).trim(); + if (cssProp === 'box-shadow') { + const inset = value.includes('inset'); + let values = value.replace(/,\s/g, ',').replace('inset', '').trim().split(/\s+/g); + const color = values.find(s => !s.match(/^\d/)); + values = values.join(' ').replace(color, '').trim(); + value = `${color} ${values}${inset ? ' inset' : ''}`; + } + if (borderWidthCssProps.includes(cssProp) && value.endsWith('px')) { + // Rounding value up avoids zoom-in issues. + // Zoom-out issues are not an expected use case. + value = `${Math.ceil(parseFloat(value))}px`; + } + return value; + }); + if (cssValues.length === 4 && weUtils.areCssValuesEqual(cssValues[3], cssValues[1], params.cssProperty, this.$target)) { + cssValues.pop(); + } + if (cssValues.length === 3 && weUtils.areCssValuesEqual(cssValues[2], cssValues[0], params.cssProperty, this.$target)) { + cssValues.pop(); + } + if (cssValues.length === 2 && weUtils.areCssValuesEqual(cssValues[1], cssValues[0], params.cssProperty, this.$target)) { + cssValues.pop(); + } + + let value = cssValues.join(' '); + if (params.withGradients && params.cssProperty === 'background-color') { + // Check if there is a gradient, in that case this is the + // value to be returned, we normally do not allow color and + // gradient at the same time (the option would remove one + // if editing the other). + const parts = backgroundImageCssToParts(styles['background-image']); + if (parts.gradient) { + value = parts.gradient; + } + } + + this.$target[0].classList.remove('o_we_force_no_transition'); + + if (params.cssProperty === 'background-color' && params.withCombinations) { + if (usedCC) { + const ccValue = weUtils.getCSSVariableValue(`o-cc${usedCC}-bg-gradient`).trim().replaceAll("'", '') + || weUtils.getCSSVariableValue(`o-cc${usedCC}-bg`).trim(); + if (weUtils.areCssValuesEqual(value, ccValue)) { + // Prevent to consider that a color is used as CC + // override in case that color is the same as the + // one used in that CC. + return ''; + } + } else { + const rgba = convertCSSColorToRgba(value); + if (rgba && rgba.opacity < 0.001) { + // Prevent to consider a transparent color is + // applied as background unless it is to override a + // CC. Simply allows to add a CC on a transparent + // snippet in the first place. + return ''; + } + } + } + // When the default color is the target's "currentColor", the + // value should be handled correctly by the option. + if (value === "currentColor") { + return styles.color; + } + + return value; + } + case 'selectColorCombination': { + if (params.colorNames) { + for (const c of params.colorNames) { + if (!weUtils.isColorCombinationName(c)) { + continue; + } + const className = weUtils.computeColorClasses([c])[0]; + if (this.$target[0].classList.contains(className)) { + return c; + } + } + } + return ''; + } + } + }, + /** + * @private + * @param {string} widgetName + * @param {Object} params + * @returns {Promise<boolean>|boolean} + */ + _computeWidgetVisibility: async function (widgetName, params) { + return true; + }, + /** + * @private + * @param {HTMLElement} el + * @returns {Object} + */ + _extraInfoFromDescriptionElement: function (el) { + return { + title: el.getAttribute('string'), + options: { + classes: el.classList, + dataAttributes: el.dataset, + tooltip: el.title, + placeholder: el.getAttribute('placeholder'), + childNodes: [...el.childNodes], + }, + }; + }, + /** + * @private + * @param {*} + * @returns {string} + */ + _normalizeWidgetValue: function (value) { + value = `${value}`.trim(); // Force to a trimmed string + value = normalizeCSSColor(value); // If is a css color, normalize it + return value; + }, + /** + * @private + * @param {HTMLElement} uiFragment + * @returns {Promise} + */ + _renderCustomWidgets: function (uiFragment) { + return Promise.resolve(); + }, + /** + * @private + * @param {HTMLElement} uiFragment + * @returns {Promise} + */ + _renderCustomXML: function (uiFragment) { + return Promise.resolve(); + }, + /** + * @private + * @param {jQuery} [$xml] - default to original xml content + * @returns {Promise} + */ + _renderOriginalXML: async function ($xml) { + const uiFragment = document.createDocumentFragment(); + ($xml || this.$originalUIElements).clone(true).appendTo(uiFragment); + + await this._renderCustomXML(uiFragment); + + // Build layouting components first + for (const [itemName, build] of [['we-row', _buildRowElement], ['we-collapse', _buildCollapseElement]]) { + uiFragment.querySelectorAll(itemName).forEach(el => { + const infos = this._extraInfoFromDescriptionElement(el); + const groupEl = build(infos.title, infos.options); + el.parentNode.insertBefore(groupEl, el); + el.parentNode.removeChild(el); + }); + } + + // Load widgets + await this._renderXMLWidgets(uiFragment); + await this._renderCustomWidgets(uiFragment); + + if (this.isDestroyed()) { + // TODO there is probably better to do. This case was found only in + // tours, where the editor is left before the widget are fully + // loaded (loadMethodsData doesn't work if the widget is destroyed). + return uiFragment; + } + + const validMethodNames = []; + for (const key in this) { + validMethodNames.push(key); + } + this._userValueWidgets.forEach(widget => { + widget.loadMethodsData(validMethodNames); + }); + + return uiFragment; + }, + /** + * @private + * @param {HTMLElement} parentEl + * @param {SnippetOptionWidget|UserValueWidget} parentWidget + * @returns {Promise} + */ + _renderXMLWidgets: function (parentEl, parentWidget) { + const proms = [...parentEl.children].map(el => { + const widgetName = el.tagName.toLowerCase(); + if (!userValueWidgetsRegistry.hasOwnProperty(widgetName)) { + return this._renderXMLWidgets(el, parentWidget); + } + + const infos = this._extraInfoFromDescriptionElement(el); + const widget = registerUserValueWidget(widgetName, parentWidget || this, infos.title, infos.options, this.$target); + return widget.insertAfter(el).then(() => { + // Remove the original element afterwards as the insertion + // operation may move some of its inner content during + // widget start. + parentEl.removeChild(el); + + if (widget.isContainer() && !widget.isDestroyed()) { + return this._renderXMLWidgets(widget.el, widget); + } + }); + }); + return Promise.all(proms); + }, + /** + * @private + * @param {...string} widgetNames + * @param {boolean} [allowParentOption=false] + * @returns {UserValueWidget[]} + */ + _requestUserValueWidgets: function (...args) { + const widgetNames = args; + let allowParentOption = false; + const lastArg = args[args.length - 1]; + if (typeof lastArg === 'boolean') { + widgetNames.pop(); + allowParentOption = lastArg; + } + + const widgets = []; + for (const widgetName of widgetNames) { + let widget = null; + this.trigger_up('user_value_widget_request', { + name: widgetName, + onSuccess: _widget => widget = _widget, + allowParentOption: allowParentOption, + }); + if (widget) { + widgets.push(widget); + } + } + return widgets; + }, + /** + * @private + * @param {function<Promise<jQuery>>} [callback] + * @returns {Promise} + */ + _rerenderXML: async function (callback) { + this._userValueWidgets.forEach(widget => widget.destroy()); + this._userValueWidgets = []; + this.$el.empty(); + + let $xml = undefined; + if (callback) { + $xml = await callback.call(this); + } + + return this._renderOriginalXML($xml).then(uiFragment => { + this.$el.append(uiFragment); + return this.updateUI(); + }); + }, + /** + * Activates the option associated to the given DOM element. + * + * @private + * @param {boolean|string} previewMode + * - truthy if the option is enabled for preview or if leaving it (in + * that second case, the value is 'reset') + * - false if the option should be activated for good + * @param {UserValueWidget} widget - the widget which triggered the option change + * @returns {Promise} + */ + _select: async function (previewMode, widget) { + let $applyTo = null; + + if (previewMode === true) { + this.options.wysiwyg.odooEditor.automaticStepUnactive('preview_option'); + } + + // Call each option method sequentially + for (const methodName of widget.getMethodsNames()) { + const widgetValue = widget.getValue(methodName); + const params = widget.getMethodsParams(methodName); + + if (params.applyTo) { + if (!$applyTo) { + $applyTo = this.$(params.applyTo); + } + const proms = Array.from($applyTo).map((subTargetEl) => { + const proxy = createPropertyProxy(this, '$target', $(subTargetEl)); + return this[methodName].call(proxy, previewMode, widgetValue, params); + }); + await Promise.all(proms); + } else { + await this[methodName](previewMode, widgetValue, params); + } + } + + if (previewMode === 'reset' || previewMode === false) { + this.options.wysiwyg.odooEditor.automaticStepActive('preview_option'); + } + + // We trigger the event on elements targeted by apply-to if any as + // this.$target could not be in an editable element while the elements + // targeted by apply-to are. + ($applyTo || this.$target).trigger('content_changed'); + }, + /** + * Used to handle attribute or data attribute value change + * + * @see this._selectValueHelper for parameters + */ + _selectAttributeHelper(value, params) { + if (!params.attributeName) { + throw new Error('Attribute name missing'); + } + return this._selectValueHelper(value, params); + }, + /** + * Used to handle value of a select + * + * @param {string} value + * @param {Object} params + * @returns {string|undefined} + */ + _selectValueHelper(value, params) { + if (params.saveUnit && !params.withUnit) { + // Values that come with an unit are saved without unit as + // data-attribute unless told otherwise. + value = value.split(params.saveUnit).join(''); + } + if (params.extraClass) { + this.$target.toggleClass(params.extraClass, params.defaultValue !== value); + } + return value; + }, + /** + * @private + * @param {HTMLElement} collapseEl + * @param {boolean|undefined} [show] + */ + _toggleCollapseEl(collapseEl, show) { + collapseEl.classList.toggle('active', show); + collapseEl.querySelector('we-toggler.o_we_collapse_toggler').classList.toggle('active', show); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onCollapseTogglerClick(ev) { + const currentCollapseEl = ev.currentTarget.closest('we-collapse'); + this._toggleCollapseEl(currentCollapseEl); + for (const collapseEl of currentCollapseEl.querySelectorAll('we-collapse')) { + this._toggleCollapseEl(collapseEl, false); + } + }, + /** + * Called when a widget notifies a preview/change/reset. + * + * @private + * @param {Event} ev + */ + _onUserValueUpdate: async function (ev) { + ev.stopPropagation(); + const widget = ev.data.widget; + const previewMode = ev.data.previewMode; + + // First check if the updated widget or any of the widgets it triggers + // will require a reload or a confirmation choice by the user. If it is + // the case, warn the user and potentially ask if he agrees to save its + // current changes. If not, just do nothing. + let requiresReload = false; + if (!ev.data.previewMode && !ev.data.isSimulatedEvent) { + const linkedWidgets = this._requestUserValueWidgets(...ev.data.triggerWidgetsNames); + const widgets = [ev.data.widget].concat(linkedWidgets); + + const warnMessage = await this._checkIfWidgetsUpdateNeedWarning(widgets); + if (warnMessage) { + const okWarning = await new Promise(resolve => { + this.dialog.add(ConfirmationDialog, { + body: warnMessage, + confirm: () => resolve(true), + cancel: () => resolve(false), + }); + }); + if (!okWarning) { + return; + } + } + + requiresReload = !!await this._checkIfWidgetsUpdateNeedReload(widgets); + } + + // Queue action so that we can later skip useless actions. + if (!this._actionQueues.get(widget)) { + this._actionQueues.set(widget, []); + } + const currentAction = {previewMode}; + this._actionQueues.get(widget).push(currentAction); + + // Ask a mutexed snippet update according to the widget value change + const shouldRecordUndo = (!previewMode && !ev.data.isSimulatedEvent); + if (shouldRecordUndo) { + this.options.wysiwyg.odooEditor.unbreakableStepUnactive(); + } + const useLoaderOnOptionPanel = ev.target.el.dataset.loaderOnOptionPanel; + this.trigger_up('snippet_edition_request', {exec: async () => { + // If some previous snippet edition in the mutex removed the target from + // the DOM, the widget can be destroyed, in that case the edition request + // is now useless and can be discarded. + if (this.isDestroyed()) { + return; + } + // Filter actions that are counterbalanced by earlier/later actions + const actionQueue = this._actionQueues.get(widget).filter(({previewMode}, i, actions) => { + const prev = actions[i - 1]; + const next = actions[i + 1]; + if (previewMode === true && next && next.previewMode) { + return false; + } else if (previewMode === 'reset' && prev && prev.previewMode) { + return false; + } + return true; + }); + // Skip action if it's been counterbalanced + if (!actionQueue.includes(currentAction)) { + this._actionQueues.set(widget, actionQueue); + return; + } + this._actionQueues.set(widget, actionQueue.filter(action => action !== currentAction)); + + if (ev.data.prepare) { + ev.data.prepare(); + } + + if (previewMode && (widget.$el.closest('[data-no-preview="true"]').length)) { + // TODO the flag should be fetched through widget params somehow + return; + } + + // Call widget option methods and update $target + await this._select(previewMode, widget); + + // If it is not preview mode, the user selected the option for good + // (so record the action) + if (shouldRecordUndo) { + this.options.wysiwyg.odooEditor.historyStep(); + } + + if (previewMode || requiresReload) { + return; + } + + await new Promise(resolve => setTimeout(() => { + // Will update the UI of the correct widgets for all options + // related to the same $target/editor + this.trigger_up('snippet_option_update', { + onSuccess: () => resolve(), + }); + // Set timeout needed so that the user event which triggered the + // option can bubble first. + })); + }, optionsLoader: useLoaderOnOptionPanel}); + + if (ev.data.isSimulatedEvent) { + // If the user value update was simulated through a trigger, we + // prevent triggering further widgets. This could be allowed at some + // point but does not work correctly in complex website cases (see + // customizeWebsite). + return; + } + + // Check linked widgets: force their value and simulate a notification + // It is possible that we don't have the widget, we continue because a + // reload might be needed. For example, change template header without + // being on a website.page (e.g: /shop). + const linkedWidgets = this._requestUserValueWidgets(...ev.data.triggerWidgetsNames); + let i = 0; + const triggerWidgetsValues = ev.data.triggerWidgetsValues; + for (const linkedWidget of linkedWidgets) { + const widgetValue = triggerWidgetsValues[i]; + if (widgetValue !== undefined) { + // FIXME right now only make this work supposing it is a + // colorpicker widget with big big hacks, this should be + // improved a lot + const normValue = this._normalizeWidgetValue(widgetValue); + if (previewMode === true) { + linkedWidget._previewColor = normValue; + } else if (previewMode === false) { + linkedWidget._previewColor = false; + linkedWidget._value = normValue; + } else { + linkedWidget._previewColor = false; + } + } + + linkedWidget.notifyValueChange(previewMode, true); + i++; + } + + if (requiresReload) { + this.trigger_up('request_save', { + reloadEditor: true, + optionSelector: this.data.selector, + url: this.data.reload, + }); + } + }, + /** + * @private + */ + _onUserValueWidgetCritical() { + this.trigger_up('remove_snippet', { + $snippet: this.$target, + }); + }, +}); +const registry = {}; + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +registry.sizing = SnippetOptionWidget.extend({ + displayOverlayOptions: true, + + /** + * @override + */ + start: function () { + const self = this; + const def = this._super.apply(this, arguments); + let isMobile = weUtils.isMobileView(this.$target[0]); + + this.$handles = this.$overlay.find('.o_handle'); + + let resizeValues = this._getSize(); + this.$handles.on('mousedown', function (ev) { + const mousedownTime = ev.timeStamp; + ev.preventDefault(); + isMobile = weUtils.isMobileView(self.$target[0]); + + // First update size values as some element sizes may not have been + // initialized on option start (hidden slides, etc) + resizeValues = self._getSize(); + const $handle = $(ev.currentTarget); + + let compass = false; + let XY = false; + if ($handle.hasClass('n')) { + compass = 'n'; + XY = 'Y'; + } else if ($handle.hasClass('s')) { + compass = 's'; + XY = 'Y'; + } else if ($handle.hasClass('e')) { + compass = 'e'; + XY = 'X'; + } else if ($handle.hasClass('w')) { + compass = 'w'; + XY = 'X'; + } else if ($handle.hasClass('nw')) { + compass = 'nw'; + XY = 'YX'; + } else if ($handle.hasClass('ne')) { + compass = 'ne'; + XY = 'YX'; + } else if ($handle.hasClass('sw')) { + compass = 'sw'; + XY = 'YX'; + } else if ($handle.hasClass('se')) { + compass = 'se'; + XY = 'YX'; + } + + // Don't call the normal resize methods if we are in a grid and + // vice-versa. + const isGrid = Object.keys(resizeValues).length === 4; + const isGridHandle = $handle[0].classList.contains('o_grid_handle'); + if (isGrid && !isGridHandle || !isGrid && isGridHandle) { + return; + } + + let resizeVal; + if (compass.length > 1) { + resizeVal = [resizeValues[compass[0]], resizeValues[compass[1]]]; + } else { + resizeVal = [resizeValues[compass]]; + } + + if (resizeVal.some(rV => !rV)) { + return; + } + + // Locking the mutex during the resize. Started here to avoid + // empty returns. + let resizeResolve; + const prom = new Promise(resolve => resizeResolve = () => resolve()); + self.trigger_up("snippet_edition_request", { exec: () => { + self.trigger_up("disable_loading_effect"); + return prom; + }}); + + // If we are in grid mode, add a background grid and place it in + // front of the other elements. + const rowEl = self.$target[0].parentNode; + let backgroundGridEl; + if (rowEl.classList.contains("o_grid_mode") && !isMobile) { + self.options.wysiwyg.odooEditor.observerUnactive('displayBackgroundGrid'); + backgroundGridEl = gridUtils._addBackgroundGrid(rowEl, 0); + gridUtils._setElementToMaxZindex(backgroundGridEl, rowEl); + self.options.wysiwyg.odooEditor.observerActive('displayBackgroundGrid'); + } + + // For loop to handle the cases where it is ne, nw, se or sw. Since + // there are two directions, we compute for both directions and we + // store the values in an array. + const directions = []; + for (const [i, resize] of resizeVal.entries()) { + const props = {}; + let current = 0; + const cssProperty = resize[2]; + const cssPropertyValue = parseInt(self.$target.css(cssProperty)); + resize[0].forEach((val, key) => { + if (self.$target.hasClass(val)) { + current = key; + } else if (resize[1][key] === cssPropertyValue) { + current = key; + } + }); + + props.resize = resize; + props.current = current; + props.begin = current; + props.beginClass = self.$target.attr('class'); + props.regClass = new RegExp('\\s*' + resize[0][current].replace(/[-]*[0-9]+/, '[-]*[0-9]+'), 'g'); + props.xy = ev['page' + XY[i]]; + props.XY = XY[i]; + props.compass = compass[i]; + + directions.push(props); + } + + self.options.wysiwyg.odooEditor.automaticStepUnactive('resizing'); + + const cursor = $handle.css('cursor') + '-important'; + const $iframeWindow = $(this.ownerDocument.defaultView); + $iframeWindow[0].document.body.classList.add(cursor); + self.$overlay.removeClass('o_handlers_idle'); + + const iframeWindowMouseMove = function (ev) { + ev.preventDefault(); + + let changeTotal = false; + for (const dir of directions) { + // dd is the number of pixels by which the mouse moved, + // compared to the initial position of the handle. + const dd = ev['page' + dir.XY] - dir.xy + dir.resize[1][dir.begin]; + const next = dir.current + (dir.current + 1 === dir.resize[1].length ? 0 : 1); + const prev = dir.current ? (dir.current - 1) : 0; + + let change = false; + // If the mouse moved to the right/down by at least 2/3 of + // the space between the previous and the next steps, the + // handle is snapped to the next step and the class is + // replaced by the one matching this step. + if (dd > (2 * dir.resize[1][next] + dir.resize[1][dir.current]) / 3) { + self.$target.attr('class', (self.$target.attr('class') || '').replace(dir.regClass, '')); + self.$target.addClass(dir.resize[0][next]); + dir.current = next; + change = true; + } + // Same as above but to the left/up. + if (prev !== dir.current && dd < (2 * dir.resize[1][prev] + dir.resize[1][dir.current]) / 3) { + self.$target.attr('class', (self.$target.attr('class') || '').replace(dir.regClass, '')); + self.$target.addClass(dir.resize[0][prev]); + dir.current = prev; + change = true; + } + + if (change) { + self._onResize(dir.compass, dir.beginClass, dir.current); + } + + changeTotal = changeTotal || change; + } + + if (changeTotal) { + self.trigger_up('cover_update'); + $handle.addClass('o_active'); + } + }; + const iframeWindowMouseUp = function (ev) { + $iframeWindow.off("mousemove", iframeWindowMouseMove); + $iframeWindow.off("mouseup", iframeWindowMouseUp); + $iframeWindow[0].document.body.classList.remove(cursor); + self.$overlay.addClass('o_handlers_idle'); + $handle.removeClass('o_active'); + + // If we are in grid mode, removes the background grid. + // Also sync the col-* class with the g-col-* class so the + // toggle to normal mode and the mobile view are well done. + if (rowEl.classList.contains("o_grid_mode") && !isMobile) { + self.options.wysiwyg.odooEditor.observerUnactive('displayBackgroundGrid'); + backgroundGridEl.remove(); + self.options.wysiwyg.odooEditor.observerActive('displayBackgroundGrid'); + gridUtils._resizeGrid(rowEl); + + const colClass = [...self.$target[0].classList].find(c => /^col-/.test(c)); + const gColClass = [...self.$target[0].classList].find(c => /^g-col-/.test(c)); + self.$target[0].classList.remove(colClass); + self.$target[0].classList.add(gColClass.substring(2)); + } + + self.options.wysiwyg.odooEditor.automaticStepActive('resizing'); + + // Freeing the mutex once the resizing is done. + resizeResolve(); + self.trigger_up("enable_loading_effect"); + + // Check whether there has been a resizing. + if (directions.every(dir => dir.begin === dir.current)) { + const mouseupTime = ev.timeStamp; + // Mouse held duration in milliseconds. + const mouseHeldDuration = mouseupTime - mousedownTime; + // If no resizing happened and if the mouse was pressed less + // than 500 ms, we assume that the user wanted to click on + // the element behind the handle. + if (mouseHeldDuration < 500) { + // Find the first element behind the overlay. + const sameCoordinatesEls = self.ownerDocument + .elementsFromPoint(ev.pageX, ev.pageY); + const toBeClickedEl = sameCoordinatesEls + .find(el => !el.closest("#oe_manipulators")); + if (toBeClickedEl) { + toBeClickedEl.click(); + } + } + return; + } + + setTimeout(function () { + self.options.wysiwyg.odooEditor.historyStep(); + + self.trigger_up("snippet_edition_request", { exec: async () => { + await new Promise(resolve => { + self.trigger_up("snippet_option_update", { onSuccess: () => resolve() }); + }); + }}); + }, 0); + }; + $iframeWindow.on("mousemove", iframeWindowMouseMove); + $iframeWindow.on("mouseup", iframeWindowMouseUp); + }); + + for (const [key, value] of Object.entries(resizeValues)) { + this.$handles.filter('.' + key).toggleClass('readonly', !value); + } + if (!isMobile && this.$target[0].classList.contains("o_grid_item")) { + this.$handles.filter('.o_grid_handle').toggleClass('readonly', false); + } + + return def; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + async updateUI() { + this._updateSizingHandles(); + return this._super(...arguments); + }, + /** + * @override + */ + setTarget: function () { + this._super(...arguments); + // TODO master: _onResize should not be called here, need to check if + // updateUI is called when the target is changed + this._onResize(); + }, + /** + * @override + */ + async updateUIVisibility() { + await this._super(...arguments); + + const isMobileView = weUtils.isMobileView(this.$target[0]); + const isGridOn = this.$target[0].classList.contains("o_grid_item"); + const isGrid = !isMobileView && isGridOn; + if (this.$target[0].parentNode && this.$target[0].parentNode.classList.contains('row')) { + // Hiding/showing the correct resize handles if we are in grid mode + // or not. + for (const handleEl of this.$handles) { + const isGridHandle = handleEl.classList.contains('o_grid_handle'); + handleEl.classList.toggle('d-none', isGrid ^ isGridHandle); + // Disabling the vertical resize if we are in mobile view. + const isVerticalSizing = handleEl.matches('.n, .s'); + handleEl.classList.toggle("readonly", isMobileView && isVerticalSizing && isGridOn); + } + + // Hiding the move handle in mobile view so we can't drag the + // columns. + const moveHandleEl = this.$overlay[0].querySelector('.o_move_handle'); + moveHandleEl.classList.toggle('d-none', isMobileView); + + // Show/hide the buttons to send back/front a grid item. + const bringFrontBackEls = this.$overlay[0].querySelectorAll('.o_front_back'); + bringFrontBackEls.forEach(button => button.classList.toggle("d-none", !isGrid)); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns an object mapping one or several cardinal direction (n, e, s, w) + * to an Array containing: + * 1) A list of classes to toggle when using this cardinal direction + * 2) A list of values these classes are supposed to set on a given CSS prop + * 3) The mentioned CSS prop + * + * Note: this object must also be saved in this.grid before being returned. + * + * @abstract + * @private + * @returns {Object} + */ + _getSize: function () {}, + /** + * Called when the snippet is being resized and its classes changes. + * + * @private + * @param {string} [compass] - resize direction ('n', 's', 'e' or 'w') + * @param {string} [beginClass] - attributes class at the beginning + * @param {integer} [current] - current increment in this.grid + */ + _onResize: function (compass, beginClass, current) { + this._updateSizingHandles(); + this._notifyResizeChange(); + }, + /** + * @private + */ + _updateSizingHandles: function () { + var self = this; + + // Adapt the resize handles according to the classes and dimensions + var resizeValues = this._getSize(); + var $handles = this.$overlay.find('.o_handle'); + for (const [direction, resizeValue] of Object.entries(resizeValues)) { + var classes = resizeValue[0]; + var values = resizeValue[1]; + var cssProperty = resizeValue[2]; + + var $handle = $handles.filter('.' + direction); + + var current = 0; + var cssPropertyValue = parseInt(self.$target.css(cssProperty)); + classes.forEach((className, key) => { + if (self.$target.hasClass(className)) { + current = key; + } else if (values[key] === cssPropertyValue) { + current = key; + } + }); + + $handle.toggleClass('o_handle_start', current === 0); + $handle.toggleClass('o_handle_end', current === classes.length - 1); + } + + // Adapt the handles to fit top and bottom sizes + this.$overlay.find('.o_handle:not(.o_grid_handle)').filter(".n, .s").toArray().forEach(handle => { + var $handle = $(handle); + var direction = $handle.hasClass('n') ? 'top' : 'bottom'; + $handle.outerHeight(self.$target.css('padding-' + direction)); + }); + }, + /** + * @override + */ + async _notifyResizeChange() { + this.$target.trigger('content_changed'); + }, +}); + +/** + * Handles the edition of padding-top and padding-bottom. + */ +registry['sizing_y'] = registry.sizing.extend({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getSize: function () { + var nClass = 'pt'; + var nProp = 'padding-top'; + var sClass = 'pb'; + var sProp = 'padding-bottom'; + if (this.$target.is('hr')) { + nClass = 'mt'; + nProp = 'margin-top'; + sClass = 'mb'; + sProp = 'margin-bottom'; + } + + var grid = []; + for (var i = 0; i <= (256 / 8); i++) { + grid.push(i * 8); + } + grid.splice(1, 0, 4); + this.grid = { + n: [grid.map(v => nClass + v), grid, nProp], + s: [grid.map(v => sClass + v), grid, sProp], + }; + return this.grid; + }, +}); +registry['sizing_x'] = registry.sizing.extend({ + /** + * @override + */ + onClone: function (options) { + this._super.apply(this, arguments); + // Below condition is added to remove offset of target element only + // and not its children to avoid design alteration of a container/block. + if (options.isCurrent) { + const targetClassList = this.$target[0].classList; + const offsetClasses = [...targetClassList] + .filter(cls => cls.match(/^offset-(lg-)?([0-9]{1,2})$/)); + targetClassList.remove(...offsetClasses); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getSize: function () { + const isMobileView = weUtils.isMobileView(this.$target[0]); + const resolutionModifier = isMobileView ? "" : "lg-"; + var width = this.$target.closest('.row').width(); + var gridE = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + var gridW = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + this.grid = { + e: [ + gridE.map(v => (`col-${resolutionModifier}${v}`)), + gridE.map(v => width / 12 * v), + "width", + ], + w: [ + gridW.map(v => (`offset-${resolutionModifier}${v}`)), + gridW.map(v => width / 12 * v), + "margin-left", + ], + }; + return this.grid; + }, + /** + * @override + */ + _onResize: function (compass, beginClass, current) { + const targetEl = this.$target[0]; + const isMobileView = weUtils.isMobileView(targetEl); + const resolutionModifier = isMobileView ? "" : "lg-"; + + if (compass === 'w' || compass === 'e') { + // (?!\S): following char cannot be a non-space character + const offsetRegex = new RegExp(`(?:^|\\s+)offset-${resolutionModifier}(\\d{1,2})(?!\\S)`); + const colRegex = new RegExp(`(?:^|\\s+)col-${resolutionModifier}(\\d{1,2})(?!\\S)`); + + const beginOffset = Number(beginClass.match(offsetRegex)?.[1] || 0); + + if (compass === 'w') { + // don't change the right border position when we change the offset (replace col size) + const beginCol = Number(beginClass.match(colRegex)?.[1] || 12); + let offset = Number(this.grid.w[0][current].match(offsetRegex)?.[1] || 0); + if (offset < 0) { + offset = 0; + } + let colSize = beginCol - (offset - beginOffset); + if (colSize <= 0) { + colSize = 1; + offset = beginOffset + beginCol - 1; + } + const offsetColRegex = new RegExp(`${offsetRegex.source}|${colRegex.source}`, "g"); + targetEl.className = targetEl.className.replace(offsetColRegex, ""); + targetEl.classList.add(`col-${resolutionModifier}${colSize > 12 ? 12 : colSize}`); + + if (offset > 0) { + targetEl.classList.add(`offset-${resolutionModifier}${offset}`); + } + if (isMobileView && offset === 0) { + targetEl.classList.remove("offset-lg-0"); + } else if ((isMobileView && offset > 0 && + !targetEl.className.match(/(^|\s+)offset-lg-\d{1,2}(?!\S)/)) || + (!isMobileView && offset === 0 && + targetEl.className.match(/(^|\s+)offset-\d{1,2}(?!\S)/))) { + targetEl.classList.add("offset-lg-0"); + } + } else if (beginOffset > 0) { + const endCol = Number(this.grid.e[0][current].match(colRegex)?.[1] || 0); + // Avoids overflowing the grid to the right if the + // column size + the offset exceeds 12. + if ((endCol + beginOffset) > 12) { + targetEl.className = targetEl.className.replace(colRegex, ""); + targetEl.classList.add(`col-${resolutionModifier}${12 - beginOffset}`); + } + } + } + this._super.apply(this, arguments); + }, + /** + * @override + */ + async _notifyResizeChange() { + this.trigger_up('option_update', { + optionName: 'StepsConnector', + name: 'change_column_size', + }); + this._super.apply(this, arguments); + }, +}); + +/** + * Handles the sizing in grid mode: edition of grid-{column|row}-{start|end}. + */ +registry['sizing_grid'] = registry.sizing.extend({ + /** + * @override + */ + _getSize() { + const rowEl = this.$target.closest('.row')[0]; + const gridProp = gridUtils._getGridProperties(rowEl); + + const rowStart = this.$target[0].style.gridRowStart; + const rowEnd = parseInt(this.$target[0].style.gridRowEnd); + const columnStart = this.$target[0].style.gridColumnStart; + const columnEnd = this.$target[0].style.gridColumnEnd; + + const gridN = []; + const gridS = []; + for (let i = 1; i < rowEnd + 12; i++) { + gridN.push(i); + gridS.push(i + 1); + } + const gridW = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + const gridE = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; + + this.grid = { + n: [gridN.map(v => ('g-height-' + (rowEnd - v))), gridN.map(v => ((gridProp.rowSize + gridProp.rowGap) * (v - 1))), 'grid-row-start'], + s: [gridS.map(v => ('g-height-' + (v - rowStart))), gridS.map(v => ((gridProp.rowSize + gridProp.rowGap) * (v - 1))), 'grid-row-end'], + w: [gridW.map(v => ('g-col-lg-' + (columnEnd - v))), gridW.map(v => ((gridProp.columnSize + gridProp.columnGap) * (v - 1))), 'grid-column-start'], + e: [gridE.map(v => ('g-col-lg-' + (v - columnStart))), gridE.map(v => ((gridProp.columnSize + gridProp.columnGap) * (v - 1))), 'grid-column-end'], + }; + + return this.grid; + }, + /** + * @override + */ + _onResize(compass, beginClass, current) { + if (compass === 'n') { + const rowEnd = parseInt(this.$target[0].style.gridRowEnd); + if (current < 0) { + this.$target[0].style.gridRowStart = 1; + } else if (current + 1 >= rowEnd) { + this.$target[0].style.gridRowStart = rowEnd - 1; + } else { + this.$target[0].style.gridRowStart = current + 1; + } + } else if (compass === 's') { + const rowStart = parseInt(this.$target[0].style.gridRowStart); + const rowEnd = parseInt(this.$target[0].style.gridRowEnd); + if (current + 2 <= rowStart) { + this.$target[0].style.gridRowEnd = rowStart + 1; + } else { + this.$target[0].style.gridRowEnd = current + 2; + } + + // Updating the grid height. + const rowEl = this.$target[0].parentNode; + const rowCount = parseInt(rowEl.dataset.rowCount); + const backgroundGridEl = rowEl.querySelector('.o_we_background_grid'); + const backgroundGridRowEnd = parseInt(backgroundGridEl.style.gridRowEnd); + let rowMove = 0; + if (this.$target[0].style.gridRowEnd > rowEnd && this.$target[0].style.gridRowEnd > rowCount + 1) { + rowMove = this.$target[0].style.gridRowEnd - rowEnd; + } else if (this.$target[0].style.gridRowEnd < rowEnd && this.$target[0].style.gridRowEnd >= rowCount + 1) { + rowMove = this.$target[0].style.gridRowEnd - rowEnd; + } + backgroundGridEl.style.gridRowEnd = backgroundGridRowEnd + rowMove; + } else if (compass === 'w') { + const columnEnd = parseInt(this.$target[0].style.gridColumnEnd); + if (current < 0) { + this.$target[0].style.gridColumnStart = 1; + } else if (current + 1 >= columnEnd) { + this.$target[0].style.gridColumnStart = columnEnd - 1; + } else { + this.$target[0].style.gridColumnStart = current + 1; + } + } else if (compass === 'e') { + const columnStart = parseInt(this.$target[0].style.gridColumnStart); + if (current + 2 > 13) { + this.$target[0].style.gridColumnEnd = 13; + } else if (current + 2 <= columnStart) { + this.$target[0].style.gridColumnEnd = columnStart + 1; + } else { + this.$target[0].style.gridColumnEnd = current + 2; + } + } + + if (compass === 'n' || compass === 's') { + const numberRows = this.$target[0].style.gridRowEnd - this.$target[0].style.gridRowStart; + this.$target.attr('class', this.$target.attr('class').replace(/\s*(g-height-)([0-9-]+)/g, '')); + this.$target.addClass('g-height-' + numberRows); + } + + if (compass === 'w' || compass === 'e') { + const numberColumns = this.$target[0].style.gridColumnEnd - this.$target[0].style.gridColumnStart; + this.$target.attr('class', this.$target.attr('class').replace(/\s*(g-col-lg-)([0-9-]+)/g, '')); + this.$target.addClass('g-col-lg-' + numberColumns); + } + }, +}); + +/** + * Controls box properties. + */ +registry.Box = SnippetOptionWidget.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * TODO this should be reviewed in master to avoid the need of using the + * 'reset' previewMode and having to remember the previous box-shadow value. + * We are forced to remember the previous box shadow before applying a new + * one as the whole box-shadow value is handled by multiple widgets. + * + * @see this.selectClass for parameters + */ + async setShadow(previewMode, widgetValue, params) { + // Check if the currently configured shadow is not using the same shadow + // mode, in which case nothing has to be done. + const styles = window.getComputedStyle(this.$target[0]); + const currentBoxShadow = styles['box-shadow'] || 'none'; + const currentMode = currentBoxShadow === 'none' + ? '' + : currentBoxShadow.includes('inset') ? 'inset' : 'outset'; + if (currentMode === widgetValue) { + return; + } + + if (previewMode === true) { + this._prevBoxShadow = currentBoxShadow; + } + + // Add/remove the shadow class + this.$target.toggleClass(params.shadowClass, !!widgetValue); + + // Change the mode of the old box shadow. If no shadow was currently + // set then get the shadow value that is supposed to be set according + // to the shadow mode. Try to apply it via the selectStyle method so + // that it is either ignored because the shadow class had its effect or + // forced (to the shadow value or none) if toggling the class is not + // enough (e.g. if the item has a default shadow coming from CSS rules, + // removing the shadow class won't be enough to remove the shadow but in + // most other cases it will). + let shadow = 'none'; + if (previewMode === 'reset') { + shadow = this._prevBoxShadow; + } else { + if (currentBoxShadow === 'none') { + shadow = this._getDefaultShadow(widgetValue, params.shadowClass); + } else { + if (widgetValue === 'outset') { + shadow = currentBoxShadow.replace('inset', '').trim(); + } else if (widgetValue === 'inset') { + shadow = currentBoxShadow + ' inset'; + } + } + } + await this.selectStyle(previewMode, shadow, Object.assign({cssProperty: 'box-shadow'}, params)); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + if (methodName === 'setShadow') { + const shadowValue = this.$target.css('box-shadow'); + if (!shadowValue || shadowValue === 'none') { + return ''; + } + return this.$target.css('box-shadow').includes('inset') ? 'inset' : 'outset'; + } + return this._super(...arguments); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'fake_inset_shadow_opt') { + return false; + } + return this._super(...arguments); + }, + /** + * @private + * @param {string} type + * @param {string} shadowClass + * @returns {string} + */ + _getDefaultShadow(type, shadowClass) { + if (!type) { + return 'none'; + } + + const el = document.createElement('div'); + el.classList.add(shadowClass); + document.body.appendChild(el); + const shadow = `${$(el).css('box-shadow')}${type === 'inset' ? ' inset' : ''}`; + el.remove(); + return shadow; + }, +}); + + + +registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutUtils, { + /** + * @override + */ + cleanUI() { + this._removeGridPreview(); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Changes the number of columns. + * + * @see this.selectClass for parameters + */ + selectCount: async function (previewMode, widgetValue, params) { + // Make sure the "Custom" option is read-only. + if (widgetValue === "custom") { + return; + } + const previousNbColumns = this.$('> .row').children().length; + let $row = this.$('> .row'); + if (!$row.length) { + const restoreCursor = preserveCursor(this.$target[0].ownerDocument); + resetOuids(this.$target[0]); + $row = this.$target.contents().wrapAll($('<div class="row"><div class="col-lg-12"/></div>')).parent().parent(); + restoreCursor(); + } + + const nbColumns = parseInt(widgetValue); + await this._updateColumnCount($row[0], (nbColumns || 1)); + // Yield UI thread to wait for event to bubble before activate_snippet is called. + // In this case this lets the select handle the click event before we switch snippet. + // TODO: make this more generic in activate_snippet event handler. + await new Promise(resolve => setTimeout(resolve)); + if (nbColumns === 0) { + const restoreCursor = preserveCursor(this.$target[0].ownerDocument); + resetOuids($row[0]); + $row.contents().unwrap().contents().unwrap(); + restoreCursor(); + this.trigger_up('activate_snippet', {$snippet: this.$target}); + } else if (previousNbColumns === 0) { + this.trigger_up('activate_snippet', {$snippet: this.$('> .row').children().first()}); + } + this.trigger_up('option_update', { + optionName: 'StepsConnector', + name: 'change_columns', + }); + }, + /** + * Changes the layout (columns or grid). + * + * @see this.selectClass for parameters + */ + async selectLayout(previewMode, widgetValue, params) { + if (widgetValue === "grid") { + const rowEl = this.$target[0].querySelector('.row'); + if (!rowEl || !rowEl.classList.contains('o_grid_mode')) { // Prevent toggling grid mode twice. + gridUtils._toggleGridMode(this.$target[0]); + this.trigger_up('activate_snippet', {$snippet: this.$target}); + } + } else { + // Toggle normal mode only if grid mode was activated (as it's in + // normal mode by default). + const rowEl = this.$target[0].querySelector('.row'); + if (rowEl && rowEl.classList.contains('o_grid_mode')) { + this._toggleNormalMode(rowEl); + this.trigger_up('activate_snippet', {$snippet: this.$target}); + } + } + this.trigger_up('option_update', { + optionName: 'StepsConnector', + name: 'change_columns', + }); + }, + /** + * Adds an image, some text or a button in the grid. + * + * @see this.selectClass for parameters + */ + async addElement(previewMode, widgetValue, params) { + const rowEl = this.$target[0].querySelector('.row'); + const elementType = widgetValue; + + // If it has been less than 15 seconds that we have added an element, + // shift the new element right and down by one cell. Otherwise, put it + // on the top left corner. + const currentTime = new Date().getTime(); + if (this.lastAddTime && (currentTime - this.lastAddTime) / 1000 < 15) { + this.lastStartPosition = [this.lastStartPosition[0] + 1, this.lastStartPosition[1] + 1]; + } else { + this.lastStartPosition = [1, 1]; // [rowStart, columnStart] + } + this.lastAddTime = currentTime; + + // Create the new column. + const newColumnEl = document.createElement('div'); + newColumnEl.classList.add('o_grid_item'); + let numberColumns, numberRows; + let imageLoadedPromise; + + if (elementType === 'image') { + // Set the columns properties. + newColumnEl.classList.add('col-lg-6', 'g-col-lg-6', 'g-height-6', 'o_grid_item_image'); + numberColumns = 6; + numberRows = 6; + + // Choose an image with the media dialog. + let isImageSaved = false; + await new Promise(resolve => { + this.call("dialog", "add", MediaDialog, { + onlyImages: true, + save: imageEl => { + isImageSaved = true; + imageLoadedPromise = new Promise(resolve => { + imageEl.addEventListener("load", () => resolve(), {once: true}); + }); + // Adds the image to the new column. + newColumnEl.appendChild(imageEl); + }, + }, { + onClose: () => resolve() + }); + }); + if (!isImageSaved) { + // Revert the current step to exclude the step saved when the + // media dialog closed. + this.options.wysiwyg.odooEditor.historyRevertCurrentStep(); + return; + } + } else if (elementType === 'text') { + newColumnEl.classList.add('col-lg-4', 'g-col-lg-4', 'g-height-2'); + numberColumns = 4; + numberRows = 2; + + // Create default text content. + const pEl = document.createElement('p'); + pEl.classList.add('o_default_snippet_text'); + pEl.textContent = _t("Write something..."); + + newColumnEl.appendChild(pEl); + } else if (elementType === 'button') { + newColumnEl.classList.add('col-lg-2', 'g-col-lg-2', 'g-height-1'); + numberColumns = 2; + numberRows = 1; + + // Create default button. + const aEl = document.createElement('a'); + aEl.href = '#'; + aEl.classList.add('mb-2', 'btn', 'btn-primary'); + aEl.textContent = "Button"; + + newColumnEl.appendChild(aEl); + } + // Place the column in the grid. + const rowStart = this.lastStartPosition[0]; + let columnStart = this.lastStartPosition[1]; + if (columnStart + numberColumns > 13) { + columnStart = 1; + this.lastStartPosition[1] = columnStart; + } + newColumnEl.style.gridArea = `${rowStart} / ${columnStart} / ${rowStart + numberRows} / ${columnStart + numberColumns}`; + + // Setting the z-index to the maximum of the grid. + gridUtils._setElementToMaxZindex(newColumnEl, rowEl); + + // Add the new column and update the grid height. + rowEl.appendChild(newColumnEl); + gridUtils._resizeGrid(rowEl); + + // Scroll to the new column if more than half of it is hidden (= out of + // the viewport or hidden by an other element). + if (elementType === "image") { + // If an image was added, wait for it to be loaded before scrolling. + await imageLoadedPromise; + } + const newColumnPosition = newColumnEl.getBoundingClientRect(); + const middleX = (newColumnPosition.left + newColumnPosition.right) / 2; + const middleY = (newColumnPosition.top + newColumnPosition.bottom) / 2; + const sameCoordinatesEl = this.ownerDocument.elementFromPoint(middleX, middleY); + if (!sameCoordinatesEl || !newColumnEl.contains(sameCoordinatesEl)) { + newColumnEl.scrollIntoView({behavior: "smooth", block: "center"}); + } + this.trigger_up('activate_snippet', {$snippet: $(newColumnEl)}); + }, + /** + * @override + */ + async selectStyle(previewMode, widgetValue, params) { + await this._super(previewMode, widgetValue, params); + + const rowEl = this.$target[0]; + const isMobileView = weUtils.isMobileView(rowEl); + if (["row-gap", "column-gap"].includes(params.cssProperty) && !isMobileView) { + // Reset the animation. + this._removeGridPreview(); + void rowEl.offsetWidth; // Trigger a DOM reflow. + + // Add an animated grid preview. + this.options.wysiwyg.odooEditor.observerUnactive("addGridPreview"); + this.gridPreviewEl = gridUtils._addBackgroundGrid(rowEl, 0); + this.gridPreviewEl.classList.add("o_we_grid_preview"); + gridUtils._setElementToMaxZindex(this.gridPreviewEl, rowEl); + this.options.wysiwyg.odooEditor.observerActive("addGridPreview"); + this.removeGridPreview = this._removeGridPreview.bind(this); + rowEl.addEventListener("animationend", this.removeGridPreview); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + if (methodName === 'selectCount') { + const isMobile = this._isMobile(); + const columnEls = this.$target[0].querySelector(":scope > .row")?.children; + return this._getNbColumns(columnEls, isMobile); + } else if (methodName === 'selectLayout') { + const rowEl = this.$target[0].querySelector('.row'); + if (rowEl && rowEl.classList.contains('o_grid_mode')) { + return "grid"; + } else { + return 'normal'; + } + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'zero_cols_opt') { + // Note: "s_allow_columns" indicates containers which may have + // bare content (without columns) and are allowed to have columns. + // By extension, we only show the "None" option on elements that + // were marked as such as they were allowed to have bare content in + // the first place. + return this.$target.is('.s_allow_columns'); + } else if (widgetName === "column_count_opt") { + // Hide the selectCount widget if the `s_nb_column_fixed` class is + // on the row. + return !this.$target[0].querySelector(":scope > .row.s_nb_column_fixed"); + } else if (widgetName === "custom_cols_opt") { + // Show "Custom" if the user altered the columns in some way (i.e. + // by adding offsets or resizing a column). This is only shown as + // an indication, but shouldn't be selectable. + const isMobile = this._isMobile(); + return this.$target[0].querySelector(":scope > .row") && + this._areColsCustomized(this.$target[0].querySelector(":scope > .row").children, + isMobile); + } + return this._super(...arguments); + }, + /** + * If the number of columns requested is greater than the number of items, + * adds new columns which are clones of the last one. If there are less + * columns than the number of items, reorganizes the elements on the right + * amount of rows. + * + * @private + * @param {HTMLElement} rowEl - the row in which to update the columns + * @param {integer} nbColumns - the number of columns requested + */ + async _updateColumnCount(rowEl, nbColumns) { + const isMobile = this._isMobile(); + // The number of elements per row before the update. + const prevNbColumns = this._getNbColumns(rowEl.children, isMobile); + + if (nbColumns === prevNbColumns) { + return; + } + this._resizeColumns(rowEl.children, nbColumns); + + const itemsDelta = nbColumns - rowEl.children.length; + if (itemsDelta > 0) { + const newItems = []; + for (let i = 0; i < itemsDelta; i++) { + const lastEl = rowEl.lastElementChild; + newItems.push(new Promise(resolve => { + this.trigger_up("clone_snippet", {$snippet: $(lastEl), onSuccess: resolve}); + })); + } + await Promise.all(newItems); + } + + this.trigger_up('cover_update'); + }, + /** + * Resizes the columns for the mobile or desktop view. + * + * @private + * @param {HTMLCollection} columnEls - the elements to resize + * @param {integer} nbColumns - the number of wanted columns + */ + _resizeColumns(columnEls, nbColumns) { + const isMobile = this._isMobile(); + const itemSize = Math.floor(12 / nbColumns) || 1; + const firstItem = this._getFirstItem(columnEls, isMobile); + const firstItemOffset = Math.floor((12 - itemSize * nbColumns) / 2); + + const resolutionModifier = isMobile ? "" : "-lg"; + const replacingRegex = + // (?!\S): following char cannot be a non-space character + new RegExp(`(?:^|\\s+)(col|offset)${resolutionModifier}(-\\d{1,2})?(?!\\S)`, "g"); + + for (const columnEl of columnEls) { + columnEl.className = columnEl.className.replace(replacingRegex, ""); + columnEl.classList.add(`col${resolutionModifier}-${itemSize}`); + + if (firstItemOffset && columnEl === firstItem) { + columnEl.classList.add(`offset${resolutionModifier}-${firstItemOffset}`); + } + const hasMobileOffset = columnEl.className.match(/(^|\s+)offset-\d{1,2}(?!\S)/); + const hasDesktopOffset = columnEl.className.match(/(^|\s+)offset-lg-[1-9][0-1]?(?!\S)/); + columnEl.classList.toggle("offset-lg-0", hasMobileOffset && !hasDesktopOffset); + } + }, + /** + * Toggles the normal mode. + * + * @private + * @param {Element} rowEl + */ + async _toggleNormalMode(rowEl) { + // Removing the grid class + rowEl.classList.remove('o_grid_mode'); + const columnEls = rowEl.children; + // Removing the grid previews (if any). + await new Promise(resolve => { + this.trigger_up("clean_ui_request", { + targetEl: this.$target[0].closest("section"), + onSuccess: resolve, + }); + }); + + for (const columnEl of columnEls) { + // Reloading the images. + gridUtils._reloadLazyImages(columnEl); + // Removing the grid properties. + gridUtils._convertToNormalColumn(columnEl); + } + // Removing the grid properties. + delete rowEl.dataset.rowCount; + // Kept for compatibility. + rowEl.style.removeProperty('--grid-item-padding-x'); + rowEl.style.removeProperty('--grid-item-padding-y'); + rowEl.style.removeProperty("gap"); + }, + /** + * Removes the grid preview that was added when changing the grid gaps. + * + * @private + */ + _removeGridPreview() { + this.options.wysiwyg.odooEditor.observerUnactive("removeGridPreview"); + this.$target[0].removeEventListener("animationend", this.removeGridPreview); + if (this.gridPreviewEl) { + this.gridPreviewEl.remove(); + delete this.gridPreviewEl; + } + delete this.removeGridPreview; + this.options.wysiwyg.odooEditor.observerActive("removeGridPreview"); + }, + /** + * @returns {boolean} + */ + _isMobile() { + return weUtils.isMobileView(this.$target[0]); + }, +}); + +registry.GridColumns = SnippetOptionWidget.extend({ + /** + * @override + */ + cleanUI() { + // Remove the padding highlights. + this._removePaddingPreview(); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @override + */ + async selectStyle(previewMode, widgetValue, params) { + await this._super(...arguments); + if (["--grid-item-padding-y", "--grid-item-padding-x"].includes(params.cssProperty)) { + // Reset the animation. + this._removePaddingPreview(); + void this.$target[0].offsetWidth; // Trigger a DOM reflow. + + // Highlight the padding when changing it, by adding a pseudo- + // element with an animated colored border inside the grid item. + this.options.wysiwyg.odooEditor.observerUnactive("addPaddingPreview"); + this.$target[0].classList.add("o_we_padding_highlight"); + this.options.wysiwyg.odooEditor.observerActive("addPaddingPreview"); + this.removePaddingPreview = this._removePaddingPreview.bind(this); + this.$target[0].addEventListener("animationend", this.removePaddingPreview); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetVisibility(widgetName, params) { + if (["grid_padding_y_opt", "grid_padding_x_opt"].includes(widgetName)) { + return this.$target[0].parentElement.classList.contains("o_grid_mode"); + } + return this._super(...arguments); + }, + /** + * Removes the padding highlights that were added when changing the grid + * item padding. + * + * @private + */ + _removePaddingPreview() { + this.options.wysiwyg.odooEditor.observerUnactive("removePaddingPreview"); + this.$target[0].removeEventListener("animationend", this.removePaddingPreview); + this.$target[0].classList.remove("o_we_padding_highlight"); + delete this.removePaddingPreview; + this.options.wysiwyg.odooEditor.observerActive("removePaddingPreview"); + }, +}); + +registry.vAlignment = SnippetOptionWidget.extend({ + /** + * @override + */ + async _computeWidgetState(methodName, params) { + const value = await this._super(...arguments); + if (methodName === 'selectClass' && !value) { + // If there is no `align-items-` class on the row, then the `align- + // items-stretch` class is selected, because the behaviors are + // equivalent in both situations. + return 'align-items-stretch'; + } + return value; + }, +}); + +/** + * Allows snippets to be moved before the preceding element or after the following. + */ +registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutUtils, { + displayOverlayOptions: true, + + /** + * @override + */ + start: function () { + var $buttons = this.$el.find('we-button'); + var $overlayArea = this.$overlay.find('.o_overlay_move_options'); + // Putting the arrows side by side. + $overlayArea.prepend($buttons[1]); + $overlayArea.prepend($buttons[0]); + + // Needed for compatibility (with already dropped snippets). + // If the target is a column, check if all the columns are either mobile + // ordered or not. If they are not consistent, then we remove the mobile + // order classes from all of them, to avoid issues. + const parentEl = this.$target[0].parentElement; + if (parentEl.classList.contains("row")) { + const columnEls = [...parentEl.children]; + const orderedColumnEls = columnEls.filter(el => el.style.order); + if (orderedColumnEls.length && orderedColumnEls.length !== columnEls.length) { + this._removeMobileOrders(orderedColumnEls); + } + } + + return this._super(...arguments); + }, + /** + * @override + */ + onClone(options) { + this._super.apply(this, arguments); + const mobileOrder = this.$target[0].style.order; + // If the order has been adapted on mobile, it must be different + // for each clone. + if (options.isCurrent && mobileOrder) { + const siblingEls = this.$target[0].parentElement.children; + const cloneEls = [...siblingEls].filter(el => el.style.order === mobileOrder); + // For cases in which multiple clones are made at the same time, we + // change the order for all clones at once. (e.g.: it happens when + // increasing the columns count.) This makes sure the clones get a + // mobile order in line with their DOM order. + cloneEls.forEach((el, i) => { + if (i > 0) { + el.style.order = siblingEls.length - cloneEls.length + i; + } + }); + } + }, + /** + * @override + */ + onMove() { + this._super.apply(this, arguments); + // Remove all the mobile order classes after a drag and drop. + this._removeMobileOrders(this.$target[0].parentElement.children); + }, + /** + * @override + */ + onRemove() { + this._super.apply(this, arguments); + const targetMobileOrder = this.$target[0].style.order; + // If the order has been adapted on mobile, the gap created by the + // removed snippet must be filled in. + if (targetMobileOrder) { + const targetOrder = parseInt(targetMobileOrder); + this._fillRemovedItemGap(this.$target[0].parentElement, targetOrder); + } + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Moves the snippet around. + * + * @see this.selectClass for parameters + */ + moveSnippet: function (previewMode, widgetValue, params) { + const isMobile = this._isMobile(); + const isNavItem = this.$target[0].classList.contains('nav-item'); + const $tabPane = isNavItem ? $(this.$target.find('.nav-link')[0].hash) : null; + const moveLeftOrRight = ["move_left_opt", "move_right_opt"].includes(params.name); + + let siblingEls, mobileOrder; + if (moveLeftOrRight) { + siblingEls = this.$target[0].parentElement.children; + mobileOrder = !!this.$target[0].style.order; + } + if (moveLeftOrRight && isMobile && !isNavItem) { + if (!mobileOrder) { + this._addMobileOrders(siblingEls); + } + this._swapMobileOrders(widgetValue, siblingEls); + } else { + switch (widgetValue) { + case "prev": { + // Consider only visible elements. + let prevEl = this.$target[0].previousElementSibling; + while (prevEl && window.getComputedStyle(prevEl).display === "none") { + prevEl = prevEl.previousElementSibling; + } + prevEl?.insertAdjacentElement("beforebegin", this.$target[0]); + if (isNavItem) { + $tabPane.prev().before($tabPane); + } + break; + } + case "next": { + // Consider only visible elements. + let nextEl = this.$target[0].nextElementSibling; + while (nextEl && window.getComputedStyle(nextEl).display === "none") { + nextEl = nextEl.nextElementSibling; + } + nextEl?.insertAdjacentElement("afterend", this.$target[0]); + if (isNavItem) { + $tabPane.next().after($tabPane); + } + break; + } + } + if (mobileOrder) { + this._removeMobileOrders(siblingEls); + } + } + if (!this.$target.is(this.data.noScroll) + && (params.name === 'move_up_opt' || params.name === 'move_down_opt')) { + const mainScrollingEl = $().getScrollingElement()[0]; + const elTop = this.$target[0].getBoundingClientRect().top; + const heightDiff = mainScrollingEl.offsetHeight - this.$target[0].offsetHeight; + const bottomHidden = heightDiff < elTop; + const hidden = elTop < 0 || bottomHidden; + if (hidden) { + dom.scrollTo(this.$target[0], { + extraOffset: 50, + forcedOffset: bottomHidden ? heightDiff - 50 : undefined, + easing: 'linear', + duration: 500, + }); + } + } + this.trigger_up('option_update', { + optionName: 'StepsConnector', + name: 'move_snippet', + }); + // Update the "Invisible Elements" panel as the order of invisible + // snippets could have changed on the page. + this.trigger_up("update_invisible_dom"); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + const moveUpOrLeft = widgetName === "move_up_opt" || widgetName === "move_left_opt"; + const moveDownOrRight = widgetName === "move_down_opt" || widgetName === "move_right_opt"; + const moveLeftOrRight = widgetName === "move_left_opt" || widgetName === "move_right_opt"; + + if (moveUpOrLeft || moveDownOrRight) { + // The arrows are not displayed if the target is in a grid and if + // not in mobile view. + const isMobileView = weUtils.isMobileView(this.$target[0]); + if (!isMobileView && this.$target[0].classList.contains("o_grid_item")) { + return false; + } + // On mobile, items' reordering is independent from desktop inside + // a snippet (left or right), not at a higher level (up or down). + if (moveLeftOrRight && isMobileView) { + const targetMobileOrder = this.$target[0].style.order; + if (targetMobileOrder) { + const siblingEls = this.$target[0].parentElement.children; + const orderModifier = widgetName === "move_left_opt" ? -1 : 1; + let delta = 0; + while (true) { + delta += orderModifier; + const nextOrder = parseInt(targetMobileOrder) + delta; + const siblingEl = [...siblingEls].find(el => el.style.order === nextOrder.toString()); + if (!siblingEl) { + break; + } + if (window.getComputedStyle(siblingEl).display === "none") { + continue; + } + return true; + } + return false; + } + } + // Consider only visible elements. + const direction = moveUpOrLeft ? "previousElementSibling" : "nextElementSibling"; + let siblingEl = this.$target[0][direction]; + while (siblingEl && window.getComputedStyle(siblingEl).display === "none") { + siblingEl = siblingEl[direction]; + } + return !!siblingEl; + } + return this._super(...arguments); + }, + /** + * Swaps the mobile orders. + * + * @param {string} widgetValue + * @param {HTMLCollection} siblingEls + */ + _swapMobileOrders(widgetValue, siblingEls) { + const targetMobileOrder = this.$target[0].style.order; + const orderModifier = widgetValue === "prev" ? -1 : 1; + let delta = 0; + while (true) { + delta += orderModifier; + const newOrder = parseInt(targetMobileOrder) + delta; + const comparedEl = [...siblingEls].find(el => el.style.order === newOrder.toString()); + if (window.getComputedStyle(comparedEl).display === "none") { + continue; + } + this.$target[0].style.order = newOrder; + comparedEl.style.order = targetMobileOrder; + break; + } + }, + /** + * @returns {Boolean} + */ + _isMobile() { + return false; + }, +}); + +/** + * Allows for media to be replaced. + */ +registry.ReplaceMedia = SnippetOptionWidget.extend({ + init: function () { + this._super(...arguments); + this._activateLinkTool = this._activateLinkTool.bind(this); + this._deactivateLinkTool = this._deactivateLinkTool.bind(this); + }, + + /** + * @override + */ + onFocus() { + this.options.wysiwyg.odooEditor.addEventListener('activate_image_link_tool', this._activateLinkTool); + this.options.wysiwyg.odooEditor.addEventListener('deactivate_image_link_tool', this._deactivateLinkTool); + // When we start editing an image, rerender the UI to ensure the + // we-select that suggests the anchors is in a consistent state. + this.rerender = true; + }, + /** + * @override + */ + onBlur() { + this.options.wysiwyg.odooEditor.removeEventListener('activate_image_link_tool', this._activateLinkTool); + this.options.wysiwyg.odooEditor.removeEventListener('deactivate_image_link_tool', this._deactivateLinkTool); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Replaces the media. + * + * @see this.selectClass for parameters + */ + async replaceMedia() { + // open mediaDialog and replace the media. + await this.options.wysiwyg.openMediaDialog({ node:this.$target[0] }); + }, + /** + * Makes the image a clickable link by wrapping it in an <a>. + * This function is also called for the opposite operation. + * + * @see this.selectClass for parameters + */ + setLink(previewMode, widgetValue, params) { + const parentEl = this.$target[0].parentNode; + if (parentEl.tagName !== 'A') { + const wrapperEl = document.createElement('a'); + this.$target[0].after(wrapperEl); + wrapperEl.appendChild(this.$target[0]); + // TODO Remove when bug fixed in Chrome. + if (this.$target[0].getBoundingClientRect().width === 0) { + // Chrome lost lazy-loaded image => Force Chrome to display image. + const src = this.$target[0].src; + this.$target[0].src = ''; + this.$target[0].src = src; + } + } else { + const fragment = document.createDocumentFragment(); + fragment.append(...parentEl.childNodes); + parentEl.replaceWith(fragment); + } + }, + /** + * Changes the image link so that the URL is opened on another tab or not + * when it is clicked. + * + * @see this.selectClass for parameters + */ + setNewWindow(previewMode, widgetValue, params) { + const linkEl = this.$target[0].parentElement; + if (widgetValue) { + linkEl.setAttribute('target', '_blank'); + } else { + linkEl.removeAttribute('target'); + } + }, + /** + * Records the target url of the hyperlink. + * + * @see this.selectClass for parameters + */ + setUrl(previewMode, widgetValue, params) { + const linkEl = this.$target[0].parentElement; + let url = widgetValue; + if (!url) { + // As long as there is no URL, the image is not considered a link. + linkEl.removeAttribute('href'); + this.$target.trigger('href_changed'); + return; + } + if (!url.startsWith('/') && !url.startsWith('#') + && !/^([a-zA-Z]*.):.+$/gm.test(url)) { + // We permit every protocol (http:, https:, ftp:, mailto:,...). + // If none is explicitly specified, we assume it is a http. + url = 'http://' + url; + } + linkEl.setAttribute('href', url); + this.rerender = true; + this.$target.trigger('href_changed'); + }, + /** + * @override + */ + async updateUI() { + if (this.rerender) { + this.rerender = false; + await this._rerenderXML(); + return; + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _activateLinkTool() { + if (this.$target[0].parentElement.tagName === 'A') { + this._requestUserValueWidgets('media_url_opt')[0].focus(); + } else { + this._requestUserValueWidgets('media_link_opt')[0].enable(); + } + }, + /** + * @private + */ + _deactivateLinkTool() { + const parentEl = this.$target[0].parentNode; + if (parentEl.tagName === 'A') { + this._requestUserValueWidgets('media_link_opt')[0].enable(); + } + }, + /** + * @override + */ + _computeWidgetState(methodName, params) { + const parentEl = this.$target[0].parentElement; + const linkEl = parentEl.tagName === 'A' ? parentEl : null; + switch (methodName) { + case 'setLink': { + return linkEl ? 'true' : ''; + } + case 'setUrl': { + let href = linkEl ? linkEl.getAttribute('href') : ''; + return href || ''; + } + case 'setNewWindow': { + const target = linkEl ? linkEl.getAttribute('target') : ''; + return target && target === '_blank' ? 'true' : ''; + } + } + return this._super(...arguments); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'media_link_opt') { + if (this.$target[0].matches('img')) { + return isImageSupportedForStyle(this.$target[0]); + } + return !this.$target[0].classList.contains('media_iframe_video'); + } + return this._super(...arguments); + }, +}); + +/* + * Abstract option to be extended by the ImageTools and BackgroundOptimize + * options that handles all the common parts. + */ +const ImageHandlerOption = SnippetOptionWidget.extend({ + /** + * @override + */ + async willStart() { + const _super = this._super.bind(this); + await this._initializeImage(); + return _super(...arguments); + }, + /** + * @override + */ + async start() { + await this._super(...arguments); + const weightEl = document.createElement('span'); + weightEl.classList.add('o_we_image_weight', 'o_we_tag', 'd-none'); + weightEl.title = _t("Size"); + this.$weight = $(weightEl); + // Perform the loading of the image info synchronously in order to + // avoid an intermediate rendering of the Blocks tab during the + // loadImageInfo RPC that obtains the file size. + // This does not update the target. + await this._applyOptions(false); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + async updateUI() { + await this._super(...arguments); + + if (this._filesize === undefined) { + this.$weight.addClass('d-none'); + await this._applyOptions(false); + } + if (this._filesize !== undefined) { + this.$weight.text(`${this._filesize.toFixed(1)} kb`); + this.$weight.removeClass('d-none'); + this._relocateWeightEl(); + } + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + selectFormat(previewMode, widgetValue, params) { + const values = widgetValue.split(' '); + const image = this._getImg(); + image.dataset.resizeWidth = values[0]; + if (image.dataset.shape) { + // If the image has a shape, modify its originalMimetype attribute. + image.dataset.originalMimetype = values[1]; + } else { + // If the image does not have a shape, modify its mimetype + // attribute. + image.dataset.mimetype = values[1]; + } + return this._applyOptions(); + }, + /** + * @see this.selectClass for parameters + */ + async setQuality(previewMode, widgetValue, params) { + if (previewMode) { + return; + } + this._getImg().dataset.quality = widgetValue; + return this._applyOptions(); + }, + /** + * @see this.selectClass for parameters + */ + glFilter(previewMode, widgetValue, params) { + const dataset = this._getImg().dataset; + if (widgetValue) { + dataset.glFilter = widgetValue; + } else { + delete dataset.glFilter; + } + return this._applyOptions(); + }, + /** + * @see this.selectClass for parameters + */ + customFilter(previewMode, widgetValue, params) { + const img = this._getImg(); + const {filterOptions} = img.dataset; + const {filterProperty} = params; + if (filterProperty === 'filterColor') { + widgetValue = normalizeColor(widgetValue); + } + const newOptions = Object.assign(JSON.parse(filterOptions || "{}"), {[filterProperty]: widgetValue}); + img.dataset.filterOptions = JSON.stringify(newOptions); + return this._applyOptions(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeVisibility() { + const src = this._getImg().getAttribute('src'); + return src && src !== '/'; + }, + /** + * @override + */ + async _computeWidgetState(methodName, params) { + const img = this._getImg(); + const _super = this._super.bind(this); + + // Make sure image is loaded because we need its naturalWidth + await new Promise((resolve, reject) => { + if (img.complete) { + resolve(); + return; + } + img.addEventListener('load', resolve, {once: true}); + img.addEventListener('error', resolve, {once: true}); + }); + + switch (methodName) { + case 'selectFormat': + return img.naturalWidth + ' ' + this._getImageMimetype(img); + case 'setFilter': + return img.dataset.filter; + case 'glFilter': + return img.dataset.glFilter || ""; + case 'setQuality': + return img.dataset.quality || 75; + case 'customFilter': { + const {filterProperty} = params; + const options = JSON.parse(img.dataset.filterOptions || "{}"); + const defaultValue = filterProperty === 'blend' ? 'normal' : 0; + return options[filterProperty] || defaultValue; + } + } + return _super(...arguments); + }, + /** + * @abstract + */ + _relocateWeightEl() {}, + /** + * @override + */ + async _renderCustomXML(uiFragment) { + const img = this._getImg(); + if (!this.originalSrc || !this._isImageSupportedForProcessing(img)) { + return; + } + const $select = $(uiFragment).find('we-select[data-name=format_select_opt]'); + (await this._computeAvailableFormats()).forEach(([value, [label, targetFormat]]) => { + $select.append(`<we-button data-select-format="${Math.round(value)} ${targetFormat}" class="o_we_badge_at_end">${label} <span class="badge rounded-pill text-bg-dark">${targetFormat.split('/')[1]}</span></we-button>`); + }); + + if (!['image/jpeg', 'image/webp'].includes(this._getImageMimetype(img))) { + const optQuality = uiFragment.querySelector('we-range[data-set-quality]'); + if (optQuality) { + optQuality.remove(); + } + } + }, + /** + * Returns a list of valid formats for a given image or an empty list if + * there is no mimetypeBeforeConversion data attribute on the image. + * + * @private + */ + async _computeAvailableFormats() { + if (!this.mimetypeBeforeConversion) { + return []; + } + const img = this._getImg(); + const original = await loadImage(this.originalSrc); + const maxWidth = img.dataset.width ? img.naturalWidth : original.naturalWidth; + const optimizedWidth = Math.min(maxWidth, this._computeMaxDisplayWidth()); + this.optimizedWidth = optimizedWidth; + const widths = { + 128: ['128px', 'image/webp'], + 256: ['256px', 'image/webp'], + 512: ['512px', 'image/webp'], + 1024: ['1024px', 'image/webp'], + 1920: ['1920px', 'image/webp'], + }; + widths[img.naturalWidth] = [_t("%spx", img.naturalWidth), 'image/webp']; + widths[optimizedWidth] = [_t("%spx (Suggested)", optimizedWidth), 'image/webp']; + const mimetypeBeforeConversion = img.dataset.mimetypeBeforeConversion; + widths[maxWidth] = [_t("%spx (Original)", maxWidth), mimetypeBeforeConversion]; + if (mimetypeBeforeConversion !== "image/webp") { + // Avoid a key collision by subtracting 0.1 - putting the webp + // above the original format one of the same size. + widths[maxWidth - 0.1] = [_t("%spx", maxWidth), 'image/webp']; + } + return Object.entries(widths) + .filter(([width]) => width <= maxWidth) + .sort(([v1], [v2]) => v1 - v2); + }, + /** + * Applies all selected options on the original image. + * + * @private + * @param {boolean} [update=true] If this is false, this does not actually + * modifies the image but only simulates the modifications on it to + * be able to update the filesize UI. + */ + async _applyOptions(update = true) { + const img = this._getImg(); + if (!update && !(img && img.complete)) { + return; + } + if (!this._isImageSupportedForProcessing(img)) { + this.originalId = null; + this._filesize = undefined; + return; + } + // Do not apply modifications if there is no original src, since it is + // needed for it. + if (!img.dataset.originalSrc) { + delete img.dataset.mimetype; + return; + } + const dataURL = await applyModifications(img, {mimetype: this._getImageMimetype(img)}); + this._filesize = getDataURLBinarySize(dataURL) / 1024; + + if (update) { + img.classList.add('o_modified_image_to_save'); + const loadedImg = await loadImage(dataURL, img); + this._applyImage(loadedImg); + // Also apply to carousel thumbnail if applicable. + weUtils.forwardToThumbnail(img); + return loadedImg; + } + return img; + }, + /** + * Loads the image's attachment info. + * + * @private + */ + async _loadImageInfo(attachmentSrc = '') { + const img = this._getImg(); + await loadImageInfo(img, attachmentSrc); + if (!img.dataset.originalId) { + this.originalId = null; + this.originalSrc = null; + return; + } + this.originalId = img.dataset.originalId; + this.originalSrc = img.dataset.originalSrc; + this.mimetypeBeforeConversion = img.dataset.mimetypeBeforeConversion; + }, + /** + * Sets the image's width to its suggested size. + * + * @private + */ + async _autoOptimizeImage() { + await this._loadImageInfo(); + await this._rerenderXML(); + const img = this._getImg(); + if (!['image/gif', 'image/svg+xml'].includes(img.dataset.mimetype)) { + // Convert to recommended format and width. + img.dataset.mimetype = 'image/webp'; + img.dataset.resizeWidth = this.optimizedWidth; + } else if (img.dataset.shape && img.dataset.originalMimetype !== "image/gif") { + img.dataset.originalMimetype = "image/webp"; + img.dataset.resizeWidth = this.optimizedWidth; + } + await this._applyOptions(); + await this.updateUI(); + }, + /** + * Returns the image that is currently being modified. + * + * @private + * @abstract + * @returns {HTMLImageElement} the image to use for modifications + */ + _getImg() {}, + /** + * Computes the image's maximum display width. + * + * @private + * @abstract + * @returns {Int} the maximum width at which the image can be displayed + */ + _computeMaxDisplayWidth() {}, + /** + * Use the processed image when it's needed in the DOM. + * + * @private + * @abstract + * @param {HTMLImageElement} img + */ + _applyImage(img) {}, + /** + * @private + * @param {HTMLImageElement} img + * @returns {String} The right mimetype used to apply options on image. + */ + _getImageMimetype(img) { + return img.dataset.mimetype; + }, + /** + * @private + */ + async _initializeImage() { + return this._loadImageInfo(); + }, + /** + * @private + * @param {HTMLImageElement} img + * @param {Boolean} [strict=false] + * @returns {Boolean} + */ + _isImageSupportedForProcessing(img, strict = false) { + return isImageSupportedForProcessing(this._getImageMimetype(img), strict); + }, + /** + * @override + */ + _computeWidgetVisibility(widgetName, params) { + if (widgetName === "format_select_opt" && !this.mimetypeBeforeConversion) { + return false; + } + if (this._isImageProcessingWidget(widgetName, params)) { + const img = this._getImg(); + return this._isImageSupportedForProcessing(img, true); + } + return isImageSupportedForStyle(this._getImg()); + }, + /** + * Indicates if an option should be applied only on supported mimetypes. + * + * @param {String} widgetName + * @param {Object} params + * @returns {Boolean} + */ + _isImageProcessingWidget(widgetName, params) { + return params.optionsPossibleValues.glFilter + || 'customFilter' in params.optionsPossibleValues + || params.optionsPossibleValues.setQuality + || widgetName === 'format_select_opt'; + }, +}); + +/** + * @param {Element} containerEl + * @param {boolean} labelIsDimension - Optional display imgsize attribute instead of animated + * @returns {Element} + */ +const _addAnimatedShapeLabel = function addAnimatedShapeLabel(containerEl, labelIsDimension = false) { + const labelEl = document.createElement('span'); + labelEl.classList.add('o_we_shape_animated_label'); + let labelStr = _t("Animated"); + const spanEl = document.createElement('span'); + if (labelIsDimension) { + const dimensionIcon = document.createElement('i'); + labelStr = containerEl.dataset.imgSize; + dimensionIcon.classList.add('fa', 'fa-expand'); + labelEl.append(dimensionIcon); + spanEl.textContent = labelStr; + } else { + labelEl.textContent = labelStr[0]; + spanEl.textContent = labelStr.substr(1); + } + labelEl.appendChild(spanEl); + containerEl.classList.add('position-relative'); + containerEl.appendChild(labelEl); + return labelEl; +}; + +/** + * Controls image width and quality. + */ +registry.ImageTools = ImageHandlerOption.extend({ + MAX_SUGGESTED_WIDTH: 1920, + + /** + * @constructor + */ + init() { + this.shapeCache = {}; + return this._super(...arguments); + }, + /** + * @override + */ + start() { + this.$target.on('image_changed.ImageOptimization', this._onImageChanged.bind(this)); + this.$target.on('image_cropped.ImageOptimization', this._onImageCropped.bind(this)); + return this._super(...arguments); + }, + /** + * @override + */ + destroy() { + this.$target.off('.ImageOptimization'); + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Displays the image cropping tools + * + * @see this.selectClass for parameters + */ + async crop() { + this.trigger_up('disable_loading_effect'); + const img = this._getImg(); + const document = this.$el[0].ownerDocument; + const imageCropWrapperElement = document.createElement('div'); + document.body.append(imageCropWrapperElement); + const imageCropWrapper = await attachComponent(this, imageCropWrapperElement, ImageCrop, { + activeOnStart: true, + media: img, + mimetype: this._getImageMimetype(img), + }); + + await new Promise(resolve => { + this.$target.one('image_cropper_destroyed', async () => { + if (isGif(this._getImageMimetype(img))) { + img.dataset[img.dataset.shape ? 'originalMimetype' : 'mimetype'] = 'image/png'; + } + await this._reapplyCurrentShape(); + resolve(); + }); + }); + imageCropWrapperElement.remove(); + imageCropWrapper.destroy(); + this.trigger_up('enable_loading_effect'); + }, + /** + * Displays the image transformation tools + * + * @see this.selectClass for parameters + */ + async transform() { + this.trigger_up('hide_overlay'); + this.trigger_up('disable_loading_effect'); + + const document = this.$target[0].ownerDocument; + const playState = this.$target[0].style.animationPlayState; + const transition = this.$target[0].style.transition; + this.$target.transfo({document}); + const destroyTransfo = () => { + this.$target.transfo('destroy'); + $(document).off('mousedown', mousedown); + window.document.removeEventListener('keydown', keydown); + } + const mousedown = mousedownEvent => { + if (!$(mousedownEvent.target).closest('.transfo-container').length) { + destroyTransfo(); + // Restore animation css properties potentially affected by the + // jQuery transfo plugin. + this.$target[0].style.animationPlayState = playState; + this.$target[0].style.transition = transition; + } + }; + $(document).on('mousedown', mousedown); + const keydown = keydownEvent => { + if (keydownEvent.key === 'Escape') { + keydownEvent.stopImmediatePropagation(); + destroyTransfo(); + } + }; + window.document.addEventListener('keydown', keydown); + + await new Promise(resolve => { + document.addEventListener('mouseup', resolve, {once: true}); + }); + this.trigger_up('enable_loading_effect'); + }, + /** + * Resets the image cropping + * + * @see this.selectClass for parameters + */ + async resetCrop() { + const img = this._getImg(); + + // Mount the ImageCrop to call the reset method. As we need the state of + // the component to be mounted before calling reset, mount it + // temporarily into the body. + const imageCropWrapperElement = document.createElement('div'); + document.body.append(imageCropWrapperElement); + const imageCropWrapper = await attachComponent(this, imageCropWrapperElement, ImageCrop, { + activeOnStart: true, + media: img, + mimetype: this._getImageMimetype(img), + }); + await imageCropWrapper.component.mountedPromise; + await imageCropWrapper.component.reset(); + imageCropWrapper.destroy(); + imageCropWrapperElement.remove(); + + await this._reapplyCurrentShape(); + }, + /** + * Resets the image rotation and translation + * + * @see this.selectClass for parameters + */ + async resetTransform() { + this.$target + .attr('style', (this.$target.attr('style') || '') + .replace(/[^;]*transform[\w:]*;?/g, '')); + }, + /** + * @see this.selectClass for parameters + */ + async setImgShape(previewMode, widgetValue, params) { + const img = this._getImg(); + const saveData = previewMode === false; + if (img.dataset.hoverEffect && !widgetValue) { + // When a shape is removed and there is a hover effect on the + // image, we then place the "Square" shape as the default because a + // shape is required for the hover effects to work. + const shapeImgSquareWidget = this._requestUserValueWidgets("shape_img_square_opt")[0]; + widgetValue = shapeImgSquareWidget.getActiveValue("setImgShape"); + } + if (widgetValue) { + await this._loadShape(widgetValue); + if (previewMode === 'reset' && img.dataset.shapeColors) { + // When we reset the shape we need to reapply the colors the + // user had selected. + await this._applyShapeAndColors(false, img.dataset.shapeColors.split(';')); + } else { + // If the preview mode === false we want to save the colors + // as the user chose their shape + await this._applyShapeAndColors(saveData); + if (saveData && img.dataset.mimetype !== 'image/svg+xml') { + img.dataset.originalMimetype = img.dataset.mimetype; + img.dataset.mimetype = 'image/svg+xml'; + } + // When the user selects a shape, we remove the data attributes + // that are not compatible with this shape. + if (saveData) { + if (!this._isTransformableShape()) { + delete img.dataset.shapeFlip; + delete img.dataset.shapeRotate; + } + if (!this._canHaveHoverEffect()) { + delete img.dataset.hoverEffect; + delete img.dataset.hoverEffectColor; + delete img.dataset.hoverEffectStrokeWidth; + delete img.dataset.hoverEffectIntensity; + img.classList.remove("o_animate_on_hover"); + } + if (!this._isAnimatedShape()) { + delete img.dataset.shapeAnimationSpeed; + } + } + } + } else { + // Re-applying the modifications and deleting the shapes + img.src = await applyModifications(img, {mimetype: this._getImageMimetype(img)}); + delete img.dataset.shape; + delete img.dataset.shapeColors; + delete img.dataset.fileName; + delete img.dataset.shapeFlip; + delete img.dataset.shapeRotate; + delete img.dataset.shapeAnimationSpeed; + if (saveData) { + img.dataset.mimetype = img.dataset.originalMimetype; + delete img.dataset.originalMimetype; + } + // Also apply to carousel thumbnail if applicable. + weUtils.forwardToThumbnail(img); + } + img.classList.add('o_modified_image_to_save'); + }, + /** + * Handles color assignment on the shape. Widget is a color picker. + * If no value, we reset to the current color palette. + * + * @see this.selectClass for parameters + */ + async setImgShapeColor(previewMode, widgetValue, params) { + const img = this._getImg(); + const newColorId = parseInt(params.colorId); + const oldColors = img.dataset.shapeColors.split(';'); + const newColors = oldColors.slice(0); + newColors[newColorId] = this._getCSSColorValue(widgetValue === '' ? `o-color-${(newColorId + 1)}` : widgetValue); + await this._applyShapeAndColors(true, newColors); + img.classList.add('o_modified_image_to_save'); + }, + /** + * Flips the image shape horizontally. + * + * @see this.selectClass for parameters + */ + async setImgShapeFlipX(previewMode, widgetValue, params) { + await this._setImgShapeFlip("x"); + }, + /** + * Flips the image shape vertically. + * + * @see this.selectClass for parameters + */ + async setImgShapeFlipY(previewMode, widgetValue, params) { + await this._setImgShapeFlip("y"); + }, + /** + * Rotates the image shape 90 degrees to the left. + * + * @see this.selectClass for parameters + */ + async setImgShapeRotateLeft(previewMode, widgetValue, params) { + await this._setImgShapeRotate(-90); + }, + /** + * Rotates the image shape 90 degrees to the right. + * + * @see this.selectClass for parameters + */ + async setImgShapeRotateRight(previewMode, widgetValue, params) { + await this._setImgShapeRotate(90); + }, + /** + * Sets the hover effects of the image shape. + * + * @see this.selectClass for parameters + */ + async setImgShapeHoverEffect(previewMode, widgetValue, params) { + const imgEl = this._getImg(); + if (previewMode !== "reset") { + this.prevHoverEffectColor = imgEl.dataset.hoverEffectColor; + this.prevHoverEffectIntensity = imgEl.dataset.hoverEffectIntensity; + this.prevHoverEffectStrokeWidth = imgEl.dataset.hoverEffectStrokeWidth; + } + delete imgEl.dataset.hoverEffectColor; + delete imgEl.dataset.hoverEffectIntensity; + delete imgEl.dataset.hoverEffectStrokeWidth; + if (previewMode === true) { + if (params.name === "hover_effect_overlay_opt") { + imgEl.dataset.hoverEffectColor = this._getCSSColorValue("black-25"); + } else if (params.name === "hover_effect_outline_opt") { + imgEl.dataset.hoverEffectColor = this._getCSSColorValue("primary"); + imgEl.dataset.hoverEffectStrokeWidth = 10; + } else { + imgEl.dataset.hoverEffectIntensity = 20; + if (params.name !== "hover_effect_mirror_blur_opt") { + imgEl.dataset.hoverEffectColor = "rgba(0, 0, 0, 0)"; + } + } + } else { + if (this.prevHoverEffectColor) { + imgEl.dataset.hoverEffectColor = this.prevHoverEffectColor; + } + if (this.prevHoverEffectIntensity) { + imgEl.dataset.hoverEffectIntensity = this.prevHoverEffectIntensity; + } + if (this.prevHoverEffectStrokeWidth) { + imgEl.dataset.hoverEffectStrokeWidth = this.prevHoverEffectStrokeWidth; + } + } + await this._reapplyCurrentShape(); + // When the hover effects are first activated from the "animationMode" + // function of the "WebsiteAnimate" class, the history was paused to + // avoid recording intermediate steps. That's why we unpause it here. + if (this.firstHoverEffect) { + this.options.wysiwyg.odooEditor.historyUnpauseSteps(); + delete this.firstHoverEffect; + } + }, + /** + * @see this.selectClass for parameters + */ + async selectDataAttribute(previewMode, widgetValue, params) { + await this._super(...arguments); + if (["shapeAnimationSpeed", "hoverEffectIntensity", "hoverEffectStrokeWidth"].includes(params.attributeName)) { + await this._reapplyCurrentShape(); + } + }, + /** + * Sets the color of hover effects. + * + * @see this.selectClass for parameters + */ + async setHoverEffectColor(previewMode, widgetValue, params) { + const img = this._getImg(); + let defaultColor = "rgba(0, 0, 0, 0)"; + if (img.dataset.hoverEffect === "overlay") { + defaultColor = "black-25"; + } else if (img.dataset.hoverEffect === "outline") { + defaultColor = "primary"; + } + img.dataset.hoverEffectColor = this._getCSSColorValue(widgetValue || defaultColor); + await this._reapplyCurrentShape(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + notify(name) { + if (name === "enable_hover_effect") { + this.trigger_up("snippet_edition_request", {exec: () => { + // Add the "square" shape to the image if it has no shape + // because the "hover effects" need a shape to work. + const imgEl = this._getImg(); + const shapeName = imgEl.dataset.shape?.split("/")[2]; + if (!shapeName) { + const shapeImgSquareWidget = this._requestUserValueWidgets("shape_img_square_opt")[0]; + shapeImgSquareWidget.enable(); + shapeImgSquareWidget.getParent().close(); // FIXME remove this ugly hack asap + } + // Add the "Overlay" hover effect to the shape. + this.firstHoverEffect = true; + const hoverEffectOverlayWidget = this._requestUserValueWidgets("hover_effect_overlay_opt")[0]; + hoverEffectOverlayWidget.enable(); + hoverEffectOverlayWidget.getParent().close(); // FIXME remove this ugly hack asap + }}); + } else if (name === "disable_hover_effect") { + this._disableHoverEffect(); + } else { + this._super(...arguments); + } + }, + /** + * @override + */ + async updateUI() { + await this._super(...arguments); + // Adapts the colorpicker label according to the selected "On Hover" + // animation. + const hoverEffectName = this.$target[0].dataset.hoverEffect; + if (hoverEffectName) { + const hoverEffectColorWidget = this.findWidget("hover_effect_color_opt"); + const needToAdaptLabel = ["image_zoom_in", "image_zoom_out", "dolly_zoom"].includes(hoverEffectName); + const labelEl = hoverEffectColorWidget.el.querySelector("we-title"); + if (!this._originalHoverEffectColorLabel) { + this._originalHoverEffectColorLabel = labelEl.textContent; + } + labelEl.textContent = needToAdaptLabel + ? _t("Overlay") + : this._originalHoverEffectColorLabel; + } + // Move the "hover effects" options to the 'websiteAnimate' options. + const hoverEffectsOptionsEl = this.$el[0].querySelector("#o_hover_effects_options"); + const animationEffectWidget = this._requestUserValueWidgets("animation_effect_opt")[0]; + if (hoverEffectsOptionsEl && animationEffectWidget) { + animationEffectWidget.getParent().$el[0].append(hoverEffectsOptionsEl); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _isTransformed() { + return this.$target.is('[style*="transform"]'); + }, + /** + * @private + */ + _isCropped() { + return this.$target.hasClass('o_we_image_cropped'); + }, + /** + * @override + */ + async _applyOptions() { + const img = await this._super(...arguments); + if (img && img.dataset.shape) { + await this._loadShape(img.dataset.shape); + if (/^data:/.test(img.src)) { + // Reapplying the shape + await this._applyShapeAndColors(true, (img.dataset.shapeColors && img.dataset.shapeColors.split(';'))); + } + } + return img; + }, + /** + * Loads the shape into cache if not already and sets it in the dataset of + * the img + * + * @param {string} shapeName identifier of the shape + */ + async _loadShape(shapeName) { + const [module, directory, fileName] = shapeName.split('/'); + let shape = this.shapeCache[fileName]; + if (!shape) { + const shapeURL = `/${encodeURIComponent(module)}/static/image_shapes/${encodeURIComponent(directory)}/${encodeURIComponent(fileName)}.svg`; + shape = await (await fetch(shapeURL)).text(); + this.shapeCache[fileName] = shape; + } + this._getImg().dataset.shape = shapeName; + }, + + /** + * Applies the shape in img.dataset.shape and replaces the previous hex + * color values with new ones or current theme + * ones then calls _writeShape() + * + * @param {boolean} save true if the colors need to be saved in the + * data-attribute + * @param {string[]} [newColors] Array of HEX color code, default + * theme colors are applied if not supplied + */ + async _applyShapeAndColors(save, newColors) { + const img = this._getImg(); + let shape = this.shapeCache[img.dataset.shape.split('/')[2]]; + + // Map the default palette colors to an array if the shape includes them + // If they do not map a NULL, this way we know if a default color is in + // the shape + const oldColors = Object.values(DEFAULT_PALETTE).map(color => shape.includes(color) ? color : null); + if (!newColors) { + // If we do not have newColors, we still replace the default + // shape's colors by the current palette's + newColors = oldColors.map((color, i) => color !== null ? this._getCSSColorValue(`o-color-${(i + 1)}`) : null); + } + newColors.forEach((color, i) => shape = shape.replace(new RegExp(oldColors[i], 'g'), this._getCSSColorValue(color))); + await this._writeShape(shape); + if (save) { + img.dataset.shapeColors = newColors.join(';'); + } + // Also apply to carousel thumbnail if applicable. + weUtils.forwardToThumbnail(img); + }, + /** + * Replace animation durations in SVG and CSS with modified values. + * + * This function takes a ratio and an SVG string containing animations. It + * uses regular expressions to find and replace the duration values in both + * CSS animation rules and SVG duration attributes based on the provided + * ratio. + * + * @param {number} speed The speed used to calculate the new animation + * durations. If speed is 0.0, the original + * durations are preserved. + * @param {string} svg The SVG string containing animations. + * @returns {string} The modified SVG string with updated animation + * durations. + */ + _replaceAnimationDuration(speed, svg) { + const ratio = (speed >= 0.0 ? 1.0 + speed : 1.0 / (1.0 - speed)).toFixed(3); + // Callback for CSS 'animation' and 'animation-duration' declarations + function callbackCssAnimationRule(match, declaration, value, unit, separator) { + value = parseFloat(value) / (ratio ? ratio : 1); + return `${declaration}${value}${unit}${separator}`; + } + + // Callback function for handling the 'dur' SVG attribute timecount + // value in accordance with the SMIL animation specification (e.g., 4s, + // 2ms). If no unit is provided, seconds are implied. + function callbackSvgDurTimecountVal(match, attribute_name, value, unit) { + value = parseFloat(value) / (ratio ? ratio : 1); + return `${attribute_name}${value}${unit ? unit : 's'}"` + } + + // Applying regex substitutions to modify animation speed in the 'svg' + // variable. + svg = svg.replace(CSS_ANIMATION_RULE_REGEX, callbackCssAnimationRule); + svg = svg.replace(SVG_DUR_TIMECOUNT_VAL_REGEX, callbackSvgDurTimecountVal); + if (CSS_ANIMATION_RATIO_REGEX.test(svg)) { + // Replace the CSS --animation_ratio variable for future purpose. + svg = svg.replace(CSS_ANIMATION_RATIO_REGEX, `--animation_ratio: ${ratio};`); + } else { + // Add the style tag with the root variable --animation ratio for + // future purpose. + const regex = /<svg .*>/m; + const subst = `$&\n\t<style>\n\t\t:root { \n\t\t\t--animation_ratio: ${ratio};\n\t\t}\n\t</style>`; + svg = svg.replace(regex, subst); + } + return svg; + }, + /** + * Sets the image in the supplied SVG and replace the src with a dataURL + * + * @param {string} svgText svg file as text + * @returns {Promise} resolved once the svg is properly loaded + * in the document + */ + async _writeShape(svgText) { + const img = this._getImg(); + let needToRefreshPublicWidgets = false; + let hasHoverEffect = false; + + // Add shape animations on hover. + if (img.dataset.hoverEffect && this._canHaveHoverEffect()) { + // The "ImageShapeHoverEffet" public widget needs to restart + // (e.g. image replacement). + needToRefreshPublicWidgets = true; + hasHoverEffect = true; + } + + const dataURL = await this.computeShape(svgText, img); + + let clonedImgEl = null; + if (hasHoverEffect) { + // This is useful during hover effects previews. Without this, in + // Chrome, the 'mouse out' animation is triggered very briefly when + // previewMode === 'reset' (when transitioning from one hover effect + // to another), causing a visual glitch. To avoid this, we hide the + // image with its clone when the source is set. + clonedImgEl = img.cloneNode(true); + this.options.wysiwyg.odooEditor.observerUnactive("addClonedImgForHoverEffectPreview"); + img.classList.add("d-none"); + img.insertAdjacentElement("afterend", clonedImgEl); + this.options.wysiwyg.odooEditor.observerActive("addClonedImgForHoverEffectPreview"); + } + const loadedImg = await loadImage(dataURL, img); + if (hasHoverEffect) { + this.options.wysiwyg.odooEditor.observerUnactive("removeClonedImgForHoverEffectPreview"); + clonedImgEl.remove(); + img.classList.remove("d-none"); + this.options.wysiwyg.odooEditor.observerActive("removeClonedImgForHoverEffectPreview"); + } + if (needToRefreshPublicWidgets) { + await this._refreshPublicWidgets(); + } + return loadedImg; + }, + /** + * Sets the image in the supplied SVG and replace the src with a dataURL + * + * @param {string} svgText svg text file + * @param img JQuery image + * @returns {Promise} resolved once the svg is properly loaded + * in the document + */ + async computeShape(svgText, img) { + const initialImageWidth = img.naturalWidth; + + // Apply the right animation speed if there is an animated shape. + const shapeAnimationSpeed = Number(img.dataset.shapeAnimationSpeed) || 0; + if (shapeAnimationSpeed) { + svgText = this._replaceAnimationDuration(shapeAnimationSpeed, svgText); + } + + const svg = new DOMParser().parseFromString(svgText, 'image/svg+xml').documentElement; + + // Modifies the SVG according to the "flip" or/and "rotate" options. + const shapeFlip = img.dataset.shapeFlip || ""; + const shapeRotate = img.dataset.shapeRotate || 0; + if ((shapeFlip || shapeRotate) && this._isTransformableShape()) { + let shapeTransformValues = []; + if (shapeFlip) { // Possible values => "x", "y", "xy" + shapeTransformValues.push(`scale${shapeFlip === "x" ? "X" : shapeFlip === "y" ? "Y" : ""}(-1)`); + } + if (shapeRotate) { // Possible values => "90", "180", "270" + shapeTransformValues.push(`rotate(${shapeRotate}deg)`); + } + // "transform-origin: center;" does not work on "#filterPath". But + // since its dimension is 1px * 1px the following solution works. + const transformOrigin = "transform-origin: 0.5px 0.5px;"; + // Applies the transformation values to the path used to create a + // mask over the SVG image. + svg.querySelector("#filterPath").setAttribute("style", `transform: ${shapeTransformValues.join(" ")}; ${transformOrigin}`); + } + + // Add shape animations on hover. + if (img.dataset.hoverEffect && this._canHaveHoverEffect()) { + this._addImageShapeHoverEffect(svg, img); + } + + const svgAspectRatio = parseInt(svg.getAttribute('width')) / parseInt(svg.getAttribute('height')); + // We will store the image in base64 inside the SVG. + // applyModifications will return a dataURL with the current filters + // and size options. + const options = { + mimetype: this._getImageMimetype(img), + perspective: svg.dataset.imgPerspective || null, + imgAspectRatio: svg.dataset.imgAspectRatio || null, + svgAspectRatio: svgAspectRatio, + }; + const imgDataURL = await applyModifications(img, options); + svg.removeChild(svg.querySelector('#preview')); + svg.querySelectorAll("image").forEach(image => { + image.setAttribute("xlink:href", imgDataURL); + }); + // Force natural width & height (note: loading the original image is + // needed for Safari where natural width & height of SVG does not return + // the correct values). + const originalImage = await loadImage(imgDataURL); + // If the svg forces the size of the shape we still want to have the resized + // width + if (!svg.dataset.forcedSize) { + svg.setAttribute('width', originalImage.naturalWidth); + svg.setAttribute('height', originalImage.naturalHeight); + } else { + const imageWidth = Math.trunc(img.dataset.resizeWidth || img.dataset.width || initialImageWidth); + const newHeight = imageWidth / svgAspectRatio; + svg.setAttribute('width', imageWidth); + svg.setAttribute('height', newHeight); + } + // Transform the current SVG in a base64 file to be saved by the server + const blob = new Blob([svg.outerHTML], { + type: 'image/svg+xml', + }); + const dataURL = await createDataURL(blob); + const imgFilename = (img.dataset.originalSrc.split('/').pop()).split('.')[0]; + img.dataset.fileName = `${imgFilename}.svg`; + return dataURL; + }, + /** + * @override + */ + _computeMaxDisplayWidth() { + const img = this._getImg(); + const computedStyles = window.getComputedStyle(img); + const displayWidth = parseFloat(computedStyles.getPropertyValue('width')); + const gutterWidth = parseFloat(computedStyles.getPropertyValue('--o-grid-gutter-width')) || 30; + + // For the logos we don't want to suggest a width too small. + if (this.$target[0].closest('nav')) { + return Math.round(Math.min(displayWidth * 3, this.MAX_SUGGESTED_WIDTH)); + // If the image is in a container(-small), it might get bigger on + // smaller screens. So we suggest the width of the current image unless + // it is smaller than the size of the container on the md breapoint + // (which is where our bootstrap columns fallback to full container + // width since we only use col-lg-* in Odoo). + } else if (img.closest('.container, .o_container_small')) { + const mdContainerMaxWidth = parseFloat(computedStyles.getPropertyValue('--o-md-container-max-width')) || 720; + const mdContainerInnerWidth = mdContainerMaxWidth - gutterWidth; + return Math.round(clamp(displayWidth, mdContainerInnerWidth, this.MAX_SUGGESTED_WIDTH)); + // If the image is displayed in a container-fluid, it might also get + // bigger on smaller screens. The same way, we suggest the width of the + // current image unless it is smaller than the max size of the container + // on the md breakpoint (which is the LG breakpoint since the container + // fluid is full-width). + } else if (img.closest('.container-fluid')) { + const lgBp = parseFloat(computedStyles.getPropertyValue('--breakpoint-lg')) || 992; + const mdContainerFluidMaxInnerWidth = lgBp - gutterWidth; + return Math.round(clamp(displayWidth, mdContainerFluidMaxInnerWidth, this.MAX_SUGGESTED_WIDTH)); + } + // If it's not in a container, it's probably not going to change size + // depending on breakpoints. We still keep a margin safety. + return Math.round(Math.min(displayWidth * 1.5, this.MAX_SUGGESTED_WIDTH)); + }, + /** + * @override + */ + _getImg() { + return this.$target[0]; + }, + /** + * @override + */ + _relocateWeightEl() { + const leftPanelEl = this.$overlay.data('$optionsSection')[0]; + const titleTextEl = leftPanelEl.querySelector('we-title > span'); + this.$weight.appendTo(titleTextEl); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName.startsWith('img-shape-color')) { + const img = this._getImg(); + const shapeName = img.dataset.shape; + const shapeColors = img.dataset.shapeColors; + if (!shapeName || !shapeColors) { + return false; + } + const colors = img.dataset.shapeColors.split(';'); + return colors[parseInt(params.colorId)]; + } + if (widgetName === "shape_anim_speed_opt") { + return this._isAnimatedShape(); + } + if (params.optionsPossibleValues.resetTransform) { + return this._isTransformed(); + } + if (params.optionsPossibleValues.resetCrop) { + return this._isCropped(); + } + if (params.optionsPossibleValues.crop) { + const img = this._getImg(); + return isImageSupportedForStyle(img) || this._isImageSupportedForProcessing(img); + } + if (["img_shape_transform_flip_x_opt", "img_shape_transform_flip_y_opt", + "img_shape_transform_rotate_x_opt", "img_shape_transform_rotate_y_opt"].includes(params.name)) { + return this._isTransformableShape(); + } + if (widgetName === "hover_effect_none_opt") { + // The hover effects are removed with the "WebsiteAnimate" animation + // selector so this option should not be visible. + return false; + } + if (params.optionsPossibleValues.setImgShapeHoverEffect) { + const imgEl = this._getImg(); + return imgEl.classList.contains("o_animate_on_hover") && this._canHaveHoverEffect(); + } + // If "Description" or "Tooltip" options. + if (["alt", "title"].includes(params.attributeName)) { + return isImageSupportedForStyle(this._getImg()); + } + // The "Square" shape is only used for hover effects. It is + // automatically set when there is an hover effect and no shape is + // chosen by the user. This shape is always hidden in the shape select. + if (widgetName === "shape_img_square_opt") { + return false; + } + if (widgetName === "remove_img_shape_opt") { + // Do not show the "remove shape" button when the "square" shape is + // enable. The "square" shape is only enable when there is a hover + // effect and it is always hidden in the shape select. + const shapeImgSquareWidget = this._requestUserValueWidgets("shape_img_square_opt")[0]; + return !shapeImgSquareWidget.isActive(); + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetState(methodName, params) { + switch (methodName) { + case 'selectStyle': { + if (params.cssProperty === 'width') { + // TODO check how to handle this the right way (here using + // inline style instead of computed because of the messy + // %-px convertion and the messy auto keyword). + const width = this.$target[0].style.width.trim(); + if (width[width.length - 1] === '%') { + return `${parseInt(width)}%`; + } + return ''; + } + break; + } + case 'transform': { + return this._isTransformed() ? 'true' : ''; + } + case 'crop': { + return this._isCropped() ? 'true' : ''; + } + case 'setImgShape': { + return this._getImg().dataset.shape || ''; + } + case 'setImgShapeColor': { + const img = this._getImg(); + return (img.dataset.shapeColors && img.dataset.shapeColors.split(';')[parseInt(params.colorId)]) || ''; + } + case 'setImgShapeFlipX': { + const imgEl = this._getImg(); + return imgEl.dataset.shapeFlip?.includes("x") || ""; + } + case 'setImgShapeFlipY': { + const imgEl = this._getImg(); + return imgEl.dataset.shapeFlip?.includes("y") || ""; + } + case 'setHoverEffectColor': { + const imgEl = this._getImg(); + return imgEl.dataset.hoverEffectColor || ""; + } + } + return this._super(...arguments); + }, + /** + * Appends the SVG as an image. + * Due to the nature of image_shapes' SVGs, it is easier to render them as + * img compared to appending their content to the DOM + * (which is what the current data-img does) + * + * @override + */ + async _renderCustomXML(uiFragment) { + await this._super(...arguments); + uiFragment.querySelectorAll('we-select-page we-button[data-set-img-shape]').forEach(btn => { + const image = document.createElement('img'); + const [moduleName, directory, shapeName] = btn.dataset.setImgShape.split('/'); + image.src = `/${encodeURIComponent(moduleName)}/static/image_shapes/${encodeURIComponent(directory)}/${encodeURIComponent(shapeName)}.svg`; + $(btn).prepend(image); + + if (btn.dataset.animated) { + _addAnimatedShapeLabel(btn); + } else if (btn.dataset.imgSize) { + _addAnimatedShapeLabel(btn, true); + } + }); + }, + /** + * @override + */ + _getImageMimetype(img) { + if (img.dataset.shape && img.dataset.originalMimetype) { + return img.dataset.originalMimetype; + } + return this._super(...arguments); + }, + /** + * Gets the CSS value of a color variable name so it can be used on shapes. + * + * @param {string} color + * @returns {string} + */ + _getCSSColorValue(color) { + if (!color || isCSSColor(color)) { + return color; + } + return weUtils.getCSSVariableValue(color); + }, + /** + * Overridden to set attachment data on theme images (with default shapes). + * + * @override + * @private + */ + async _initializeImage() { + const _super = this._super.bind(this); + let img = this._getImg(); + + // Check first if the `src` and eventual `data-original-src` attributes + // are correct (i.e. the await are not rejected), as they may have been + // wrongly hardcoded in some templates. + let checkedAttribute = 'src'; + try { + await loadImage(img.src); + if (img.dataset.originalSrc) { + checkedAttribute = 'originalSrc'; + await loadImage(img.dataset.originalSrc); + } + } catch { + if (checkedAttribute === 'src') { + // If `src` does not exist, replace the image by a placeholder. + Object.keys(img.dataset).forEach(key => delete img.dataset[key]); + img.dataset.mimetype = 'image/png'; + const newSrc = '/web/image/web.image_placeholder'; + img = await loadImage(newSrc, img); + return this._loadImageInfo(newSrc); + } else { + // If `data-original-src` does not exist, remove the `data- + // original-*` attributes (they will be set correctly afterwards + // in `_loadImageInfo`). + delete img.dataset.originalId; + delete img.dataset.originalSrc; + delete img.dataset.originalMimetype; + } + } + + let match = img.src.match(/\/web_editor\/image_shape\/(\w+\.\w+)/); + if (img.dataset.shape && match) { + match = match[1]; + if (match.endsWith("_perspective")) { + // As an image might already have been modified with a + // perspective for some customized snippets in themes. We need + // to find the original image to set the 'data-original-src' + // attribute. + match = match.slice(0, -12); + } + return this._loadImageInfo(`/web/image/${encodeURIComponent(match)}`); + } + return _super(...arguments); + }, + /** + * @override + * @private + */ + async _loadImageInfo() { + await this._super(...arguments); + const img = this._getImg(); + if (img.dataset.shape) { + if (img.dataset.mimetype !== "image/svg+xml") { + img.dataset.originalMimetype = img.dataset.mimetype; + } + if (!this._isImageSupportedForProcessing(img)) { + delete img.dataset.shape; + delete img.dataset.shapeColors; + delete img.dataset.fileName; + delete img.dataset.originalMimetype; + delete img.dataset.shapeFlip; + delete img.dataset.shapeRotate; + delete img.dataset.hoverEffect; + delete img.dataset.hoverEffectColor; + delete img.dataset.hoverEffectStrokeWidth; + delete img.dataset.hoverEffectIntensity; + img.classList.remove("o_animate_on_hover"); + delete img.dataset.shapeAnimationSpeed; + return; + } + if (img.dataset.mimetype !== "image/svg+xml") { + // Image data-mimetype should be changed to SVG since + // loadImageInfo() will set the original attachment mimetype on + // it. + img.dataset.mimetype = "image/svg+xml"; + } + } + }, + /** + * @private + */ + async _reapplyCurrentShape() { + const img = this._getImg(); + if (img.dataset.shape) { + await this._loadShape(img.dataset.shape); + await this._applyShapeAndColors(true, (img.dataset.shapeColors && img.dataset.shapeColors.split(';'))); + img.classList.add("o_modified_image_to_save"); + } + }, + /** + * @override + */ + _isImageProcessingWidget(widgetName, params) { + if (widgetName === 'shape_img_opt') { + return !isGif(this._getImageMimetype(this._getImg())); + } + return this._super(...arguments); + }, + /** + * Flips the image shape (vertically or/and horizontally). + * + * @private + * @param {string} flipValue image shape flip value + */ + async _setImgShapeFlip(flipValue) { + const imgEl = this._getImg(); + const currentFlipValue = imgEl.dataset.shapeFlip || ""; + const newFlipValue = currentFlipValue.includes(flipValue) + ? currentFlipValue.replace(flipValue, "") + : currentFlipValue + flipValue; + if (newFlipValue) { + imgEl.dataset.shapeFlip = newFlipValue === "yx" ? "xy" : newFlipValue; + } else { + delete imgEl.dataset.shapeFlip; + } + await this._applyShapeAndColors(true, imgEl.dataset.shapeColors?.split(";")); + imgEl.classList.add("o_modified_image_to_save"); + }, + /** + * Rotates the image shape 90 degrees. + * + * @private + * @param {integer} rotation rotation value + */ + async _setImgShapeRotate(rotation) { + const imgEl = this._getImg(); + const currentRotateValue = parseInt(imgEl.dataset.shapeRotate) || 0; + const newRotateValue = (currentRotateValue + rotation + 360) % 360; + if (newRotateValue) { + imgEl.dataset.shapeRotate = newRotateValue; + } else { + delete imgEl.dataset.shapeRotate; + } + await this._applyShapeAndColors(true, imgEl.dataset.shapeColors?.split(";")); + imgEl.classList.add("o_modified_image_to_save"); + }, + /** + * Checks if the shape is in the "devices" category. + * + * @private + * @returns {boolean} + */ + _isDeviceShape() { + const imgEl = this._getImg(); + const shapeName = imgEl.dataset.shape; + if (!shapeName) { + return false; + } + const shapeCategory = imgEl.dataset.shape.split("/")[1]; + return shapeCategory === "devices"; + }, + /** + * Checks if the shape is transformable. + * + * @private + * @returns {boolean} + */ + _isTransformableShape() { + const shapeImgWidget = this._requestUserValueWidgets("shape_img_opt")[0]; + return (shapeImgWidget && !shapeImgWidget.getMethodsParams().noTransform) && !this._isDeviceShape(); + }, + /** + * Checks if the shape is in animated. + * + * @private + * @returns {boolean} + */ + _isAnimatedShape() { + const shapeImgWidget = this._requestUserValueWidgets("shape_img_opt")[0]; + return shapeImgWidget?.getMethodsParams().animated; + }, + /** + * Checks if the shape can have a hover effect. + * + * @private + * @returns {boolean} + */ + _canHaveHoverEffect() { + return !this._isDeviceShape() && !this._isAnimatedShape() && this._isImageSupportedForShapes(); + }, + /** + * Adds hover effect to the SVG. + * + * @private + * @param {HTMLElement} svgEl + * @param {HTMLImageElement} [img] img element + */ + async _addImageShapeHoverEffect(svgEl, img) { + let rgba = null; + let rbg = null; + let opacity = null; + // Add the required parts for the hover effects to the SVG. + const hoverEffectName = img.dataset.hoverEffect; + if (!this.hoverEffectsSvg) { + this.hoverEffectsSvg = await this._getHoverEffects(); + } + const hoverEffectEls = this.hoverEffectsSvg.querySelectorAll(`#${hoverEffectName} > *`); + hoverEffectEls.forEach(hoverEffectEl => { + svgEl.appendChild(hoverEffectEl.cloneNode(true)); + }); + // Modifies the svg according to the chosen hover effect and the value + // of the options. + const animateEl = svgEl.querySelector("animate"); + const animateTransformEls = svgEl.querySelectorAll("animateTransform"); + const animateElValues = animateEl?.getAttribute("values"); + let animateTransformElValues = animateTransformEls[0]?.getAttribute("values"); + if (img.dataset.hoverEffectColor) { + rgba = convertCSSColorToRgba(img.dataset.hoverEffectColor); + rbg = `rgb(${rgba.red},${rgba.green},${rgba.blue})`; + opacity = rgba.opacity / 100; + if (!["outline", "image_mirror_blur"].includes(hoverEffectName)) { + svgEl.querySelector('[fill="hover_effect_color"]').setAttribute("fill", rbg); + animateEl.setAttribute("values", animateElValues.replace("hover_effect_opacity", opacity)); + } + } + switch (hoverEffectName) { + case "outline": { + svgEl.querySelector('[stroke="hover_effect_color"]').setAttribute("stroke", rbg); + svgEl.querySelector('[stroke-opacity="hover_effect_opacity"]').setAttribute("stroke-opacity", opacity); + // The stroke width needs to be multiplied by two because half + // of the stroke is invisible since it is centered on the path. + const strokeWidth = parseInt(img.dataset.hoverEffectStrokeWidth) * 2; + animateEl.setAttribute("values", animateElValues.replace("hover_effect_stroke_width", strokeWidth)); + break; + } + case "image_zoom_in": + case "image_zoom_out": + case "dolly_zoom": { + const imageEl = svgEl.querySelector("image"); + const clipPathEl = svgEl.querySelector("#clip-path"); + imageEl.setAttribute("id", "shapeImage"); + // Modify the SVG so that the clip-path is not zoomed when the + // image is zoomed. + imageEl.setAttribute("style", "transform-origin: center; width: 100%; height: 100%"); + imageEl.setAttribute("preserveAspectRatio", "none"); + svgEl.setAttribute("viewBox", "0 0 1 1"); + svgEl.setAttribute("preserveAspectRatio", "none"); + clipPathEl.setAttribute("clipPathUnits", "userSpaceOnUse"); + const clipPathValue = imageEl.getAttribute("clip-path"); + imageEl.removeAttribute("clip-path"); + const gEl = document.createElementNS("http://www.w3.org/2000/svg", "g"); + gEl.setAttribute("clip-path", clipPathValue); + imageEl.parentNode.replaceChild(gEl, imageEl); + gEl.appendChild(imageEl); + let zoomValue = 1.01 + parseInt(img.dataset.hoverEffectIntensity) / 200; + animateTransformEls[0].setAttribute("values", animateTransformElValues.replace("hover_effect_zoom", zoomValue)); + if (hoverEffectName === "image_zoom_out") { + // Set zoom intensity for the image. + const styleAttr = svgEl.querySelector("style"); + styleAttr.textContent = styleAttr.textContent.replace("hover_effect_zoom", zoomValue); + } + if (hoverEffectName === "dolly_zoom") { + clipPathEl.setAttribute("style", "transform-origin: center;"); + // Set zoom intensity for clip-path and overlay. + zoomValue = 0.99 - parseInt(img.dataset.hoverEffectIntensity) / 2000; + animateTransformEls.forEach((animateTransformEl, index) => { + if (index > 0) { + animateTransformElValues = animateTransformEl.getAttribute("values"); + animateTransformEl.setAttribute("values", animateTransformElValues.replace("hover_effect_zoom", zoomValue)); + } + }); + } + break; + } + case "image_mirror_blur": { + const imageEl = svgEl.querySelector("image"); + imageEl.setAttribute('id', 'shapeImage'); + imageEl.setAttribute('style', 'transform-origin: center;'); + const imageMirrorEl = imageEl.cloneNode(); + imageMirrorEl.setAttribute("id", 'shapeImageMirror'); + imageMirrorEl.setAttribute("filter", "url(#blurFilter)"); + imageEl.insertAdjacentElement("beforebegin", imageMirrorEl); + const zoomValue = 0.99 - parseInt(img.dataset.hoverEffectIntensity) / 200; + animateTransformEls[0].setAttribute("values", animateTransformElValues.replace("hover_effect_zoom", zoomValue)); + break; + } + } + }, + /** + * Gets the hover effects list. + * + * @private + * @returns {HTMLElement} + */ + _getHoverEffects() { + const hoverEffectsURL = "/website/static/src/svg/hover_effects.svg"; + return fetch(hoverEffectsURL) + .then(response => response.text()) + .then(text => { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(text, "text/xml"); + return xmlDoc.getElementsByTagName("svg")[0]; + }); + }, + /** + * Disables the hover effect on the image. + * + * @private + */ + async _disableHoverEffect() { + const imgEl = this._getImg(); + const shapeName = imgEl.dataset.shape?.split("/")[2]; + delete imgEl.dataset.hoverEffect; + delete imgEl.dataset.hoverEffectColor; + delete imgEl.dataset.hoverEffectStrokeWidth; + delete imgEl.dataset.hoverEffectIntensity; + await this._applyOptions(); + // If "Square" shape, remove it, it doesn't make sense to keep it + // without hover effect. + if (shapeName === "geo_square") { + this._requestUserValueWidgets("remove_img_shape_opt")[0].enable(); + } + }, + /** + * @override + */ + async _select(previewMode, widget) { + await this._super(...arguments); + // This is a special case where we need to override the "_select" + // function in order to trigger mouse events for hover effects on the + // images when previewing the options. This is done here because if it + // was done in one of the widget methods, the animation would be + // canceled when "_refreshPublicWidgets" is executed in the "_super" + if (widget.$el[0].closest("#o_hover_effects_options")) { + const hasSetImgShapeHoverEffectMethod = widget.getMethodsNames().includes("setImgShapeHoverEffect"); + // We trigger the animation when preview mode is "false", except for + // the "setImgShapeHoverEffect" option, where we trigger it when + // preview mode is "true". + if (previewMode === hasSetImgShapeHoverEffectMethod) { + this.$target[0].dispatchEvent(new Event("mouseover")); + this.hoverTimeoutId = setTimeout(() => { + this.$target[0].dispatchEvent(new Event("mouseout")); + }, 700); + } else if (previewMode === "reset") { + clearTimeout(this.hoverTimeoutId); + } + } + }, + /** + * Checks if a shape can be applied on the target. + * + * @private + * @returns {boolean} + */ + _isImageSupportedForShapes() { + const imgEl = this._getImg(); + return imgEl.dataset.originalId && this._isImageSupportedForProcessing(imgEl); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Reloads image data and auto-optimizes the new image. + * + * @private + * @param {Event} ev + */ + async _onImageChanged(ev) { + this.trigger_up('snippet_edition_request', {exec: async () => { + await this._autoOptimizeImage(); + this.trigger_up('cover_update'); + }}); + }, + /** + * Available widths will change, need to rerender the width select. + * + * @private + * @param {Event} ev + */ + async _onImageCropped(ev) { + await this._rerenderXML(); + }, +}); + +/** + * Controls background image width and quality. + */ +registry.BackgroundOptimize = ImageHandlerOption.extend({ + /** + * @override + */ + start() { + this.$target.on('background_changed.BackgroundOptimize', this._onBackgroundChanged.bind(this)); + return this._super(...arguments); + }, + /** + * @override + */ + destroy() { + this.$target.off('.BackgroundOptimize'); + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getImg() { + return this.img; + }, + /** + * @override + */ + _computeMaxDisplayWidth() { + return 1920; + }, + /** + * Initializes this.img to an image with the background image url as src. + * + * @override + */ + async _loadImageInfo() { + this.img = new Image(); + // In the case of a parallax, the background of the snippet is actually + // set on a child <span> and should be focused here. This is necessary + // because, at this point, the $target has not yet been updated in the + // notify() method ("option_update" event), although the event is + // properly fired from the parallax. + const targetEl = this.$target[0].classList.contains("oe_img_bg") + ? this.$target[0] : this.$target[0].querySelector(":scope > .s_parallax_bg.oe_img_bg"); + if (targetEl) { + Object.entries(targetEl.dataset).filter(([key]) => + isBackgroundImageAttribute(key)).forEach(([key, value]) => { + this.img.dataset[key] = value; + }); + const src = getBgImageURL(targetEl); + // Don't set the src if not relative (ie, not local image: cannot be + // modified) + this.img.src = src.startsWith("/") ? src : ""; + } + return await this._super(...arguments); + }, + /** + * @override + */ + _relocateWeightEl() { + this.trigger_up('option_update', { + optionNames: ['BackgroundImage'], + name: 'add_size_indicator', + data: this.$weight, + }); + }, + /** + * @override + */ + _applyImage(img) { + const parts = backgroundImageCssToParts(this.$target.css('background-image')); + parts.url = `url('${img.getAttribute('src')}')`; + const combined = backgroundImagePartsToCss(parts); + this.$target.css('background-image', combined); + // Apply modification on the DOM HTML element that is currently being + // modified. + this.$target[0].classList.add("o_modified_image_to_save"); + // First delete the data attributes relative to the image background + // from the target as a data attribute could have been be removed (ex: + // glFilter). + for (const attribute in this.$target[0].dataset) { + if (isBackgroundImageAttribute(attribute)) { + delete this.$target[0].dataset[attribute]; + } + } + Object.entries(img.dataset).forEach(([key, value]) => { + this.$target[0].dataset[key] = value; + }); + this.$target[0].dataset.bgSrc = img.getAttribute("src"); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Reloads image data when the background is changed. + * + * @private + */ + async _onBackgroundChanged(ev, previewMode) { + ev.stopPropagation(); + if (!previewMode) { + this.trigger_up('snippet_edition_request', {exec: async () => { + await this._autoOptimizeImage(); + }}); + } + }, +}); + +registry.BackgroundToggler = SnippetOptionWidget.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Toggles background image on or off. + * + * @see this.selectClass for parameters + */ + toggleBgImage(previewMode, widgetValue, params) { + if (!widgetValue) { + this.$target.find('> .o_we_bg_filter').remove(); + // TODO: use setWidgetValue instead of calling background directly when possible + const [bgImageWidget] = this._requestUserValueWidgets('bg_image_opt'); + const bgImageOpt = bgImageWidget.getParent(); + return bgImageOpt.background(false, '', bgImageWidget.getMethodsParams('background')); + } else { + // TODO: use trigger instead of el.click when possible + this._requestUserValueWidgets('bg_image_opt')[0].el.click(); + } + }, + /** + * Toggles background shape on or off. + * + * @see this.selectClass for parameters + */ + toggleBgShape(previewMode, widgetValue, params) { + const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt'); + const shapeOption = shapeWidget.getParent(); + // TODO: open select after shape was selected? + // TODO: use setWidgetValue instead of calling shapeOption method directly when possible + return shapeOption._toggleShape(); + }, + /** + * Sets a color filter. + * + * @see this.selectClass for parameters + */ + async selectFilterColor(previewMode, widgetValue, params) { + // Find the filter element. + let filterEl = this.$target[0].querySelector(':scope > .o_we_bg_filter'); + + // If the filter would be transparent, remove it / don't create it. + const rgba = widgetValue && convertCSSColorToRgba(widgetValue); + if (!widgetValue || rgba && rgba.opacity < 0.001) { + if (filterEl) { + filterEl.remove(); + } + return; + } + + // Create the filter if necessary. + if (!filterEl) { + filterEl = document.createElement('div'); + filterEl.classList.add('o_we_bg_filter'); + const lastBackgroundEl = this._getLastPreFilterLayerElement(); + if (lastBackgroundEl) { + $(lastBackgroundEl).after(filterEl); + } else { + this.$target.prepend(filterEl); + } + } + + // Apply the color on the filter. + const obj = createPropertyProxy(this, '$target', $(filterEl)); + params.cssProperty = 'background-color'; + return this.selectStyle.call(obj, previewMode, widgetValue, params); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + switch (methodName) { + case 'toggleBgImage': { + const [bgImageWidget] = this._requestUserValueWidgets('bg_image_opt'); + const bgImageOpt = bgImageWidget.getParent(); + return !!bgImageOpt._computeWidgetState('background', bgImageWidget.getMethodsParams('background')); + } + case 'toggleBgShape': { + const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt'); + const shapeOption = shapeWidget.getParent(); + return !!shapeOption._computeWidgetState('shape', shapeWidget.getMethodsParams('shape')); + } + case 'selectFilterColor': { + const filterEl = this.$target[0].querySelector(':scope > .o_we_bg_filter'); + if (!filterEl) { + return ''; + } + const obj = createPropertyProxy(this, '$target', $(filterEl)); + params.cssProperty = 'background-color'; + return this._computeWidgetState.call(obj, 'selectStyle', params); + } + } + return this._super(...arguments); + }, + /** + * @private + */ + _getLastPreFilterLayerElement() { + return null; + }, +}); + +/** + * Handles the edition of snippet's background image. + */ +registry.BackgroundImage = SnippetOptionWidget.extend({ + /** + * @override + */ + start: function () { + this.__customImageSrc = getBgImageURL(this.$target[0]); + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Handles a background change. + * + * @see this.selectClass for parameters + */ + background: async function (previewMode, widgetValue, params) { + if (previewMode === true) { + this.__customImageSrc = getBgImageURL(this.$target[0]); + } else if (previewMode === 'reset') { + widgetValue = this.__customImageSrc; + } else { + this.__customImageSrc = widgetValue; + } + + this._setBackground(widgetValue); + + if (previewMode !== 'reset') { + removeOnImageChangeAttrs.forEach(attr => delete this.$target[0].dataset[attr]); + this.$target.trigger('background_changed', [previewMode]); + } + }, + /** + * Changes the main color of dynamic SVGs. + * + * @see this.selectClass for parameters + */ + async dynamicColor(previewMode, widgetValue, params) { + const currentSrc = getBgImageURL(this.$target[0]); + switch (previewMode) { + case true: + this.previousSrc = currentSrc; + break; + case 'reset': + this._setBackground(this.previousSrc); + return; + } + const newURL = new URL(currentSrc, window.location.origin); + newURL.searchParams.set(params.colorName, normalizeColor(widgetValue)); + const src = newURL.pathname + newURL.search; + await loadImage(src); + this._setBackground(src); + if (!previewMode) { + this.previousSrc = src; + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + notify(name, data) { + if (name === 'add_size_indicator') { + this._requestUserValueWidgets('bg_image_opt')[0].$el.after(data); + } else { + this._super(...arguments); + } + }, + /** + * @override + */ + setTarget: function () { + // When we change the target of this option we need to transfer the + // background-image and the dataset information relative to this image + // from the old target to the new one. + const oldBgURL = getBgImageURL(this.$target); + const isModifiedImage = this.$target[0].classList.contains("o_modified_image_to_save"); + const filteredOldDataset = Object.entries(this.$target[0].dataset).filter(([key]) => { + return isBackgroundImageAttribute(key); + }); + // Delete the dataset information relative to the background-image of + // the old target. + filteredOldDataset.forEach(([key]) => { + delete this.$target[0].dataset[key]; + }); + // It is important to delete ".o_modified_image_to_save" from the old + // target as its image source will be deleted. + this.$target[0].classList.remove("o_modified_image_to_save"); + this._setBackground(''); + this._super(...arguments); + if (oldBgURL) { + this._setBackground(oldBgURL); + filteredOldDataset.forEach(([key, value]) => { + this.$target[0].dataset[key] = value; + }); + this.$target[0].classList.toggle("o_modified_image_to_save", isModifiedImage); + } + + // TODO should be automatic for all options as equal to the start method + this.__customImageSrc = getBgImageURL(this.$target[0]); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'background': + return getBgImageURL(this.$target[0]); + case 'dynamicColor': + return new URL(getBgImageURL(this.$target[0]), window.location.origin).searchParams.get(params.colorName); + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility(widgetName, params) { + if ('colorName' in params) { + const src = new URL(getBgImageURL(this.$target[0]), window.location.origin); + return src.searchParams.has(params.colorName); + } else if (widgetName === 'main_color_opt') { + const src = new URL(getBgImageURL(this.$target[0]), window.location.origin); + return src.origin === window.location.origin && src.pathname.startsWith('/web_editor/shape/'); + } + return this._super(...arguments); + }, + /** + * @private + * @param {string} backgroundURL + */ + _setBackground(backgroundURL) { + const parts = backgroundImageCssToParts(this.$target.css('background-image')); + if (backgroundURL) { + parts.url = `url('${backgroundURL}')`; + this.$target.addClass('oe_img_bg o_bg_img_center'); + } else { + delete parts.url; + this.$target[0].classList.remove( + "oe_img_bg", + "o_bg_img_center", + "o_modified_image_to_save", + ); + } + const combined = backgroundImagePartsToCss(parts); + // We use selectStyle so that if when a background image is removed the + // remaining image matches the o_cc's gradient background, it can be + // removed too. + this.selectStyle(false, combined, { + cssProperty: 'background-image', + }); + }, +}); + +/** + * Handles background shapes. + */ +registry.BackgroundShape = SnippetOptionWidget.extend({ + /** + * @override + */ + updateUI({assetsChanged} = {}) { + if (this.rerender || assetsChanged) { + this.rerender = false; + return this._rerenderXML(); + } + return this._super.apply(this, arguments); + }, + /** + * @override + */ + onBuilt() { + // Flip classes should no longer be used but are still present in some + // theme snippets. + if (this.$target[0].querySelector('.o_we_flip_x, .o_we_flip_y')) { + this._handlePreviewState(false, () => { + return {flip: this._getShapeData().flip}; + }); + } + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Sets the current background shape. + * + * @see this.selectClass for params + */ + shape(previewMode, widgetValue, params) { + this._handlePreviewState(previewMode, () => { + return { + shape: widgetValue, + colors: this._getImplicitColors(widgetValue, this._getShapeData().colors), + flip: [], + animated: params.animated, + shapeAnimationSpeed: this._getShapeData().shapeAnimationSpeed, + }; + }); + }, + /** + * Sets the current background shape's colors. + * + * @see this.selectClass for params + */ + color(previewMode, widgetValue, params) { + this._handlePreviewState(previewMode, () => { + const {colorName} = params; + const {colors: previousColors} = this._getShapeData(); + const newColor = normalizeColor(widgetValue) || this._getDefaultColors()[colorName]; + const newColors = Object.assign(previousColors, {[colorName]: newColor}); + return {colors: newColors}; + }); + }, + /** + * Flips the shape on its x axis. + * + * @see this.selectClass for params + */ + flipX(previewMode, widgetValue, params) { + this._flipShape(previewMode, 'x'); + }, + /** + * Flips the shape on its y axis. + * + * @see this.selectClass for params + */ + flipY(previewMode, widgetValue, params) { + this._flipShape(previewMode, 'y'); + }, + /** + * Shows/Hides the shape on mobile. + * + * @see this.selectClass for params + */ + showOnMobile(previewMode, widgetValue, params) { + this._handlePreviewState(previewMode, () => { + return {showOnMobile: !this._getShapeData().showOnMobile}; + }); + }, + /** + * Sets the speed of the animation of a background shape. + * + * @see this.selectClass for params + */ + setBgShapeAnimationSpeed(previewMode, widgetValue, params) { + this._handlePreviewState(previewMode, () => { + return { shapeAnimationSpeed: widgetValue }; + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + switch (methodName) { + case 'shape': { + return this._getShapeData().shape; + } + case 'color': { + const {shape, colors: customColors} = this._getShapeData(); + const colors = Object.assign(this._getDefaultColors(), customColors); + const color = shape && colors[params.colorName]; + return color || ''; + } + case 'flipX': { + // Compat: flip classes are no longer used but may be present in client db + const hasFlipClass = this.$target.find('> .o_we_shape.o_we_flip_x').length !== 0; + return hasFlipClass || this._getShapeData().flip.includes('x'); + } + case 'flipY': { + // Compat: flip classes are no longer used but may be present in client db + const hasFlipClass = this.$target.find('> .o_we_shape.o_we_flip_y').length !== 0; + return hasFlipClass || this._getShapeData().flip.includes('y'); + } + case 'showOnMobile': { + return this._getShapeData().showOnMobile; + } + case "setBgShapeAnimationSpeed": { + return this._getShapeData().shapeAnimationSpeed; + } + } + return this._super(...arguments); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === "bg_shape_anim_speed_opt") { + const bgShapeWidget = this._requestUserValueWidgets("bg_shape_opt")[0]; + return bgShapeWidget.getMethodsParams().animated === "true"; + } + return this._super(...arguments); + }, + /** + * @override + */ + _renderCustomXML(uiFragment) { + Object.keys(this._getDefaultColors()).map(colorName => { + uiFragment.querySelector('[data-name="colors"]') + .prepend($(`<we-colorpicker data-color="true" data-color-name="${colorName}"></we-colorpicker>`)[0]); + }); + + // Inventory shape URLs per class. + const style = window.getComputedStyle(this.$target[0]); + const palette = [1, 2, 3, 4, 5].map(n => style.getPropertyValue(`--o-cc${n}-bg`)).join(); + if (palette !== this._lastShapePalette) { + this._lastShapePalette = palette; + this._shapeBackgroundImagePerClass = {}; + for (const styleSheet of this.$target[0].ownerDocument.styleSheets) { + if (styleSheet.href && new URL(styleSheet.href).host !== location.host) { + // In some browsers, if a stylesheet is loaded from a different domain + // accessing cssRules results in a SecurityError. + continue; + } + for (const rule of [...styleSheet.cssRules]) { + if (rule.selectorText && rule.selectorText.startsWith(".o_we_shape.")) { + this._shapeBackgroundImagePerClass[rule.selectorText] = rule.style.backgroundImage; + } + } + } + } + + uiFragment.querySelectorAll('we-select-pager we-button[data-shape]').forEach(btn => { + const btnContent = document.createElement('div'); + btnContent.classList.add('o_we_shape_btn_content', 'position-relative', 'border-dark'); + const btnContentInnerDiv = document.createElement('div'); + btnContentInnerDiv.classList.add('o_we_shape'); + btnContent.appendChild(btnContentInnerDiv); + + if (btn.dataset.animated) { + _addAnimatedShapeLabel(btnContent); + } + + const {shape} = btn.dataset; + const shapeEl = btnContent.querySelector('.o_we_shape'); + const shapeClassName = `o_${shape.replace(/\//g, '_')}`; + shapeEl.classList.add(shapeClassName); + // Match current palette. + const shapeBackgroundImage = this._shapeBackgroundImagePerClass[`.o_we_shape.${shapeClassName}`]; + shapeEl.style.setProperty("background-image", shapeBackgroundImage); + btn.append(btnContent); + }); + return uiFragment; + }, + /** + * Flips the shape on its x/y axis. + * + * @param {boolean} previewMode + * @param {'x'|'y'} axis the axis of the shape that should be flipped. + */ + _flipShape(previewMode, axis) { + this._handlePreviewState(previewMode, () => { + const flip = new Set(this._getShapeData().flip); + if (flip.has(axis)) { + flip.delete(axis); + } else { + flip.add(axis); + } + return {flip: [...flip]}; + }); + }, + /** + * Inserts or removes the given container at the right position in the + * document. + * + * @param {HTMLElement} [newContainer] container to insert, null to remove + */ + _insertShapeContainer(newContainer) { + const target = this.$target[0]; + + const shapeContainer = target.querySelector(':scope > .o_we_shape'); + if (shapeContainer) { + this._removeShapeEl(shapeContainer); + } + if (newContainer) { + const preShapeLayerElement = this._getLastPreShapeLayerElement(); + if (preShapeLayerElement) { + $(preShapeLayerElement).after(newContainer); + } else { + this.$target.prepend(newContainer); + } + } + return newContainer; + }, + /** + * Creates and inserts a container for the shape with the right classes. + * + * @param {string} shape the shape name for which to create a container + */ + _createShapeContainer(shape) { + const shapeContainer = this._insertShapeContainer(document.createElement('div')); + this.$target[0].style.position = 'relative'; + shapeContainer.className = `o_we_shape o_${shape.replace(/\//g, '_')}`; + return shapeContainer; + }, + /** + * Handles everything related to saving state before preview and restoring + * it after a preview or locking in the changes when not in preview. + * + * @param {boolean} previewMode + * @param {function} computeShapeData function to compute the new shape data. + */ + _handlePreviewState(previewMode, computeShapeData) { + const target = this.$target[0]; + + let changedShape = false; + if (previewMode === 'reset') { + this._insertShapeContainer(this.prevShapeContainer); + if (this.prevShape) { + target.dataset.oeShapeData = this.prevShape; + } else { + delete target.dataset.oeShapeData; + } + return; + } else { + if (previewMode === true) { + const shapeContainer = target.querySelector(':scope > .o_we_shape'); + this.prevShapeContainer = shapeContainer && shapeContainer.cloneNode(true); + this.prevShape = target.dataset.oeShapeData; + } + const curShapeData = target.dataset.oeShapeData || {}; + const newShapeData = computeShapeData(); + const {shape: curShape} = curShapeData; + changedShape = newShapeData.shape !== curShape; + this._markShape(newShapeData); + if (previewMode === false && changedShape) { + // Need to rerender for correct number of colorpickers + this.rerender = true; + } + } + + // Updates/removes the shape container as needed and gives it the + // correct background shape + const json = target.dataset.oeShapeData; + const {shape, colors, flip = [], animated = 'false', showOnMobile, shapeAnimationSpeed} = json ? JSON.parse(json) : {}; + let shapeContainer = target.querySelector(':scope > .o_we_shape'); + if (!shape) { + return this._insertShapeContainer(null); + } + // When changing shape we want to reset the shape container (for transparency color) + if (changedShape) { + shapeContainer = this._createShapeContainer(shape); + } + // Compat: remove old flip classes as flipping is now done inside the svg + shapeContainer.classList.remove('o_we_flip_x', 'o_we_flip_y'); + + shapeContainer.classList.toggle('o_we_animated', animated === 'true'); + if (colors || flip.length || parseFloat(shapeAnimationSpeed) !== 0) { + // Custom colors/flip/speed, overwrite shape that is set by the class + $(shapeContainer).css('background-image', `url("${this._getShapeSrc()}")`); + shapeContainer.style.backgroundPosition = ''; + if (flip.length) { + let [xPos, yPos] = $(shapeContainer) + .css('background-position') + .split(' ') + .map(p => parseFloat(p)); + // -X + 2*Y is a symmetry of X around Y, this is a symmetry around 50% + xPos = flip.includes('x') ? -xPos + 100 : xPos; + yPos = flip.includes('y') ? -yPos + 100 : yPos; + shapeContainer.style.backgroundPosition = `${xPos}% ${yPos}%`; + } + } else { + // Remove custom bg image and let the shape class set the bg shape + $(shapeContainer).css('background-image', ''); + $(shapeContainer).css('background-position', ''); + } + shapeContainer.classList.toggle('o_shape_show_mobile', !!showOnMobile); + if (previewMode === false) { + this.prevShapeContainer = shapeContainer.cloneNode(true); + this.prevShape = target.dataset.oeShapeData; + } + }, + /** + * @private + * @param {HTMLElement} shapeEl + */ + _removeShapeEl(shapeEl) { + shapeEl.remove(); + }, + /** + * Overwrites shape properties with the specified data. + * + * @private + * @param {Object} newData an object with the new data + */ + _markShape(newData) { + const defaultColors = this._getDefaultColors(); + const shapeData = Object.assign(this._getShapeData(), newData); + const areColorsDefault = Object.entries(shapeData.colors).every(([colorName, colorValue]) => { + return defaultColors[colorName] && colorValue.toLowerCase() === defaultColors[colorName].toLowerCase(); + }); + if (areColorsDefault) { + delete shapeData.colors; + } + if (!shapeData.shape) { + delete this.$target[0].dataset.oeShapeData; + } else { + this.$target[0].dataset.oeShapeData = JSON.stringify(shapeData); + } + }, + /** + * @private + */ + _getLastPreShapeLayerElement() { + const $filterEl = this.$target.find('> .o_we_bg_filter'); + if ($filterEl.length) { + return $filterEl[0]; + } + return null; + }, + /** + * Returns the src of the shape corresponding to the current parameters. + * + * @private + */ + _getShapeSrc() { + const { shape, colors, flip, shapeAnimationSpeed } = this._getShapeData(); + if (!shape) { + return ''; + } + const searchParams = Object.entries(colors) + .map(([colorName, colorValue]) => { + const encodedCol = encodeURIComponent(colorValue); + return `${colorName}=${encodedCol}`; + }); + if (flip.length) { + searchParams.push(`flip=${encodeURIComponent(flip.sort().join(''))}`); + } + if (Number(shapeAnimationSpeed)) { + searchParams.push(`shapeAnimationSpeed=${encodeURIComponent(shapeAnimationSpeed)}`); + } + return `/web_editor/shape/${encodeURIComponent(shape)}.svg?${searchParams.join('&')}`; + }, + /** + * Retrieves current shape data from the target's dataset. + * + * @private + * @param {HTMLElement} [target=this.$target[0]] the target on which to read + * the shape data. + */ + _getShapeData(target = this.$target[0]) { + const defaultData = { + shape: '', + colors: this._getDefaultColors($(target)), + flip: [], + showOnMobile: false, + shapeAnimationSpeed: "0", + }; + const json = target.dataset.oeShapeData; + return json ? Object.assign(defaultData, JSON.parse(json.replace(/'/g, '"'))) : defaultData; + }, + /** + * Returns the default colors for the currently selected shape. + * + * @private + * @param {jQueryElement} [$target=this.$target] the target on which to read + * the shape data. + */ + _getDefaultColors($target = this.$target) { + const $shapeContainer = $target.find('> .o_we_shape') + .clone() + .addClass('d-none') + // Needs to be in document for bg-image class to take effect + .appendTo(this.$target[0].ownerDocument.body); + const shapeContainer = $shapeContainer[0]; + $shapeContainer.css('background-image', ''); + const shapeSrc = shapeContainer && getBgImageURL(shapeContainer); + $shapeContainer.remove(); + if (!shapeSrc) { + return {}; + } + const url = new URL(shapeSrc, window.location.origin); + return Object.fromEntries(url.searchParams.entries()); + }, + /** + * Returns the default colors for the a shape in the selector. + * + * @private + * @param {String} shapeId identifier of the shape + */ + _getShapeDefaultColors(shapeId) { + const $shapeContainer = this.$el.find(".o_we_bg_shape_menu we-button[data-shape='" + shapeId + "'] div.o_we_shape"); + const shapeContainer = $shapeContainer[0]; + const shapeSrc = shapeContainer && getBgImageURL(shapeContainer); + const url = new URL(shapeSrc, window.location.origin); + return Object.fromEntries(url.searchParams.entries()); + }, + /** + * Returns the implicit colors for the currently selected shape. + * + * The implicit colors are use upon shape selection. They are computed as: + * - the default colors + * - patched with each set of colors of previous siblings shape + * - patched with the colors of the previously selected shape + * - filtered to only keep the colors involved in the current shape + * + * @private + * @param {String} shape identifier of the selected shape + * @param {Object} previousColors colors of the shape before its replacement + */ + _getImplicitColors(shape, previousColors) { + const defaultColors = this._getShapeDefaultColors(shape); + let colors = previousColors || {}; + let sibling = this.$target[0].previousElementSibling; + while (sibling) { + colors = Object.assign(this._getShapeData(sibling).colors || {}, colors); + sibling = sibling.previousElementSibling; + } + const defaultKeys = Object.keys(defaultColors); + colors = Object.assign(defaultColors, colors); + return pick(colors, ...defaultKeys); + }, + /** + * Toggles whether there is a shape or not, to be called from bg toggler. + * + * @private + */ + _toggleShape() { + if (this._getShapeData().shape) { + return this._handlePreviewState(false, () => ({shape: ''})); + } else { + const target = this.$target[0]; + const previousSibling = target.previousElementSibling; + const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt'); + const possibleShapes = shapeWidget.getMethodsParams('shape').possibleValues; + let shapeToSelect; + if (previousSibling) { + const previousShape = this._getShapeData(previousSibling).shape; + shapeToSelect = possibleShapes.find((shape, i) => { + return possibleShapes[i - 1] === previousShape; + }); + } + // If there is no previous sibling, if the previous sibling had the + // last shape selected or if the previous shape could not be found + // in the possible shapes, default to the first shape. ([0] being no + // shapes selected.) + if (!shapeToSelect) { + shapeToSelect = possibleShapes[1]; + } + // Only show on mobile by default if toggled from mobile view + const showOnMobile = weUtils.isMobileView(this.$target[0]); + this.trigger_up('snippet_edition_request', {exec: () => { + // options for shape will only be available after _toggleShape() returned + this._requestUserValueWidgets('bg_shape_opt')[0].enable(); + }}); + this._createShapeContainer(shapeToSelect); + return this._handlePreviewState(false, () => ( + { + shape: shapeToSelect, + colors: this._getImplicitColors(shapeToSelect), + showOnMobile, + } + )); + } + }, +}); + +/** + * Handles the edition of snippets' background image position. + */ +registry.BackgroundPosition = SnippetOptionWidget.extend({ + /** + * @override + */ + start: function () { + this._super.apply(this, arguments); + + this._initOverlay(); + + // Resize overlay content on window resize because background images + // change size, and on carousel slide because they sometimes take up + // more space and move elements around them. + $(window).on('resize.bgposition', () => this._dimensionOverlay()); + }, + /** + * @override + */ + destroy: function () { + this._toggleBgOverlay(false); + $(window).off('.bgposition'); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Sets the background type (cover/repeat pattern). + * + * @see this.selectClass for params + */ + backgroundType: function (previewMode, widgetValue, params) { + this.$target.toggleClass('o_bg_img_opt_repeat', widgetValue === 'repeat-pattern'); + this.$target.css('background-position', ''); + this.$target.css('background-size', widgetValue !== 'repeat-pattern' ? '' : '100px'); + }, + /** + * Saves current background position and enables overlay. + * + * @see this.selectClass for params + */ + backgroundPositionOverlay: async function (previewMode, widgetValue, params) { + // Updates the internal image + await new Promise(resolve => { + this.img = document.createElement('img'); + this.img.addEventListener('load', () => resolve()); + this.img.src = getBgImageURL(this.$target[0]); + }); + + const position = this.$target.css('background-position').split(' ').map(v => parseInt(v)); + const delta = this._getBackgroundDelta(); + // originalPosition kept in % for when movement in one direction doesn't make sense + this.originalPosition = { + left: position[0], + top: position[1], + }; + // Convert % values to pixels for current position because mouse movement is in pixels + this.currentPosition = { + left: position[0] / 100 * delta.x || 0, + top: position[1] / 100 * delta.y || 0, + }; + // Make sure the element is in a visible area. + const rect = this.$target[0].getBoundingClientRect(); + const viewportTop = $(window).scrollTop(); + const viewportBottom = viewportTop + $(window).height(); + const visibleHeight = rect.top < viewportTop + ? Math.max(0, Math.min(viewportBottom, rect.bottom) - viewportTop) // Starts above + : rect.top < viewportBottom + ? Math.min(viewportBottom, rect.bottom) - rect.top // Starts inside + : 0; // Starts after + if (visibleHeight < 200) { + await dom.scrollTo(this.$target[0], {extraOffset: 50}); + } + this._toggleBgOverlay(true); + }, + /** + * @override + */ + selectStyle: function (previewMode, widgetValue, params) { + if (params.cssProperty === 'background-size' + && !this.$target.hasClass('o_bg_img_opt_repeat')) { + // Disable the option when the image is in cover mode, otherwise + // the background-size: auto style may be forced. + return; + } + this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeVisibility: function () { + return this._super(...arguments) && !!getBgImageURL(this.$target[0]); + }, + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + if (methodName === 'backgroundType') { + return this.$target.css('background-repeat') === 'repeat' ? 'repeat-pattern' : 'cover'; + } + return this._super(...arguments); + }, + /** + * Initializes the overlay, binds events to the buttons, inserts it in + * the DOM. + * + * @private + */ + _initOverlay: function () { + this.$backgroundOverlay = $(renderToElement('web_editor.background_position_overlay')); + this.$overlayContent = this.$backgroundOverlay.find('.o_we_overlay_content'); + this.$overlayBackground = this.$overlayContent.find('.o_overlay_background'); + + this.$backgroundOverlay.on('click', '.o_btn_apply', () => { + this.$target.css('background-position', this.$bgDragger.css('background-position')); + this._toggleBgOverlay(false); + }); + this.$backgroundOverlay.on('click', '.o_btn_discard', () => { + this._toggleBgOverlay(false); + }); + + this.$backgroundOverlay.insertAfter(this.$overlay); + }, + /** + * Sets the overlay in the right place so that the draggable background + * renders over the target, and size the background item like the target. + * + * @private + */ + _dimensionOverlay: function () { + if (!this.$backgroundOverlay.is('.oe_active')) { + return; + } + // TODO: change #wrapwrap after web_editor rework. + const $wrapwrap = $(this.ownerDocument.body).find("#wrapwrap"); + const targetOffset = this.$target.offset(); + + this.$backgroundOverlay.css({ + width: $wrapwrap.innerWidth(), + height: $wrapwrap.innerHeight(), + }); + + this.$overlayContent.offset(targetOffset); + + this.$bgDragger.css({ + width: `${this.$target.innerWidth()}px`, + height: `${this.$target.innerHeight()}px`, + }); + + const topPos = Math.max(0, $(window).scrollTop() - this.$target.offset().top); + this.$overlayContent.find('.o_we_overlay_buttons').css('top', `${topPos}px`); + }, + /** + * Toggles the overlay's display and renders a background clone inside of it. + * + * @private + * @param {boolean} activate toggle the overlay on (true) or off (false) + */ + _toggleBgOverlay: function (activate) { + if (!this.$backgroundOverlay || this.$backgroundOverlay.is('.oe_active') === activate) { + return; + } + + if (!activate) { + this.$backgroundOverlay.removeClass('oe_active'); + this.trigger_up('unblock_preview_overlays'); + this.trigger_up('activate_snippet', {$snippet: this.$target}); + + $(document).off('click.bgposition'); + if (this.$bgDragger) { + this.$bgDragger.tooltip('dispose'); + } + return; + } + + this.trigger_up('hide_overlay'); + this.trigger_up('activate_snippet', { + $snippet: this.$target, + previewMode: true, + }); + this.trigger_up('block_preview_overlays'); + + // Create empty clone of $target with same display size, make it draggable and give it a tooltip. + this.$bgDragger = this.$target.clone().empty(); + // Prevent clone from being seen as editor if target is editor (eg. background on root tag) + this.$bgDragger.removeClass('o_editable'); + // Some CSS child selector rules will not be applied since the clone has a different container from $target. + // The background-attachment property should be the same in both $target & $bgDragger, this will keep the + // preview more "wysiwyg" instead of getting different result when bg position saved (e.g. parallax snippet) + // TODO: improve this to copy all style from $target and override it with overlay related style (copying all + // css into $bgDragger will not work since it will change overlay content style too). + this.$bgDragger.css('background-attachment', this.$target.css('background-attachment')); + this.$bgDragger.on('mousedown', this._onDragBackgroundStart.bind(this)); + this.$bgDragger.tooltip({ + title: 'Click and drag the background to adjust its position!', + trigger: 'manual', + container: this.$backgroundOverlay + }); + + // Replace content of overlayBackground, activate the overlay and give it the right dimensions. + this.$overlayBackground.empty().append(this.$bgDragger); + this.$backgroundOverlay.addClass('oe_active'); + this._dimensionOverlay(); + this.$bgDragger.tooltip('show'); + + // Needs to be deferred or the click event that activated the overlay deactivates it as well. + // This is caused by the click event which we are currently handling bubbling up to the document. + window.setTimeout(() => $(document).on('click.bgposition', this._onDocumentClicked.bind(this)), 0); + }, + /** + * Returns the difference between the target's size and the background's + * rendered size. Background position values in % are a percentage of this. + * + * @private + */ + _getBackgroundDelta: function () { + const bgSize = this.$target.css('background-size'); + if (bgSize !== 'cover') { + let [width, height] = bgSize.split(' '); + if (width === 'auto' && (height === 'auto' || !height)) { + return { + x: this.$target.outerWidth() - this.img.naturalWidth, + y: this.$target.outerHeight() - this.img.naturalHeight, + }; + } + // At least one of width or height is not auto, so we can use it to calculate the other if it's not set + [width, height] = [parseInt(width), parseInt(height)]; + return { + x: this.$target.outerWidth() - (width || (height * this.img.naturalWidth / this.img.naturalHeight)), + y: this.$target.outerHeight() - (height || (width * this.img.naturalHeight / this.img.naturalWidth)), + }; + } + + const renderRatio = Math.max( + this.$target.outerWidth() / this.img.naturalWidth, + this.$target.outerHeight() / this.img.naturalHeight + ); + + return { + x: this.$target.outerWidth() - Math.round(renderRatio * this.img.naturalWidth), + y: this.$target.outerHeight() - Math.round(renderRatio * this.img.naturalHeight), + }; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Drags the overlay's background image, copied to target on "Apply". + * + * @private + */ + _onDragBackgroundStart: function (ev) { + ev.preventDefault(); + this.$bgDragger.addClass('o_we_grabbing'); + const $document = $(this.$target[0].ownerDocument); + $document.on('mousemove.bgposition', this._onDragBackgroundMove.bind(this)); + $document.one('mouseup', () => { + this.$bgDragger.removeClass('o_we_grabbing'); + $document.off('mousemove.bgposition'); + }); + }, + /** + * Drags the overlay's background image, copied to target on "Apply". + * + * @private + */ + _onDragBackgroundMove: function (ev) { + ev.preventDefault(); + + const delta = this._getBackgroundDelta(); + this.currentPosition.left = clamp(this.currentPosition.left + ev.originalEvent.movementX, [0, delta.x]); + this.currentPosition.top = clamp(this.currentPosition.top + ev.originalEvent.movementY, [0, delta.y]); + + const percentPosition = { + left: this.currentPosition.left / delta.x * 100, + top: this.currentPosition.top / delta.y * 100, + }; + // In cover mode, one delta will be 0 and dividing by it will yield Infinity. + // Defaulting to originalPosition in that case (can't be dragged) + percentPosition.left = isFinite(percentPosition.left) ? percentPosition.left : this.originalPosition.left; + percentPosition.top = isFinite(percentPosition.top) ? percentPosition.top : this.originalPosition.top; + + this.$bgDragger.css('background-position', `${percentPosition.left}% ${percentPosition.top}%`); + + function clamp(val, bounds) { + // We sort the bounds because when one dimension of the rendered background is + // larger than the container, delta is negative, and we want to use it as lower bound + bounds = bounds.sort(); + return Math.max(bounds[0], Math.min(val, bounds[1])); + } + }, + /** + * Deactivates the overlay if the user clicks outside of it. + * + * @private + */ + _onDocumentClicked: function (ev) { + if (!$(ev.target).closest('.o_we_background_position_overlay').length) { + this._toggleBgOverlay(false); + } + }, +}); + +/** + * Marks color levels of any element that may get or has a color classes. This + * is done for the specific main colorpicker option so that those are marked on + * snippet drop (so that base snippet definition do not need to care about that) + * and on first focus (for compatibility). + */ +registry.ColoredLevelBackground = registry.BackgroundToggler.extend({ + /** + * @override + */ + start: function () { + this._markColorLevel(); + return this._super(...arguments); + }, + /** + * @override + */ + onBuilt: function () { + this._markColorLevel(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds a specific class indicating the element is colored so that nested + * color classes work (we support one-level). Removing it is not useful, + * technically the class can be added on anything that *may* receive a color + * class: this does not come with any CSS rule. + * + * @private + */ + _markColorLevel: function () { + this.options.wysiwyg.odooEditor.observerUnactive('_markColorLevel'); + this.$target.addClass('o_colored_level'); + this.options.wysiwyg.odooEditor.observerActive('_markColorLevel'); + }, +}); + +registry.ContainerWidth = SnippetOptionWidget.extend({ + /** + * @override + */ + cleanForSave: function () { + this.$target.removeClass('o_container_preview'); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @override + */ + selectClass: async function (previewMode, widgetValue, params) { + await this._super(...arguments); + if (previewMode === 'reset') { + this.$target.removeClass('o_container_preview'); + } else if (previewMode) { + this.$target.addClass('o_container_preview'); + } + this.trigger_up('option_update', { + optionName: 'StepsConnector', + name: 'change_container_width', + }); + }, +}); + +/** + * Allows to replace a text value with the name of a database record. + * @todo replace this mechanism with real backend m2o field ? + */ +registry.many2one = SnippetOptionWidget.extend({ + init() { + this._super(...arguments); + this.orm = this.bindService("orm"); + }, + + /** + * @override + */ + async willStart() { + const {oeMany2oneModel, oeMany2oneId} = this.$target[0].dataset; + this.fields = ['name', 'display_name']; + return Promise.all([ + this._super(...arguments), + this.orm.read(oeMany2oneModel, [parseInt(oeMany2oneId)], this.fields).then(([initialRecord]) => { + this.initialRecord = initialRecord; + }), + ]); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for params + */ + async changeRecord(previewMode, widgetValue, params) { + const target = this.$target[0]; + if (previewMode === 'reset') { + // Have to set the jQ data because it's used to update the record in other + // parts of the page, but have to set the dataset because used for saving. + this.$target.data('oeMany2oneId', this.prevId); + target.dataset.oeMany2oneId = this.prevId; + this.$target.empty().append(this.$prevContents); + return this._rerenderContacts(this.prevId, this.prevRecordName); + } + + const record = JSON.parse(params.recordData); + if (previewMode === true) { + this.prevId = parseInt(target.dataset.oeMany2oneId); + this.$prevContents = this.$target.contents(); + this.prevRecordName = this.prevRecordName || this.initialRecord.name; + } + + this.$target.data('oeMany2oneId', record.id); + target.dataset.oeMany2oneId = record.id; + + if (target.dataset.oeType !== 'contact') { + target.textContent = record.name; + } + await this._rerenderContacts(record.id, record.name); + + if (previewMode === false) { + this.prevId = record.id; + this.$prevContents = this.$target.contents(); + this.prevRecordName = record.name; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + if (methodName === 'changeRecord') { + return this.$target[0].dataset.oeMany2oneId; + } + return this._super(...arguments); + }, + /** + * @override + */ + async _renderCustomXML(uiFragment) { + const many2oneWidget = document.createElement('we-many2one'); + many2oneWidget.dataset.changeRecord = ''; + + const model = this.$target[0].dataset.oeMany2oneModel; + const [{name: modelName}] = await this.orm.searchRead("ir.model", [['model', '=', model]], ['name']); + many2oneWidget.setAttribute('String', modelName); + many2oneWidget.dataset.model = model; + many2oneWidget.dataset.fields = JSON.stringify(this.fields); + uiFragment.appendChild(many2oneWidget); + }, + /** + * @private + */ + async _rerenderContacts(contactId, defaultText) { + // Rerender this same field in other places in the page (with different + // contact-options). Many2ones with the same contact options will just + // copy the HTML of the current m2o on content_changed. Not sure why we + // only do this for contacts, or why we do this here instead of in the + // wysiwyg like we do for replacing text on content_changed + const selector = [ + `[data-oe-model="${this.$target.data('oe-model')}"]`, + `[data-oe-id="${this.$target.data('oe-id')}"]`, + `[data-oe-field="${this.$target.data('oe-field')}"]`, + `[data-oe-contact-options!='${this.$target[0].dataset.oeContactOptions}']`, + ].join(''); + let $toRerender = $(selector); + if (this.$target[0].dataset.oeType === 'contact') { + $toRerender = $toRerender.add(this.$target); + } + await Promise.all($toRerender + .attr('data-oe-many2one-id', contactId).data('oe-many2one-id', contactId) + .map(async (i, node) => { + if (node.dataset.oeType === 'contact') { + const html = await this.orm.call( + "ir.qweb.field.contact", + "get_record_to_html", + [[contactId]], + {options: JSON.parse(node.dataset.oeContactOptions)} + ); + $(node).html(html); + } else { + node.textContent = defaultText; + } + })); + }, +}); +/** + * Allows to display a warning message on outdated snippets. + */ +registry.VersionControl = SnippetOptionWidget.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Replaces an outdated snippet by its new version. + */ + async replaceSnippet() { + // Getting the new block version. + let newBlockEl; + this.trigger_up("find_snippet_template", { + snippet: this.$target[0], + callback: (snippet) => { + newBlockEl = snippet.baseBody.cloneNode(true); + }, + }); + // Replacing the block. + this.options.wysiwyg.odooEditor.historyPauseSteps(); + this.$target[0].classList.add("d-none"); // Hiding the block to replace it smoothly. + this.$target[0].insertAdjacentElement("beforebegin", newBlockEl); + // Initializing the new block as if it was dropped: the mutex needs to + // be free for that so we wait for it first. + this.options.wysiwyg.waitForEmptyMutexAction().then(async () => { + await new Promise((resolve) => { + this.options.wysiwyg.snippetsMenuBus.trigger("CALL_POST_SNIPPET_DROP", { + $snippet: $(newBlockEl), + onSuccess: resolve, + }); + }); + await new Promise(resolve => { + this.trigger_up("remove_snippet", + {$snippet: this.$target, onSuccess: resolve, shouldRecordUndo: false} + ); + }); + this.options.wysiwyg.odooEditor.historyUnpauseSteps(); + newBlockEl.classList.remove("oe_snippet_body"); + this.options.wysiwyg.odooEditor.historyStep(); + }); + }, + /** + * Allows to still access the options of an outdated block, despite the + * warning. + */ + discardAlert() { + const alertEl = this.$el[0].querySelector("we-alert"); + const optionsSectionEl = this.$overlay.data("$optionsSection")[0]; + alertEl.remove(); + optionsSectionEl.classList.remove("o_we_outdated_block_options"); + // Preventing the alert to reappear at each render. + controlledSnippets.add(this.$target[0].dataset.snippet); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _renderCustomXML(uiFragment) { + const snippetName = this.$target[0].dataset.snippet; + // Do not display the alert if it was previously discarded. + if (controlledSnippets.has(snippetName)) { + return; + } + this.trigger_up("get_snippet_versions", { + snippetName: snippetName, + onSuccess: snippetVersions => { + const isUpToDate = snippetVersions && ["vjs", "vcss", "vxml"].every(key => this.$target[0].dataset[key] === snippetVersions[key]); + if (!isUpToDate) { + uiFragment.prepend(renderToElement("web_editor.outdated_block_message")); + // Hide the other options, to only have the alert displayed. + const optionsSectionEl = this.$overlay.data("$optionsSection")[0]; + optionsSectionEl.classList.add("o_we_outdated_block_options"); + } + }, + }); + }, +}); + +/** + * Handle the save of a snippet as a template that can be reused later + */ +registry.SnippetSave = SnippetOptionWidget.extend({ + isTopOption: true, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + saveSnippet: function (previewMode, widgetValue, params) { + return new Promise(resolve => { + this.dialog.add(ConfirmationDialog, { + body: _t("To save a snippet, we need to save all your previous modifications and reload the page."), + cancel: () => resolve(false), + confirmLabel: _t("Save and Reload"), + confirm: () => { + const isButton = this.$target[0].matches("a.btn"); + const snippetKey = !isButton ? this.$target[0].dataset.snippet : "s_button"; + let thumbnailURL; + this.trigger_up('snippet_thumbnail_url_request', { + key: snippetKey, + onSuccess: url => thumbnailURL = url, + }); + let context; + this.trigger_up('context_get', { + callback: ctx => context = ctx, + }); + this.trigger_up('request_save', { + reloadEditor: true, + invalidateSnippetCache: true, + onSuccess: async () => { + const defaultSnippetName = !isButton + ? _t("Custom %s", this.data.snippetName) + : _t("Custom Button"); + const targetCopyEl = this.$target[0].cloneNode(true); + targetCopyEl.classList.add('s_custom_snippet'); + delete targetCopyEl.dataset.name; + if (isButton) { + targetCopyEl.classList.remove("mb-2"); + targetCopyEl.classList.add("o_snippet_drop_in_only", "s_custom_button"); + } + // By the time onSuccess is called after request_save, the + // current widget has been destroyed and is orphaned, so this._rpc + // will not work as it can't trigger_up. For this reason, we need + // to bypass the service provider and use the global RPC directly + + // Get editable parent TODO find proper method to get it directly + let editableParentEl; + for (const parentEl of this.options.getContentEditableAreas()) { + if (parentEl.contains(this.$target[0])) { + editableParentEl = parentEl; + break; + } + } + context['model'] = editableParentEl.dataset.oeModel; + context['field'] = editableParentEl.dataset.oeField; + context['resId'] = editableParentEl.dataset.oeId; + await rpc(`/web/dataset/call_kw/ir.ui.view/save_snippet`, { + model: "ir.ui.view", + method: "save_snippet", + args: [], + kwargs: { + 'name': defaultSnippetName, + 'arch': targetCopyEl.outerHTML, + 'template_key': this.options.snippets, + 'snippet_key': snippetKey, + 'thumbnail_url': thumbnailURL, + 'context': context, + }, + }); + }, + }); + resolve(true); + }, + }); + }); + }, +}); + +/** + * Handles the dynamic colors for dynamic SVGs. + */ +registry.DynamicSvg = SnippetOptionWidget.extend({ + /** + * @override + */ + start() { + this.$target.on('image_changed.DynamicSvg', this._onImageChanged.bind(this)); + return this._super(...arguments); + }, + /** + * @override + */ + destroy() { + this.$target.off('.DynamicSvg'); + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Sets the dynamic SVG's dynamic color. + * + * @see this.selectClass for params + */ + async color(previewMode, widgetValue, params) { + const target = this.$target[0]; + switch (previewMode) { + case true: + this.previousSrc = target.getAttribute('src'); + break; + case 'reset': + target.src = this.previousSrc; + return; + } + const newURL = new URL(target.src, window.location.origin); + newURL.searchParams.set(params.colorName, normalizeColor(widgetValue)); + const src = newURL.pathname + newURL.search; + await loadImage(src); + target.src = src; + if (!previewMode) { + this.previousSrc = src; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + switch (methodName) { + case 'color': + return new URL(this.$target[0].src, window.location.origin).searchParams.get(params.colorName); + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility(widgetName, params) { + if ('colorName' in params) { + return new URL(this.$target[0].src, window.location.origin).searchParams.get(params.colorName); + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeVisibility(methodName, params) { + return this.$target.is("img[src^='/web_editor/shape/']"); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onImageChanged(methodName, params) { + return this.updateUI(); + }, +}); + +/** + * Allows to handle snippets with a list of items. + */ +registry.MultipleItems = SnippetOptionWidget.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + async addItem(previewMode, widgetValue, params) { + const $target = this.$(params.item); + const addBeforeItem = params.addBefore === 'true'; + if ($target.length) { + await new Promise(resolve => { + this.trigger_up('clone_snippet', { + $snippet: $target, + onSuccess: resolve, + }); + }); + if (addBeforeItem) { + $target.before($target.next()); + } + if (params.selectItem !== 'false') { + this.trigger_up('activate_snippet', { + $snippet: addBeforeItem ? $target.prev() : $target.next(), + }); + } + this._addItemCallback($target); + } + }, + /** + * @see this.selectClass for parameters + */ + async removeItem(previewMode, widgetValue, params) { + const $target = this.$(params.item); + if ($target.length) { + await new Promise(resolve => { + this.trigger_up('remove_snippet', { + $snippet: $target, + onSuccess: resolve, + }); + }); + this._removeItemCallback($target); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Allows to add behaviour when item added. + * + * @private + * @abstract + * @param {jQueryElement} $target + */ + _addItemCallback($target) {}, + /** + * @private + * @abstract + * @param {jQueryElement} $target + */ + _removeItemCallback($target) {}, +}); + +registry.SelectTemplate = SnippetOptionWidget.extend({ + custom_events: Object.assign({}, SnippetOptionWidget.prototype.custom_events, { + 'user_value_widget_opening': '_onWidgetOpening', + }), + + /** + * @constructor + */ + init() { + this._super(...arguments); + this.containerSelector = ''; + this.selectTemplateWidgetName = ''; + this.orm = this.bindService("orm"); + }, + /** + * @constructor + */ + async start() { + this.containerEl = this.containerSelector ? this.$target.find(this.containerSelector)[0] : this.$target[0]; + this._templates = {}; + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Changes the snippet layout. + * + * @see this.selectClass for parameters + */ + async selectTemplate(previewMode, widgetValue, params) { + await this._templatesLoading; + + if (previewMode === 'reset') { + if (!this.beforePreviewNodes) { + // FIXME should not be necessary: only needed because we have a + // strange 'reset' sent after a non-preview + return; + } + + // Empty the container and restore the original content + while (this.containerEl.lastChild) { + this.containerEl.removeChild(this.containerEl.lastChild); + } + for (const node of this.beforePreviewNodes) { + this.containerEl.appendChild(node); + } + this.beforePreviewNodes = null; + return; + } + + if (!this.beforePreviewNodes) { + // We are about the apply a template on non-previewed content, + // save that content's nodes. + this.beforePreviewNodes = [...this.containerEl.childNodes]; + } + // Empty the container and add the template content + while (this.containerEl.lastChild) { + this.containerEl.removeChild(this.containerEl.lastChild); + } + this.containerEl.insertAdjacentHTML('beforeend', this._templates[widgetValue]); + + if (!previewMode) { + // The original content to keep saved has to be retrieved just + // before the preview (if we save it now, we might miss other items + // added by other options or custo). + this.beforePreviewNodes = null; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Retrieves a template either from cache or through RPC. + * + * @private + * @param {string} xmlid + * @returns {string} + */ + async _getTemplate(xmlid) { + if (!this._templates[xmlid]) { + this._templates[xmlid] = await this.orm.call( + "ir.ui.view", + "render_public_asset", + [`${xmlid}`, {}], + { context: this.options.context } + ); + } + return this._templates[xmlid]; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onWidgetOpening(ev) { + if (this._templatesLoading || ev.target.getName() !== this.selectTemplateWidgetName) { + return; + } + const templateParams = ev.target.getMethodsParams('selectTemplate'); + const proms = templateParams.possibleValues.map(async xmlid => { + if (!xmlid) { + return; + } + // TODO should be better and retrieve all rendering in one RPC (but + // those ~10 RPC are only done once per edit mode if the option is + // opened, so I guess this is acceptable). + await this._getTemplate(xmlid); + }); + this._templatesLoading = Promise.all(proms); + }, +}); + +/* + * Abstract option to be extended by the Carousel and gallery options (through + * the "CarouselHandler" option) that handles all the common parts (reordering + * of elements). + */ +registry.GalleryHandler = SnippetOptionWidget.extend({ + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Handles reordering of items. + * + * @override + */ + notify(name, data) { + this._super(...arguments); + if (name === "reorder_items") { + const itemsEls = this._getItemsGallery(); + const oldPosition = itemsEls.indexOf(data.itemEl); + if (oldPosition === 0 && data.position === "prev") { + data.position = "last"; + } else if (oldPosition === itemsEls.length - 1 && data.position === "next") { + data.position = "first"; + } + itemsEls.splice(oldPosition, 1); + switch (data.position) { + case "first": + itemsEls.unshift(data.itemEl); + break; + case "prev": + itemsEls.splice(Math.max(oldPosition - 1, 0), 0, data.itemEl); + break; + case "next": + itemsEls.splice(oldPosition + 1, 0, data.itemEl); + break; + case "last": + itemsEls.push(data.itemEl); + break; + } + this._reorderItems(itemsEls, itemsEls.indexOf(data.itemEl)); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Called to get the items of the gallery sorted by index if any (see + * gallery option) or by the order on the DOM otherwise. + * + * @abstract + * @returns {HTMLElement[]} + */ + _getItemsGallery() {}, + /** + * Called to reorder the items of the gallery. + * + * @abstract + * @param {HTMLElement[]} itemsEls - the items to reorder. + * @param {integer} newItemPosition - the new position of the moved items. + */ + _reorderItems(itemsEls, newItemPosition) {}, +}); + +/* + * Abstract option to be extended by the Carousel and gallery options that + * handles the update of the carousel indicator. + */ +registry.CarouselHandler = registry.GalleryHandler.extend({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Update the carousel indicator. + * + * @private + * @param {integer} position - the position of the indicator to activate on + * the carousel. + */ + _updateIndicatorAndActivateSnippet(position) { + const carouselEl = this.$target[0].classList.contains("carousel") ? this.$target[0] + : this.$target[0].querySelector(".carousel"); + carouselEl.classList.remove("slide"); + $(carouselEl).carousel(position); + for (const indicatorEl of this.$target[0].querySelectorAll(".carousel-indicators li")) { + indicatorEl.classList.remove("active"); + } + this.$target[0].querySelector(`.carousel-indicators li[data-bs-slide-to="${position}"]`) + .classList.add("active"); + this.trigger_up("activate_snippet", { + $snippet: $(this.$target[0].querySelector(".carousel-item.active img")), + ifInactiveOptions: true, + }); + carouselEl.classList.add("slide"); + }, +}); + + +export default { + SnippetOptionWidget: SnippetOptionWidget, + snippetOptionRegistry: registry, + + NULL_ID: NULL_ID, + UserValueWidget: UserValueWidget, + userValueWidgetsRegistry: userValueWidgetsRegistry, + UnitUserValueWidget: UnitUserValueWidget, + + addTitleAndAllowedAttributes: _addTitleAndAllowedAttributes, + buildElement: _buildElement, + buildTitleElement: _buildTitleElement, + buildRowElement: _buildRowElement, + buildCollapseElement: _buildCollapseElement, + + addAnimatedShapeLabel: _addAnimatedShapeLabel, + + // Other names for convenience + Class: SnippetOptionWidget, + registry: registry, + serviceCached, + clearServiceCache, + clearControlledSnippets, +}; diff --git a/addons/web_editor/static/src/js/editor/snippets.options.xml b/addons/web_editor/static/src/js/editor/snippets.options.xml new file mode 100644 index 0000000000000..5497ec97f2f05 --- /dev/null +++ b/addons/web_editor/static/src/js/editor/snippets.options.xml @@ -0,0 +1,799 @@ +<?xml version="1.0" encoding="utf-8"?> +<templates id="template" xml:space="preserve"> +<t t-name="web_editor.SnippetOptionComponent"> + <t t-set="className" t-value="`snippet-option-${props.snippetOption.name}`"/> + <we-customizeblock-option t-att-class="{'d-none': !renderContext.showUI, [className]: true}"> + <!-- Some options do not have a template --> + <t t-if="props.snippetOption.template" t-call="{{props.snippetOption.template}}"/> + </we-customizeblock-option> +</t> +<t t-name="web_editor.TestSnippetOption"> + <WeMediapicker mediaType="'videos'"/> + <WeButton selectClass="'test_class'">Test Class</WeButton> + <WeRow> + <t t-set-slot="title">Checkbox</t> + <WeCheckbox selectClass="'test_class'">Test Class</WeCheckbox> + </WeRow> + <WeSelect> + <WeButton selectClass="'test_class_1'" name="'test_class_1'">Test Class 1</WeButton> + <WeButton selectClass="'test_class_2'">Test Class 2</WeButton> + <WeButton selectClass="'test_class_3'">Test Class 3</WeButton> + <WeButton selectClass="'test_class_4'">Test Class 4</WeButton> + <WeButton selectClass="'test_class_5'">Test Class 5</WeButton> + </WeSelect> + <WeButtonGroup> + <t t-set-slot="title">Test Button Group</t> + <WeButton selectClass="'test_class_6'">6</WeButton> + <WeButton selectClass="'test_class_7'">7</WeButton> + <WeButton selectClass="'test_class_8'">8</WeButton> + </WeButtonGroup> + <WeInput unit="'px'" step="'5'" selectStyle="'0'" cssProperty="'border-width'" extraClass="'border'"/> + <WeDatetime selectDataAttribute="''" attributeName="'datetime'"/> + <WeDatetime selectDataAttribute="''" attributeName="'dateonly'" pickerType="'date'"/> + <WeButton selectClass="''" dependencies="'test_class_1'">Show when test_class_1</WeButton> + <WeRow> + <t t-set-slot="title">Row</t> + <WeButton dependencies="'test_class_1'" selectClass="'visible_class_test'">Button</WeButton> + </WeRow> + <WeRow> + <t t-set-slot="title">Low to high</t> + <WeRange selectDataAttribute="''" + attributeName="'range'" + min="'-100'" + step="'10'"/> + </WeRow> + <WeRow> + <t t-set-slot="title">High to low</t> + <WeRange string="'Reversed Range'" + selectDataAttribute="''" + attributeName="'revrange'" + displayRangeValue="'true'" + min="'100'" + max="'-100'" + step="'10'"/> + </WeRow> + <!-- Try with `hasDefault` = unique/multiple or not set, with/out `addItemTitle`, `inputType` = number, with/out `defaultValue`, + with/out `availableRecords`, with/out `unsortable`, `notEditable`, `allowEmpty`, `newElementsNotToggleable`, `renderOnInputBlur` + --> + <WeList addItemTitle="'Add new item to list'" renderListItems="''" hasDefault="'unique'" availableRecords='`[{"id":1,"title":"delectus aut autem","completed":false,"display_name":"delectus aut autem"},{"id":2,"title":"quis ut nam facilis et officia qui ","completed":false,"display_name":"quis ut nam facilis et officia qui "},{"id":3,"title":"fugiat veniam minus","completed":false,"display_name":"fugiat veniam minus"},{"id":4,"title":"et porro tempora","completed":true,"display_name":"et porro tempora"},{"id":5,"title":"laboriosam mollitia et enim quasi adipisci quia provident illum","completed":false,"display_name":"laboriosam mollitia et enim quasi adipisci quia provident illum"},{"id":6,"title":"qui ullam ratione quibusdam voluptatem quia omnis","completed":false,"display_name":"qui ullam ratione quibusdam voluptatem quia omnis"},{"id":7,"title":"illo expedita consequatur quia in","completed":false,"display_name":"illo expedita consequatur quia in"},{"id":8,"title":"quo adipisci enim quam ut ab","completed":true,"display_name":"quo adipisci enim quam ut ab"},{"id":9,"title":"molestiae perspiciatis ipsa","completed":false,"display_name":"molestiae perspiciatis ipsa"},{"id":10,"title":"illo est ratione doloremque quia maiores aut","completed":true,"display_name":"illo est ratione doloremque quia maiores aut"}]`'> + <!-- <WeList addItemTitle="'Add new item to list'" renderListItems="''" hasDefault="'multiple'" defaultValue="'Something'"> --> + <t t-set-slot="title">A wonderful WeList</t> + </WeList> + <!-- Disabled because creates errors in tours with restricted editor + <WeMany2one title="'Menu'" + setPageTemplate="''" + model="'website.page'" + noPreview="'true'" + domain='`[["is_published", "=", true]]`' + createMethod="'createPage'"/> + <WeMany2many noPreview="'true'" + model="'website.menu'" + m2oField="'group_ids'" + setGroups="''" + createMethod="'createGroup'"> + <t t-set-slot="title">Menu groups</t> + </WeMany2many> + --> +</t> + +<t t-name="web_editor.snippet_move_option_vertical"> + <Overlay targetEl="$overlay[0].querySelector('.o_overlay_move_options')"> + <WeButton class="'fa fa-fw fa-angle-up'" moveSnippet="'prev'" noPreview="'true'" name="'move_up_opt'"/> + <WeButton class="'fa fa-fw fa-angle-down'" moveSnippet="'next'" noPreview="'true'" name="'move_down_opt'"/> + </Overlay> +</t> +<t t-name="web_editor.snippet_move_option_horizontal"> + <Overlay targetEl="$overlay[0].querySelector('.o_overlay_move_options')"> + <WeButton class="'fa fa-fw fa-angle-left'" moveSnippet="'prev'" noPreview="'true'" name="'move_left_opt'"/> + <WeButton class="'fa fa-fw fa-angle-right'" moveSnippet="'next'" noPreview="'true'" name="'move_right_opt'"/> + </Overlay> +</t> + +<t t-name="web_editor.container_width"> + <WeButtonGroup title.translate="Content Width"> + <WeButton selectClass="'o_container_small'" + tooltip.translate="Small" + img="'/website/static/src/img/snippets_options/content_width_small.svg'"/> + <WeButton selectClass="'container'" + img="'/website/static/src/img/snippets_options/content_width_normal.svg'" + tooltip.translate="Regular"/> + <WeButton selectClass="'container-fluid'" + img="'/website/static/src/img/snippets_options/content_width_full.svg'" + tooltip.translate="Full"/> + </WeButtonGroup> +</t> + +<t t-name="web_editor.WeTitle"> + <we-title t-att-class="props.class" t-att-style="props.style" + t-att-data-tooltip="props.tooltip"> + <t t-if="props.slots?.title" t-slot="title"/> + <t t-else="" t-out="props.title"/> + </we-title> +</t> + +<t t-name="web_editor.WeRow"> + <we-row t-att-class="{'d-none': !this.state.show, [props.class]: true}" t-att-data-tooltip="props.tooltip"> + <WeTitle t-if="props.slots?.title || props.title" + title="props.title" slots="{title: props.slots?.title}" + style="props.fontFamily ? `font-family: ${props.fontFamily}` : ''"/> + <div> + <t t-slot="default"/> + </div> + </we-row> +</t> + +<t t-name="web_editor.WeButton"> + <!-- + TODO: @owl-options tooltips are not automatically translated. To Check + How we can solve that issue. + --> + <we-button t-ref="text-content" + t-on-mouseenter="this._onUserValuePreview" + t-on-mouseleave="this._onUserValueReset" + t-on-click="this._onButtonClick" class="o_we_user_value_widget" + t-att-data-tooltip="props.tooltip" + t-att-class="getAllClasses()" + t-att="getAllDataAttributes()"> + <div> + <t t-if="this.img"> + <img t-att-src="this.previewImg && this.state.isPreviewed() ? this.previewImg : this.img"/> + </t> + <t t-if="this.props.icon"> + <i t-attf-class="fa {{ this.props.icon }}"/> + </t> + <t t-if="this.svg"> + <t t-out="this.svg"/> + </t> + <t t-slot="default"/> + </div> + </we-button> +</t> +<t t-name="web_editor.WeCheckbox"> + <we-button t-ref="text-content" + t-on-mouseenter="this._onUserValuePreview" + t-on-mouseleave="this._onUserValueReset" + t-on-click="this._onButtonClick" class="o_we_user_value_widget o_we_checkbox_wrapper" + t-att-data-tooltip="props.tooltip" + t-att-class="getAllClasses()" + t-att="getAllDataAttributes()"> + <WeTitle t-if="props.slots?.title || props.title" + title="props.title" slots="{title: props.slots?.title}"/> + <div> + <we-checkbox/> + </div> + </we-button> +</t> +<t t-name="web_editor.WeSelect"> + <we-select class="o_we_user_value_widget" t-att-class="getAllClasses()" t-on-click="_onClick" + t-att="getAllDataAttributes()"> + <WeTitle t-if="props.slots?.title || props.title" + title="props.title" slots="{title: props.slots?.title}"/> + <div> + <we-toggler t-att-class="{ 'active': state.opened }"> + <t t-if="state.toggler.textContent" t-out="state.toggler.textContent"/> + <t t-if="state.toggler.faIcon"> + <i t-att-class="`fa ${state.toggler.faIcon}`"/> + </t> + <t t-if="state.toggler.imgSrc"> + <img t-att-src="state.toggler.imgSrc"/> + </t> + </we-toggler> + <we-selection-items t-ref="menu"> + <t t-slot="default"/> + </we-selection-items> + <span class="o_we_dropdown_caret"/> + </div> + </we-select> +</t> +<t t-name="web_editor.WeButtonGroup"> + <we-button-group class="o_we_user_value_widget" t-att-class="getAllClasses()"> + <WeTitle t-if="props.slots?.title || props.title" tooltip="props.tooltip" + title="props.title" slots="{title: props.slots?.title}"/> + <div> + <we-selection-items> + <t t-slot="default"/> + </we-selection-items> + </div> + </we-button-group> +</t> +<t t-name="web_editor.WeInput"> + <we-input class="o_we_user_value_widget" t-att-class="getAllClasses()" + t-att="getAllDataAttributes()"> + <WeTitle t-if="props.slots?.title || props.title" + title="props.title" slots="{title: props.slots?.title}"/> + <div> + <t t-set="isNumber" t-value="this.state._isNumeric() || !! !!this.props.hideUnit"/> + <t t-set="showUnit" t-value="(!!this.props.unit || !!this.props.fakeUnit) and !this.props.hideUnit"/> + <input t-ref="input" t-att-name="this.props.optionName" + t-att-type="isNumber ? 'number' : 'text'" + t-att-class="{'text-end': isNumber, 'text-start': !isNumber}" + autocomplete="chrome-off" + t-att-placeholder="this.props.placeholder" + t-on-input="this._onInputInput" + t-on-blur="this._onInputBlur" + t-on-change="this._onUserValueChange" + t-on-keydown="this._onInputKeydown" + t-att-value="this.state.value" + /> + <span t-if="showUnit" t-out="this.props.fakeUnit || this.props.unit"/> + </div> + </we-input> +</t> +<t t-name="web_editor.WeDatetime"> + <we-input class="o_we_user_value_widget o_we_large" t-att-class="getAllClasses()" + t-att="getAllDataAttributes()"> + <WeTitle t-if="props.slots?.title || props.title" + title="props.title" slots="{title: props.slots?.title}"/> + <div> + <input t-ref="input" type="text" class="datetimepicker-input mx-0 text-start" + t-on-blur="this._onInputBlur" + t-on-input="this._onDateInputInput" + /> + </div> + </we-input> +</t> +<t t-name="web_editor.WeRange"> + <we-range class="o_we_user_value_widget" t-att-class="getAllClasses()" + t-att="getAllDataAttributes()"> + <WeTitle t-if="props.slots?.title || props.title" + title="props.title" slots="{title: props.slots?.title}"/> + <div> + <!-- TODO w-100 did not exist in legacy, but output spans beyond div without it --> + <input type="range" class="w-100" t-att-class="{o_we_inverted_range: this.inverted}" + t-att-min="this.min" + t-att-max="this.state.max" + t-att-step="this.props.step" + t-att-value="this.state.value" + t-on-change="this._onInputChange" + t-on-input="this._onInputInput" + /> + <output t-if="this.props.displayRangeValue" class="ms-2" t-out="this.state.displayValue"/> + </div> + </we-range> +</t> +<t t-name="web_editor.WeMediapicker"> + <WeTitle t-if="props.slots?.title || props.title" + title="props.title" slots="{title: props.slots?.title}"/> + <we-button class="o_we_user_value_widget" + t-on-click="this._onEditMedia" + t-att-data-tooltip="props.tooltip" + t-att-class="getAllClasses()"> + <div> + <t t-if="props.buttonStyle"><i class="fa fa-fw fa-camera" alt="Add media"></i></t> + <t t-else="">Replace</t> + </div> + </we-button> +</t> +<t t-name="web_editor.WeColorpicker"> + <we-select class="o_we_user_value_widget o_we_so_color_palette" t-att-class="getAllClasses()" + t-att-data-tooltip="props.tooltip" t-on-click="this._onClick"> + <WeTitle t-if="props.slots?.title || props.title" + title="props.title" slots="{title: props.slots?.title}"/> + <div> + <span class="o_we_color_preview" t-att="getPreviewAttributes()"/> + <we-toggler t-att-class="{ 'active': state.opened }"/> + <we-selection-items t-ref="menu"> + <div class="o_we_color_palette_wrapper" style="display: contents;"> + <!-- Only render the ColorPalette when the toggler is active --> + <ColorPalette t-if="state.opened" t-props="options" selectedCC="this.state.ccValue" selectedColor="this.state.value" /> + </div> + </we-selection-items> + <span class="o_we_dropdown_caret"/> + </div> + </we-select> +</t> +<t t-name="web_editor.WeMulti"> + <we-multi class="o_we_user_value_widget" t-att-class="getAllClasses()"> + <WeTitle t-if="props.slots?.title || props.title" + title="props.title" slots="{title: props.slots?.title}"/> + <div> + <t t-slot="default"/> + </div> + </we-multi> +</t> +<t t-name="web_editor.WeList"> + <we-list class="o_we_user_value_widget o_we_fw" t-att-class="getAllClasses()" t-on-mousedown="_onWeListMousedown"> + <WeTitle t-if="props.slots?.title || props.title" + title="props.title" slots="{title: props.slots?.title}"/> + <div> + <div class="o_we_table_wrapper"> + <table t-ref="table"> + <t t-foreach="state.listRecords" t-as="listRecord" t-key="listRecord.id"> + <tr> + <td t-if="props.unsortable !== 'true'"> + <we-button class="o_we_drag_handle o_we_link fa fa-fw fa-arrows" data-no-preview="true"/> + </td> + <td class="o_we_list_record_name" t-att-class="{[listRecord.firstInputClass]: props.doubleInput === 'true'}"> + <input t-att-type="props.inputType" t-att-value="listRecord.display_name || props.defaultValue" + t-att-name="listRecord.id" t-att-placeholder="listRecord.placeholder" + t-att="this.getInputDataAtts(listRecord)" + t-on-input="_onListItemBlurInput" + t-on-blur="_onListItemBlurInput" + t-att-disabled="!state.isCustom"/> + </td> + <td t-if="props.doubleInput === 'true'" t-att-class="listRecord.secondInputClass"> + <input type="text" t-att-value="listRecord.secondInputText" + t-att-name="listRecord.id" + t-on-input="_onListItemBlurInput" + t-on-blur="_onListItemBlurInput"/> + </td> + <td t-if="props.hasDefault and !listRecord.notToggleable"> + <we-button class="o_we_user_value_widget o_we_checkbox_wrapper" + t-att-data-select="listRecord.id" + t-att-class="{'active': state.selected.includes(listRecord.id) || listRecord.selected}" + t-on-click="_onAddItemCheckboxClick"> + <we-checkbox/> + </we-button> + </td> + <td t-if="!listRecord.undeletable"> + <we-button class="o_we_select_remove_option o_we_link o_we_text_danger fa fa-fw fa-minus" + t-att-data-remove-option="listRecord.id" t-on-click="_onRemoveItemClick"/> + </td> + </tr> + </t> + </table> + </div> + <t t-slot="createUserValue"/> + <t t-if="!props.slots?.createUserValue"> + <we-button t-if="state.isCustom" class="o_we_list_add_optional" t-on-click="_onAddCustomItemClick"> + <t t-out="props.addItemTitle"/> + </we-button> + <we-select t-else="" class="o_we_user_value_widget o_we_add_list_item" t-on-click="_onClick"> + <div> + <we-toggler t-att-class="{'active': state.opened}" t-out="props.addItemTitle"/> + <we-selection-items> + <t t-set="noRecordInList" t-value="true"/> + <t t-foreach="state.existingRecords" t-as="record" t-key="record.id"> + <t t-if="!state.listRecords.find(v => v.id === record.id)"> + <t t-set="noRecordInList" t-value="false"/> + <we-button class="o_we_list_add_existing" t-att-data-add-option="record.id" data-no-preview="true" t-on-click="_onAddExistingItemClick"> + <div t-out="record.display_name"/> + </we-button> + </t> + </t> + <we-title t-if="noRecordInList">No more records</we-title> + </we-selection-items> + </div> + </we-select> + </t> + </div> + </we-list> +</t> + +<t t-name="web_editor.WeMany2one" t-inherit="web_editor.WeSelect" t-inherit-mode="primary"> + <!-- The component already extends WeSelect: we can't use <WeSelect> here, + we have to xpath it. --> + <xpath expr="//we-select" position="attributes"> + <attribute name="class" add="o_we_many2one" separator=" "/> + </xpath> + <xpath expr="//we-selection-items/t" position="replace"> + <div class="o_we_m2o_search"> + <t t-set="input_placeholder">Search for records</t> + <input t-att-placeholder="input_placeholder" t-on-input="_onSearchInput" t-on-keydown="_onSearchKeydown"/> + </div> + <t t-foreach="state.records" t-as="record" t-key="record.id"> + <WeButton t-props="getButtonProps(record)" t-esc="record.display_name"/> + </t> + <t t-set="search_more_title">Search to show more records</t> + <div t-if="state.hasMore" class="o_we_m2o_search_more" t-att-title="search_more_title" t-on-click="_onSearchMoreClick"> + Search more... + </div> + <t t-if="props.createMethod"> + <WeRow class="'o_we_full_row o_we_m2o_create p-1'"> + <input type="text" class="o_we_large" t-on-input="_onCreateInputInput"/> + <WeButton class="'flex-grow-0 flex-shrink-0'" noPreview="'true'" t-props="{[props.createMethod]: ''}"> + Create + </WeButton> + </WeRow> + </t> + </xpath> +</t> +<t t-name="web_editor.WeMany2many"> + <div class="o_we_user_value_widget o_we_m2m position-relative" t-att-class="getAllClasses()"> + <WeTitle t-if="props.slots?.title || props.title" + title="props.title" slots="{title: props.slots?.title}"/> + <div> + <WeList unsortable="'true'" notEditable="'true'" allowEmpty="'true'"> + <t t-set-slot="createUserValue"> + <WeMany2one createUserValue="'true'" t-props="state.m2oProps"/> + </t> + </WeList> + + </div> + </div> +</t> + +<t t-name="web_editor.WeSelectPager"> + <we-select class="o_we_user_value_widget o_we_select_pager" t-att-class="getAllClasses()" t-on-click="_onClick"> + <div> + <we-toggler t-out="state.toggler.textContent" class="o_we_toggler_pager" t-att-class="{ 'active': state.opened }"/> + <we-selection-items t-ref="menu" class="o_we_has_pager position-fixed top-0 end-0 z-1 rounded-0"> + <header class="o_pager_nav d-flex flex-column flex-wrap flex-shrink-0 mh-100"> + <div class="d-flex align-items-center"> + <button class="o_pager_nav_angle fa fa-angle-left btn btn-secondary bg-transparent border-0" t-on-click="_onClickCloseMenu"/> + <t t-slot="title"/> + </div> + <div class="d-flex" t-on-click="_onClickScrollPage"> + <t t-slot="buttons"/> + </div> + </header> + <div class="o_pager_container" t-on-scroll="__onScroll"> + <t t-slot="default"/> + </div> + </we-selection-items> + </div> + </we-select> +</t> + +<t t-name="web_editor.snippet_options_image_optimization_widgets"> + <t t-set="indent" t-value="props.snippetOption.indent"/> + <WeSelect class="indent and 'o_we_sublevel_2'" title.translate="Filter"> + <WeButton glFilter="''">None</WeButton> + <WeButton glFilter="'blur'">Blur</WeButton> + <WeButton glFilter="'1977'">1977</WeButton> + <WeButton glFilter="'aden'">Aden</WeButton> + <WeButton glFilter="'brannan'">Brannan</WeButton> + <WeButton glFilter="'earlybird'">EarlyBird</WeButton> + <WeButton glFilter="'inkwell'">Inkwell</WeButton> + <WeButton glFilter="'maven'">Maven</WeButton> + <WeButton glFilter="'toaster'">Toaster</WeButton> + <WeButton glFilter="'walden'">Walden</WeButton> + <WeButton glFilter="'valencia'">Valencia</WeButton> + <WeButton glFilter="'xpro'">Xpro</WeButton> + <WeButton glFilter="'custom'" name="'custom_glfilter_opt'">Custom</WeButton> + </WeSelect> + <WeRow class="indent and 'o_we_sublevel_3' or 'o_we_sublevel_1'" title.translate="Color"> + <WeSelect filterProperty="'blend'" dependencies="'custom_glfilter_opt'"> + <WeButton customFilter="'normal'">Normal</WeButton> + <WeButton customFilter="'overlay'">overlay</WeButton> + <WeButton customFilter="'screen'">screen</WeButton> + <WeButton customFilter="'multiply'">multiply</WeButton> + <WeButton customFilter="'lighter'">add</WeButton> + <WeButton customFilter="'exclusion'">exclusion</WeButton> + <WeButton customFilter="'darken'">darken</WeButton> + <WeButton customFilter="'lighten'">lighten</WeButton> + </WeSelect> + <WeColorpicker dependencies="'custom_glfilter_opt'" customFilter="''" filterProperty="'filterColor'" excluded="'common, theme'"/> + </WeRow> + <WeRange class="indent and 'o_we_sublevel_3' or 'o_we_sublevel_1'" + dependencies="'custom_glfilter_opt'" + customFilter="''" + filterProperty="'saturation'" + min="'-100'" + step="'10'" + title.translate="Saturation"/> + <WeRange class="indent and 'o_we_sublevel_3' or 'o_we_sublevel_1'" + dependencies="'custom_glfilter_opt'" + customFilter="''" + filterProperty="'contrast'" + min="'-100'" + step="'10'" + title.translate="Contrast"/> + <WeRange class="indent and 'o_we_sublevel_3' or 'o_we_sublevel_1'" + dependencies="'custom_glfilter_opt'" + customFilter="''" + filterProperty="'brightness'" + min="'-100'" + step="'10'" + title.translate="Brightness"/> + <WeRange class="indent and 'o_we_sublevel_3' or 'o_we_sublevel_1'" + dependencies="'custom_glfilter_opt'" + customFilter="''" + filterProperty="'sepia'" + step="'5'" + title.translate="Sepia"/> + <WeRange class="indent and 'o_we_sublevel_3' or 'o_we_sublevel_1'" + dependencies="'custom_glfilter_opt'" + customFilter="''" + filterProperty="'blur'" + max="'2000'" + step="'100'" + title.translate="Blur"/> + + <WeSelect class="indent and 'o_we_sublevel_2'" + name="'format_select_opt'" + title.translate="Format"> + <t t-set="availableFormats" t-value="renderContext.availableFormats"/> + <t t-foreach="renderContext.availableFormats || []" t-as="format" t-key="format"> + <t t-set="value" t-value="format[0]"/> + <t t-set="label" t-value="format[1][0]"/> + <t t-set="targetFormat" t-value="format[1][1]"/> + <WeButton selectFormat="`${Math.round(value)} ${targetFormat}`" class="'o_we_badge_at_end'"> + <t t-out="label"/> <span class="badge rounded-pill text-bg-dark"><t t-out="targetFormat.split('/')[1]"/></span> + </WeButton> + </t> + </WeSelect> + + <WeRange t-if="!renderContext.noQuality" class="indent and 'o_we_sublevel_2'" + setQuality="''" title.translate="Quality"/> +</t> + +<t t-name="web_editor.snippet_options_background_color_widget"> + <WeColorpicker tooltip.translate="Color" + name="'bg_color_opt'" + selectColorCombination="option.withColorCombinations ? '' : false" + selectStyle="''" + cssProperty="'background-color'" + colorPrefix="'bg-'" + withCombinations="option.withColorCombinations and 'selectColorCombination' or false" + withGradients="option.withGradients and 'true' or false" + cssCompatible="option.cssCompatible and 'true' or false"/> +</t> +<t t-name="web_editor.snippet_options_background_options"> + <t t-set="option" t-value="props.snippetOption"/> + <WeRow t-if="option.withColors or option.withImages" class="'o_we_full_row'" + title.translate="Background"> + <t t-if="option.withColors" t-call="web_editor.snippet_options_background_color_widget"/> + <t t-if="option.withImages"> + <WeButton tooltip.translate="Image" class="'ms-auto fa fa-fw fa-camera'" + name="'bg_image_toggle_opt'" + dependencies="option.imagesDependencies || false" + toggleBgImage="'true'" + noPreview="'true'"/> + <t t-if="option.withShapes"> + <WeButton tooltip.translate="Shape" + toggleBgShape="'true'" + dependencies="option.imagesDependencies || false" + noPreview="'true'" + img="'/web_editor/static/src/img/snippets_options/bg_shape.svg'"/> + </t> + </t> + </WeRow> +</t> +<t t-name="web_editor.animated_shape_label"> + <span class="o_we_shape_animated_label"> + <t t-if="imgSize"> + <i class="fa fa-expand"/><span t-out="imgSize"/> + </t> + <t t-else=""> + <!-- We do it like that to keep the translations. --> + <t t-set="animated">Animated</t> + <t t-out="animated[0]"/><span t-out="animated.substring(1)"/> + </t> + </span> +</t> +<t t-name="web_editor.WeShapeBtn"> + <t t-set="shapeClassName" t-value="`o_${props.shape.replace(new RegExp('/', 'g'), '_')}`"/> + <t t-set="bgUrl" t-value="renderContext._shapeBackgroundImagePerClass[`.o_we_shape.${shapeClassName}`]"/> + <WeButton shape="props.shape" selectLabel="props.selectLabel" animated="props.animated and 'true'"> + <div class="o_we_shape_btn_content position-relative border-dark"> + <div class="o_we_shape" + t-att-class="shapeClassName" + t-attf-style="background-image: {{bgUrl}}"> + <t t-if="props.animated"> + <t t-call="web_editor.animated_shape_label"/> + </t> + </div> + </div> + </WeButton> +</t> +<t t-name="web_editor.BackgroundShapes"> + <WeRow class="'o_we_sublevel_1'" title.translate="Shapes"> + <!-- TODO: @owl-options remove the owl in the name --> + <WeSelectPager dependencies="'!shape_none_opt'" name="'bg_shape_opt'"> + <t t-set-slot="title"> + <h5 class="o_pager_nav_title mb-0 text-white">Shapes</h5> + </t> + <t t-set-slot="buttons"> + <button type="button" class="o_pager_nav_btn p-0 text-uppercase active" data-scroll-to="o_scroll_bgshapes_basic"> + <span class="w-100">Basic</span> + </button> + <button type="button" class="o_pager_nav_btn p-0 text-uppercase" data-scroll-to="o_scroll_bgshapes_linear"> + <span class="w-100">Linear</span> + </button> + <button type="button" class="o_pager_nav_btn p-0 text-uppercase" data-scroll-to="o_scroll_bgshapes_creative"> + <span class="w-100">Creative</span> + </button> + </t> + <div class="o_scroll_bgshapes_basic"> + <we-title class="">Geometrics</we-title> + <we-select-page> + <WeShapeBtn shape="'web_editor/Origins/02_001'" selectLabel="'Origins 01'"/> + <WeShapeBtn shape="'web_editor/Origins/04_001'" selectLabel="'Origins 02'"/> + <WeShapeBtn shape="'web_editor/Origins/05'" selectLabel="'Origins 03'"/> + <WeShapeBtn shape="'web_editor/Origins/06_001'" selectLabel="'Origins 04'"/> + <WeShapeBtn shape="'web_editor/Origins/07_002'" selectLabel="'Origins 05'"/> + <WeShapeBtn shape="'web_editor/Origins/08'" selectLabel="'Origins 06'"/> + <WeShapeBtn shape="'web_editor/Origins/09_001'" selectLabel="'Origins 07'"/> + <WeShapeBtn shape="'web_editor/Origins/11_001'" selectLabel="'Origins 08'"/> + <WeShapeBtn shape="'web_editor/Origins/14_001'" selectLabel="'Origins 09'"/> + <WeShapeBtn shape="'web_editor/Origins/16'" selectLabel="'Origins 10'" animated="true"/> + <WeShapeBtn shape="'web_editor/Origins/17'" selectLabel="'Origins 11'" animated="true"/> + <WeShapeBtn shape="'web_editor/Origins/18'" selectLabel="'Origins 12'" animated="true"/> + </we-select-page> + <we-title>Bold</we-title> + <we-select-page string="Bold"> + <WeShapeBtn shape="'web_editor/Bold/01'" selectLabel="'Bold 01'"/> + <WeShapeBtn shape="'web_editor/Bold/02'" selectLabel="'Bold 02'"/> + <WeShapeBtn shape="'web_editor/Bold/03'" selectLabel="'Bold 03'"/> + <WeShapeBtn shape="'web_editor/Bold/04'" selectLabel="'Bold 04'"/> + <WeShapeBtn shape="'web_editor/Bold/05_001'" selectLabel="'Bold 05'"/> + <WeShapeBtn shape="'web_editor/Bold/06_001'" selectLabel="'Bold 06'"/> + <WeShapeBtn shape="'web_editor/Bold/07_001'" selectLabel="'Bold 07'"/> + <WeShapeBtn shape="'web_editor/Bold/08'" selectLabel="'Bold 08'"/> + <WeShapeBtn shape="'web_editor/Bold/09'" selectLabel="'Bold 09'"/> + <WeShapeBtn shape="'web_editor/Bold/10_001'" selectLabel="'Bold 10'"/> + <WeShapeBtn shape="'web_editor/Bold/11_001'" selectLabel="'Bold 11'"/> + <WeShapeBtn shape="'web_editor/Bold/12_001'" selectLabel="'Bold 12'"/> + </we-select-page> + <we-select-page string="Blobs"> + <WeShapeBtn shape="'web_editor/Blobs/01_001'" selectLabel="'Blobs 01'" animated="true"/> + <WeShapeBtn shape="'web_editor/Blobs/02'" selectLabel="'Blobs 02'"/> + <WeShapeBtn shape="'web_editor/Blobs/03'" selectLabel="'Blobs 03'"/> + <WeShapeBtn shape="'web_editor/Blobs/04'" selectLabel="'Blobs 04'"/> + <WeShapeBtn shape="'web_editor/Blobs/05'" selectLabel="'Blobs 05'"/> + <WeShapeBtn shape="'web_editor/Blobs/06'" selectLabel="'Blobs 06'"/> + <WeShapeBtn shape="'web_editor/Blobs/07'" selectLabel="'Blobs 07'"/> + <WeShapeBtn shape="'web_editor/Blobs/08'" selectLabel="'Blobs 08'"/> + <WeShapeBtn shape="'web_editor/Blobs/09'" selectLabel="'Blobs 09'"/> + <WeShapeBtn shape="'web_editor/Blobs/10_001'" selectLabel="'Blobs 10'"/> + <WeShapeBtn shape="'web_editor/Blobs/11'" selectLabel="'Blobs 11'"/> + <WeShapeBtn shape="'web_editor/Blobs/12'" selectLabel="'Blobs 12'"/> + </we-select-page> + </div> + <div class="o_scroll_bgshapes_linear"> + <we-title>Airy & Zigs</we-title> + <we-select-page string="Airy & Zigs"> + <WeShapeBtn shape="'web_editor/Airy/01'" selectLabel="'Airy 01'"/> + <WeShapeBtn shape="'web_editor/Airy/02'" selectLabel="'Airy 02'"/> + <WeShapeBtn shape="'web_editor/Airy/03_001'" selectLabel="'Airy 03'" animated="true"/> + <WeShapeBtn shape="'web_editor/Airy/04_001'" selectLabel="'Airy 04'" animated="true"/> + <WeShapeBtn shape="'web_editor/Airy/05_001'" selectLabel="'Airy 05'" animated="true"/> + <WeShapeBtn shape="'web_editor/Airy/06'" selectLabel="'Airy 06'"/> + <WeShapeBtn shape="'web_editor/Airy/07'" selectLabel="'Airy 07'"/> + <WeShapeBtn shape="'web_editor/Airy/08'" selectLabel="'Airy 08'"/> + <WeShapeBtn shape="'web_editor/Airy/09'" selectLabel="'Airy 09'"/> + <WeShapeBtn shape="'web_editor/Airy/10'" selectLabel="'Airy 10'"/> + <WeShapeBtn shape="'web_editor/Airy/11'" selectLabel="'Airy 11'"/> + <WeShapeBtn shape="'web_editor/Airy/12_001'" selectLabel="'Airy 12'" animated="true"/> + <WeShapeBtn shape="'web_editor/Airy/13_001'" selectLabel="'Airy 13'" animated="true"/> + <WeShapeBtn shape="'web_editor/Airy/14'" selectLabel="'Airy 14'"/> + <WeShapeBtn shape="'web_editor/Zigs/01_001'" selectLabel="'Zigs 01'" animated="true"/> + <WeShapeBtn shape="'web_editor/Zigs/02_001'" selectLabel="'Zigs 02'" animated="true"/> + <WeShapeBtn shape="'web_editor/Zigs/03'" selectLabel="'Zigs 03'"/> + <WeShapeBtn shape="'web_editor/Zigs/04'" selectLabel="'Zigs 04'"/> + <WeShapeBtn shape="'web_editor/Zigs/05'" selectLabel="'Zigs 05'"/> + <WeShapeBtn shape="'web_editor/Zigs/06'" selectLabel="'Zigs 06'"/> + </we-select-page> + </div> + <div class="o_scroll_bgshapes_creative"> + <we-title>Wavy</we-title> + <we-select-page string="Wavy"> + <WeShapeBtn shape="'web_editor/Wavy/01_001'" selectLabel="'Wavy 01'"/> + <WeShapeBtn shape="'web_editor/Wavy/02_001'" selectLabel="'Wavy 02'"/> + <WeShapeBtn shape="'web_editor/Wavy/03'" selectLabel="'Wavy 03'"/> + <WeShapeBtn shape="'web_editor/Wavy/10'" selectLabel="'Wavy 04'"/> + <WeShapeBtn shape="'web_editor/Wavy/24'" selectLabel="'Wavy 05'" animated="true"/> + <WeShapeBtn shape="'web_editor/Wavy/25'" selectLabel="'Wavy 06'" animated="true"/> + <WeShapeBtn shape="'web_editor/Wavy/26'" selectLabel="'Wavy 07'" animated="true"/> + <WeShapeBtn shape="'web_editor/Wavy/27'" selectLabel="'Wavy 08'" animated="true"/> + <WeShapeBtn shape="'web_editor/Wavy/04'" selectLabel="'Wavy 09'"/> + <WeShapeBtn shape="'web_editor/Wavy/05'" selectLabel="'Wavy 10'"/> + <WeShapeBtn shape="'web_editor/Wavy/06_001'" selectLabel="'Wavy 11'"/> + <WeShapeBtn shape="'web_editor/Wavy/07'" selectLabel="'Wavy 12'"/> + <WeShapeBtn shape="'web_editor/Wavy/08'" selectLabel="'Wavy 13'"/> + <WeShapeBtn shape="'web_editor/Wavy/09'" selectLabel="'Wavy 14'"/> + <WeShapeBtn shape="'web_editor/Wavy/11'" selectLabel="'Wavy 15'"/> + <WeShapeBtn shape="'web_editor/Wavy/12_001'" selectLabel="'Wavy 16'"/> + <WeShapeBtn shape="'web_editor/Wavy/28'" selectLabel="'Wavy 17'" animated="true"/> + <WeShapeBtn shape="'web_editor/Wavy/13_001'" selectLabel="'Wavy 18'"/> + <WeShapeBtn shape="'web_editor/Wavy/14'" selectLabel="'Wavy 19'"/> + <WeShapeBtn shape="'web_editor/Wavy/15'" selectLabel="'Wavy 20'"/> + <WeShapeBtn shape="'web_editor/Wavy/16'" selectLabel="'Wavy 21'"/> + <WeShapeBtn shape="'web_editor/Wavy/17'" selectLabel="'Wavy 22'"/> + <WeShapeBtn shape="'web_editor/Wavy/18'" selectLabel="'Wavy 23'"/> + <WeShapeBtn shape="'web_editor/Wavy/19'" selectLabel="'Wavy 24'"/> + <WeShapeBtn shape="'web_editor/Wavy/20'" selectLabel="'Wavy 25'"/> + <WeShapeBtn shape="'web_editor/Wavy/21'" selectLabel="'Wavy 26'"/> + <WeShapeBtn shape="'web_editor/Wavy/22'" selectLabel="'Wavy 27'"/> + <WeShapeBtn shape="'web_editor/Wavy/23'" selectLabel="'Wavy 28'"/> + </we-select-page> + <we-title>Block & Rainy</we-title> + <we-select-page string="Blocks & Rainy"> + <WeShapeBtn shape="'web_editor/Blocks/02_001'" selectLabel="'Blocks 01'"/> + <WeShapeBtn shape="'web_editor/Blocks/01_001'" selectLabel="'Blocks 02'"/> + <WeShapeBtn shape="'web_editor/Blocks/03'" selectLabel="'Blocks 03'"/> + <WeShapeBtn shape="'web_editor/Blocks/04'" selectLabel="'Blocks 04'"/> + <WeShapeBtn shape="'web_editor/Rainy/01_001'" selectLabel="'Rainy 01'" animated="true"/> + <WeShapeBtn shape="'web_editor/Rainy/02_001'" selectLabel="'Rainy 02'" animated="true"/> + <WeShapeBtn shape="'web_editor/Rainy/06'" selectLabel="'Rainy 03'"/> + <WeShapeBtn shape="'web_editor/Rainy/07'" selectLabel="'Rainy 04'"/> + <WeShapeBtn shape="'web_editor/Rainy/10'" selectLabel="'Rainy 05'" animated="true"/> + <WeShapeBtn shape="'web_editor/Rainy/04'" selectLabel="'Rainy 06'"/> + <WeShapeBtn shape="'web_editor/Rainy/05_001'" selectLabel="'Rainy 07'"/> + <WeShapeBtn shape="'web_editor/Rainy/03_001'" selectLabel="'Rainy 08'" animated="true"/> + <WeShapeBtn shape="'web_editor/Rainy/08_001'" selectLabel="'Rainy 09'" animated="true"/> + <WeShapeBtn shape="'web_editor/Rainy/09_001'" selectLabel="'Rainy 10'"/> + </we-select-page> + <we-title>Floating Shapes</we-title> + <we-select-page string="Floating Shapes"> + <WeShapeBtn shape="'web_editor/Floats/01'" selectLabel="'Float 01'" animated="true"/> + <WeShapeBtn shape="'web_editor/Floats/02'" selectLabel="'Float 02'" animated="true"/> + <WeShapeBtn shape="'web_editor/Floats/03'" selectLabel="'Float 03'" animated="true"/> + <WeShapeBtn shape="'web_editor/Floats/04'" selectLabel="'Float 04'" animated="true"/> + <WeShapeBtn shape="'web_editor/Floats/05'" selectLabel="'Float 05'" animated="true"/> + <WeShapeBtn shape="'web_editor/Floats/06'" selectLabel="'Float 06'" animated="true"/> + <WeShapeBtn shape="'web_editor/Floats/07'" selectLabel="'Float 07'" animated="true"/> + <WeShapeBtn shape="'web_editor/Floats/08'" selectLabel="'Float 08'" animated="true"/> + <WeShapeBtn shape="'web_editor/Floats/09'" selectLabel="'Float 09'" animated="true"/> + <WeShapeBtn shape="'web_editor/Floats/10'" selectLabel="'Float 10'" animated="true"/> + <WeShapeBtn shape="'web_editor/Floats/11'" selectLabel="'Float 11'" animated="true"/> + <WeShapeBtn shape="'web_editor/Floats/12'" selectLabel="'Float 12'" animated="true"/> + <WeShapeBtn shape="'web_editor/Floats/13'" selectLabel="'Float 13'" animated="true"/> + <WeShapeBtn shape="'web_editor/Floats/14'" selectLabel="'Float 14'" animated="true"/> + </we-select-page> + </div> + </WeSelectPager> + <WeButton class="'o_we_device'" showOnMobile="'false'" tooltip.translate="Show/Hide on Mobile" + dependencies="'!shape_none_opt'" noPreview="'true'" + img="'/website/static/src/img/snippets_options/mobile_invisible.svg'"/> + <WeButton shape="''" name="'shape_none_opt'" dependencies="'!shape_none_opt'" noPreview="'true'" class="'o_we_image_shape_remove o_we_bg_danger fa fa-fw fa-times'"/> + </WeRow> + <WeRow class="'o_we_sublevel_2'" title.translate="Flip"> + <WeButton class="'oi oi-fw oi-arrows-h'" flipX="'true'" noPreview="'true'" dependencies="'!shape_none_opt'"/> + <WeButton class="'oi oi-fw oi-arrows-v'" flipY="'true'" noPreview="'true'" dependencies="'!shape_none_opt'"/> + </WeRow> + <WeRow class="'o_we_sublevel_2'" name="'colors'" title.translate="Colors"> + <t t-foreach="renderContext.colorPickers" t-as="colorName" t-key="colorName"> + <WeColorpicker color="'true'" colorName="colorName"/> + </t> + <WeColorpicker selectStyle="''" cssProperty="'background-color'" colorPrefix="'bg-'" applyTo="'> .o_we_shape'"/> + </WeRow> + <WeRow class="'o_we_sublevel_2'" title.translate="Speed"> + <WeRange name="'bg_shape_anim_speed_opt'" + noPreview="'true'" + displayRangeValue="'true'" + setBgShapeAnimationSpeed="''" + max="'2'" + min="'-2'" + step="'0.1'" + toRatio="'true'"/> + </WeRow> +</t> +<t t-name="web_editor.BackgroundImage"> + <WeRow title.translate="Image" class="'o_we_sublevel_1'"> + <WeMediapicker tooltip.translate="Edit image" + mediaType="'images'" + background="''" + name="'bg_image_opt'" + dependencies="'bg_image_opt'"/> + <span t-if="renderContext.filesize" t-out="renderContext.filesize" class="o_we_image_weight o_we_tag"/> + </WeRow> + <WeRow class="'o_we_sublevel_2'" title.translate="Main Color" name="'main_color_opt'"> + <WeColorpicker dynamicColor="'true'" colorName="'c1'"/> + <WeColorpicker dynamicColor="'true'" colorName="'c2'"/> + <WeColorpicker dynamicColor="'true'" colorName="'c3'"/> + <WeColorpicker dynamicColor="'true'" colorName="'c4'"/> + <WeColorpicker dynamicColor="'true'" colorName="'c5'"/> + </WeRow> +</t> +<t t-name="web_editor.BackgroundPosition"> + <WeRow class="'o_we_sublevel_2'" title.translate="Position"> + <WeSelect noPreview="'true'"> + <WeButton backgroundType="'cover'">Cover</WeButton> + <WeButton backgroundType="'repeat-pattern'" name="'background_repeat_opt'">Repeat pattern</WeButton> + </WeSelect> + <WeButton class="'fa fa-fw fa-crosshairs'" tooltip.translate="Background Position" + backgroundPositionOverlay="'true'" noPreview="'true'"/> + </WeRow> + <WeMulti cssProperty="'background-size'" dependencies="'background_repeat_opt'"> + <WeInput title.translate="Width" class="'o_we_sublevel_3'" + selectStyle="'auto'" placeholder.translate="auto" unit="'px'"/> + <WeInput title.translate="Height" class="'o_we_sublevel_3'" + selectStyle="'auto'" placeholder.translate="auto" unit="'px'"/> + </WeMulti> +</t> +<t t-name="web_editor.BackgroundFilter"> + <t t-set="color_filter_dependencies" t-value="'bg_image_toggle_opt'"/> + <WeColorpicker title.translate="Color Filter" + class="'o_we_sublevel_2'" + dependencies="color_filter_dependencies" + name="'bg_filter_opt'" + selectFilterColor="''" + colorPrefix="'bg-'" + opacity="'0.5'" + withGradients="'1'" + selectedTab="'gradients'" + excluded="'theme, common'"/> +</t> +</templates> diff --git a/addons/web_editor/static/src/js/editor/snippets.registry.js b/addons/web_editor/static/src/js/editor/snippets.registry.js new file mode 100644 index 0000000000000..3bfe6ce424a84 --- /dev/null +++ b/addons/web_editor/static/src/js/editor/snippets.registry.js @@ -0,0 +1,92 @@ +/** @odoo-module **/ +import { registry } from "@web/core/registry"; + + +export function registerOption(name, def, options) { + if (!def.module) { + def.module = "web_editor"; + } + return registry.category("snippet_options").add(name, def, options); +} + +export const SNIPPET_ADDITION_OPTION_ID = "so_snippet_addition"; +export const CONTENT_ADDITION_OPTION_ID = "so_content_addition"; + +/** + * Register a selector for generic snippet dropIn. + * + * @param {string} selector The selector to add. + */ +export function registerSnippetAdditionSelector(selector) { + registry.category("snippet_options").get(SNIPPET_ADDITION_OPTION_ID).addSelector(selector); +} + +/** + * Register a selector for generic inner content drop. + * This also adds the selector as a dropNear target for other inner content. + * + * @param {string} selector The selector to add. + */ +export function registerContentAdditionSelector(selector) { + registry.category("snippet_options").get(CONTENT_ADDITION_OPTION_ID).addSelector(selector); +} + + +// TODO: @owl-options - some selectors should be defined in there respective +// snippet options js file +const snippetAdditionSelectors = [ + "section", + ".parallax", + ".s_hr" +]; +registerOption(SNIPPET_ADDITION_OPTION_ID, { + _selector: snippetAdditionSelectors.join(", "), + get selector() { + return this._selector; + }, + dropIn: ":not(p).oe_structure:not(.oe_structure_solo), :not(.o_mega_menu):not(p)[data-oe-type=html], :not(p).oe_structure.oe_structure_solo:not(:has(> section:not(.s_snippet_group), > div:not(.o_hook_drop_zone)))", + + addSelector(selector) { + this._selector = this._selector + ", " + selector; + }, +}); + +// TODO: @owl-options - some selectors should be defined in there respective +// snippet options js file +const contentAdditionSelectors = [ + "blockquote", + ".s_card:not(.s_timeline_card)", + ".s_alert", + ".o_facebook_page", + ".s_share", + ".s_social_media", + ".s_rating", + ".s_hr", + ".s_google_map", + ".s_map", + ".s_countdown", + ".s_chart", + ".s_text_highlight", + ".s_progress_bar", + ".s_badge", + ".s_embed_code", + ".s_donation", + ".s_add_to_cart", + ".s_online_appointment", + ".o_snippet_drop_in_only", + ".s_image" +]; +registerOption(CONTENT_ADDITION_OPTION_ID, { + _selector: contentAdditionSelectors.join(", "), + get selector() { + return this._selector; + }, + get dropNear() { + return "p, h1, h2, h3, ul, ol, div:not(.o_grid_item_image) > img, .btn, " + this._selector; + }, + dropIn: "nav", + + addSelector(selector) { + this._selector = this._selector + ", " + selector; + }, +}); diff --git a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss index dfd47c071085a..69a20509229ec 100644 --- a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss +++ b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss @@ -1442,10 +1442,6 @@ display: inline-block; color: inherit; height: 100%; - - &.o_we_matrix_remove_col, &.o_we_matrix_remove_row { - display: none; - } } input { border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color; @@ -1685,22 +1681,22 @@ padding-right: .5em; font-family: FontAwesome; } + } - input { - flex: 1 1 auto; - color: inherit; - border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color; - border-radius: $o-we-sidebar-content-field-border-radius; - background-color: $o-we-sidebar-content-field-input-bg; - padding: 1px $o-we-sidebar-content-field-clickable-spacing; + :is(.o_we_m2o_search, .o_we_m2o_create) input { + flex: 1 1 auto; + color: inherit; + border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color; + border-radius: $o-we-sidebar-content-field-border-radius; + background-color: $o-we-sidebar-content-field-input-bg; + padding: 1px $o-we-sidebar-content-field-clickable-spacing; - &:focus { - outline: none; - border-color: $o-we-sidebar-content-field-input-border-color; - } - &::placeholder { - color: $o-we-sidebar-content-field-control-item-color; - } + &:focus { + outline: none; + border-color: $o-we-sidebar-content-field-input-border-color; + } + &::placeholder { + color: $o-we-sidebar-content-field-control-item-color; } } @@ -1851,7 +1847,7 @@ margin-bottom: -($o-we-sidebar-content-field-spacing - $-inner-spacing); background-clip: padding-box; - > :first-child, .o_we_collapse_toggler { + > :first-child, .o_we_collapse_toggler:not(.o_we_user_value_widget) { margin-top: $-inner-spacing; } we-toggler.o_we_collapse_toggler { diff --git a/addons/web_editor/static/src/xml/snippets.xml b/addons/web_editor/static/src/xml/snippets.xml index 606e4af66f446..60087091e35b9 100644 --- a/addons/web_editor/static/src/xml/snippets.xml +++ b/addons/web_editor/static/src/xml/snippets.xml @@ -216,7 +216,8 @@ </div> </t> </div> - <div class="o_we_customize_panel" t-ref="customize-panel" t-att-class="{ 'd-none': state.currentTab === constructor.tabs.BLOCKS }"> + <div class="o_we_customize_panel" t-ref="customize-panel" t-att-class="{ 'd-none': state.currentTab === constructor.tabs.BLOCKS }"> + <SnippetOptionsManager editors="snippetEditors" enabledEditors="state.enabledEditorHierarchy" onOptionMounted="onOptionMounted" activateTab.bind="activateTab" execWithLoadingEffect.bind="this.execWithLoadingEffect"/> <we-customizeblock-options id="o_we_editor_toolbar_container" t-att-class="{ 'd-none': !state.showToolbar }"> <we-title> <span t-out="state.toolbarTitle"/> @@ -259,4 +260,35 @@ </ul> </t> + <t t-name="web_editor.SnippetOptionsManager"> + <t t-foreach="getEditors()" t-as="editor" t-key="editor.key"> + <t t-set="snippetOptions" t-value="editor.getOptions()"/> + <we-customizeblock-options t-if="snippetOptions.length > 0" t-att-class="{'d-none': props.enabledEditors.indexOf(editor) lt 0 }"> + <we-title> + <span> + <t t-out="editor.name"/> + <t t-out="editor.extraTitle"/> + </span> + <we-top-button-group> + <t t-foreach="snippetOptions" t-as="option" t-key="option_index"> + <t t-if="option.isTopOption" t-component="option.renderingComponent" + snippetOption="option" + onOptionMounted="props.onOptionMounted"/> + </t> + <we-button class="fa fa-fw fa-clone oe_snippet_clone o_we_link o_we_hover_success" data-title="Duplicate Container" aria-label="Duplicate Container" t-on-click="(ev) => editor._onCloneClick(ev)"/> + <we-button class="fa fa-fw fa-trash oe_snippet_remove o_we_link o_we_hover_danger" data-title="Remove Block" aria-label="Remove Block" t-on-click="(ev) => editor._onRemoveClick(ev)"/> + </we-top-button-group> + </we-title> + <t t-foreach="snippetOptions" t-as="option" t-key="option_index"> + <!-- todo: @owl-options this div encapsulation is + there so solve a weird crash --> + <div t-if="!option.isTopOption"> + <t t-component="option.renderingComponent" + snippetOption="option" + onOptionMounted="props.onOptionMounted"/> + </div> + </t> + </we-customizeblock-options> + </t> + </t> </templates> diff --git a/addons/web_editor/static/tests/test_utils.js b/addons/web_editor/static/tests/test_utils.js index fc6728bb6608a..f3e6da6116169 100644 --- a/addons/web_editor/static/tests/test_utils.js +++ b/addons/web_editor/static/tests/test_utils.js @@ -5,7 +5,7 @@ import testUtils from "@web/../tests/legacy_tests/helpers/test_utils"; import { patch } from "@web/core/utils/patch"; import * as OdooEditorLib from "@web_editor/js/editor/odoo-editor/src/OdooEditor"; import { Wysiwyg } from '@web_editor/js/wysiwyg/wysiwyg'; -import options from "@web_editor/js/editor/snippets.options"; +import options from "@web_editor/js/editor/snippets.options.legacy"; import { TABLE_ATTRIBUTES, TABLE_STYLES } from '@web_editor/js/backend/convert_inline'; export const COLOR_PICKER_TEMPLATE = ` diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index 2a90cf3065884..da66c66de4a81 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -95,7 +95,6 @@ 'views/snippets/s_dynamic_snippet.xml', 'views/snippets/s_dynamic_snippet_carousel.xml', 'views/snippets/s_embed_code.xml', - 'views/snippets/s_website_controller_page_listing_layout.xml', 'views/snippets/s_website_form.xml', 'views/snippets/s_searchbar.xml', 'views/snippets/s_button.xml', @@ -241,39 +240,72 @@ 'website/static/src/scss/website.edit_mode.scss', 'website/static/src/js/editor/snippets.editor.js', 'website/static/src/js/editor/snippets.options.js', + 'website/static/src/js/editor/snippets.options.xml', + 'website/static/src/js/editor/snippets.options.legacy.js', + 'website/static/src/js/editor/snippets.registry.js', + 'website/static/src/js/editor/options/common/*', + 'website/static/src/snippets/s_alert/options.js', + 'website/static/src/snippets/s_alert/options.xml', 'website/static/src/snippets/s_facebook_page/options.js', + 'website/static/src/snippets/s_facebook_page/options.xml', 'website/static/src/snippets/s_image/options.js', 'website/static/src/snippets/s_image_gallery/options.js', + 'website/static/src/snippets/s_image_gallery/options.xml', 'website/static/src/snippets/s_image_gallery/000.xml', 'website/static/src/snippets/s_instagram_page/options.js', 'website/static/src/snippets/s_card/001.xml', 'website/static/src/snippets/s_card/options.js', + 'website/static/src/snippets/s_card/options.xml', + 'website/static/src/snippets/s_instagram_page/options.xml', 'website/static/src/snippets/s_countdown/options.js', 'website/static/src/snippets/s_countdown/options.xml', + 'website/static/src/snippets/s_embed_code/options.xml', 'website/static/src/snippets/s_masonry_block/options.js', + 'website/static/src/snippets/s_masonry_block/options.xml', 'website/static/src/snippets/s_popup/options.js', + 'website/static/src/snippets/s_popup/options.legacy.js', + 'website/static/src/snippets/s_popup/options.xml', 'website/static/src/snippets/s_product_catalog/options.js', + 'website/static/src/snippets/s_product_catalog/options.xml', 'website/static/src/snippets/s_chart/options.js', + 'website/static/src/snippets/s_chart/options.xml', 'website/static/src/snippets/s_rating/options.js', + 'website/static/src/snippets/s_rating/options.xml', 'website/static/src/snippets/s_tabs/options.js', + 'website/static/src/snippets/s_tabs/options.xml', 'website/static/src/snippets/s_progress_bar/options.js', + 'website/static/src/snippets/s_progress_bar/options.xml', + 'website/static/src/snippets/s_blockquote/options.js', + 'website/static/src/snippets/s_blockquote/options.xml', + 'website/static/src/snippets/s_showcase/options.js', 'website/static/src/snippets/s_table_of_content/options.js', 'website/static/src/snippets/s_timeline/options.js', + 'website/static/src/snippets/s_timeline/options.xml', 'website/static/src/snippets/s_media_list/options.js', + 'website/static/src/snippets/s_media_list/options.xml', 'website/static/src/snippets/s_google_map/options.js', + 'website/static/src/snippets/s_google_map/options.xml', 'website/static/src/snippets/s_map/options.js', + 'website/static/src/snippets/s_map/options.xml', 'website/static/src/snippets/s_dynamic_snippet/options.js', + 'website/static/src/snippets/s_dynamic_snippet/options.xml', 'website/static/src/snippets/s_dynamic_snippet_carousel/options.js', + 'website/static/src/snippets/s_dynamic_snippet_carousel/options.xml', 'website/static/src/snippets/s_website_controller_page_listing_layout/options.js', + 'website/static/src/snippets/s_website_controller_page_listing_layout/options.xml', 'website/static/src/snippets/s_website_form/options.js', + 'website/static/src/snippets/s_website_form/options.xml', 'website/static/src/js/form_editor_registry.js', 'website/static/src/js/send_mail_form.js', 'website/static/src/xml/website_form.xml', 'website/static/src/xml/website.editor.xml', 'website/static/src/xml/website_form_editor.xml', 'website/static/src/snippets/s_searchbar/options.js', + 'website/static/src/snippets/s_searchbar/options.xml', 'website/static/src/snippets/s_social_media/options.js', + 'website/static/src/snippets/s_social_media/options.xml', 'website/static/src/snippets/s_process_steps/options.js', + 'website/static/src/snippets/s_process_steps/options.xml', 'website/static/src/js/editor/widget_link.js', 'website/static/src/js/widgets/link_popover_widget.js', 'website/static/src/xml/website.cookies_bar.xml', diff --git a/addons/website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.js b/addons/website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.js index f26522e037190..08c525658e499 100644 --- a/addons/website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.js +++ b/addons/website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.js @@ -440,6 +440,60 @@ export class WysiwygAdapterComponent extends Wysiwyg { return super.destroy(...arguments); } + /** + * Get an option linked to the page. + * + * @param {string} name The name of the page option + * @return {*} + */ + getPageOption(name) { + return this.pageOptions[name].value; + } + /** + * Toggles or force an option linked to the page. + * + * @see {PageOption} + * + * @param {string} name The name of the page option + * @param {*} [value] The value if needed to be forced + */ + togglePageOption(name, value) { + const pageOption = this.pageOptions[name]; + pageOption.value = value === undefined ? !pageOption.value : value; + } + /** + * Opens the menu dialog. + * + * @param {string} name - The link's display text + * @param {string} url - The link's url + * @param {function} saveCallback - Called when saved + * @param {boolean} [isMegaMenu] + */ + openMenuDialog(name, url, saveCallback, isMegaMenu = false) { + this.dialogs.add(MenuDialog, { + name: name, + url: url, + isMegaMenu: isMegaMenu, + save: async (...args) => { + await saveCallback(...args); + }, + }); + } + /** + * Opens the edit menu dialog. + * + * @param {number} rootId - The ID of the root menu to edit + */ + openEditMenuDialog(rootId) { + return this.dialogs.add(EditMenuDialog, { + rootID: rootId, + save: () => { + // TODO: Rework _onSaveRequest to not take Events + return this._onSaveRequest({ data: { reload: true } }); + }, + }); + } + //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -655,6 +709,7 @@ export class WysiwygAdapterComponent extends Wysiwyg { * * @param {Event} event the event that triggerd the action. * @returns {*} + * @deprecated TODO @owl-options - legacy: remove once no usage * @private */ async _handleAction(event) { @@ -662,33 +717,14 @@ export class WysiwygAdapterComponent extends Wysiwyg { const params = event.data.params; switch (actionName) { case 'get_page_option': - return event.data.onSuccess(this.pageOptions[params[0]].value); + return event.data.onSuccess(this.getPageOption(params[0])); case 'toggle_page_option': - this._togglePageOption(...params); + this.togglePageOption(params[0].name, params[0].value); return event.data.onSuccess(); case 'edit_menu': - return this.dialogs.add(EditMenuDialog, { - rootID: params[0], - save: () => { - // TODO: Rework _onSaveRequest to not take Events - this._onSaveRequest({ data: { reload: true} }); - }, - }); + return this.openEditMenuDialog(params[0]); } } - /** - * Toggles or force an option linked to the page. - * - * @see {PageOption} - * @param {Object} params - * @param {string} params.name the name of the page option, - * @param {*} params.value the value if needed to be forced - * @private - */ - _togglePageOption(params) { - const pageOption = this.pageOptions[params.name]; - pageOption.value = params.value === undefined ? !pageOption.value : params.value; - } /** * Triggers an event on the iframe's public root. * @@ -900,13 +936,12 @@ export class WysiwygAdapterComponent extends Wysiwyg { 'will_remove_snippet': this._onRootEventRequest.bind(this), 'request_save': this._onSaveRequest.bind(this), 'context_get': this._onContextGet.bind(this), - 'action_demand': this._handleAction.bind(this), + 'action_demand': this._handleAction.bind(this), // TODO: @owl-options - legacy: remove once no usage 'request_cancel': this._onCancelRequest.bind(this), 'snippet_will_be_cloned': this._onSnippetWillBeCloned.bind(this), 'snippet_cloned': this._onSnippetCloned.bind(this), 'snippet_removed': this._onSnippetRemoved.bind(this), 'reload_bundles': this._reloadBundles.bind(this), - 'menu_dialog': this._onMenuDialogRequest.bind(this), 'open_edit_head_body_dialog': this._onOpenEditHeadBodyDialog.bind(this), }; @@ -1291,22 +1326,6 @@ export class WysiwygAdapterComponent extends Wysiwyg { this._addEditorMessages(); } } - /** - * Adds / Edit an entry in the website menu. - * - * @param event - * @private - */ - _onMenuDialogRequest(event) { - this.dialogs.add(MenuDialog, { - name: event.data.name, - url: event.data.url, - isMegaMenu: event.data.isMegaMenu, - save: async (...args) => { - await event.data.save(...args); - }, - }); - } /** * Called when a child needs to know about the views that can * be toggled on or off on a specific view related to the editable. diff --git a/addons/website/static/src/js/editor/snippets.editor.js b/addons/website/static/src/js/editor/snippets.editor.js index ed57677bafc22..84224cbfcbfa4 100644 --- a/addons/website/static/src/js/editor/snippets.editor.js +++ b/addons/website/static/src/js/editor/snippets.editor.js @@ -5,9 +5,9 @@ import { Dialog } from "@web/core/dialog/dialog"; import { useChildRef, useService } from "@web/core/utils/hooks"; import { user } from "@web/core/user"; import weSnippetEditor from "@web_editor/js/editor/snippets.editor"; -import wSnippetOptions from "@website/js/editor/snippets.options"; +import wLegacySnippetOptions from "@website/js/editor/snippets.options.legacy"; import * as OdooEditorLib from "@web_editor/js/editor/odoo-editor/src/utils/utils"; -import { Component, onMounted, onWillStart, useEffect, useRef, useState } from "@odoo/owl"; +import { Component, onMounted, onWillStart, useEffect, useRef, useState, useSubEnv } from "@odoo/owl"; import { throttleForAnimation } from "@web/core/utils/timing"; import { switchTextHighlight } from "@website/js/text_processing"; import { registry } from "@web/core/registry"; @@ -18,7 +18,7 @@ snippetsEditorRegistry.add("no_parent_editor_snippets", ["s_popup", "o_mega_menu const getDeepRange = OdooEditorLib.getDeepRange; const getTraversedNodes = OdooEditorLib.getTraversedNodes; -const FontFamilyPickerUserValueWidget = wSnippetOptions.FontFamilyPickerUserValueWidget; +const FontFamilyPickerUserValueWidget = wLegacySnippetOptions.FontFamilyPickerUserValueWidget; const ANIMATED_TEXT_SELECTOR = ".o_animated_text"; const HIGHLIGHTED_TEXT_SELECTOR = ".o_text_highlight"; @@ -58,6 +58,10 @@ export class WebsiteSnippetsMenu extends weSnippetEditor.SnippetsMenu { * @override */ setup() { + useSubEnv({ + gmapApiRequest: this._onGMapAPIRequest.bind(this), + gmapApiKeyRequest: this._onGMapAPIKeyRequest.bind(this), + }); super.setup(); this.notification = useService("notification"); this.dialog = useService("dialog"); @@ -224,6 +228,15 @@ export class WebsiteSnippetsMenu extends weSnippetEditor.SnippetsMenu { FontFamilyPickerUserValueWidget.prototype.fontVariables = fontVariables; return super._computeSnippetTemplates(html); } + /** + * @override + */ + getOptions() { + const options = super.getOptions().filter(([optionID, option]) => { + return ["website", "web_editor"].includes(option.module); + }); + return options; + } /** * Depending of the demand, reconfigure they gmap key or configure it * if not already defined. diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js index 64d67b76e3fd9..11300dd689c9d 100644 --- a/addons/website/static/src/js/editor/snippets.options.js +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -6,9 +6,10 @@ import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_d import { Dialog } from "@web/core/dialog/dialog"; import { rpc } from "@web/core/network/rpc"; import { user } from "@web/core/user"; -import { useChildRef } from "@web/core/utils/hooks"; +import { registry } from "@web/core/registry"; +import { useChildRef, useService } from "@web/core/utils/hooks"; import weUtils from "@web_editor/js/common/utils"; -import options from "@web_editor/js/editor/snippets.options"; +import options from "@web_editor/js/editor/snippets.options.legacy"; import { NavbarLinkPopoverWidget } from "@website/js/widgets/link_popover_widget"; import wUtils from "@website/js/utils"; import { @@ -17,7 +18,7 @@ import { isImageSupportedForStyle, loadImageInfo, } from "@web_editor/js/editor/image_processing"; -import "@website/snippets/s_popup/options"; +import { SnippetPopup } from "../../snippets/s_popup/options"; import { range } from "@web/core/utils/numbers"; import { _t } from "@web/core/l10n/translation"; import {Domain} from "@web/core/domain"; @@ -35,11 +36,32 @@ import { drawTextHighlightSVG, } from "@website/js/text_processing"; -import { Component, markup, useEffect, useRef, useState } from "@odoo/owl"; +import { patch } from "@web/core/utils/patch"; +import { session } from "@web/session"; +import { Component, markup, onMounted, onWillUnmount, useEffect, useRef, useState } from "@odoo/owl"; -const InputUserValueWidget = options.userValueWidgetsRegistry['we-input']; -const SelectUserValueWidget = options.userValueWidgetsRegistry['we-select']; -const Many2oneUserValueWidget = options.userValueWidgetsRegistry['we-many2one']; +import { + BackgroundToggler, + Box, + CarouselHandler, + LayoutColumn, + Many2oneUserValue, + registerBackgroundOptions, + registerOption, + SelectTemplate, + SelectUserValue, + serviceCached, + SnippetMove, + SnippetOption, + SnippetOptionComponent, + UserValue, + UserValueComponent, + vAlignment, + WeButton, + WeInput, + WeSelect, +} from '@web_editor/js/editor/snippets.options'; +import { registerWebsiteOption } from "./snippets.registry"; options.UserValueWidget.include({ loadMethodsData() { @@ -61,12 +83,17 @@ options.UserValueWidget.include({ }, }); -Many2oneUserValueWidget.include({ - init() { - this._super(...arguments); - this.fields = this.bindService("field"); +patch(Many2oneUserValue.prototype, { + /** + * @override + */ + constructorPatch() { + // We can't do that with `constructor()` because super calls a static + // property with `this.constructor.prop`. Overriding the constructor in + // a patch makes it impossible to call such static properties. + super.constructorPatch(...arguments); + this.fields = this.env.services.field; }, - /** * @override */ @@ -86,44 +113,38 @@ Many2oneUserValueWidget.include({ }, }); -const UrlPickerUserValueWidget = InputUserValueWidget.extend({ - events: Object.assign({}, InputUserValueWidget.prototype.events || {}, { - 'click .o_we_redirect_to': '_onRedirectTo', - }), - - /** - * @override - */ - start: async function () { - await this._super(...arguments); - const linkButton = document.createElement('we-button'); - const icon = document.createElement('i'); - icon.classList.add('fa', 'fa-fw', 'fa-external-link'); - linkButton.classList.add('o_we_redirect_to', 'o_we_link', 'ms-1'); - linkButton.title = _t("Preview this URL in a new tab"); - linkButton.appendChild(icon); - this.containerEl.after(linkButton); - this.el.classList.add('o_we_large'); - this.inputEl.classList.add('text-start'); - const options = { - classes: { - "ui-autocomplete": 'o_website_ui_autocomplete' - }, - body: this.getParent().$target[0].ownerDocument.body, - urlChosen: this._onWebsiteURLChosen.bind(this), - }; - this.unmountAutocompleteWithPages = wUtils.autocompleteWithPages(this.inputEl, options); - }, +class WeUrlPicker extends WeInput { + static template = "website.WeUrlPicker"; + static defaultProps = { + ...WeInput.defaultProps, + unit: "", + saveUnit: "", + }; + setup() { + super.setup(); + this.website = useService('website'); + useEffect((inputEl) => { + const options = { + classes: { + "ui-autocomplete": 'o_website_ui_autocomplete' + }, + urlChosen: this._onWebsiteURLChosen.bind(this), + }; + const unmountAutocompleteWithPages = wUtils.autocompleteWithPages(inputEl, options); + return () => unmountAutocompleteWithPages(); + }, () => [this.inputRef.el]); + } + // TODO maybe these open & close can be removed open() { - this._super(...arguments); + super.open(...arguments); document.querySelector(".o_website_ui_autocomplete")?.classList?.remove("d-none"); - }, + } close() { - this._super(...arguments); + super.close(...arguments); document.querySelector(".o_website_ui_autocomplete")?.classList?.add("d-none"); - }, + } //-------------------------------------------------------------------------- // Handlers @@ -135,26 +156,22 @@ const UrlPickerUserValueWidget = InputUserValueWidget.extend({ * @private * @param {OdooEvent} ev */ - _onWebsiteURLChosen: function (ev) { - this._value = this.inputEl.value; + _onWebsiteURLChosen(ev) { + this.state.value = this.inputRef.el.value; this._onUserValueChange(ev); - }, + } /** * Redirects to the URL the widget currently holds. * * @private */ - _onRedirectTo: function () { - if (this._value) { - window.open(this._value, '_blank'); + _onRedirectTo() { + if (this.state.value) { + window.open(this.state.value, '_blank'); } - }, - destroy() { - this.unmountAutocompleteWithPages?.(); - this.unmountAutocompleteWithPages = null; - this._super(...arguments); } -}); +} +registry.category("snippet_widgets").add("WeUrlPicker", WeUrlPicker); class GoogleFontAutoComplete extends AutoComplete { setup() { @@ -181,25 +198,9 @@ class GoogleFontAutoComplete extends AutoComplete { } } -const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ - events: Object.assign({}, SelectUserValueWidget.prototype.events || {}, { - 'click .o_we_add_font_btn': '_onAddFontClick', - 'click .o_we_delete_font_btn': '_onDeleteFontClick', - }), - fontVariables: [], // Filled by editor menu when all options are loaded - - /** - * @override - */ - init() { - this.dialog = this.bindService("dialog"); - this.orm = this.bindService("orm"); - return this._super(...arguments); - }, - /** - * @override - */ - start: async function () { +class FontFamilyUserValue extends SelectUserValue { + constructor() { + super(...arguments); const style = window.getComputedStyle(this.$target[0].ownerDocument.documentElement); const nbFonts = parseInt(weUtils.getCSSVariableValue('number-of-fonts', style)); // User fonts served by google server. @@ -222,8 +223,6 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ }); this.allFonts = []; - await this._super(...arguments); - const fontsToLoad = []; for (const font of this.googleFonts) { const fontURL = `https://fonts.googleapis.com/css?family=${encodeURIComponent(font).replace(/%20/g, '+')}`; @@ -234,16 +233,14 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ const fontURL = `/web/content/${encodeURIComponent(attachmentId)}`; fontsToLoad.push(fontURL); } - // TODO ideally, remove the <link> elements created once this widget - // instance is destroyed (although it should not hurt to keep them for - // the whole backend lifecycle). const proms = fontsToLoad.map(async fontURL => loadCSS(fontURL)); - const fontsLoadingProm = Promise.all(proms); + this.fontsLoadingProm = Promise.all(proms); - const fontEls = []; - const methodName = this.el.dataset.methodName || 'customizeWebsiteVariable'; - const variable = this.el.dataset.variable; + this._fonts = []; const themeFontsNb = nbFonts - (this.googleLocalFonts.length + this.googleFonts.length + this.uploadedLocalFonts.length); + const localFontsOffset = nbFonts - this.googleLocalFonts.length - this.uploadedLocalFonts.length; + const uploadedFontsOffset = nbFonts - this.uploadedLocalFonts.length; + for (let fontNb = 0; fontNb < nbFonts; fontNb++) { const realFontNb = fontNb + 1; const fontKey = weUtils.getCSSVariableValue(`font-number-${realFontNb}`, style); @@ -255,79 +252,60 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ fontName = _t("System Fonts"); fontFamily = 'var(--o-system-fonts)'; } - const fontEl = document.createElement('we-button'); - fontEl.setAttribute('string', fontName); - fontEl.dataset.variable = variable; - fontEl.dataset[methodName] = fontKey; - fontEl.dataset.fontFamily = fontFamily; - const iconWrapperEl = document.createElement("div"); - iconWrapperEl.classList.add("text-end"); - fontEl.appendChild(iconWrapperEl); - if ((realFontNb <= themeFontsNb) && !isSystemFonts) { - // Add the "cloud" icon next to the theme's default fonts - // because they are served by Google. - iconWrapperEl.appendChild(Object.assign(document.createElement('i'), { - role: 'button', - className: 'text-info me-2 fa fa-cloud', - title: _t("This font is hosted and served to your visitors by Google servers"), - })); - } - fontEls.push(fontEl); - this.menuEl.appendChild(fontEl); - } - - if (this.uploadedLocalFonts.length) { - const uploadedLocalFontsEls = fontEls.splice(-this.uploadedLocalFonts.length); - uploadedLocalFontsEls.forEach((el, index) => { - $(el).find(".text-end").append(renderToFragment('website.delete_font_btn', { - index: index, - local: "uploaded", - })); - }); - } - - if (this.googleLocalFonts.length) { - const googleLocalFontsEls = fontEls.splice(-this.googleLocalFonts.length); - googleLocalFontsEls.forEach((el, index) => { - $(el).find(".text-end").append(renderToFragment('website.delete_font_btn', { - index: index, - local: "google", - })); - }); - } - if (this.googleFonts.length) { - const googleFontsEls = fontEls.splice(-this.googleFonts.length); - googleFontsEls.forEach((el, index) => { - $(el).find(".text-end").append(renderToFragment('website.delete_font_btn', { - index: index, - })); + let type = "cloud"; + let indexForType = fontNb - themeFontsNb; + if (fontNb >= localFontsOffset) { + if (fontNb <= uploadedFontsOffset) { + type = "google"; + indexForType = fontNb - localFontsOffset; + } else { + type = "uploaded"; + indexForType = fontNb - uploadedFontsOffset; + } + } + this._fonts.push({ + type, + indexForType, + fontFamily, + string: fontName, }); } + } - $(this.menuEl).append($(renderToElement('website.add_font_btn', { - variable: variable, - }))); + async start() { + return this.fontsLoadingProm; + } - return fontsLoadingProm; - }, + get fonts() { + return this._fonts; + } +} - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- +class WeFontFamilyPicker extends WeSelect { + static isContainer = true; + static StateModel = FontFamilyUserValue; + static template = "website.WeFontFamilyPicker"; + static components = { WeSelect, WeButton }; + static defaultProps = { + ...WeSelect.defaultProps, + selectMethod: "customizeWebsiteVariable", + }; + fontVariables = []; // Filled by editor menu when all options are loaded - /** - * @override - */ - async setValue() { - await this._super(...arguments); + setup() { + super.setup(); + this.dialog = useService("dialog"); + this.orm = useService("orm"); + } - this.menuTogglerEl.style.fontFamily = ''; - const activeWidget = this._userValueWidgets.find(widget => !widget.isPreviewed() && widget.isActive()); - if (activeWidget) { - this.menuTogglerEl.style.fontFamily = activeWidget.el.dataset.fontFamily; - } - }, + forwardProps(fontValue) { + const result = Object.assign({}, this.props, { + [this.props.selectMethod]: fontValue.fontFamily, + }); + delete result.selectMethod; + return result; + } //-------------------------------------------------------------------------- // Handlers @@ -336,7 +314,7 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ /** * @private */ - async _onAddFontClick(ev) { + async _onAddFontClick() { const addFontDialog = class extends Component { static template = "website.dialog.addFont"; static components = { GoogleFontAutoComplete, Dialog }; @@ -488,7 +466,6 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ this.state.uploadedFontFaces = previewFontFaces; } }; - const variable = $(ev.currentTarget).data('variable'); this.dialog.add(addFontDialog, { title: _t("Add a Google font or upload a custom font"), onClickSave: async (state) => { @@ -496,7 +473,7 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ const uploadedFontFaces = state.uploadedFontFaces; let font = undefined; if (uploadedFontName && uploadedFontFaces) { - const fontExistsLocally = this.uploadedLocalFonts.some(localFont => localFont.split(':')[0] === `'${uploadedFontName}'`); + const fontExistsLocally = this.state.uploadedLocalFonts.some(localFont => localFont.split(':')[0] === `'${uploadedFontName}'`); if (fontExistsLocally) { this.dialog.add(ConfirmationDialog, { title: _t("Font exists"), @@ -505,8 +482,8 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ return; } const homonymGoogleFontExists = - this.googleFonts.some(font => font === uploadedFontName) || - this.googleLocalFonts.some(font => font.split(':')[0] === `'${uploadedFontName}'`); + this.state.googleFonts.some(font => font === uploadedFontName) || + this.state.googleLocalFonts.some(font => font.split(':')[0] === `'${uploadedFontName}'`); if (homonymGoogleFontExists) { this.dialog.add(ConfirmationDialog, { title: _t("Font name already used"), @@ -523,7 +500,7 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ mimetype: "text/css", "public": true, }]]); - this.uploadedLocalFonts.push(`'${uploadedFontName}': ${fontCssId}`); + this.state.uploadedLocalFonts.push(`'${uploadedFontName}': ${fontCssId}`); font = uploadedFontName; } else { let isValidFamily = false; @@ -552,8 +529,8 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ // If the font already exists, it will only be added if // the user chooses to add it locally when it is already // imported from the Google Fonts server. - const fontExistsLocally = this.googleLocalFonts.some(localFont => localFont.split(':')[0] === fontName); - const fontExistsOnServer = this.allFonts.includes(fontName); + const fontExistsLocally = this.state.googleLocalFonts.some(localFont => localFont.split(':')[0] === fontName); + const fontExistsOnServer = this.state.allFonts.includes(fontName); const preventFontAddition = fontExistsLocally || (fontExistsOnServer && googleFontServe); if (preventFontAddition) { this.dialog.add(ConfirmationDialog, { @@ -563,16 +540,16 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ return; } if (googleFontServe) { - this.googleFonts.push(font); + this.state.googleFonts.push(font); } else { - this.googleLocalFonts.push(`'${font}': ''`); + this.state.googleLocalFonts.push(`'${font}': ''`); } } - this.trigger_up('fonts_custo_request', { - values: {[variable]: `'${font}'`}, - googleFonts: this.googleFonts, - googleLocalFonts: this.googleLocalFonts, - uploadedLocalFonts: this.uploadedLocalFonts, + this.state.option._onFontsCustoRequest({ + values: {[this.props.variable]: `'${font}'`}, + googleFonts: this.state.googleFonts, + googleLocalFonts: this.state.googleLocalFonts, + uploadedLocalFonts: this.state.uploadedLocalFonts, }); let styleEl = document.head.querySelector(`[id='WebsiteThemeFontPreview-${font}']`); if (styleEl) { @@ -588,17 +565,16 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ } }, }); - }, + } /** * @private - * @param {Event} ev + * @param {Event} ev TODO update */ - _onDeleteFontClick: async function (ev) { - ev.preventDefault(); + async _onDeleteFontClick(font) { const values = {}; const save = await new Promise(resolve => { - this.dialog.add(ConfirmationDialog, { + this.env.services.dialog.add(ConfirmationDialog, { body: _t("Deleting a font requires a reload of the page. This will save all your changes and reload the page, are you sure you want to proceed?"), confirm: () => resolve(true), cancel: () => resolve(false), @@ -609,29 +585,29 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ } // Remove Google font - const fontIndex = parseInt(ev.target.dataset.fontIndex); - const localFont = ev.target.dataset.localFont; + const fontIndex = font.indexForType; + const localFont = font.type; let fontName; if (localFont === 'uploaded') { - const font = this.uploadedLocalFonts[fontIndex].split(':'); + const font = this.state.uploadedLocalFonts[fontIndex].split(':'); // Remove double quotes fontName = font[0].substring(1, font[0].length - 1); values['delete-font-attachment-id'] = font[1]; - this.uploadedLocalFonts.splice(fontIndex, 1); + this.state.uploadedLocalFonts.splice(fontIndex, 1); } else if (localFont === 'google') { - const googleFont = this.googleLocalFonts[fontIndex].split(':'); + const googleFont = this.state.googleLocalFonts[fontIndex].split(':'); // Remove double quotes fontName = googleFont[0].substring(1, googleFont[0].length - 1); values['delete-font-attachment-id'] = googleFont[1]; - this.googleLocalFonts.splice(fontIndex, 1); + this.state.googleLocalFonts.splice(fontIndex, 1); } else { - fontName = this.googleFonts[fontIndex]; - this.googleFonts.splice(fontIndex, 1); + fontName = this.state.googleFonts[fontIndex]; + this.state.googleFonts.splice(fontIndex, 1); } // Adapt font variable indexes to the removal - const style = window.getComputedStyle(this.$target[0].ownerDocument.documentElement); - FontFamilyPickerUserValueWidget.prototype.fontVariables.forEach((variable) => { + const style = window.getComputedStyle(this.state.$target[0].ownerDocument.documentElement); + this.fontVariables.forEach((variable) => { const value = weUtils.getCSSVariableValue(variable, style); if (value.substring(1, value.length - 1) === fontName) { // If an element is using the google font being removed, reset @@ -639,44 +615,29 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ values[variable] = 'null'; } }); - - this.trigger_up('fonts_custo_request', { + this.state.option._onFontsCustoRequest({ values: values, - googleFonts: this.googleFonts, - googleLocalFonts: this.googleLocalFonts, - uploadedLocalFonts: this.uploadedLocalFonts, + googleFonts: this.state.googleFonts, + googleLocalFonts: this.state.googleLocalFonts, + uploadedLocalFonts: this.state.uploadedLocalFonts, }); - }, -}); - -const GPSPicker = InputUserValueWidget.extend({ - // Explicitly not consider all InputUserValueWidget events. E.g. we actually - // don't want input focusout messing with the google map API. Because of - // this, clicking on google map autocomplete suggestion on Firefox was not - // working properly. - events: {}, + } +} +registry.category("snippet_widgets").add("WeFontFamilyPicker", WeFontFamilyPicker); - /** - * @constructor - */ - init() { - this._super(...arguments); - this._gmapCacheGPSToPlace = {}; +class GpsUserValue extends UserValue { + _gmapCacheGPSToPlace = {}; - // The google API will be loaded inside the website iframe. Let's try - // not having to load it in the backend too and just using the iframe - // google object instead. + constructor() { + super(...arguments); + this._state._gmapLoaded = false; + this._state.gmapPlace = {}; this.contentWindow = this.$target[0].ownerDocument.defaultView; - - this.notification = this.bindService("notification"); - }, - /** - * @override - */ - async willStart() { - await this._super(...arguments); - this._gmapLoaded = await new Promise(resolve => { - this.trigger_up('gmap_api_request', { + } + async start() { + super.start(); + this._state._gmapLoaded = await new Promise(resolve => { + this.env.gmapApiRequest({data: { editableMode: true, configureIfNecessary: true, onSuccess: key => { @@ -687,44 +648,16 @@ const GPSPicker = InputUserValueWidget.extend({ // TODO see _notifyGMapError, this tries to trigger an error // early but this is not consistent with new gmap keys. - this._nearbySearch('(50.854975,4.3753899)', !!key) - .then(place => resolve(!!place)); + const startLocation = this.$target[0].dataset.mapGps || "(50.854975,4.3753899)"; + this._nearbySearch(startLocation, !!key) + .then(place => { + this._state.gmapPlace = place; + resolve(!!place); + }); }, - }); + }, stopPropagation: () => {}}); }); - if (!this._gmapLoaded && !this._gmapErrorNotified) { - this.trigger_up('user_value_widget_critical'); - return; - } - }, - /** - * @override - */ - async start() { - await this._super(...arguments); - this.el.classList.add('o_we_large'); - if (!this._gmapLoaded) { - return; - } - - this._gmapAutocomplete = new this.contentWindow.google.maps.places.Autocomplete(this.inputEl, {types: ['geocode']}); - this.contentWindow.google.maps.event.addListener(this._gmapAutocomplete, 'place_changed', this._onPlaceChanged.bind(this)); - }, - /** - * @override - */ - destroy() { - this._super(...arguments); - - // Without this, the google library injects elements inside the backend - // DOM but do not remove them once the editor is left. Notice that - // this is also done when the widget is destroyed for another reason - // than leaving the editor, but if the google API needs that container - // again afterwards, it will simply recreate it. - for (const el of document.body.querySelectorAll('.pac-container')) { - el.remove(); - } - }, + } //-------------------------------------------------------------------------- // Public @@ -733,24 +666,26 @@ const GPSPicker = InputUserValueWidget.extend({ /** * @override */ - getMethodsParams: function (methodName) { - return Object.assign({gmapPlace: this._gmapPlace || {}}, this._super(...arguments)); - }, + getMethodsParams(methodName) { + return Object.assign({gmapPlace: this._state.gmapPlace || {}}, super.getMethodsParams(...arguments)); + } /** * @override */ async setValue() { - await this._super(...arguments); - if (!this._gmapLoaded) { + await super.setValue(...arguments); + if (!this._state._gmapLoaded) { return; } - this._gmapPlace = await this._nearbySearch(this._value); - - if (this._gmapPlace) { - this.inputEl.value = this._gmapPlace.formatted_address; - } - }, + this._state.gmapPlace = await this._nearbySearch(this.value); + } + get formattedAddress() { + return this._state.gmapPlace?.formatted_address; + } + get isGmapLoaded() { + return this._state._gmapLoaded; + } //-------------------------------------------------------------------------- // Private @@ -808,7 +743,7 @@ const GPSPicker = InputUserValueWidget.extend({ } }); }); - }, + } /** * Indicates to the user there is an error with the google map API and * re-opens the configuration dialog. For good measures, this also notifies @@ -827,11 +762,11 @@ const GPSPicker = InputUserValueWidget.extend({ } this._gmapErrorNotified = true; - this.notification.add( + this.env.services.notification.add( _t("A Google Map error occurred. Make sure to read the key configuration popup carefully."), { type: 'danger', sticky: true } ); - this.trigger_up('gmap_api_request', { + this.env.services.website.websiteRootInstance.trigger_up('gmap_api_request', { editableMode: true, reconfigure: true, onSuccess: () => { @@ -839,8 +774,39 @@ const GPSPicker = InputUserValueWidget.extend({ }, }); - setTimeout(() => this.trigger_up('user_value_widget_critical')); - }, + // TODO user_value_widget_critical + setTimeout(() => this.env.services.website.websiteRootInstance.trigger_up('user_value_widget_critical')); + } +} + +class WeGpsPicker extends UserValueComponent { + static template = "website.WeGpsPicker"; + static StateModel = GpsUserValue; + setup() { + super.setup(); + this.inputRef = useRef("input"); + + // The google API will be loaded inside the website iframe. Let's try + // not having to load it in the backend too and just using the iframe + // google object instead. + useEffect((gmapLoaded, inputEl) => { + if (gmapLoaded && inputEl) { + const contentWindow = this.state.$target[0].ownerDocument.defaultView; + this._gmapAutocomplete = new contentWindow.google.maps.places.Autocomplete(this.inputRef.el, {types: ['geocode']}); + contentWindow.google.maps.event.addListener(this._gmapAutocomplete, 'place_changed', this._onPlaceChanged.bind(this)); + } + }, () => [this.state.isGmapLoaded, this.inputRef.el]); + onWillUnmount(() => { + // Without this, the google library injects elements inside the backend + // DOM but do not remove them once the editor is left. Notice that + // this is also done when the widget is destroyed for another reason + // than leaving the editor, but if the google API needs that container + // again afterwards, it will simply recreate it. + for (const el of document.body.querySelectorAll('.pac-container')) { + el.remove(); + } + }); + } //-------------------------------------------------------------------------- // Handlers @@ -853,34 +819,34 @@ const GPSPicker = InputUserValueWidget.extend({ _onPlaceChanged(ev) { const gmapPlace = this._gmapAutocomplete.getPlace(); if (gmapPlace && gmapPlace.geometry) { - this._gmapPlace = gmapPlace; - const location = this._gmapPlace.geometry.location; - const oldValue = this._value; - this._value = `(${location.lat()},${location.lng()})`; - this._gmapCacheGPSToPlace[this._value] = gmapPlace; - if (oldValue !== this._value) { + this.state.gmapPlace = gmapPlace; + const location = this.state.gmapPlace.geometry.location; + const oldValue = this.state.value; + this.state.value = `(${location.lat()},${location.lng()})`; + this.state._gmapCacheGPSToPlace[this.state.value] = gmapPlace; + if (oldValue !== this.state.value) { this._onUserValueChange(ev); } } - }, -}); + } +} +registry.category("snippet_widgets").add("WeGpsPicker", WeGpsPicker); +/* options.userValueWidgetsRegistry['we-urlpicker'] = UrlPickerUserValueWidget; options.userValueWidgetsRegistry['we-fontfamilypicker'] = FontFamilyPickerUserValueWidget; options.userValueWidgetsRegistry['we-gpspicker'] = GPSPicker; +*/ //:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -options.Class.include({ - custom_events: Object.assign({}, options.Class.prototype.custom_events || {}, { - 'fonts_custo_request': '_onFontsCustoRequest', - }), +patch(SnippetOption.prototype, { specialCheckAndReloadMethodsNames: ['customizeWebsiteViews', 'customizeWebsiteVariable', 'customizeWebsiteColor'], /** * @override */ - init() { - this._super(...arguments); + constructorPatch() { + super.constructorPatch(...arguments); // Since the website is displayed in an iframe, its jQuery // instance is not the same as the editor. This property allows // for easy access to bootstrap plugins (Carousel, Modal, ...). @@ -889,8 +855,6 @@ options.Class.include({ // triggers a custom event, only that same jQuery instance will // trigger handlers set with `.on`. this.$bsTarget = this.ownerDocument.defaultView.$(this.$target[0]); - - this.orm = this.bindService("orm"); }, //-------------------------------------------------------------------------- @@ -936,7 +900,7 @@ options.Class.include({ * @override */ async _checkIfWidgetsUpdateNeedReload(widgets) { - const needReload = await this._super(...arguments); + const needReload = await super._checkIfWidgetsUpdateNeedReload(...arguments); if (needReload) { return needReload; } @@ -954,7 +918,7 @@ options.Class.include({ /** * @override */ - _computeWidgetState: async function (methodName, params) { + async _computeWidgetState(methodName, params) { switch (methodName) { case 'customizeWebsiteViews': { return this._getEnabledCustomizeValues(params.possibleValues, true); @@ -982,7 +946,7 @@ options.Class.include({ return this._getEnabledCustomizeValues(params.possibleValues, false); } } - return this._super(...arguments); + return super._computeWidgetState(...arguments); }, /** * @private @@ -1038,7 +1002,7 @@ options.Class.include({ // Some public widgets may depend on the variables that were // customized, so we have to restart them *all*. await new Promise((resolve, reject) => { - this.trigger_up('widgets_start_request', { + this.env.services.website.websiteRootInstance.trigger_up("widgets_start_request", { editableMode: true, onSuccess: () => resolve(), onFailure: () => reject(), @@ -1152,7 +1116,7 @@ options.Class.include({ Object.keys(values).forEach((key) => { values[key] = values[key] || defaultValue; }); - return this.orm.call("web_editor.assets", "make_scss_customization", [url, values]); + return this.env.services.orm.call("web_editor.assets", "make_scss_customization", [url, values]); }, /** * Refreshes all public widgets related to the given element. @@ -1163,7 +1127,7 @@ options.Class.include({ */ _refreshPublicWidgets: async function ($el) { return new Promise((resolve, reject) => { - this.trigger_up('widgets_start_request', { + this.env.services.website.websiteRootInstance?.trigger_up('widgets_start_request', { editableMode: true, $target: $el || this.$target, onSuccess: resolve, @@ -1176,24 +1140,30 @@ options.Class.include({ */ _reloadBundles: async function() { return new Promise((resolve, reject) => { - this.trigger_up('reload_bundles', { - onSuccess: () => resolve(), - onFailure: () => reject(), - }); + const event = { + target: this, + name: "reload_bundles", + data: { + onSuccess: () => resolve(), + onFailure: () => reject(), + }, + stopped: false, + }; + this.options.wysiwyg._trigger_up(event); }); }, /** * @override */ - _select: async function (previewMode, widget) { - await this._super(...arguments); + async _select(previewMode, widget) { + await super._select(...arguments); // Some blocks flicker when we start their public widgets, so we skip // the refresh for them to avoid the flickering. const targetNoRefreshSelector = ".s_instagram_page"; // TODO: we should review the way public widgets are restarted when // converting to OWL and a new API. - if (this.options.isWebsite && !widget.$el.closest('[data-no-widget-refresh="true"]').length + if (this.options.isWebsite && widget._methodsParams.noWidgetRefresh !== "true" && !this.$target[0].matches(targetNoRefreshSelector)) { // TODO the flag should be retrieved through widget params somehow await this._refreshPublicWidgets(); @@ -1206,13 +1176,11 @@ options.Class.include({ /** * @private - * @param {OdooEvent} ev + * TODO: @owl-options update doc + * @param {Object} */ - _onFontsCustoRequest(ev) { - const values = ev.data.values ? Object.assign({}, ev.data.values) : {}; - const googleFonts = ev.data.googleFonts; - const googleLocalFonts = ev.data.googleLocalFonts; - const uploadedLocalFonts = ev.data.uploadedLocalFonts; + _onFontsCustoRequest({values, googleFonts, googleLocalFonts, uploadedLocalFonts}) { + values = values ? Object.assign({}, values) : {}; if (googleFonts.length) { values['google-fonts'] = "('" + googleFonts.join("', '") + "')"; } else { @@ -1228,15 +1196,20 @@ options.Class.include({ } else { values['uploaded-local-fonts'] = 'null'; } - this.trigger_up('snippet_edition_request', {exec: async () => { + this.options.snippetEditionRequest({exec: async () => { return this._makeSCSSCusto('/website/static/src/scss/options/user_values.scss', values); }}); - this.trigger_up('request_save', { + this.env.requestSave({ reloadEditor: true, }); }, }); +options.Class.include({ + // TODO Keep until WebsiteLevelColor is converted + specialCheckAndReloadMethodsNames: ['customizeWebsiteViews', 'customizeWebsiteVariable', 'customizeWebsiteColor'], +}); + function _getLastPreFilterLayerElement($el) { // Make sure parallax and video element are considered to be below the // color filters / shape @@ -1251,7 +1224,7 @@ function _getLastPreFilterLayerElement($el) { return null; } -options.registry.BackgroundToggler.include({ +patch(BackgroundToggler.prototype, { /** * Toggles background video on or off. * @@ -1262,11 +1235,11 @@ options.registry.BackgroundToggler.include({ this.$target.find('> .o_we_bg_filter').remove(); // TODO: use setWidgetValue instead of calling background directly when possible const [bgVideoWidget] = this._requestUserValueWidgets('bg_video_opt'); - const bgVideoOpt = bgVideoWidget.getParent(); + const bgVideoOpt = bgVideoWidget.option; return bgVideoOpt._setBgVideo(false, ''); } else { // TODO: use trigger instead of el.click when possible - this._requestUserValueWidgets('bg_video_opt')[0].el.click(); + this._requestUserValueWidgets('bg_video_opt')[0].enable(); } }, @@ -1281,7 +1254,7 @@ options.registry.BackgroundToggler.include({ if (methodName === 'toggleBgVideo') { return this.$target[0].classList.contains('o_background_video'); } - return this._super(...arguments); + return super._computeWidgetState(...arguments); }, /** * TODO an overall better management of background layers is needed @@ -1293,7 +1266,7 @@ options.registry.BackgroundToggler.include({ if (el) { return el; } - return this._super(...arguments); + return super._getLastPreFilterLayerElement(...arguments); }, }); @@ -1411,7 +1384,7 @@ options.registry.ReplaceMedia.include({ }, }); -options.registry.BackgroundVideo = options.Class.extend({ +class BackgroundVideo extends SnippetOption { //-------------------------------------------------------------------------- // Options @@ -1422,12 +1395,12 @@ options.registry.BackgroundVideo = options.Class.extend({ * * @see this.selectClass for parameters */ - background: function (previewMode, widgetValue, params) { + background(previewMode, widgetValue, params) { if (previewMode === 'reset' && this.videoSrc) { return this._setBgVideo(false, this.videoSrc); } return this._setBgVideo(previewMode, widgetValue); - }, + } //-------------------------------------------------------------------------- // Private @@ -1436,7 +1409,7 @@ options.registry.BackgroundVideo = options.Class.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { if (methodName === 'background') { if (this.$target[0].classList.contains('o_background_video')) { return this.$('> .o_bg_video_container iframe').attr('src'); @@ -1444,7 +1417,7 @@ options.registry.BackgroundVideo = options.Class.extend({ return ''; } return this._super(...arguments); - }, + } /** * Updates the background video used by the snippet. * @@ -1452,7 +1425,7 @@ options.registry.BackgroundVideo = options.Class.extend({ * @see this.selectClass for parameters * @returns {Promise} */ - _setBgVideo: async function (previewMode, value) { + async _setBgVideo(previewMode, value) { this.$('> .o_bg_video_container').toggleClass('d-none', previewMode === true); if (previewMode !== false) { @@ -1468,8 +1441,8 @@ options.registry.BackgroundVideo = options.Class.extend({ delete target.dataset.bgVideoSrc; } await this._refreshPublicWidgets(); - }, -}); + } +} options.registry.WebsiteLevelColor = options.Class.extend({ specialCheckAndReloadMethodsNames: options.Class.prototype.specialCheckAndReloadMethodsNames @@ -1936,12 +1909,13 @@ options.registry.ThemeColors = options.registry.OptionsTab.extend({ }, }); -options.registry.menu_data = options.Class.extend({ - init() { - this._super(...arguments); - this.orm = this.bindService("orm"); - this.notification = this.bindService("notification"); - }, +export class MenuElementOverlay extends SnippetOption { + constructor() { + super(...arguments); + this.notification = this.env.services.notification; + this.orm = this.env.services.orm; + this.website = this.env.services.website; + } /** * When the users selects a menu, a popover is shown with 4 possible @@ -1952,25 +1926,21 @@ options.registry.menu_data = options.Class.extend({ * * @override */ - start: function () { - const wysiwyg = $(this.ownerDocument.getElementById('wrapwrap')).data('wysiwyg'); + async willStart() { const popoverContainer = this.ownerDocument.getElementById('oe_manipulators'); NavbarLinkPopoverWidget.createFor({ target: this.$target[0], - wysiwyg, + wysiwyg: this.options.wysiwyg, container: popoverContainer, notify: this.notification.add, checkIsWebsiteDesigner: () => user.hasGroup("website.group_website_designer"), onEditLinkClick: (widget) => { var $menu = widget.$target.find('[data-oe-id]'); - this.trigger_up('menu_dialog', { - name: $menu.text(), - url: $menu.parent().attr('href'), - save: (name, url) => { - let websiteId; - this.trigger_up('context_get', { - callback: ctx => websiteId = ctx['website_id'], - }); + this.options.wysiwyg.openMenuDialog( + $menu.text(), + $menu.parent().attr('href'), + (name, url) => { + const websiteId = this.website.currentWebsite.id; const data = { id: $menu.data('oe-id'), name, @@ -1980,43 +1950,49 @@ options.registry.menu_data = options.Class.extend({ "website.menu", "save", [websiteId, {'data': [data]}] - ).then(function () { - widget.wysiwyg.odooEditor.observerUnactive(); + ).then(() => { + this.options.wysiwyg.odooEditor.observerUnactive(); widget.$target.attr('href', url); $menu.text(name); - widget.wysiwyg.odooEditor.observerActive(); + this.options.wysiwyg.odooEditor.observerActive(); }); }, - }); + ); widget.popover.hide(); }, onEditMenuClick: (widget) => { const contentMenu = widget.target.closest('[data-content_menu_id]'); const rootID = contentMenu ? parseInt(contentMenu.dataset.content_menu_id, 10) : undefined; - this.trigger_up('action_demand', { - actionName: 'edit_menu', - params: [rootID], - }); + this.options.wysiwyg.openEditMenuDialog(rootID); }, }); - return this._super(...arguments); - }, + return super.willStart(...arguments); + } /** * When the users selects another element on the page, makes sure the * popover is closed. * * @override */ - onBlur: function () { + async onBlur() { this.$target.popover('hide'); - }, + } +} + +registerWebsiteOption("MenuElementOverlay", { + Class: MenuElementOverlay, + selector: ".top_menu li > a, [data-content_menu_id] li > a", + exclude: ".dropdown-toggle, li.o_header_menu_button a, [data-toggle], .o_offcanvas_logo", + noCheck: true, }); -options.registry.Carousel = options.registry.CarouselHandler.extend({ +class Carousel extends CarouselHandler { /** * @override */ - start: function () { + constructor() { + super(...arguments); + this.$bsTarget.carousel('pause'); this.$indicators = this.$target.find('.carousel-indicators'); this.$controls = this.$target.find('.carousel-control-prev, .carousel-control-next, .carousel-indicators'); @@ -2029,7 +2005,7 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ let _slideTimestamp; this.$bsTarget.on('slide.bs.carousel.carousel_option', () => { _slideTimestamp = window.performance.now(); - setTimeout(() => this.trigger_up('hide_overlay')); + setTimeout(() => this.env.hideOverlay()); }); this.$bsTarget.on('slid.bs.carousel.carousel_option', () => { // slid.bs.carousel is most of the time fired too soon by bootstrap @@ -2038,52 +2014,48 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ // should be enough... const _slideDuration = (window.performance.now() - _slideTimestamp); setTimeout(() => { - this.trigger_up('activate_snippet', { - $snippet: this.$target.find('.carousel-item.active'), - ifInactiveOptions: true, - }); + this.env.activateSnippet(this.$target.find('.carousel-item.active'), false, true); this.$bsTarget.trigger('active_slide_targeted'); }, 0.2 * _slideDuration); }); - - return this._super.apply(this, arguments); - }, + } /** * @override */ - destroy: function () { - this._super.apply(this, arguments); + destroy() { + super.destroy(...arguments); this.$bsTarget.off('.carousel_option'); - }, + } /** * @override */ - onBuilt: function () { + onBuilt() { this._assignUniqueID(); - }, + } /** * @override */ - onClone: function () { + onClone() { this._assignUniqueID(); - }, + } /** * @override */ - cleanForSave: function () { + // TODO: @owl-options check if this should be cleanUI() rather than cleanForSave() + cleanUI() { const $items = this.$target.find('.carousel-item'); $items.removeClass('next prev left right active').first().addClass('active'); this.$indicators.find('li').removeClass('active').empty().first().addClass('active'); - }, + } /** * @override */ - notify: function (name, data) { - this._super(...arguments); + notify(name, data) { + super.notify(...arguments); if (name === 'add_slide') { this._addSlide(); } - }, + } //-------------------------------------------------------------------------- // Options @@ -2094,7 +2066,7 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ */ addSlide(previewMode, widgetValue, params) { this._addSlide(); - }, + } //-------------------------------------------------------------------------- // Private @@ -2106,7 +2078,7 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ * * @private */ - _assignUniqueID: function () { + _assignUniqueID() { const id = 'myCarousel' + Date.now(); this.$target.attr('id', id); this.$target.find('[data-bs-target]').attr('data-bs-target', '#' + id); @@ -2118,7 +2090,7 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ $el.attr('href', '#' + id); } }); - }, + } /** * Adds a slide. * @@ -2138,13 +2110,13 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ .removeClass('active') .insertAfter($active); this.$bsTarget.carousel('next'); - }, + } /** * @override */ _getItemsGallery() { return Array.from(this.$target[0].querySelectorAll(".carousel-item")); - }, + } /** * @override */ @@ -2157,36 +2129,36 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ carouselInnerEl.append(itemsEl); } this._updateIndicatorAndActivateSnippet(newItemPosition); - }, - + } +} +registerWebsiteOption("Carousel", { + Class: Carousel, + template: "website.Carousel", + selector: "section", + target: "> .carousel", }); -options.registry.CarouselItem = options.Class.extend({ - isTopOption: true, - forceNoDeleteButton: true, +class CarouselItem extends SnippetOption { + static isTopOption = true; + static forceNoDeleteButton = true; /** * @override */ - start: function () { + constructor() { + super(...arguments); + this.$carousel = this.$bsTarget.closest('.carousel'); this.$indicators = this.$carousel.find('.carousel-indicators'); this.$controls = this.$carousel.find('.carousel-control-prev, .carousel-control-next, .carousel-indicators'); - - var leftPanelEl = this.$overlay.data('$optionsSection')[0]; - var titleTextEl = leftPanelEl.querySelector('we-title > span'); - this.counterEl = document.createElement('span'); - titleTextEl.appendChild(this.counterEl); - - return this._super(...arguments); - }, + } /** * @override */ - destroy: function () { - this._super(...arguments); + destroy() { + super.destroy(...arguments); this.$carousel.off('.carousel_item_option'); - }, + } //-------------------------------------------------------------------------- // Public @@ -2197,13 +2169,14 @@ options.registry.CarouselItem = options.Class.extend({ * * @override */ - updateUI: async function () { - await this._super(...arguments); + async updateUI() { + await super.updateUI(...arguments); const $items = this.$carousel.find('.carousel-item'); const $activeSlide = $items.filter('.active'); - const updatedText = ` (${$activeSlide.index() + 1}/${$items.length})`; - this.counterEl.textContent = updatedText; - }, + // TODO: @owl-options: block the editor UI until the new options are + // created. + this.callbacks.updateExtraTitle(` (${$activeSlide.index() + 1}/${$items.length})`); + } //-------------------------------------------------------------------------- // Options @@ -2213,17 +2186,17 @@ options.registry.CarouselItem = options.Class.extend({ * @see this.selectClass for parameters */ addSlideItem(previewMode, widgetValue, params) { - this.trigger_up('option_update', { + this.callbacks.notifyOptions({ optionName: 'Carousel', name: 'add_slide', }); - }, + } /** * Removes the current slide. * * @see this.selectClass for parameters. */ - removeSlide: function (previewMode) { + removeSlide(previewMode) { const $items = this.$carousel.find('.carousel-item'); const newLength = $items.length - 1; if (!this.removing && newLength > 0) { @@ -2245,13 +2218,13 @@ options.registry.CarouselItem = options.Class.extend({ this.removing = true; this.$carousel.carousel('prev'); } - }, + } /** * Goes to next slide or previous slide. * * @see this.selectClass for parameters */ - switchToSlide: function (previewMode, widgetValue, params) { + switchToSlide(previewMode, widgetValue, params) { switch (widgetValue) { case 'left': this.$controls.filter('.carousel-control-prev')[0].click(); @@ -2260,21 +2233,30 @@ options.registry.CarouselItem = options.Class.extend({ this.$controls.filter('.carousel-control-next')[0].click(); break; } - }, + } +} +registerWebsiteOption("CarouselItem", { + Class: CarouselItem, + template: "website.CarouselItem", + selector: ".s_carousel .carousel-item, .s_quotes_carousel .carousel-item", }); -options.registry.Parallax = options.Class.extend({ +class Parallax extends SnippetOption { /** * @override */ - async start() { + async willStart() { this.parallaxEl = this.$target.find('> .s_parallax_bg')[0] || null; - this._updateBackgroundOptions(); + // Delay the notify that changes the target because options that handle + // the target might not be initialized yet. + this.env.snippetEditionRequest(() => { + this._updateBackgroundOptions(); + }); this.$target.on('content_changed.ParallaxOption', this._onExternalUpdate.bind(this)); - return this._super(...arguments); - }, + return super.willStart(...arguments); + } /** * @override */ @@ -2286,20 +2268,20 @@ options.registry.Parallax = options.Class.extend({ if (this.parallaxEl) { this._refreshPublicWidgets(); } - }, + } /** * @override */ onMove() { this._refreshPublicWidgets(); - }, + } /** * @override */ destroy() { - this._super(...arguments); + super.destroy(); this.$target.off('.ParallaxOption'); - }, + } //-------------------------------------------------------------------------- // Options @@ -2311,7 +2293,7 @@ options.registry.Parallax = options.Class.extend({ * @see this.selectClass for parameters */ async selectDataAttribute(previewMode, widgetValue, params) { - await this._super(...arguments); + await super.selectDataAttribute(...arguments); if (params.attributeName !== 'scrollBackgroundRatio') { return; } @@ -2334,7 +2316,7 @@ options.registry.Parallax = options.Class.extend({ } this._updateBackgroundOptions(); - }, + } //-------------------------------------------------------------------------- // Private @@ -2345,7 +2327,7 @@ options.registry.Parallax = options.Class.extend({ */ async _computeVisibility(widgetName) { return !this.$target.hasClass('o_background_video'); - }, + } /** * @override */ @@ -2363,8 +2345,8 @@ options.registry.Parallax = options.Class.extend({ } } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * Updates external background-related option to work with the parallax * element instead of the original target when necessary. @@ -2372,12 +2354,12 @@ options.registry.Parallax = options.Class.extend({ * @private */ _updateBackgroundOptions() { - this.trigger_up('option_update', { + this.callbacks.notifyOptions({ optionNames: ['BackgroundImage', 'BackgroundPosition', 'BackgroundOptimize'], name: 'target', data: this.parallaxEl ? $(this.parallaxEl) : this.$target, }); - }, + } //-------------------------------------------------------------------------- // Handlers @@ -2404,8 +2386,8 @@ options.registry.Parallax = options.Class.extend({ widget.enable(); widget.getParent().close(); // FIXME remove this ugly hack asap } - }, -}); + } +} options.registry.collapse = options.Class.extend({ /** @@ -2487,14 +2469,11 @@ options.registry.collapse = options.Class.extend({ }, }); -options.registry.HeaderElements = options.Class.extend({ - /** - * @constructor - */ - init() { - this._super(...arguments); +export class HeaderElements extends SnippetOption { + constructor() { + super(...arguments); this._rpc = options.serviceCached(rpc); - }, + } //-------------------------------------------------------------------------- // Private @@ -2504,7 +2483,6 @@ options.registry.HeaderElements = options.Class.extend({ * @override */ async _computeWidgetVisibility(widgetName, params) { - const _super = this._super.bind(this); switch (widgetName) { case "header_language_selector_opt": this._languages = await this._rpc.call("/website/get_languages"); @@ -2513,23 +2491,62 @@ options.registry.HeaderElements = options.Class.extend({ } break; } - return _super(...arguments); + return super._computeWidgetVisibility(...arguments); + } +} + +registerWebsiteOption("HeaderElements", { + Class: HeaderElements, + template: "website.HeaderElements", + selector: "#wrapwrap > header", + noCheck: true, + data: { + groups: ["website.group_website_designer"], + }, +}, { + sequence: 80, +}); + +registerWebsiteOption("HeaderScrollEffect", { + template: "website.HeaderScrollEffect", + selector: "#wrapwrap > header", + noCheck: true, + data: { + groups: ["website.group_website_designer"], }, +}, { + sequence: 50, }); -options.registry.HeaderNavbar = options.Class.extend({ +registerWebsiteOption("HeaderLanguageSelector", { + template: "website.HeaderLanguageSelector", + selector: "#wrapwrap > header nav.navbar .o_header_language_selector", + noCheck: true, + data: { + groups: ["website.group_website_designer"], + }, +}); + +registerWebsiteOption("HeaderBrand", { + template: "website.HeaderBrand", + selector: "#wrapwrap > header nav.navbar .navbar-brand", + noCheck: true, + data: { + groups: ["website.group_website_designer"], + }, +}); + +export class HeaderNavbar extends SnippetOption { /** * Particular case: we want the option to be associated on the header navbar * in XML so that the related options only appear on navbar click (not * header), in a different section, etc... but we still want the target to * be the header itself. - * - * @constructor */ - init() { - this._super(...arguments); + constructor() { + super(...arguments); this.setTarget(this.$target.closest('#wrapwrap > header')); - }, + } //-------------------------------------------------------------------------- // Private @@ -2548,14 +2565,37 @@ options.registry.HeaderNavbar = options.Class.extend({ return !!this.$('.navbar-brand').length; } } - return this._super(...arguments); + return super._computeWidgetVisibility(...arguments); + } +} + +registerWebsiteOption("HeaderNavbar", { + Class: HeaderNavbar, + template: "website.HeaderNavbar", + selector: "#wrapwrap > header nav.navbar", + noCheck: true, + data: { + groups: ["website.group_website_designer"], }, }); -const VisibilityPageOptionUpdate = options.Class.extend({ - pageOptionName: undefined, - showOptionWidgetName: undefined, - shownValue: '', +/** + * @abstract + */ +export class VisibilityPageOptionUpdate extends SnippetOption { + /** + * @abstract + * @type {string} + */ + static pageOptionName = undefined; + + constructor({ callbacks, options }) { + super(...arguments); + this.requestUserValue = callbacks.requestUserValue; + this.updateSnippetOptionVisibility = callbacks.updateSnippetOptionVisibility; + this.wysiwyg = options.wysiwyg; + this.shownValue = ""; + } /** * @override @@ -2572,9 +2612,8 @@ const VisibilityPageOptionUpdate = options.Class.extend({ // header appear for edition, its actual visibility for the page is // toggled (otherwise it would be about editing an element which // is actually never displayed on the page). - const widget = this._requestUserValueWidgets(this.showOptionWidgetName)[0]; - widget.enable(); - }, + await this.visibility(this.shownValue); + } //-------------------------------------------------------------------------- // Options @@ -2585,16 +2624,9 @@ const VisibilityPageOptionUpdate = options.Class.extend({ */ async visibility(previewMode, widgetValue, params) { const show = (widgetValue !== 'hidden'); - await new Promise((resolve, reject) => { - this.trigger_up('action_demand', { - actionName: 'toggle_page_option', - params: [{name: this.pageOptionName, value: show}], - onSuccess: () => resolve(), - onFailure: reject, - }); - }); - this.trigger_up('snippet_option_visibility_update', {show: show}); - }, + await this.wysiwyg.togglePageOption(this.constructor.pageOptionName, show); + this.updateSnippetOptionVisibility(show); + } //-------------------------------------------------------------------------- // Private @@ -2609,26 +2641,21 @@ const VisibilityPageOptionUpdate = options.Class.extend({ return shown ? this.shownValue : 'hidden'; } return this._super(...arguments); - }, + } /** * @private - * @returns {boolean} + * @returns {Promise<boolean>} */ async _isShown() { - return new Promise((resolve, reject) => { - this.trigger_up('action_demand', { - actionName: 'get_page_option', - params: [this.pageOptionName], - onSuccess: v => resolve(!!v), - onFailure: reject, - }); - }); - }, -}); + return await this.wysiwyg.getPageOption(this.constructor.pageOptionName); + } +} -options.registry.TopMenuVisibility = VisibilityPageOptionUpdate.extend({ - pageOptionName: 'header_visible', - showOptionWidgetName: 'regular_header_visibility_opt', +export class TopMenuVisibility extends VisibilityPageOptionUpdate { + /** + * @override + */ + static pageOptionName = "header_visible"; //-------------------------------------------------------------------------- // Options @@ -2640,14 +2667,14 @@ options.registry.TopMenuVisibility = VisibilityPageOptionUpdate.extend({ * @see this.selectClass for params */ async visibility(previewMode, widgetValue, params) { - await this._super(...arguments); + await super.visibility(...arguments); await this._changeVisibility(widgetValue); // TODO this is hacky but changing the header visibility may have an // effect on features like FullScreenHeight which depend on viewport // size so we simulate a resize. const targetWindow = this.$target[0].ownerDocument.defaultView; targetWindow.dispatchEvent(new targetWindow.Event('resize')); - }, + } //-------------------------------------------------------------------------- // Private @@ -2662,55 +2689,39 @@ options.registry.TopMenuVisibility = VisibilityPageOptionUpdate.extend({ return; } const transparent = (widgetValue === 'transparent'); - await new Promise((resolve, reject) => { - this.trigger_up('action_demand', { - actionName: 'toggle_page_option', - params: [{name: 'header_overlay', value: transparent}], - onSuccess: () => resolve(), - onFailure: reject, - }); - }); + await this.wysiwyg.togglePageOption("header_overlay", transparent); if (!transparent) { return; } - // TODO should be able to change both options at the same time, as the - // `params` list suggests. - await new Promise((resolve, reject) => { - this.trigger_up('action_demand', { - actionName: 'toggle_page_option', - params: [{name: 'header_color', value: ''}], - onSuccess: () => resolve(), - onFailure: reject, - }); - }); - await new Promise(resolve => { - this.trigger_up('action_demand', { - actionName: 'toggle_page_option', - params: [{name: 'header_text_color', value: ''}], - onSuccess: () => resolve(), - }); - }); - }, + await this.wysiwyg.togglePageOption("header_color", ""); + await this.wysiwyg.togglePageOption("header_text_color", ""); + } /** * @override */ async _computeWidgetState(methodName, params) { - const _super = this._super.bind(this); if (methodName === 'visibility') { - this.shownValue = await new Promise((resolve, reject) => { - this.trigger_up('action_demand', { - actionName: 'get_page_option', - params: ['header_overlay'], - onSuccess: v => resolve(v ? 'transparent' : 'regular'), - onFailure: reject, - }); - }); + const pageHeaderOverlay = await this.wysiwyg.getPageOption("header_overlay"); + this.shownValue = pageHeaderOverlay ? "transparent" : "regular"; } - return _super(...arguments); - }, + return super._computeWidgetState(...arguments); + } +} +registerWebsiteOption("TopMenuVisibility", { + Class: TopMenuVisibility, + template: "website.TopMenuVisibility", + selector: "[data-main-object^='website.page('] #wrapwrap > header", + noCheck: true, +}, { + sequence: 60, }); -options.registry.topMenuColor = options.Class.extend({ +export class TopMenuColor extends SnippetOption { + + constructor({ options }) { + super(...arguments); + this.wysiwyg = options.wysiwyg; + } //-------------------------------------------------------------------------- // Options @@ -2720,19 +2731,12 @@ options.registry.topMenuColor = options.Class.extend({ * @override */ async selectStyle(previewMode, widgetValue, params) { - await this._super(...arguments); + await super.selectStyle(...arguments); if (widgetValue && !isCSSColor(widgetValue)) { widgetValue = params.colorPrefix + widgetValue; } - await new Promise((resolve, reject) => { - this.trigger_up('action_demand', { - actionName: 'toggle_page_option', - params: [{name: params.pageOptionName, value: widgetValue}], - onSuccess: resolve, - onFailure: reject, - }); - }); - }, + this.wysiwyg.togglePageOption(params.pageOptionName, widgetValue); + } //-------------------------------------------------------------------------- // Private @@ -2741,27 +2745,29 @@ options.registry.topMenuColor = options.Class.extend({ /** * @override */ - _computeVisibility: async function () { - const show = await this._super(...arguments); + async _computeVisibility() { + const show = await super._computeVisibility(...arguments); if (!show) { return false; } - return new Promise((resolve, reject) => { - this.trigger_up('action_demand', { - actionName: 'get_page_option', - params: ['header_overlay'], - onSuccess: value => resolve(!!value), - onFailure: reject, - }); - }); - }, + return !!this.wysiwyg.getPageOption("header_overlay"); + } +} + +registerWebsiteOption("TopMenuColor", { + Class: TopMenuColor, + template: "website.TopMenuColor", + selector: "[data-main-object^='website.page('] #wrapwrap > header", + noCheck: true, +}, { + sequence: 70, }); /** * Manage the visibility of snippets on mobile/desktop. */ -options.registry.DeviceVisibility = options.Class.extend({ - +options.registry.DeviceVisibility = options.Class.extend({}); +export class DeviceVisibility extends SnippetOption { //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- @@ -2786,14 +2792,14 @@ options.registry.DeviceVisibility = options.Class.extend({ // Update invisible elements. const isMobile = wUtils.isMobile(this); - this.trigger_up('snippet_option_visibility_update', {show: widgetValue !== (isMobile ? 'no_mobile' : 'no_desktop')}); - }, + this.callbacks.updateSnippetOptionVisibility(widgetValue !== (isMobile ? 'no_mobile' : 'no_desktop')); + } /** * @override */ async onTargetHide() { this.$target[0].classList.remove('o_snippet_override_invisible'); - }, + } /** * @override */ @@ -2805,13 +2811,13 @@ options.registry.DeviceVisibility = options.Class.extend({ ) && isMobilePreview === isMobileHidden) { this.$target[0].classList.add('o_snippet_override_invisible'); } - }, + } /** * @override */ cleanForSave() { this.$target[0].classList.remove('o_snippet_override_invisible'); - }, + } //-------------------------------------------------------------------------- // Private @@ -2832,8 +2838,8 @@ options.registry.DeviceVisibility = options.Class.extend({ } return ''; } - return await this._super(...arguments); - }, + return await super._computeWidgetState(...arguments); + } /** * @override */ @@ -2841,52 +2847,74 @@ options.registry.DeviceVisibility = options.Class.extend({ if (this.$target[0].classList.contains('s_table_of_content_main')) { return false; } - return this._super(...arguments); + return super._computeWidgetVisibility(...arguments); } +} +registerWebsiteOption("DeviceVisibility", { + Class: DeviceVisibility, + template: "website.DeviceVisibility", + selector: "section .row > div", + exclude: ".s_col_no_resize.row > div, .s_masonry_block .s_col_no_resize", }); /** * Hide/show footer in the current page. */ -options.registry.HideFooter = VisibilityPageOptionUpdate.extend({ - pageOptionName: 'footer_visible', - showOptionWidgetName: 'hide_footer_page_opt', - shownValue: 'shown', +export class HideFooter extends VisibilityPageOptionUpdate { + /** + * @override + */ + static pageOptionName = "footer_visible"; + + constructor() { + super(...arguments); + this.shownValue = "shown"; + } +} +registerWebsiteOption("HideFooter", { + Class: HideFooter, + template: "website.HideFooter", + selector: "[data-main-object^='website.page('] #wrapwrap > footer", + noCheck: true, + data: { + groups: ["website.group_website_designer"], + }, +}); + +registerWebsiteOption("FooterScrolltop", { + template: "website.FooterScrolltop", + selector: "#wrapwrap > footer", + noCheck: true, + data: { + groups: ["website.group_website_designer"], + }, }); /** * Handles the edition of snippet's anchor name. */ -options.registry.anchor = options.Class.extend({ - isTopOption: true, - +export class Anchor extends SnippetOption { /** * @override */ - init() { - this._super(...arguments); - this.notification = this.bindService("notification"); - }, - /** - * @override - */ - start() { - // Generate anchor and copy it to clipboard on click, show the tooltip on success - const buttonEl = this.el.querySelector("we-button"); + static isTopOption = true; + + constructor() { + super(...arguments); + this.notification = this.env.services.notification; + } + + async onBuilt() { this.isModal = this.$target[0].classList.contains("modal"); - if (buttonEl && !this.isModal) { - this._buildClipboard(buttonEl); - } + } - return this._super(...arguments); - }, /** * @override */ - onClone: function () { + onClone() { this.$target.removeAttr('data-anchor'); this.$target.filter(':not(.carousel)').removeAttr('id'); - }, + } //-------------------------------------------------------------------------- // Public @@ -2896,11 +2924,19 @@ options.registry.anchor = options.Class.extend({ * @override */ notify(name, data) { - this._super(...arguments); + super.notify(...arguments); if (name === "modalAnchor") { - this._buildClipboard(data.buttonEl); + this._copyAnchorToClipboard(); } - }, + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + copyAnchorToClipboard() { + this._copyAnchorToClipboard(); + } //-------------------------------------------------------------------------- // Private @@ -2908,25 +2944,22 @@ options.registry.anchor = options.Class.extend({ /** * @private - * @param {Element} buttonEl */ - _buildClipboard(buttonEl) { - buttonEl.addEventListener("click", async (ev) => { - const anchorLink = this._getAnchorLink(); - await browser.navigator.clipboard.writeText(anchorLink); + _copyAnchorToClipboard() { + const anchorLink = this._getAnchorLink(); + browser.navigator.clipboard.writeText(anchorLink).then(() => { const message = markup(_t("Anchor copied to clipboard<br>Link: %s", anchorLink)); this.notification.add(message, { type: "success", - buttons: [{name: _t("Edit"), onClick: () => this._openAnchorDialog(buttonEl), primary: true}], + buttons: [{name: _t("Edit"), onClick: () => this._openAnchorDialog(), primary: true}], }); }); - }, + } /** * @private - * @param {Element} buttonEl */ - _openAnchorDialog(buttonEl) { + _openAnchorDialog() { const anchorDialog = class extends Component { static template = "website.dialog.anchorName"; static props = { close: Function, confirm: Function, delete: Function, currentAnchor: String }; @@ -2962,7 +2995,7 @@ options.registry.anchor = options.Class.extend({ inputEl.classList.toggle('is-invalid', alreadyExists); if (!alreadyExists) { this._setAnchorName(anchorName); - buttonEl.click(); + this._copyAnchorToClipboard(); return true; } }, @@ -2974,12 +3007,12 @@ options.registry.anchor = options.Class.extend({ }; } this.dialog.add(anchorDialog, props); - }, + } /** * @private * @param {String} value */ - _setAnchorName: function (value) { + _setAnchorName(value) { if (value) { this.$target[0].id = value; if (!this.isModal) { @@ -2989,14 +3022,14 @@ options.registry.anchor = options.Class.extend({ this.$target.removeAttr('id data-anchor'); } this.$target.trigger('content_changed'); - }, + } /** * Returns anchor text. * * @private * @returns {string} */ - _getAnchorLink: function () { + _getAnchorLink() { if (!this.$target[0].id) { const $titles = this.$target.find('h1, h2, h3, h4, h5, h6'); const title = $titles.length > 0 ? $titles[0].innerText : this.data.snippetName; @@ -3009,7 +3042,7 @@ options.registry.anchor = options.Class.extend({ } const pathName = this.isModal ? "" : this.ownerDocument.location.pathname; return `${pathName}#${this.$target[0].id}`; - }, + } /** * Creates a safe id/anchor from text. * @@ -3017,12 +3050,23 @@ options.registry.anchor = options.Class.extend({ * @param {string} text * @returns {string} */ - _text2Anchor: function (text) { + _text2Anchor(text) { return encodeURIComponent(text.trim().replace(/\s+/g, '-')); - }, + } +} +registerWebsiteOption("Anchor", { + Class: Anchor, + template: "website.anchor", + selector: ":not(p).oe_structure > *, :not(p)[data-oe-type=html] > *", + exclude: ".modal *, .oe_structure .oe_structure *, [data-oe-type=html] .oe_structure *, .s_popup", +}); +registerWebsiteOption("AnchorModal", { + Class: Anchor, + selector: ".s_popup", + target: ".modal", }); -options.registry.HeaderBox = options.registry.Box.extend({ +export class HeaderBox extends Box { //-------------------------------------------------------------------------- // Options @@ -3042,8 +3086,8 @@ options.registry.HeaderBox = options.registry.Box.extend({ } return this.customizeWebsiteVariable(previewMode, widgetValue, params); } - return this._super(...arguments); - }, + return super.selectStyle(...arguments); + } /** * @override */ @@ -3055,18 +3099,28 @@ options.registry.HeaderBox = options.registry.Box.extend({ const defaultShadow = this._getDefaultShadow(widgetValue, params.shadowClass); return this.customizeWebsiteVariable(previewMode, defaultShadow, params); } - return this._super(...arguments); - }, + return super.setShadow(...arguments); + } //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- + /** + * @override + */ + async _getRenderContext() { + return { + ...(await super._getRenderContext(...arguments)), + noBorderRadius: this.$target[0].classList.contains("o_header_force_no_radius"), + }; + } + /** * @override */ async _computeWidgetState(methodName, params) { - const value = await this._super(...arguments); + const value = await super._computeWidgetState(...arguments); if (methodName === "selectStyle" && params.cssProperty === "border-width") { // One-sided borders return "0px 0px 3px 0px", which prevents the // option from being displayed properly. We only keep the affected @@ -3074,10 +3128,22 @@ options.registry.HeaderBox = options.registry.Box.extend({ return value.replace(/(^|\s)0px/gi, "").trim() || value; } return value; - }, + } +} +registerWebsiteOption("HeaderBox", { + Class: HeaderBox, + template: "website.HeaderBox", + selector: "#wrapwrap > header", + target: "nav", + noCheck: true, + data: { + groups: ["website.group_website_designer"], + }, +}, { + sequence: 40, }); -options.registry.CookiesBar = options.registry.SnippetPopup.extend({ +export class CookiesBar extends SnippetPopup { //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- @@ -3087,16 +3153,9 @@ options.registry.CookiesBar = options.registry.SnippetPopup.extend({ * * @see this.selectClass for parameters */ - selectLayout: function (previewMode, widgetValue, params) { - let websiteId; - this.trigger_up('context_get', { - callback: function (ctx) { - websiteId = ctx['website_id']; - }, - }); - + selectLayout(previewMode, widgetValue, params) { const $template = $(renderToElement(`website.cookies_bar.${widgetValue}`, { - websiteId: websiteId, + websiteId: this.website.currentWebsite.id, })); const $content = this.$target.find('.modal-content'); @@ -3135,7 +3194,14 @@ options.registry.CookiesBar = options.registry.SnippetPopup.extend({ } $content.empty().append($template); - }, + } +} + +registerWebsiteOption("CookiesBar", { + Class: CookiesBar, + template: "website.cookie_bar_options", + selector: "#website_cookies_bar", + target: ".modal", }); /** @@ -3326,14 +3392,11 @@ options.registry.CoverProperties = options.Class.extend({ }, }); -options.registry.ScrollButton = options.Class.extend({ - /** - * @override - */ - start: async function () { - await this._super(...arguments); - this.$button = this.$('.o_scroll_button'); - }, +class ScrollButton extends SnippetOption { + constructor() { + super(...arguments); + this.$button = this.$target.find('.o_scroll_button'); + } //-------------------------------------------------------------------------- // Options @@ -3352,11 +3415,11 @@ options.registry.ScrollButton = options.Class.extend({ this.$button.detach(); } } - }, + } /** * Toggles the scroll down button. */ - toggleButton: function (previewMode, widgetValue, params) { + toggleButton(previewMode, widgetValue, params) { if (widgetValue) { if (!this.$button.length) { const anchor = document.createElement('a'); @@ -3382,12 +3445,12 @@ options.registry.ScrollButton = options.Class.extend({ } else { this.$button.detach(); } - }, + } /** * @override */ async selectClass(previewMode, widgetValue, params) { - await this._super(...arguments); + await super.selectClass(...arguments); // If a "d-lg-block" class exists on the section (e.g., for mobile // visibility option), it should be replaced with a "d-lg-flex" class. // This ensures that the section has the "display: flex" property @@ -3404,7 +3467,7 @@ options.registry.ScrollButton = options.Class.extend({ this.$target[0].classList.add(hasDisplayFlex ? "d-lg-flex" : "d-lg-block"); } } - }, + } //-------------------------------------------------------------------------- // Private @@ -3420,17 +3483,17 @@ options.registry.ScrollButton = options.Class.extend({ const minHeightEl = uiFragment.querySelector('[data-name="minheight_auto_opt"]'); minHeightEl.parentElement.setAttribute('string', _t("Min-Height")); } - }, + } /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { switch (methodName) { case 'toggleButton': return !!this.$button.parent().length; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ @@ -3438,59 +3501,76 @@ options.registry.ScrollButton = options.Class.extend({ if (widgetName === 'fixed_height_opt') { return (this.$target[0].dataset.snippet === 's_image_gallery'); } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } +} +registerWebsiteOption("ScrollButton", { + Class: ScrollButton, + template: "website.scroll_button_option", + selector: "section", + exclude: "[data-snippet] :not(.oe_structure) > [data-snippet], .s_instagram_page", }); -options.registry.ConditionalVisibility = options.registry.DeviceVisibility.extend({ - /** - * @constructor - */ - init() { - this._super(...arguments); + +options.registry.ConditionalVisibility = options.registry.DeviceVisibility.extend({}); +class ConditionalVisibilityComponent extends SnippetOptionComponent { + setup() { + super.setup(...arguments); + + onMounted(() => { + for (const widget of Object.values(this.props.snippetOption.instance._userValues)) { + const params = widget.getMethodsParams(); + if (params.saveAttribute) { + this.props.snippetOption.instance.optionsAttributes.push({ + saveAttribute: params.saveAttribute, + attributeName: params.attributeName, + // If callWith dataAttribute is not specified, the default + // field to check on the record will be .value for values + // coming from another widget than M2M. + callWith: params.callWith || 'value', + }); + } + } + }); + } +} +class ConditionalVisibility extends DeviceVisibility { + static defaultRenderingComponent = ConditionalVisibilityComponent; + + constructor() { + super(...arguments); this.optionsAttributes = []; - }, + this.orm = serviceCached(this.env, "orm"); + } /** * @override */ - async start() { - await this._super(...arguments); - - for (const widget of this._userValueWidgets) { - const params = widget.getMethodsParams(); - if (params.saveAttribute) { - this.optionsAttributes.push({ - saveAttribute: params.saveAttribute, - attributeName: params.attributeName, - // If callWith dataAttribute is not specified, the default - // field to check on the record will be .value for values - // coming from another widget than M2M. - callWith: params.callWith || 'value', - }); - } - } - }, + async _getRenderContext() { + const context = await super._getRenderContext(...arguments); + context.countryCode = session.geoip_country_code; + context.currentWebsite = (await this.orm.searchRead( + "website", + [["id", "=", this.env.services.website.currentWebsite.id]], + ["language_ids"] + ))[0]; + return context; + } /** * @override */ async onTargetHide() { - await this._super(...arguments); + await super.onTargetHide(...arguments); if (this.$target[0].classList.contains('o_snippet_invisible')) { this.$target[0].classList.add('o_conditional_hidden'); } - }, + } /** * @override */ async onTargetShow() { - await this._super(...arguments); + await super.onTargetShow(...arguments); this.$target[0].classList.remove('o_conditional_hidden'); - }, - // Todo: remove me in master. - /** - * @override - */ - cleanForSave() {}, + } //-------------------------------------------------------------------------- // Options @@ -3511,7 +3591,7 @@ options.registry.ConditionalVisibility = options.registry.DeviceVisibility.exten } this._updateCSSSelectors(); - }, + } /** * Selects a value for target's data-attributes. * Should be used instead of selectRecord if the visibility is not related @@ -3529,21 +3609,18 @@ options.registry.ConditionalVisibility = options.registry.DeviceVisibility.exten } this._updateCSSSelectors(); - }, + } /** * Opens the toggler when 'conditional' is selected. * * @override */ async selectDataAttribute(previewMode, widgetValue, params) { - await this._super(...arguments); + await super.selectDataAttribute(...arguments); if (params.attributeName === 'visibility') { const targetEl = this.$target[0]; - if (widgetValue === 'conditional') { - const collapseEl = this.$el.children('we-collapse')[0]; - this._toggleCollapseEl(collapseEl); - } else { + if (widgetValue !== 'conditional') { // TODO create a param to allow doing this automatically for genericSelectDataAttribute? delete targetEl.dataset.visibility; @@ -3552,13 +3629,13 @@ options.registry.ConditionalVisibility = options.registry.DeviceVisibility.exten delete targetEl.dataset[`${attribute.saveAttribute}Rule`]; } } - this.trigger_up('snippet_option_visibility_update', {show: true}); + this.callbacks.updateSnippetOptionVisibility(true); } else if (!params.isVisibilityCondition) { return; } this._updateCSSSelectors(); - }, + } //-------------------------------------------------------------------------- // Private @@ -3575,8 +3652,8 @@ options.registry.ConditionalVisibility = options.registry.DeviceVisibility.exten const selectedValue = this.$target[0].dataset[params.saveAttribute]; return selectedValue ? JSON.parse(selectedValue)[0].value : params.attributeDefaultValue; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * Reads target's attributes and creates CSS selectors. * Stores them in data-attributes to then be reapplied by @@ -3651,7 +3728,12 @@ options.registry.ConditionalVisibility = options.registry.DeviceVisibility.exten } else { delete this.$target[0].dataset.visibilityId; } - }, + } +} +registerWebsiteOption("ConditionalVisibility", { + Class: ConditionalVisibility, + template: "website.ConditionalVisibility", + selector: "section, .s_hr", }); options.registry.WebsiteAnimate = options.Class.extend({ @@ -4069,14 +4151,16 @@ options.registry.TextHighlight = options.Class.extend({ /** * Replaces current target with the specified template layout */ -options.registry.MegaMenuLayout = options.registry.SelectTemplate.extend({ +export class MegaMenuLayout extends SelectTemplate { /** * @override */ - init() { - this._super(...arguments); + static forceNoDeleteButton = true; + + constructor() { + super(...arguments); this.selectTemplateWidgetName = 'mega_menu_template_opt'; - }, + } //-------------------------------------------------------------------------- // Public @@ -4093,9 +4177,9 @@ options.registry.MegaMenuLayout = options.registry.SelectTemplate.extend({ data.onSuccess(); }); } else { - this._super(...arguments); + super.notify(...arguments); } - }, + } //-------------------------------------------------------------------------- // Private @@ -4104,28 +4188,36 @@ options.registry.MegaMenuLayout = options.registry.SelectTemplate.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { if (methodName === 'selectTemplate') { return this._getCurrentTemplateXMLID(); } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @private * @returns {string} xmlid of the current template. */ - _getCurrentTemplateXMLID: function () { + _getCurrentTemplateXMLID() { const templateDefiningClass = this.containerEl.querySelector('section') .classList.value.split(' ').filter(cl => cl.startsWith('s_mega_menu'))[0]; return `website.${templateDefiningClass}`; - }, + } +} +registerWebsiteOption("MegaMenuLayout", { + Class: MegaMenuLayout, + template: "web_editor.mega_menu_layout_options", + selector: ".o_mega_menu", }); /** * Hides delete and clone buttons for Mega Menu block. */ -options.registry.MegaMenuNoDelete = options.Class.extend({ - forceNoDeleteButton: true, +export class MegaMenuNoDelete extends SnippetOption { + /** + * @override + */ + static forceNoDeleteButton = true; /** * @override @@ -4140,7 +4232,16 @@ options.registry.MegaMenuNoDelete = options.Class.extend({ } }); }); - }, + } +} +registerWebsiteOption("MegaMenuNoDelete", { + Class: MegaMenuLayout, + selector: ".o_mega_menu > section", +}); +registerWebsiteOption("MegaMenuNoDeleteDrop", { + selector: ".o_mega_menu .nav > .nav-link", + dropIn: ".o_mega_menu nav", + dropNear: ".o_mega_menu .nav-link", }); options.registry.sizing.include({ @@ -4197,6 +4298,16 @@ options.registry.sizing.include({ }, }); +registerWebsiteOption("InfoPage", { + template: "website.InfoPage", + selector: "main:has(.o_website_info)", + noCheck: true, + data: { + groups: ["website.group_website_designer"], + pageOptions: true, + }, +}); + options.registry.SwitchableViews = options.Class.extend({ /** * @override @@ -4295,8 +4406,8 @@ options.registry.GridImage = options.Class.extend({ }, }); -options.registry.GalleryElement = options.Class.extend({ +class GalleryElement extends SnippetOption { //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- @@ -4310,7 +4421,7 @@ options.registry.GalleryElement = options.Class.extend({ const optionName = this.$target[0].classList.contains("carousel-item") ? "Carousel" : "GalleryImageList"; const itemEl = this.$target[0]; - this.trigger_up("option_update", { + this.callbacks.notifyOptions({ optionName: optionName, name: "reorder_items", data: { @@ -4318,8 +4429,13 @@ options.registry.GalleryElement = options.Class.extend({ position: widgetValue, }, }); - }, -}); + } +} +registerWebsiteOption("GalleryElement", { + Class: GalleryElement, + template: "website.GalleryElement", + selector: ".s_image_gallery img, .s_carousel .carousel-item", +}, { sequence: 10 }); options.registry.Button = options.Class.extend({ /** @@ -4420,25 +4536,132 @@ options.registry.Button = options.Class.extend({ }, }); -options.registry.layout_column.include({ +class WebsiteLayoutColumn extends LayoutColumn { /** * @override */ _isMobile() { - return wUtils.isMobile(this); - }, + return this.env.services.website.context.isMobile; + } +} +registerWebsiteOption("WebsiteLayoutColumns", { + Class: WebsiteLayoutColumn, + template: "website.layout_column", + selector: "section, section.s_carousel_wrapper .carousel-item", + target: "> *:has(> .row), > .s_allow_columns", + exclude: ".s_masonry_block, .s_features_grid, .s_media_list, .s_table_of_content, .s_process_steps, .s_image_gallery" +}, { sequence: 15 }); + tags: ["website"], + + +registerWebsiteOption("card_color_border_shadow", { + Class: Box, + template: "website.card_color_border_shadow", + selector: ".s_three_columns .row > div, .s_comparisons .row > div", + target: ".card", }); -options.registry.SnippetMove.include({ +registerWebsiteOption("card_color", { + template: "website.card_color", + selector: ".accordion .card", +}); + +registerWebsiteOption("horizontal_alignment", { + template: "website.horizontal_alignment_option", + selector: ".s_share, .s_text_highlight, .s_social_media", +}); + +registerWebsiteOption("block_width_option", { + template: "website.block_width_option", + selector: ".s_blockquote, .s_text_highlight", +}); + +registerWebsiteOption("block_align_option", { + template: "website.block_align_option", + selector: ".s_alert, .s_card, .s_blockquote, .s_text_highlight", +}); + +registerWebsiteOption("vertical_alignment", { + class: vAlignment, + template: "website.vertical_alignment_option", + selector: ".s_text_image, .s_image_text, .s_three_columns", + target: ".row", +}); + +registerWebsiteOption("share_social_media", { + template: "website.share_social_media_option", + selector: ".s_share, .s_social_media", +}); + +patch(SnippetMove.prototype, { /** * @override */ _isMobile() { - return wUtils.isMobile(this); + return this.env.services.website.context.isMobile; }, }); -export default { - UrlPickerUserValueWidget: UrlPickerUserValueWidget, - FontFamilyPickerUserValueWidget: FontFamilyPickerUserValueWidget, -}; +export function websiteRegisterBackgroundOptions(key, options) { + options.module = "website"; + registerBackgroundOptions(key, options, (name) => name === "toggler" && "website.snippet_options_background_options"); + if (options.withVideos) { + registerWebsiteOption(`${key}-bgVideo`, { + Class: BackgroundVideo, + template: "website.BackgroundVideo", + ...options, + }, { sequence: 30 }); + } + if (options.withImages) { + registerWebsiteOption(`${key}-parallax`, { + Class: Parallax, + template: "website.Parallax", + ...options, + }, { sequence: 30 }); + } + +} + +export const onlyBgColorSelector = "section .row > div, .s_text_highlight, .s_mega_menu_thumbnails_footer, .s_hr"; +export const onlyBgColorExclude = ".s_col_no_bgcolor, .s_col_no_bgcolor.row > div, .s_masonry_block .row > div, .s_color_blocks_2 .row > div, .s_image_gallery .row > div, .s_text_cover .row > .o_not_editable, [data-snippet] :not(.oe_structure) > .s_hr"; +export const baseOnlyBgImageSelector = ".s_tabs .oe_structure > *, footer .oe_structure > *"; +export const onlyBgImageSelector = baseOnlyBgImageSelector; +export const onlyBgImageExclude = ""; +export const bothBgColorImageSelector = "section, .carousel-item, .s_masonry_block .row > div, .s_color_blocks_2 .row > div, .parallax, .s_text_cover .row > .o_not_editable"; +export const bothBgColorImageExclude = baseOnlyBgImageSelector + ", .s_carousel_wrapper, .s_image_gallery .carousel-item, .s_google_map, .s_map, [data-snippet] :not(.oe_structure) > [data-snippet], .s_masonry_block .s_col_no_resize"; + +websiteRegisterBackgroundOptions("BothBgImage", { + selector: bothBgColorImageSelector, + exclude: bothBgColorImageExclude, + withColors: true, + withImages: true, + withVideos: true, + withShapes: true, + withColorCombinations: true, + withGradients: true, +}); + +websiteRegisterBackgroundOptions("OnlyBgColor", { + selector: onlyBgColorSelector, + exclude: onlyBgColorExclude, + withColors: true, + withImages: false, + withColorCombinations: true, + withGradients: true, +}); + +websiteRegisterBackgroundOptions("OnlyBgImage", { + selector: onlyBgImageSelector, + exclude: onlyBgImageExclude, + withColors: false, + withImages: true, + withVideos: true, + withShapes: true, +}); + +registerWebsiteOption("ColumnsOnly", { + Class: WebsiteLayoutColumn, + template: "website.columns_only", + selector: "section.s_features_grid, section.s_process_steps", + target: "> *:has(> .row), > .s_allow_columns", +}, { sequence: 15 }); diff --git a/addons/website/static/src/js/editor/snippets.options.legacy.js b/addons/website/static/src/js/editor/snippets.options.legacy.js new file mode 100644 index 0000000000000..ac99720e5aedd --- /dev/null +++ b/addons/website/static/src/js/editor/snippets.options.legacy.js @@ -0,0 +1,4223 @@ +/** @odoo-module **/ + +import { loadCSS } from "@web/core/assets"; +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import { Dialog } from "@web/core/dialog/dialog"; +import { rpc } from "@web/core/network/rpc"; +import { user } from "@web/core/user"; +import { useChildRef } from "@web/core/utils/hooks"; +import weUtils from "@web_editor/js/common/utils"; +import options from "@web_editor/js/editor/snippets.options.legacy"; +import { NavbarLinkPopoverWidget } from "@website/js/widgets/link_popover_widget"; +import wUtils from "@website/js/utils"; +import { + applyModifications, + isImageCorsProtected, + isImageSupportedForStyle, + loadImageInfo, +} from "@web_editor/js/editor/image_processing"; +import "@website/snippets/s_popup/options"; +import { range } from "@web/core/utils/numbers"; +import { _t } from "@web/core/l10n/translation"; +import {Domain} from "@web/core/domain"; +import { + isCSSColor, + convertCSSColorToRgba, + convertRgbaToCSSColor, + convertRgbToHsl, + convertHslToRgb, + } from '@web/core/utils/colors'; +import { renderToElement, renderToFragment } from "@web/core/utils/render"; +import { browser } from "@web/core/browser/browser"; +import { + removeTextHighlight, + drawTextHighlightSVG, +} from "@website/js/text_processing"; + +import { Component, markup, useRef, useState } from "@odoo/owl"; + +const InputUserValueWidget = options.userValueWidgetsRegistry['we-input']; +const SelectUserValueWidget = options.userValueWidgetsRegistry['we-select']; +const Many2oneUserValueWidget = options.userValueWidgetsRegistry['we-many2one']; + +options.UserValueWidget.include({ + loadMethodsData() { + this._super(...arguments); + + // Method names are sorted alphabetically by default. Exception here: + // we make sure, customizeWebsiteVariable is considered after + // customizeWebsiteViews so that the variable is used to show to active + // value when both methods are used at the same time. + // TODO find a better way. + const indexVariable = this._methodsNames.indexOf('customizeWebsiteVariable'); + if (indexVariable >= 0) { + const indexView = this._methodsNames.indexOf('customizeWebsiteViews'); + if (indexView >= 0) { + this._methodsNames[indexVariable] = 'customizeWebsiteViews'; + this._methodsNames[indexView] = 'customizeWebsiteVariable'; + } + } + }, +}); + +Many2oneUserValueWidget.include({ + init() { + this._super(...arguments); + this.fields = this.bindService("field"); + }, + + /** + * @override + */ + async _getSearchDomain() { + // Add the current website's domain if the model has a website_id field. + // Note that the `_rpc` method is cached in Many2X user value widget, + // see `_rpcCache`. + const websiteIdField = await this.fields.loadFields(this.options.model, { + fieldNames: ["website_id"], + }); + const modelHasWebsiteId = !!websiteIdField["website_id"]; + if (modelHasWebsiteId && !this.options.domain.find(arr => arr[0] === "website_id")) { + this.options.domain = + Domain.and([this.options.domain, wUtils.websiteDomain(this)]).toList(); + } + return this.options.domain; + }, +}); + +const UrlPickerUserValueWidget = InputUserValueWidget.extend({ + events: Object.assign({}, InputUserValueWidget.prototype.events || {}, { + 'click .o_we_redirect_to': '_onRedirectTo', + }), + + /** + * @override + */ + start: async function () { + await this._super(...arguments); + const linkButton = document.createElement('we-button'); + const icon = document.createElement('i'); + icon.classList.add('fa', 'fa-fw', 'fa-external-link'); + linkButton.classList.add('o_we_redirect_to', 'o_we_link', 'ms-1'); + linkButton.title = _t("Preview this URL in a new tab"); + linkButton.appendChild(icon); + this.containerEl.after(linkButton); + this.el.classList.add('o_we_large'); + this.inputEl.classList.add('text-start'); + const options = { + classes: { + "ui-autocomplete": 'o_website_ui_autocomplete' + }, + body: this.getParent().$target[0].ownerDocument.body, + urlChosen: this._onWebsiteURLChosen.bind(this), + }; + this.unmountAutocompleteWithPages = wUtils.autocompleteWithPages(this.inputEl, options); + }, + + open() { + this._super(...arguments); + document.querySelector(".o_website_ui_autocomplete")?.classList?.remove("d-none"); + }, + + close() { + this._super(...arguments); + document.querySelector(".o_website_ui_autocomplete")?.classList?.add("d-none"); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the autocomplete change the input value. + * + * @private + * @param {OdooEvent} ev + */ + _onWebsiteURLChosen: function (ev) { + this._value = this.inputEl.value; + this._onUserValueChange(ev); + }, + /** + * Redirects to the URL the widget currently holds. + * + * @private + */ + _onRedirectTo: function () { + if (this._value) { + window.open(this._value, '_blank'); + } + }, + destroy() { + this.unmountAutocompleteWithPages?.(); + this.unmountAutocompleteWithPages = null; + this._super(...arguments); + } +}); + +const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ + events: Object.assign({}, SelectUserValueWidget.prototype.events || {}, { + 'click .o_we_add_google_font_btn': '_onAddGoogleFontClick', + 'click .o_we_delete_google_font_btn': '_onDeleteGoogleFontClick', + }), + fontVariables: [], // Filled by editor menu when all options are loaded + + /** + * @override + */ + init() { + this.dialog = this.bindService("dialog"); + return this._super(...arguments); + }, + /** + * @override + */ + start: async function () { + const style = window.getComputedStyle(this.$target[0].ownerDocument.documentElement); + const nbFonts = parseInt(weUtils.getCSSVariableValue('number-of-fonts', style)); + // User fonts served by google server. + const googleFontsProperty = weUtils.getCSSVariableValue('google-fonts', style); + this.googleFonts = googleFontsProperty ? googleFontsProperty.split(/\s*,\s*/g) : []; + this.googleFonts = this.googleFonts.map(font => font.substring(1, font.length - 1)); // Unquote + // Local user fonts. + const googleLocalFontsProperty = weUtils.getCSSVariableValue('google-local-fonts', style); + this.googleLocalFonts = googleLocalFontsProperty ? + googleLocalFontsProperty.slice(1, -1).split(/\s*,\s*/g) : []; + // If a same font exists both remotely and locally, we remove the remote + // font to prioritize the local font. The remote one will never be + // displayed or loaded as long as the local one exists. + this.googleFonts = this.googleFonts.filter(font => { + const localFonts = this.googleLocalFonts.map(localFont => localFont.split(":")[0]); + return localFonts.indexOf(`'${font}'`) === -1; + }); + this.allFonts = []; + + await this._super(...arguments); + + const fontsToLoad = []; + for (const font of this.googleFonts) { + const fontURL = `https://fonts.googleapis.com/css?family=${encodeURIComponent(font).replace(/%20/g, '+')}`; + fontsToLoad.push(fontURL); + } + for (const font of this.googleLocalFonts) { + const attachmentId = font.split(/\s*:\s*/)[1]; + const fontURL = `/web/content/${encodeURIComponent(attachmentId)}`; + fontsToLoad.push(fontURL); + } + // TODO ideally, remove the <link> elements created once this widget + // instance is destroyed (although it should not hurt to keep them for + // the whole backend lifecycle). + const proms = fontsToLoad.map(async fontURL => loadCSS(fontURL)); + const fontsLoadingProm = Promise.all(proms); + + const fontEls = []; + const methodName = this.el.dataset.methodName || 'customizeWebsiteVariable'; + const variable = this.el.dataset.variable; + const themeFontsNb = nbFonts - (this.googleLocalFonts.length + this.googleFonts.length); + for (let fontNb = 0; fontNb < nbFonts; fontNb++) { + const realFontNb = fontNb + 1; + const fontKey = weUtils.getCSSVariableValue(`font-number-${realFontNb}`, style); + this.allFonts.push(fontKey); + let fontName = fontKey.slice(1, -1); // Unquote + let fontFamily = fontName; + const isSystemFonts = fontName === "SYSTEM_FONTS"; + if (isSystemFonts) { + fontName = _t("System Fonts"); + fontFamily = 'var(--o-system-fonts)'; + } + const fontEl = document.createElement('we-button'); + fontEl.setAttribute('string', fontName); + fontEl.dataset.variable = variable; + fontEl.dataset[methodName] = fontKey; + fontEl.dataset.fontFamily = fontFamily; + if ((realFontNb <= themeFontsNb) && !isSystemFonts) { + // Add the "cloud" icon next to the theme's default fonts + // because they are served by Google. + fontEl.appendChild(Object.assign(document.createElement('i'), { + role: 'button', + className: 'text-info me-2 fa fa-cloud', + title: _t("This font is hosted and served to your visitors by Google servers"), + })); + } + fontEls.push(fontEl); + this.menuEl.appendChild(fontEl); + } + + if (this.googleLocalFonts.length) { + const googleLocalFontsEls = fontEls.splice(-this.googleLocalFonts.length); + googleLocalFontsEls.forEach((el, index) => { + $(el).append(renderToFragment('website.delete_google_font_btn', { + index: index, + local: "true", + })); + }); + } + + if (this.googleFonts.length) { + const googleFontsEls = fontEls.splice(-this.googleFonts.length); + googleFontsEls.forEach((el, index) => { + $(el).append(renderToFragment('website.delete_google_font_btn', { + index: index, + })); + }); + } + + $(this.menuEl).append($(renderToElement('website.add_google_font_btn', { + variable: variable, + }))); + + return fontsLoadingProm; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + async setValue() { + await this._super(...arguments); + + this.menuTogglerEl.style.fontFamily = ''; + const activeWidget = this._userValueWidgets.find(widget => !widget.isPreviewed() && widget.isActive()); + if (activeWidget) { + this.menuTogglerEl.style.fontFamily = activeWidget.el.dataset.fontFamily; + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onAddGoogleFontClick: function (ev) { + const addGoogleFontDialog = class extends Component { + static template = "website.dialog.addGoogleFont"; + static components = { Dialog }; + static props = { close: Function, title: String, onClickSave: Function }; + title = _t("Add a Google Font"); + state = useState({ valid: true, loading: false, googleServe: true }); + fontInput = useRef("fontInput"); + async onClickSave() { + if (this.state.loading) { + return; + } + this.state.loading = true; + const shouldClose = await this.props.onClickSave(this.state, this.fontInput.el); + if (shouldClose) { + this.props.close(); + return; + } + this.state.loading = false; + } + onClickCancel() { + this.props.close(); + } + }; + const variable = $(ev.currentTarget).data('variable'); + this.dialog.add(addGoogleFontDialog, { + title: _t("Add a Google Font"), + onClickSave: async (state, inputEl) => { + // if font page link (what is expected) + let m = inputEl.value.match(/\bspecimen\/([\w+]+)/); + if (!m) { + // if embed code (so that it works anyway if the user put the embed code instead of the page link) + m = inputEl.value.match(/\bfamily=([\w+]+)/); + if (!m) { + inputEl.classList.add('is-invalid'); + return; + } + } + + let isValidFamily = false; + + try { + // Font family is an encoded query parameter: + // "Open+Sans" needs to remain "Open+Sans". + const result = await fetch("https://fonts.googleapis.com/css?family=" + m[1] + ':300,300i,400,400i,700,700i', {method: 'HEAD'}); + // Google fonts server returns a 400 status code if family is not valid. + if (result.ok) { + isValidFamily = true; + } + } catch (error) { + console.error(error); + } + + if (!isValidFamily) { + inputEl.classList.add('is-invalid'); + return; + } + + const font = m[1].replace(/\+/g, ' '); + const googleFontServe = state.googleServe; + const fontName = `'${font}'`; + // If the font already exists, it will only be added if + // the user chooses to add it locally when it is already + // imported from the Google Fonts server. + const fontExistsLocally = this.googleLocalFonts.some(localFont => localFont.split(':')[0] === fontName); + const fontExistsOnServer = this.allFonts.includes(fontName); + const preventFontAddition = fontExistsLocally || (fontExistsOnServer && googleFontServe); + if (preventFontAddition) { + inputEl.classList.add('is-invalid'); + // Show custom validity error message. + inputEl.setCustomValidity(_t("This font already exists, you can only add it as a local font to replace the server version.")); + inputEl.reportValidity(); + return; + } + if (googleFontServe) { + this.googleFonts.push(font); + } else { + this.googleLocalFonts.push(`'${font}': ''`); + } + this.trigger_up('google_fonts_custo_request', { + values: {[variable]: `'${font}'`}, + googleFonts: this.googleFonts, + googleLocalFonts: this.googleLocalFonts, + }); + return true; + }, + }); + }, + /** + * @private + * @param {Event} ev + */ + _onDeleteGoogleFontClick: async function (ev) { + ev.preventDefault(); + const values = {}; + + const save = await new Promise(resolve => { + this.dialog.add(ConfirmationDialog, { + body: _t("Deleting a font requires a reload of the page. This will save all your changes and reload the page, are you sure you want to proceed?"), + confirm: () => resolve(true), + cancel: () => resolve(false), + }); + }); + if (!save) { + return; + } + + // Remove Google font + const googleFontIndex = parseInt(ev.target.dataset.fontIndex); + const isLocalFont = ev.target.dataset.localFont; + let googleFontName; + if (isLocalFont) { + const googleFont = this.googleLocalFonts[googleFontIndex].split(':'); + // Remove double quotes + googleFontName = googleFont[0].substring(1, googleFont[0].length - 1); + values['delete-font-attachment-id'] = googleFont[1]; + this.googleLocalFonts.splice(googleFontIndex, 1); + } else { + googleFontName = this.googleFonts[googleFontIndex]; + this.googleFonts.splice(googleFontIndex, 1); + } + + // Adapt font variable indexes to the removal + const style = window.getComputedStyle(this.$target[0].ownerDocument.documentElement); + FontFamilyPickerUserValueWidget.prototype.fontVariables.forEach((variable) => { + const value = weUtils.getCSSVariableValue(variable, style); + if (value.substring(1, value.length - 1) === googleFontName) { + // If an element is using the google font being removed, reset + // it to the theme default. + values[variable] = 'null'; + } + }); + + this.trigger_up('google_fonts_custo_request', { + values: values, + googleFonts: this.googleFonts, + googleLocalFonts: this.googleLocalFonts, + }); + }, +}); + +const GPSPicker = InputUserValueWidget.extend({ + // Explicitly not consider all InputUserValueWidget events. E.g. we actually + // don't want input focusout messing with the google map API. Because of + // this, clicking on google map autocomplete suggestion on Firefox was not + // working properly. + events: {}, + + /** + * @constructor + */ + init() { + this._super(...arguments); + this._gmapCacheGPSToPlace = {}; + + // The google API will be loaded inside the website iframe. Let's try + // not having to load it in the backend too and just using the iframe + // google object instead. + this.contentWindow = this.$target[0].ownerDocument.defaultView; + + this.notification = this.bindService("notification"); + }, + /** + * @override + */ + async willStart() { + await this._super(...arguments); + this._gmapLoaded = await new Promise(resolve => { + this.trigger_up('gmap_api_request', { + editableMode: true, + configureIfNecessary: true, + onSuccess: key => { + if (!key) { + resolve(false); + return; + } + + // TODO see _notifyGMapError, this tries to trigger an error + // early but this is not consistent with new gmap keys. + this._nearbySearch('(50.854975,4.3753899)', !!key) + .then(place => resolve(!!place)); + }, + }); + }); + if (!this._gmapLoaded && !this._gmapErrorNotified) { + this.trigger_up('user_value_widget_critical'); + return; + } + }, + /** + * @override + */ + async start() { + await this._super(...arguments); + this.el.classList.add('o_we_large'); + if (!this._gmapLoaded) { + return; + } + + this._gmapAutocomplete = new this.contentWindow.google.maps.places.Autocomplete(this.inputEl, {types: ['geocode']}); + this.contentWindow.google.maps.event.addListener(this._gmapAutocomplete, 'place_changed', this._onPlaceChanged.bind(this)); + }, + /** + * @override + */ + destroy() { + this._super(...arguments); + + // Without this, the google library injects elements inside the backend + // DOM but do not remove them once the editor is left. Notice that + // this is also done when the widget is destroyed for another reason + // than leaving the editor, but if the google API needs that container + // again afterwards, it will simply recreate it. + for (const el of document.body.querySelectorAll('.pac-container')) { + el.remove(); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getMethodsParams: function (methodName) { + return Object.assign({gmapPlace: this._gmapPlace || {}}, this._super(...arguments)); + }, + /** + * @override + */ + async setValue() { + await this._super(...arguments); + if (!this._gmapLoaded) { + return; + } + + this._gmapPlace = await this._nearbySearch(this._value); + + if (this._gmapPlace) { + this.inputEl.value = this._gmapPlace.formatted_address; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {string} gps + * @param {boolean} [notify=true] + * @returns {Promise} + */ + async _nearbySearch(gps, notify = true) { + if (this._gmapCacheGPSToPlace[gps]) { + return this._gmapCacheGPSToPlace[gps]; + } + + const p = gps.substring(1).slice(0, -1).split(','); + const location = new this.contentWindow.google.maps.LatLng(p[0] || 0, p[1] || 0); + return new Promise(resolve => { + const service = new this.contentWindow.google.maps.places.PlacesService(document.createElement('div')); + service.nearbySearch({ + // Do a 'nearbySearch' followed by 'getDetails' to avoid using + // GMap Geocoder which the user may not have enabled... but + // ideally Geocoder should be used to get the exact location at + // those coordinates and to limit billing query count. + location: location, + radius: 1, + }, (results, status) => { + const GMAP_CRITICAL_ERRORS = [ + this.contentWindow.google.maps.places.PlacesServiceStatus.REQUEST_DENIED, + this.contentWindow.google.maps.places.PlacesServiceStatus.UNKNOWN_ERROR + ]; + if (status === this.contentWindow.google.maps.places.PlacesServiceStatus.OK) { + service.getDetails({ + placeId: results[0].place_id, + fields: ['geometry', 'formatted_address'], + }, (place, status) => { + if (status === this.contentWindow.google.maps.places.PlacesServiceStatus.OK) { + this._gmapCacheGPSToPlace[gps] = place; + resolve(place); + } else if (GMAP_CRITICAL_ERRORS.includes(status)) { + if (notify) { + this._notifyGMapError(); + } + resolve(); + } + }); + } else if (GMAP_CRITICAL_ERRORS.includes(status)) { + if (notify) { + this._notifyGMapError(); + } + resolve(); + } else { + resolve(); + } + }); + }); + }, + /** + * Indicates to the user there is an error with the google map API and + * re-opens the configuration dialog. For good measures, this also notifies + * a critical error which normally removes the related snippet entirely. + * + * @private + */ + _notifyGMapError() { + // TODO this should be better to detect all errors. This is random. + // When misconfigured (wrong APIs enabled), sometimes Google throw + // errors immediately (which then reaches this code), sometimes it + // throws them later (which then induces an error log in the console + // and random behaviors). + if (this._gmapErrorNotified) { + return; + } + this._gmapErrorNotified = true; + + this.notification.add( + _t("A Google Map error occurred. Make sure to read the key configuration popup carefully."), + { type: 'danger', sticky: true } + ); + this.trigger_up('gmap_api_request', { + editableMode: true, + reconfigure: true, + onSuccess: () => { + this._gmapErrorNotified = false; + }, + }); + + setTimeout(() => this.trigger_up('user_value_widget_critical')); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onPlaceChanged(ev) { + const gmapPlace = this._gmapAutocomplete.getPlace(); + if (gmapPlace && gmapPlace.geometry) { + this._gmapPlace = gmapPlace; + const location = this._gmapPlace.geometry.location; + const oldValue = this._value; + this._value = `(${location.lat()},${location.lng()})`; + this._gmapCacheGPSToPlace[this._value] = gmapPlace; + if (oldValue !== this._value) { + this._onUserValueChange(ev); + } + } + }, +}); +options.userValueWidgetsRegistry['we-urlpicker'] = UrlPickerUserValueWidget; +options.userValueWidgetsRegistry['we-fontfamilypicker'] = FontFamilyPickerUserValueWidget; +options.userValueWidgetsRegistry['we-gpspicker'] = GPSPicker; + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +options.Class.include({ + custom_events: Object.assign({}, options.Class.prototype.custom_events || {}, { + 'google_fonts_custo_request': '_onGoogleFontsCustoRequest', + }), + specialCheckAndReloadMethodsNames: ['customizeWebsiteViews', 'customizeWebsiteVariable', 'customizeWebsiteColor'], + + /** + * @override + */ + init() { + this._super(...arguments); + // Since the website is displayed in an iframe, its jQuery + // instance is not the same as the editor. This property allows + // for easy access to bootstrap plugins (Carousel, Modal, ...). + // This is only needed because jQuery doesn't send custom events + // the same way native javascript does. So if a jQuery instance + // triggers a custom event, only that same jQuery instance will + // trigger handlers set with `.on`. + this.$bsTarget = this.ownerDocument.defaultView.$(this.$target[0]); + + this.orm = this.bindService("orm"); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + customizeWebsiteViews: async function (previewMode, widgetValue, params) { + await this._customizeWebsite(previewMode, widgetValue, params, 'views'); + }, + /** + * @see this.selectClass for parameters + */ + customizeWebsiteVariable: async function (previewMode, widgetValue, params) { + await this._customizeWebsite(previewMode, widgetValue, params, 'variable'); + }, + /** + * @see this.selectClass for parameters + */ + customizeWebsiteVariables: async function (previewMode, widgetValue, params) { + await this._customizeWebsite(previewMode, widgetValue, params, 'variables'); + }, + /** + * @see this.selectClass for parameters + */ + customizeWebsiteColor: async function (previewMode, widgetValue, params) { + await this._customizeWebsite(previewMode, widgetValue, params, 'color'); + }, + /** + * @see this.selectClass for parameters + */ + async customizeWebsiteAssets(previewMode, widgetValue, params) { + await this._customizeWebsite(previewMode, widgetValue, params, 'assets'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _checkIfWidgetsUpdateNeedReload(widgets) { + const needReload = await this._super(...arguments); + if (needReload) { + return needReload; + } + for (const widget of widgets) { + const methodsNames = widget.getMethodsNames(); + const methodNamesToCheck = this.data.pageOptions + ? methodsNames + : methodsNames.filter(m => this.specialCheckAndReloadMethodsNames.includes(m)); + if (methodNamesToCheck.some(m => widget.getMethodsParams(m).reload)) { + return true; + } + } + return false; + }, + /** + * @override + */ + _computeWidgetState: async function (methodName, params) { + switch (methodName) { + case 'customizeWebsiteViews': { + return this._getEnabledCustomizeValues(params.possibleValues, true); + } + case 'customizeWebsiteVariable': { + const ownerDocument = this.$target[0].ownerDocument; + const style = ownerDocument.defaultView.getComputedStyle(ownerDocument.documentElement); + let finalValue = weUtils.getCSSVariableValue(params.variable, style); + if (!params.colorNames) { + return finalValue; + } + let tempValue = finalValue; + while (tempValue) { + finalValue = tempValue; + tempValue = weUtils.getCSSVariableValue(tempValue.replaceAll("'", ''), style); + } + return finalValue; + } + case 'customizeWebsiteColor': { + const ownerDocument = this.$target[0].ownerDocument; + const style = ownerDocument.defaultView.getComputedStyle(ownerDocument.documentElement); + return weUtils.getCSSVariableValue(params.color, style); + } + case 'customizeWebsiteAssets': { + return this._getEnabledCustomizeValues(params.possibleValues, false); + } + } + return this._super(...arguments); + }, + /** + * @private + */ + _customizeWebsite: async function (previewMode, widgetValue, params, type) { + // Never allow previews for theme customizations + if (previewMode) { + return; + } + + switch (type) { + case 'views': + await this._customizeWebsiteData(widgetValue, params, true); + break; + case 'variable': + await this._customizeWebsiteVariable(widgetValue, params); + break; + case "variables": + const defaultVariables = params.defaultVariables ? + Object.fromEntries(params.defaultVariables.split(",") + .map((variable) => variable.split(":").map(v => v.trim()))) : + {}; + const overriddenVariables = Object.fromEntries(widgetValue.split(",") + .map((variable) => variable.split(":").map(v => v.trim()))); + const variables = Object.assign(defaultVariables, overriddenVariables); + await this._customizeWebsiteVariables(variables, params.nullValue); + break; + case 'color': + await this._customizeWebsiteColor(widgetValue, params); + break; + case 'assets': + await this._customizeWebsiteData(widgetValue, params, false); + break; + default: + if (params.customCustomization) { + await params.customCustomization.call(this, widgetValue, params); + } + } + + if (params.reload || params.noBundleReload) { + // Caller will reload the page, nothing needs to be done anymore. + return; + } + await this._refreshBundles(); + }, + /** + * @private + */ + async _refreshBundles() { + // Finally, only update the bundles as no reload is required + await this._reloadBundles(); + + // Some public widgets may depend on the variables that were + // customized, so we have to restart them *all*. + await new Promise((resolve, reject) => { + this.trigger_up('widgets_start_request', { + editableMode: true, + onSuccess: () => resolve(), + onFailure: () => reject(), + }); + }); + }, + /** + * @private + */ + async _customizeWebsiteColor(color, params) { + await this._customizeWebsiteColors({[params.color]: color}, params); + }, + /** + * @private + */ + async _customizeWebsiteColors(colors, params) { + colors = colors || {}; + + const baseURL = '/website/static/src/scss/options/colors/'; + const colorType = params.colorType ? (params.colorType + '_') : ''; + const url = `${baseURL}user_${colorType}color_palette.scss`; + + const finalColors = {}; + for (const [colorName, color] of Object.entries(colors)) { + finalColors[colorName] = color; + if (color) { + if (weUtils.isColorCombinationName(color)) { + finalColors[colorName] = parseInt(color); + } else if (!isCSSColor(color)) { + finalColors[colorName] = `'${color}'`; + } + } + } + return this._makeSCSSCusto(url, finalColors, params.nullValue); + }, + /** + * @private + */ + _customizeWebsiteVariable: async function (value, params) { + return this._makeSCSSCusto('/website/static/src/scss/options/user_values.scss', { + [params.variable]: value, + }, params.nullValue); + }, + /** + * Customizes several website variables at the same time. + * + * @private + * @param {Object} values: value per key variable + * @param {string} nullValue: string that represent null + */ + _customizeWebsiteVariables: async function (values, nullValue) { + await this._makeSCSSCusto('/website/static/src/scss/options/user_values.scss', values, nullValue); + await this._refreshBundles(); + }, + /** + * @private + */ + async _customizeWebsiteData(value, params, isViewData) { + const allDataKeys = this._getDataKeysFromPossibleValues(params.possibleValues); + const keysToEnable = value.split(/\s*,\s*/); + const enableDataKeys = allDataKeys.filter(value => keysToEnable.includes(value)); + const disableDataKeys = allDataKeys.filter(value => !enableDataKeys.includes(value)); + const resetViewArch = !!params.resetViewArch; + + return rpc('/website/theme_customize_data', { + 'is_view_data': isViewData, + 'enable': enableDataKeys, + 'disable': disableDataKeys, + 'reset_view_arch': resetViewArch, + }); + }, + /** + * @private + */ + _getDataKeysFromPossibleValues(possibleValues) { + const allDataKeys = []; + for (const dataKeysStr of possibleValues) { + allDataKeys.push(...dataKeysStr.split(/\s*,\s*/)); + } + // return only unique non-empty strings + return allDataKeys.filter((v, i, arr) => v && arr.indexOf(v) === i); + }, + /** + * @private + * @param {Array} possibleValues + * @param {Boolean} isViewData true = "ir.ui.view", false = "ir.asset" + * @returns {String} + */ + async _getEnabledCustomizeValues(possibleValues, isViewData) { + const allDataKeys = this._getDataKeysFromPossibleValues(possibleValues); + const enabledValues = await rpc('/website/theme_customize_data_get', { + 'keys': allDataKeys, + 'is_view_data': isViewData, + }); + let mostValuesStr = ''; + let mostValuesNb = 0; + for (const valuesStr of possibleValues) { + const enableValues = valuesStr.split(/\s*,\s*/); + if (enableValues.length > mostValuesNb + && enableValues.every(value => enabledValues.includes(value))) { + mostValuesStr = valuesStr; + mostValuesNb = enableValues.length; + } + } + return mostValuesStr; // Need to return the exact same string as in possibleValues + }, + /** + * @private + */ + _makeSCSSCusto: async function (url, values, defaultValue = 'null') { + Object.keys(values).forEach((key) => { + values[key] = values[key] || defaultValue; + }); + return this.orm.call("web_editor.assets", "make_scss_customization", [url, values]); + }, + /** + * Refreshes all public widgets related to the given element. + * + * @private + * @param {jQuery} [$el=this.$target] + * @returns {Promise} + */ + _refreshPublicWidgets: async function ($el) { + return new Promise((resolve, reject) => { + this.trigger_up('widgets_start_request', { + editableMode: true, + $target: $el || this.$target, + onSuccess: resolve, + onFailure: reject, + }); + }); + }, + /** + * @private + */ + _reloadBundles: async function() { + return new Promise((resolve, reject) => { + this.trigger_up('reload_bundles', { + onSuccess: () => resolve(), + onFailure: () => reject(), + }); + }); + }, + /** + * @override + */ + _select: async function (previewMode, widget) { + await this._super(...arguments); + + // Some blocks flicker when we start their public widgets, so we skip + // the refresh for them to avoid the flickering. + const targetNoRefreshSelector = ".s_instagram_page"; + // TODO: we should review the way public widgets are restarted when + // converting to OWL and a new API. + if (this.options.isWebsite && !widget.$el.closest('[data-no-widget-refresh="true"]').length + && !this.$target[0].matches(targetNoRefreshSelector)) { + // TODO the flag should be retrieved through widget params somehow + await this._refreshPublicWidgets(); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onGoogleFontsCustoRequest: function (ev) { + const values = ev.data.values ? Object.assign({}, ev.data.values) : {}; + const googleFonts = ev.data.googleFonts; + const googleLocalFonts = ev.data.googleLocalFonts; + if (googleFonts.length) { + values['google-fonts'] = "('" + googleFonts.join("', '") + "')"; + } else { + values['google-fonts'] = 'null'; + } + if (googleLocalFonts.length) { + values['google-local-fonts'] = "(" + googleLocalFonts.join(", ") + ")"; + } else { + values['google-local-fonts'] = 'null'; + } + this.trigger_up('snippet_edition_request', {exec: async () => { + return this._makeSCSSCusto('/website/static/src/scss/options/user_values.scss', values); + }}); + this.trigger_up('request_save', { + reloadEditor: true, + }); + }, +}); + +function _getLastPreFilterLayerElement($el) { + // Make sure parallax and video element are considered to be below the + // color filters / shape + const $bgVideo = $el.find('> .o_bg_video_container'); + if ($bgVideo.length) { + return $bgVideo[0]; + } + const $parallaxEl = $el.find('> .s_parallax_bg'); + if ($parallaxEl.length) { + return $parallaxEl[0]; + } + return null; +} + +options.registry.BackgroundToggler.include({ + /** + * Toggles background video on or off. + * + * @see this.selectClass for parameters + */ + toggleBgVideo(previewMode, widgetValue, params) { + if (!widgetValue) { + this.$target.find('> .o_we_bg_filter').remove(); + // TODO: use setWidgetValue instead of calling background directly when possible + const [bgVideoWidget] = this._requestUserValueWidgets('bg_video_opt'); + const bgVideoOpt = bgVideoWidget.getParent(); + return bgVideoOpt._setBgVideo(false, ''); + } else { + // TODO: use trigger instead of el.click when possible + this._requestUserValueWidgets('bg_video_opt')[0].el.click(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + if (methodName === 'toggleBgVideo') { + return this.$target[0].classList.contains('o_background_video'); + } + return this._super(...arguments); + }, + /** + * TODO an overall better management of background layers is needed + * + * @override + */ + _getLastPreFilterLayerElement() { + const el = _getLastPreFilterLayerElement(this.$target); + if (el) { + return el; + } + return this._super(...arguments); + }, +}); + +options.registry.BackgroundShape.include({ + /** + * TODO need a better management of background layers + * + * @override + */ + _getLastPreShapeLayerElement() { + const el = this._super(...arguments); + if (el) { + return el; + } + return _getLastPreFilterLayerElement(this.$target); + }, + /** + * @override + */ + _removeShapeEl(shapeEl) { + this.trigger_up('widgets_stop_request', { + $target: $(shapeEl), + }); + return this._super(...arguments); + }, +}); + +options.registry.ReplaceMedia.include({ + /** + * Adds an anchor to the url. + * Here "anchor" means a specific section of a page. + * + * @see this.selectClass for parameters + */ + setAnchor(previewMode, widgetValue, params) { + const linkEl = this.$target[0].parentElement; + let url = linkEl.getAttribute('href'); + url = url.split('#')[0]; + linkEl.setAttribute('href', url + widgetValue); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + if (methodName === 'setAnchor') { + const parentEl = this.$target[0].parentElement; + if (parentEl.tagName === 'A') { + const href = parentEl.getAttribute('href') || ''; + return href ? `#${href.split('#')[1]}` : ''; + } + return ''; + } + return this._super(...arguments); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'media_link_anchor_opt') { + const parentEl = this.$target[0].parentElement; + const linkEl = parentEl.tagName === 'A' ? parentEl : null; + const href = linkEl ? linkEl.getAttribute('href') : false; + return href && href.startsWith('/'); + } + return this._super(...arguments); + }, + /** + * Fills the dropdown with the available anchors for the page referenced in + * the href. + * + * @override + */ + async _renderCustomXML(uiFragment) { + if (!this.options.isWebsite) { + return this._super(...arguments); + } + await this._super(...arguments); + + + + const oldURLWidgetEl = uiFragment.querySelector('[data-name="media_url_opt"]'); + + const URLWidgetEl = document.createElement('we-urlpicker'); + // Copy attributes + for (const {name, value} of oldURLWidgetEl.attributes) { + URLWidgetEl.setAttribute(name, value); + } + URLWidgetEl.title = _t("Hint: Type '/' to search an existing page and '#' to link to an anchor."); + oldURLWidgetEl.replaceWith(URLWidgetEl); + + const hrefValue = this.$target[0].parentElement.getAttribute('href'); + if (!hrefValue || !hrefValue.startsWith('/')) { + return; + } + const urlWithoutAnchor = hrefValue.split('#')[0]; + const selectEl = document.createElement('we-select'); + selectEl.dataset.name = 'media_link_anchor_opt'; + selectEl.dataset.dependencies = 'media_url_opt'; + selectEl.dataset.noPreview = 'true'; + selectEl.classList.add('o_we_sublevel_1'); + selectEl.setAttribute('string', _t("Page Anchor")); + const anchors = await wUtils.loadAnchors(urlWithoutAnchor); + for (const anchor of anchors) { + const weButtonEl = document.createElement('we-button'); + weButtonEl.dataset.setAnchor = anchor; + weButtonEl.textContent = anchor; + selectEl.append(weButtonEl); + } + URLWidgetEl.after(selectEl); + }, +}); + +options.registry.BackgroundVideo = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Sets the target's background video. + * + * @see this.selectClass for parameters + */ + background: function (previewMode, widgetValue, params) { + if (previewMode === 'reset' && this.videoSrc) { + return this._setBgVideo(false, this.videoSrc); + } + return this._setBgVideo(previewMode, widgetValue); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + if (methodName === 'background') { + if (this.$target[0].classList.contains('o_background_video')) { + return this.$('> .o_bg_video_container iframe').attr('src'); + } + return ''; + } + return this._super(...arguments); + }, + /** + * Updates the background video used by the snippet. + * + * @private + * @see this.selectClass for parameters + * @returns {Promise} + */ + _setBgVideo: async function (previewMode, value) { + this.$('> .o_bg_video_container').toggleClass('d-none', previewMode === true); + + if (previewMode !== false) { + return; + } + + this.videoSrc = value; + var target = this.$target[0]; + target.classList.toggle('o_background_video', !!(value && value.length)); + if (value && value.length) { + target.dataset.bgVideoSrc = value; + } else { + delete target.dataset.bgVideoSrc; + } + await this._refreshPublicWidgets(); + }, +}); + +options.registry.WebsiteLevelColor = options.Class.extend({ + specialCheckAndReloadMethodsNames: options.Class.prototype.specialCheckAndReloadMethodsNames + .concat(['customizeWebsiteLayer2Color']), + /** + * @constructor + */ + init() { + this._super(...arguments); + this._rpc = options.serviceCached(rpc); + }, + /** + * @see this.selectClass for parameters + */ + async customizeWebsiteLayer2Color(previewMode, widgetValue, params) { + if (previewMode) { + return; + } + params.color = params.layerColor; + params.variable = params.layerGradient; + let color = undefined; + let gradient = undefined; + if (weUtils.isColorGradient(widgetValue)) { + color = ''; + gradient = widgetValue; + } else { + color = widgetValue; + gradient = ''; + } + await this.customizeWebsiteVariable(previewMode, gradient, params); + params.noBundleReload = false; + return this.customizeWebsiteColor(previewMode, color, params); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeWidgetState(methodName, params) { + if (methodName === 'customizeWebsiteLayer2Color') { + params.variable = params.layerGradient; + const gradient = await this._computeWidgetState('customizeWebsiteVariable', params); + if (gradient) { + return gradient.substring(1, gradient.length - 1); // Unquote + } + params.color = params.layerColor; + return this._computeWidgetState('customizeWebsiteColor', params); + } + return this._super(...arguments); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + const _super = this._super.bind(this); + if ( + [ + "footer_language_selector_label_opt", + "footer_language_selector_opt", + ].includes(widgetName) + ) { + this._languages = await this._rpc.call("/website/get_languages"); + if (this._languages.length === 1) { + return false; + } + } + return _super(...arguments); + }, +}); + +options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ + GRAY_PARAMS: {EXTRA_SATURATION: "gray-extra-saturation", HUE: "gray-hue"}, + + /** + * @override + */ + init() { + this._super(...arguments); + this.grayParams = {}; + this.grays = {}; + this.orm = this.bindService("orm"); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + async updateUI() { + // The bg-XXX classes have been updated (and could be updated by another + // option like changing color palette) -> update the preview element. + const ownerDocument = this.$target[0].ownerDocument; + const style = ownerDocument.defaultView.getComputedStyle(ownerDocument.documentElement); + const grayPreviewEls = this.$el.find(".o_we_gray_preview span"); + for (const e of grayPreviewEls) { + const bgValue = weUtils.getCSSVariableValue(e.getAttribute('variable'), style); + e.style.setProperty("background-color", bgValue, "important"); + } + + // If the gray palette has been generated by Odoo standard option, + // the hue of all gray is the same and the saturation has been + // increased/decreased by the same amount for all grays in + // comparaison with BS grays. However the system supports any + // gray palette. + + const hues = []; + const saturationDiffs = []; + let oneHasNoSaturation = false; + const baseStyle = getComputedStyle(document.documentElement); + for (let id = 100; id <= 900; id += 100) { + const gray = weUtils.getCSSVariableValue(`${id}`, style); + const grayRGB = convertCSSColorToRgba(gray); + const grayHSL = convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue); + + const baseGray = weUtils.getCSSVariableValue(`base-${id}`, baseStyle); + const baseGrayRGB = convertCSSColorToRgba(baseGray); + const baseGrayHSL = convertRgbToHsl(baseGrayRGB.red, baseGrayRGB.green, baseGrayRGB.blue); + + if (grayHSL.saturation > 0.01) { + if (grayHSL.lightness > 0.01 && grayHSL.lightness < 99.99) { + hues.push(grayHSL.hue); + } + if (grayHSL.saturation < 99.99) { + saturationDiffs.push(grayHSL.saturation - baseGrayHSL.saturation); + } + } else { + oneHasNoSaturation = true; + } + } + this.grayHueIsDefined = !!hues.length; + + // Average of angles: we need to take the average of found hues + // because even if grays are supposed to be set to the exact + // same hue by the Odoo editor, there might be rounding errors + // during the conversion from RGB to HSL as the HSL system + // allows to represent more colors that the RGB hexadecimal + // notation (also: hue 360 = hue 0 and should not be averaged to 180). + // This also better support random gray palettes. + this.grayParams[this.GRAY_PARAMS.HUE] = (!hues.length) ? 0 : Math.round((Math.atan2( + hues.map(hue => Math.sin(hue * Math.PI / 180)).reduce((memo, value) => memo + value, 0) / hues.length, + hues.map(hue => Math.cos(hue * Math.PI / 180)).reduce((memo, value) => memo + value, 0) / hues.length + ) * 180 / Math.PI) + 360) % 360; + + // Average of found saturation diffs, or all grays have no + // saturation, or all grays are fully saturated. + this.grayParams[this.GRAY_PARAMS.EXTRA_SATURATION] = saturationDiffs.length + ? saturationDiffs.reduce((memo, value) => memo + value, 0) / saturationDiffs.length + : (oneHasNoSaturation ? -100 : 100); + + await this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @override + */ + async customizeGray(previewMode, widgetValue, params) { + // Gray parameters are used *on the JS side* to compute the grays that + // will be saved in the database. We indeed need those grays to be + // computed here for faster previews so this allows to not duplicate + // most of the logic. Also, this gives flexibility to maybe allow full + // customization of grays in custo and themes. Also, this allows to ease + // migration if the computation here was to change: the user grays would + // still be unchanged as saved in the database. + + this.grayParams[params.param] = parseInt(widgetValue); + for (let i = 1; i < 10; i++) { + const key = (100 * i).toString(); + this.grays[key] = this._buildGray(key); + } + + // Preview UI update + this.$el.find(".o_we_gray_preview").each((_, e) => { + e.style.setProperty("background-color", this.grays[e.getAttribute('variable')], "important"); + }); + + // Save all computed (JS side) grays in database + await this._customizeWebsite(previewMode, undefined, Object.assign({}, params, { + customCustomization: () => { // TODO this could be prettier + return this._customizeWebsiteColors(this.grays, Object.assign({}, params, { + colorType: 'gray', + })); + }, + })); + }, + /** + * @see this.selectClass for parameters + */ + async configureApiKey(previewMode, widgetValue, params) { + return new Promise(resolve => { + this.trigger_up('gmap_api_key_request', { + editableMode: true, + reconfigure: true, + onSuccess: () => resolve(), + }); + }); + }, + /** + * @see this.selectClass for parameters + */ + async customizeBodyBgType(previewMode, widgetValue, params) { + if (widgetValue === 'NONE') { + this.bodyImageType = 'image'; + return this.customizeBodyBg(previewMode, '', params); + } + // TODO improve: hack to click on external image picker + this.bodyImageType = widgetValue; + const widget = this._requestUserValueWidgets(params.imagepicker)[0]; + widget.enable(); + }, + /** + * @override + */ + async customizeBodyBg(previewMode, widgetValue, params) { + await this._customizeWebsiteVariables({ + 'body-image-type': this.bodyImageType, + 'body-image': widgetValue ? `'${widgetValue}'` : '', + }, params.nullValue); + }, + async openCustomCodeDialog(previewMode, widgetValue, params) { + return new Promise(resolve => { + this.trigger_up('open_edit_head_body_dialog', { + onSuccess: resolve, + }); + }); + }, + /** + * @see this.selectClass for parameters + */ + async switchTheme(previewMode, widgetValue, params) { + const save = await new Promise(resolve => { + this.dialog.add(ConfirmationDialog, { + body: _t("Changing theme requires to leave the editor. This will save all your changes, are you sure you want to proceed? Be careful that changing the theme will reset all your color customizations."), + confirm: () => resolve(true), + cancel: () => resolve(false), + }); + }); + if (!save) { + return; + } + this.trigger_up('request_save', { + reload: false, + action: 'website.theme_install_kanban_action', + }); + }, + /** + * @see this.selectClass for parameters + */ + async addLanguage(previewMode, widgetValue, params) { + // Retrieve the website id to check by default the website checkbox in + // the dialog box 'action_view_base_language_install' + const websiteId = this.options.context.website_id; + const save = await new Promise((resolve) => { + this.dialog.add(ConfirmationDialog, { + body: _t("Adding a language requires to leave the editor. This will save all your changes, are you sure you want to proceed?"), + confirm: () => resolve(true), + cancel: () => resolve(false), + }); + }); + if (!save) { + return; + } + this.trigger_up("request_save", { + reload: false, + action: "base.action_view_base_language_install", + options: { + additionalContext: { + params: { + website_id: websiteId, + url_return: "[lang]", + } + }, + } + }); + }, + /** + * @see this.selectClass for parameters + */ + async customizeButtonStyle(previewMode, widgetValue, params) { + await this._customizeWebsiteVariables({ + [`btn-${params.button}-outline`]: widgetValue === "outline" ? "true" : "false", + [`btn-${params.button}-flat`]: widgetValue === "flat" ? "true" : "false", + }, params.nullValue); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {String} id + * @returns {String} the adjusted color of gray + */ + _buildGray(id) { + // Getting base grays defined in color_palette.scss + const gray = weUtils.getCSSVariableValue(`base-${id}`, getComputedStyle(document.documentElement)); + const grayRGB = convertCSSColorToRgba(gray); + const hsl = convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue); + const adjustedGrayRGB = convertHslToRgb(this.grayParams[this.GRAY_PARAMS.HUE], + Math.min(Math.max(hsl.saturation + this.grayParams[this.GRAY_PARAMS.EXTRA_SATURATION], 0), 100), + hsl.lightness); + return convertRgbaToCSSColor(adjustedGrayRGB.red, adjustedGrayRGB.green, adjustedGrayRGB.blue); + }, + /** + * @override + */ + async _renderCustomXML(uiFragment) { + await this._super(...arguments); + const extraSaturationRangeEl = uiFragment.querySelector(`we-range[data-param=${this.GRAY_PARAMS.EXTRA_SATURATION}]`); + if (extraSaturationRangeEl) { + const baseGrays = range(100, 1000, 100).map(id => { + const gray = weUtils.getCSSVariableValue(`base-${id}`); + const grayRGB = convertCSSColorToRgba(gray); + const hsl = convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue); + return {id: id, hsl: hsl}; + }); + const first = baseGrays[0]; + const maxValue = baseGrays.reduce((gray, value) => { + return gray.hsl.saturation > value.hsl.saturation ? gray : value; + }, first); + const minValue = baseGrays.reduce((gray, value) => { + return gray.hsl.saturation < value.hsl.saturation ? gray : value; + }, first); + extraSaturationRangeEl.dataset.max = 100 - minValue.hsl.saturation; + extraSaturationRangeEl.dataset.min = -maxValue.hsl.saturation; + } + }, + /** + * @override + */ + async _checkIfWidgetsUpdateNeedWarning(widgets) { + const warningMessage = await this._super(...arguments); + if (warningMessage) { + return warningMessage; + } + for (const widget of widgets) { + if (widget.getMethodsNames().includes('customizeWebsiteVariable') + && widget.getMethodsParams('customizeWebsiteVariable').variable === 'color-palettes-name') { + const hasCustomizedColors = weUtils.getCSSVariableValue('has-customized-colors'); + if (hasCustomizedColors && hasCustomizedColors !== 'false') { + return _t("Changing the color palette will reset all your color customizations, are you sure you want to proceed?"); + } + } + } + return ''; + }, + /** + * @override + */ + async _computeWidgetState(methodName, params) { + if (methodName === 'customizeBodyBgType') { + const bgImage = getComputedStyle(this.ownerDocument.querySelector('#wrapwrap'))['background-image']; + if (bgImage === 'none') { + return "NONE"; + } + return weUtils.getCSSVariableValue('body-image-type'); + } + if (methodName === 'customizeGray') { + // See updateUI override + return this.grayParams[params.param]; + } + if (methodName === 'customizeButtonStyle') { + const isOutline = weUtils.getCSSVariableValue(`btn-${params.button}-outline`); + const isFlat = weUtils.getCSSVariableValue(`btn-${params.button}-flat`); + return isFlat === "true" ? "flat" : isOutline === "true" ? "outline" : "fill"; + } + return this._super(...arguments); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'body_bg_image_opt') { + return false; + } + if (params.param === this.GRAY_PARAMS.HUE) { + return this.grayHueIsDefined; + } + if (params.removeFont) { + const font = await this._computeWidgetState('customizeWebsiteVariable', { + variable: params.removeFont, + }); + return !!font; + } + return this._super(...arguments); + }, +}); + +options.registry.ThemeColors = options.registry.OptionsTab.extend({ + /** + * @override + */ + async start() { + // Checks for support of the old color system + const style = window.getComputedStyle(this.$target[0].ownerDocument.documentElement); + const supportOldColorSystem = weUtils.getCSSVariableValue('support-13-0-color-system', style) === 'true'; + const hasCustomizedOldColorSystem = weUtils.getCSSVariableValue('has-customized-13-0-color-system', style) === 'true'; + this._showOldColorSystemWarning = supportOldColorSystem && hasCustomizedOldColorSystem; + + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + async updateUIVisibility() { + await this._super(...arguments); + const oldColorSystemEl = this.el.querySelector('.o_old_color_system_warning'); + oldColorSystemEl.classList.toggle('d-none', !this._showOldColorSystemWarning); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _renderCustomXML(uiFragment) { + const paletteSelectorEl = uiFragment.querySelector('[data-variable="color-palettes-name"]'); + const style = window.getComputedStyle(document.documentElement); + const allPaletteNames = weUtils.getCSSVariableValue('palette-names', style).split(', ').map((name) => { + return name.replace(/'/g, ""); + }); + for (const paletteName of allPaletteNames) { + const btnEl = document.createElement('we-button'); + btnEl.classList.add('o_palette_color_preview_button'); + btnEl.dataset.customizeWebsiteVariable = `'${paletteName}'`; + [1, 3, 2].forEach(c => { + const colorPreviewEl = document.createElement('span'); + colorPreviewEl.classList.add('o_palette_color_preview'); + const color = weUtils.getCSSVariableValue(`o-palette-${paletteName}-o-color-${c}`, style); + colorPreviewEl.style.backgroundColor = color; + btnEl.appendChild(colorPreviewEl); + }); + paletteSelectorEl.appendChild(btnEl); + } + + const presetCollapseEl = uiFragment.querySelector('we-collapse.o_we_theme_presets_collapse'); + let ccPreviewEls = []; + for (let i = 1; i <= 5; i++) { + const collapseEl = document.createElement('we-collapse'); + const ccPreviewEl = $(renderToElement('web_editor.color.combination.preview.legacy'))[0]; + ccPreviewEl.classList.add('text-center', `o_cc${i}`, 'o_colored_level', 'o_we_collapse_toggler'); + collapseEl.appendChild(ccPreviewEl); + collapseEl.appendChild(renderToFragment('website.color_combination_edition', {number: i})); + ccPreviewEls.push(ccPreviewEl); + presetCollapseEl.appendChild(collapseEl); + } + await this._super(...arguments); + }, +}); + +options.registry.menu_data = options.Class.extend({ + init() { + this._super(...arguments); + this.orm = this.bindService("orm"); + this.notification = this.bindService("notification"); + }, + + /** + * When the users selects a menu, a popover is shown with 4 possible + * actions: follow the link in a new tab, copy the menu link, edit the menu, + * or edit the menu tree. + * The popover shows a preview of the menu link. Remote URL only show the + * favicon. + * + * @override + */ + start: function () { + const wysiwyg = $(this.ownerDocument.getElementById('wrapwrap')).data('wysiwyg'); + const popoverContainer = this.ownerDocument.getElementById('oe_manipulators'); + NavbarLinkPopoverWidget.createFor({ + target: this.$target[0], + wysiwyg, + container: popoverContainer, + notify: this.notification.add, + checkIsWebsiteDesigner: () => user.hasGroup("website.group_website_designer"), + onEditLinkClick: (widget) => { + var $menu = widget.$target.find('[data-oe-id]'); + this.trigger_up('menu_dialog', { + name: $menu.text(), + url: $menu.parent().attr('href'), + save: (name, url) => { + let websiteId; + this.trigger_up('context_get', { + callback: ctx => websiteId = ctx['website_id'], + }); + const data = { + id: $menu.data('oe-id'), + name, + url, + }; + return this.orm.call( + "website.menu", + "save", + [websiteId, {'data': [data]}] + ).then(function () { + widget.wysiwyg.odooEditor.observerUnactive(); + widget.$target.attr('href', url); + $menu.text(name); + widget.wysiwyg.odooEditor.observerActive(); + }); + }, + }); + widget.popover.hide(); + }, + onEditMenuClick: (widget) => { + const contentMenu = widget.target.closest('[data-content_menu_id]'); + const rootID = contentMenu ? parseInt(contentMenu.dataset.content_menu_id, 10) : undefined; + this.trigger_up('action_demand', { + actionName: 'edit_menu', + params: [rootID], + }); + }, + }); + return this._super(...arguments); + }, + /** + * When the users selects another element on the page, makes sure the + * popover is closed. + * + * @override + */ + onBlur: function () { + this.$target.popover('hide'); + }, +}); + +options.registry.Carousel = options.registry.CarouselHandler.extend({ + /** + * @override + */ + start: function () { + this.$bsTarget.carousel('pause'); + this.$indicators = this.$target.find('.carousel-indicators'); + this.$controls = this.$target.find('.carousel-control-prev, .carousel-control-next, .carousel-indicators'); + + // Prevent enabling the carousel overlay when clicking on the carousel + // controls (indeed we want it to change the carousel slide then enable + // the slide overlay) + See "CarouselItem" option. + this.$controls.addClass('o_we_no_overlay'); + + let _slideTimestamp; + this.$bsTarget.on('slide.bs.carousel.carousel_option', () => { + _slideTimestamp = window.performance.now(); + setTimeout(() => this.trigger_up('hide_overlay')); + }); + this.$bsTarget.on('slid.bs.carousel.carousel_option', () => { + // slid.bs.carousel is most of the time fired too soon by bootstrap + // since it emulates the transitionEnd with a setTimeout. We wait + // here an extra 20% of the time before retargeting edition, which + // should be enough... + const _slideDuration = (window.performance.now() - _slideTimestamp); + setTimeout(() => { + this.trigger_up('activate_snippet', { + $snippet: this.$target.find('.carousel-item.active'), + ifInactiveOptions: true, + }); + this.$bsTarget.trigger('active_slide_targeted'); + }, 0.2 * _slideDuration); + }); + + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + this.$bsTarget.off('.carousel_option'); + }, + /** + * @override + */ + onBuilt: function () { + this._assignUniqueID(); + }, + /** + * @override + */ + onClone: function () { + this._assignUniqueID(); + }, + /** + * @override + */ + cleanForSave: function () { + const $items = this.$target.find('.carousel-item'); + $items.removeClass('next prev left right active').first().addClass('active'); + this.$indicators.find('li').removeClass('active').empty().first().addClass('active'); + }, + /** + * @override + */ + notify: function (name, data) { + this._super(...arguments); + if (name === 'add_slide') { + this._addSlide(); + } + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + addSlide(previewMode, widgetValue, params) { + this._addSlide(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Creates a unique ID for the carousel and reassign data-attributes that + * depend on it. + * + * @private + */ + _assignUniqueID: function () { + const id = 'myCarousel' + Date.now(); + this.$target.attr('id', id); + this.$target.find('[data-bs-target]').attr('data-bs-target', '#' + id); + this.$target.find('[data-bs-slide], [data-bs-slide-to]').toArray().forEach((el) => { + var $el = $(el); + if ($el.attr('data-bs-target')) { + $el.attr('data-bs-target', '#' + id); + } else if ($el.attr('href')) { + $el.attr('href', '#' + id); + } + }); + }, + /** + * Adds a slide. + * + * @private + */ + _addSlide() { + const $items = this.$target.find('.carousel-item'); + this.$controls.removeClass('d-none'); + const $active = $items.filter('.active'); + this.$indicators.append($('<li>', { + 'data-bs-target': '#' + this.$target.attr('id'), + 'data-bs-slide-to': $items.length, + })); + this.$indicators.append(' '); + // Need to remove editor data from the clone so it gets its own. + $active.clone(false) + .removeClass('active') + .insertAfter($active); + this.$bsTarget.carousel('next'); + }, + /** + * @override + */ + _getItemsGallery() { + return Array.from(this.$target[0].querySelectorAll(".carousel-item")); + }, + /** + * @override + */ + _reorderItems(itemsEls, newItemPosition) { + const carouselInnerEl = this.$target[0].querySelector(".carousel-inner"); + // First, empty the content of the carousel. + carouselInnerEl.replaceChildren(); + // Then fill it with the new slides. + for (const itemsEl of itemsEls) { + carouselInnerEl.append(itemsEl); + } + this._updateIndicatorAndActivateSnippet(newItemPosition); + }, + +}); + +options.registry.CarouselItem = options.Class.extend({ + isTopOption: true, + forceNoDeleteButton: true, + + /** + * @override + */ + start: function () { + this.$carousel = this.$bsTarget.closest('.carousel'); + this.$indicators = this.$carousel.find('.carousel-indicators'); + this.$controls = this.$carousel.find('.carousel-control-prev, .carousel-control-next, .carousel-indicators'); + + var leftPanelEl = this.$overlay.data('$optionsSection')[0]; + var titleTextEl = leftPanelEl.querySelector('we-title > span'); + this.counterEl = document.createElement('span'); + titleTextEl.appendChild(this.counterEl); + + return this._super(...arguments); + }, + /** + * @override + */ + destroy: function () { + this._super(...arguments); + this.$carousel.off('.carousel_item_option'); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Updates the slide counter. + * + * @override + */ + updateUI: async function () { + await this._super(...arguments); + const $items = this.$carousel.find('.carousel-item'); + const $activeSlide = $items.filter('.active'); + const updatedText = ` (${$activeSlide.index() + 1}/${$items.length})`; + this.counterEl.textContent = updatedText; + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + addSlideItem(previewMode, widgetValue, params) { + this.trigger_up('option_update', { + optionName: 'Carousel', + name: 'add_slide', + }); + }, + /** + * Removes the current slide. + * + * @see this.selectClass for parameters. + */ + removeSlide: function (previewMode) { + const $items = this.$carousel.find('.carousel-item'); + const newLength = $items.length - 1; + if (!this.removing && newLength > 0) { + // The active indicator is deleted to ensure that the other + // indicators will still work after the deletion. + const $toDelete = $items.filter('.active').add(this.$indicators.find('.active')); + this.$carousel.one('active_slide_targeted.carousel_item_option', () => { + $toDelete.remove(); + // To ensure the proper functioning of the indicators, their + // attributes must reflect the position of the slides. + const indicatorsEls = this.$indicators[0].querySelectorAll('li'); + for (let i = 0; i < indicatorsEls.length; i++) { + indicatorsEls[i].setAttribute('data-bs-slide-to', i); + } + this.$controls.toggleClass('d-none', newLength === 1); + this.$carousel.trigger('content_changed'); + this.removing = false; + }); + this.removing = true; + this.$carousel.carousel('prev'); + } + }, + /** + * Goes to next slide or previous slide. + * + * @see this.selectClass for parameters + */ + switchToSlide: function (previewMode, widgetValue, params) { + switch (widgetValue) { + case 'left': + this.$controls.filter('.carousel-control-prev')[0].click(); + break; + case 'right': + this.$controls.filter('.carousel-control-next')[0].click(); + break; + } + }, +}); + +options.registry.Parallax = options.Class.extend({ + /** + * @override + */ + async start() { + this.parallaxEl = this.$target.find('> .s_parallax_bg')[0] || null; + this._updateBackgroundOptions(); + + this.$target.on('content_changed.ParallaxOption', this._onExternalUpdate.bind(this)); + + return this._super(...arguments); + }, + /** + * @override + */ + onFocus() { + // Refresh the parallax animation on focus; at least useful because + // there may have been changes in the page that influenced the parallax + // rendering (new snippets, ...). + // TODO make this automatic. + if (this.parallaxEl) { + this._refreshPublicWidgets(); + } + }, + /** + * @override + */ + onMove() { + this._refreshPublicWidgets(); + }, + /** + * @override + */ + destroy() { + this._super(...arguments); + this.$target.off('.ParallaxOption'); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Build/remove parallax. + * + * @see this.selectClass for parameters + */ + async selectDataAttribute(previewMode, widgetValue, params) { + await this._super(...arguments); + if (params.attributeName !== 'scrollBackgroundRatio') { + return; + } + + const isParallax = (widgetValue !== '0'); + this.$target.toggleClass('parallax', isParallax); + this.$target.toggleClass('s_parallax_is_fixed', widgetValue === '1'); + this.$target.toggleClass('s_parallax_no_overflow_hidden', (widgetValue === '0' || widgetValue === '1')); + if (isParallax) { + if (!this.parallaxEl) { + this.parallaxEl = document.createElement('span'); + this.parallaxEl.classList.add('s_parallax_bg'); + this.$target.prepend(this.parallaxEl); + } + } else { + if (this.parallaxEl) { + this.parallaxEl.remove(); + this.parallaxEl = null; + } + } + + this._updateBackgroundOptions(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeVisibility(widgetName) { + return !this.$target.hasClass('o_background_video'); + }, + /** + * @override + */ + async _computeWidgetState(methodName, params) { + if (methodName === 'selectDataAttribute' && params.parallaxTypeOpt) { + const attrName = params.attributeName; + const attrValue = (this.$target[0].dataset[attrName] || params.attributeDefaultValue).trim(); + switch (attrValue) { + case '0': + case '1': { + return attrValue; + } + default: { + return (attrValue.startsWith('-') ? '-1.5' : '1.5'); + } + } + } + return this._super(...arguments); + }, + /** + * Updates external background-related option to work with the parallax + * element instead of the original target when necessary. + * + * @private + */ + _updateBackgroundOptions() { + this.trigger_up('option_update', { + optionNames: ['BackgroundImage', 'BackgroundPosition', 'BackgroundOptimize'], + name: 'target', + data: this.parallaxEl ? $(this.parallaxEl) : this.$target, + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called on any snippet update to check if the parallax should still be + * enabled or not. + * + * TODO there is probably a better system to implement to solve this issue. + * + * @private + * @param {Event} ev + */ + _onExternalUpdate(ev) { + if (!this.parallaxEl) { + return; + } + const bgImage = this.parallaxEl.style.backgroundImage; + if (!bgImage || bgImage === 'none' || this.$target.hasClass('o_background_video')) { + // The parallax option was enabled but the background image was + // removed: disable the parallax option. + const widget = this._requestUserValueWidgets('parallax_none_opt')[0]; + widget.enable(); + widget.getParent().close(); // FIXME remove this ugly hack asap + } + }, +}); + +options.registry.collapse = options.Class.extend({ + /** + * @override + */ + start: function () { + var self = this; + this.$bsTarget.on('shown.bs.collapse hidden.bs.collapse', '[role="tabpanel"]', function () { + self.trigger_up('cover_update'); + self.$target.trigger('content_changed'); + }); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + onBuilt: function () { + this._createIDs(); + }, + /** + * @override + */ + onClone: function () { + this._createIDs(); + }, + /** + * @override + */ + onMove: function () { + this._createIDs(); + var $panel = this.$bsTarget.find('.collapse').removeData('bs.collapse'); + if ($panel.attr('aria-expanded') === 'true') { + $panel.closest('.accordion').find('.collapse[aria-expanded="true"]') + .filter((i, el) => (el !== $panel[0])) + .collapse('hide') + .one('hidden.bs.collapse', function () { + $panel.trigger('shown.bs.collapse'); + }); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Associates unique ids on collapse elements. + * + * @private + */ + _createIDs: function () { + let time = new Date().getTime(); + const $tablist = this.$target.closest('[role="tablist"]'); + const $tab = this.$target.find('[role="tab"]'); + const $panel = this.$target.find('[role="tabpanel"]'); + const $body = this.$target.closest('body'); + + const setUniqueId = ($elem, label) => { + let elemId = $elem.attr('id'); + if (!elemId || $body.find('[id="' + elemId + '"]').length > 1) { + do { + time++; + elemId = label + time; + } while ($body.find('#' + elemId).length); + $elem.attr('id', elemId); + } + return elemId; + }; + + const tablistId = setUniqueId($tablist, 'myCollapse'); + $panel.attr('data-bs-parent', '#' + tablistId); + $panel.data('bs-parent', '#' + tablistId); + + const panelId = setUniqueId($panel, 'myCollapseTab'); + $tab.attr('data-bs-target', '#' + panelId); + $tab.data('bs-target', '#' + panelId); + + $tab[0].setAttribute("aria-controls", panelId); + }, +}); + +options.registry.HeaderElements = options.Class.extend({ + /** + * @constructor + */ + init() { + this._super(...arguments); + this._rpc = options.serviceCached(rpc); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + const _super = this._super.bind(this); + switch (widgetName) { + case "header_language_selector_opt": + this._languages = await this._rpc.call("/website/get_languages"); + if (this._languages.length === 1) { + return false; + } + break; + } + return _super(...arguments); + }, +}); + +options.registry.HeaderNavbar = options.Class.extend({ + /** + * Particular case: we want the option to be associated on the header navbar + * in XML so that the related options only appear on navbar click (not + * header), in a different section, etc... but we still want the target to + * be the header itself. + * + * @constructor + */ + init() { + this._super(...arguments); + this.setTarget(this.$target.closest('#wrapwrap > header')); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Needs to be done manually for now because data-dependencies + * doesn't work with "AND" conditions. + * TODO: improve this. + * + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + switch (widgetName) { + case 'option_logo_height_scrolled': { + return !!this.$('.navbar-brand').length; + } + } + return this._super(...arguments); + }, +}); + +const VisibilityPageOptionUpdate = options.Class.extend({ + pageOptionName: undefined, + showOptionWidgetName: undefined, + shownValue: '', + + /** + * @override + */ + async onTargetShow() { + if (await this._isShown()) { + // onTargetShow may be called even if the element is already shown. + // In most cases, this is not a problem but here it is as the code + // that follows clicks on the visibility checkbox regardless of its + // status. This avoids searching for that checkbox entirely. + return; + } + // TODO improve: here we make a hack so that if we make the invisible + // header appear for edition, its actual visibility for the page is + // toggled (otherwise it would be about editing an element which + // is actually never displayed on the page). + const widget = this._requestUserValueWidgets(this.showOptionWidgetName)[0]; + widget.enable(); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for params + */ + async visibility(previewMode, widgetValue, params) { + const show = (widgetValue !== 'hidden'); + await new Promise((resolve, reject) => { + this.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: this.pageOptionName, value: show}], + onSuccess: () => resolve(), + onFailure: reject, + }); + }); + this.trigger_up('snippet_option_visibility_update', {show: show}); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeWidgetState(methodName, params) { + if (methodName === 'visibility') { + const shown = await this._isShown(); + return shown ? this.shownValue : 'hidden'; + } + return this._super(...arguments); + }, + /** + * @private + * @returns {boolean} + */ + async _isShown() { + return new Promise((resolve, reject) => { + this.trigger_up('action_demand', { + actionName: 'get_page_option', + params: [this.pageOptionName], + onSuccess: v => resolve(!!v), + onFailure: reject, + }); + }); + }, +}); + +options.registry.TopMenuVisibility = VisibilityPageOptionUpdate.extend({ + pageOptionName: 'header_visible', + showOptionWidgetName: 'regular_header_visibility_opt', + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Handles the switching between 3 differents visibilities of the header. + * + * @see this.selectClass for params + */ + async visibility(previewMode, widgetValue, params) { + await this._super(...arguments); + await this._changeVisibility(widgetValue); + // TODO this is hacky but changing the header visibility may have an + // effect on features like FullScreenHeight which depend on viewport + // size so we simulate a resize. + const targetWindow = this.$target[0].ownerDocument.defaultView; + targetWindow.dispatchEvent(new targetWindow.Event('resize')); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _changeVisibility(widgetValue) { + const show = (widgetValue !== 'hidden'); + if (!show) { + return; + } + const transparent = (widgetValue === 'transparent'); + await new Promise((resolve, reject) => { + this.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: 'header_overlay', value: transparent}], + onSuccess: () => resolve(), + onFailure: reject, + }); + }); + if (!transparent) { + return; + } + // TODO should be able to change both options at the same time, as the + // `params` list suggests. + await new Promise((resolve, reject) => { + this.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: 'header_color', value: ''}], + onSuccess: () => resolve(), + onFailure: reject, + }); + }); + await new Promise(resolve => { + this.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: 'header_text_color', value: ''}], + onSuccess: () => resolve(), + }); + }); + }, + /** + * @override + */ + async _computeWidgetState(methodName, params) { + const _super = this._super.bind(this); + if (methodName === 'visibility') { + this.shownValue = await new Promise((resolve, reject) => { + this.trigger_up('action_demand', { + actionName: 'get_page_option', + params: ['header_overlay'], + onSuccess: v => resolve(v ? 'transparent' : 'regular'), + onFailure: reject, + }); + }); + } + return _super(...arguments); + }, +}); + +options.registry.topMenuColor = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @override + */ + async selectStyle(previewMode, widgetValue, params) { + await this._super(...arguments); + if (widgetValue && !isCSSColor(widgetValue)) { + widgetValue = params.colorPrefix + widgetValue; + } + await new Promise((resolve, reject) => { + this.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: params.pageOptionName, value: widgetValue}], + onSuccess: resolve, + onFailure: reject, + }); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeVisibility: async function () { + const show = await this._super(...arguments); + if (!show) { + return false; + } + return new Promise((resolve, reject) => { + this.trigger_up('action_demand', { + actionName: 'get_page_option', + params: ['header_overlay'], + onSuccess: value => resolve(!!value), + onFailure: reject, + }); + }); + }, +}); + +/** + * Manage the visibility of snippets on mobile/desktop. + */ +options.registry.DeviceVisibility = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Toggles the device visibility. + * + * @see this.selectClass for parameters + */ + async toggleDeviceVisibility(previewMode, widgetValue, params) { + this.$target[0].classList.remove('d-none', 'd-md-none', 'd-lg-none', + 'o_snippet_mobile_invisible', 'o_snippet_desktop_invisible', + 'o_snippet_override_invisible', + ); + const style = getComputedStyle(this.$target[0]); + this.$target[0].classList.remove(`d-md-${style['display']}`, `d-lg-${style['display']}`); + if (widgetValue === 'no_desktop') { + this.$target[0].classList.add('d-lg-none', 'o_snippet_desktop_invisible'); + } else if (widgetValue === 'no_mobile') { + this.$target[0].classList.add(`d-lg-${style['display']}`, 'd-none', 'o_snippet_mobile_invisible'); + } + + // Update invisible elements. + const isMobile = wUtils.isMobile(this); + this.trigger_up('snippet_option_visibility_update', {show: widgetValue !== (isMobile ? 'no_mobile' : 'no_desktop')}); + }, + /** + * @override + */ + async onTargetHide() { + this.$target[0].classList.remove('o_snippet_override_invisible'); + }, + /** + * @override + */ + async onTargetShow() { + const isMobilePreview = weUtils.isMobileView(this.$target[0]); + const isMobileHidden = this.$target[0].classList.contains("o_snippet_mobile_invisible"); + if ((this.$target[0].classList.contains('o_snippet_mobile_invisible') + || this.$target[0].classList.contains('o_snippet_desktop_invisible') + ) && isMobilePreview === isMobileHidden) { + this.$target[0].classList.add('o_snippet_override_invisible'); + } + }, + /** + * @override + */ + cleanForSave() { + this.$target[0].classList.remove('o_snippet_override_invisible'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeWidgetState(methodName, params) { + if (methodName === 'toggleDeviceVisibility') { + const classList = [...this.$target[0].classList]; + if (classList.includes('d-none') && + classList.some(className => className.match(/^d-(md|lg)-/))) { + return 'no_mobile'; + } + if (classList.some(className => className.match(/d-(md|lg)-none/))) { + return 'no_desktop'; + } + return ''; + } + return await this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility(widgetName, params) { + if (this.$target[0].classList.contains('s_table_of_content_main')) { + return false; + } + return this._super(...arguments); + } +}); + +/** + * Hide/show footer in the current page. + */ +options.registry.HideFooter = VisibilityPageOptionUpdate.extend({ + pageOptionName: 'footer_visible', + showOptionWidgetName: 'hide_footer_page_opt', + shownValue: 'shown', +}); + +/** + * Handles the edition of snippet's anchor name. + */ +options.registry.anchor = options.Class.extend({ + isTopOption: true, + + /** + * @override + */ + init() { + this._super(...arguments); + this.notification = this.bindService("notification"); + }, + /** + * @override + */ + start() { + // Generate anchor and copy it to clipboard on click, show the tooltip on success + const buttonEl = this.el.querySelector("we-button"); + this.isModal = this.$target[0].classList.contains("modal"); + if (buttonEl && !this.isModal) { + this._buildClipboard(buttonEl); + } + + return this._super(...arguments); + }, + /** + * @override + */ + onClone: function () { + this.$target.removeAttr('data-anchor'); + this.$target.filter(':not(.carousel)').removeAttr('id'); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + notify(name, data) { + this._super(...arguments); + if (name === "modalAnchor") { + this._buildClipboard(data.buttonEl); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Element} buttonEl + */ + _buildClipboard(buttonEl) { + buttonEl.addEventListener("click", async (ev) => { + const anchorLink = this._getAnchorLink(); + await browser.navigator.clipboard.writeText(anchorLink); + const message = markup(_t("Anchor copied to clipboard<br>Link: %s", anchorLink)); + this.notification.add(message, { + type: "success", + buttons: [{name: _t("Edit"), onClick: () => this._openAnchorDialog(buttonEl), primary: true}], + }); + }); + }, + + /** + * @private + * @param {Element} buttonEl + */ + _openAnchorDialog(buttonEl) { + const anchorDialog = class extends Component { + static template = "website.dialog.anchorName"; + static props = { close: Function, confirm: Function, delete: Function, currentAnchor: String }; + static components = { Dialog }; + title = _t("Link Anchor"); + modalRef = useChildRef(); + onClickConfirm() { + const shouldClose = this.props.confirm(this.modalRef); + if (shouldClose) { + this.props.close(); + } + } + onClickDelete() { + this.props.delete(); + this.props.close(); + } + onClickDiscard() { + this.props.close(); + } + }; + const props = { + confirm: (modalRef) => { + const inputEl = modalRef.el.querySelector(".o_input_anchor_name"); + const anchorName = this._text2Anchor(inputEl.value); + if (this.$target[0].id === anchorName) { + // If the chosen anchor name is already the one used by the + // element, close the dialog and do nothing else + return true; + } + + const alreadyExists = !!this.ownerDocument.getElementById(anchorName); + modalRef.el.querySelector('.o_anchor_already_exists').classList.toggle('d-none', !alreadyExists); + inputEl.classList.toggle('is-invalid', alreadyExists); + if (!alreadyExists) { + this._setAnchorName(anchorName); + buttonEl.click(); + return true; + } + }, + currentAnchor: decodeURIComponent(this.$target.attr('id')), + }; + if (this.$target.attr('id')) { + props["delete"] = () => { + this._setAnchorName(); + }; + } + this.dialog.add(anchorDialog, props); + }, + /** + * @private + * @param {String} value + */ + _setAnchorName: function (value) { + if (value) { + this.$target[0].id = value; + if (!this.isModal) { + this.$target[0].dataset.anchor = true; + } + } else { + this.$target.removeAttr('id data-anchor'); + } + this.$target.trigger('content_changed'); + }, + /** + * Returns anchor text. + * + * @private + * @returns {string} + */ + _getAnchorLink: function () { + if (!this.$target[0].id) { + const $titles = this.$target.find('h1, h2, h3, h4, h5, h6'); + const title = $titles.length > 0 ? $titles[0].innerText : this.data.snippetName; + const anchorName = this._text2Anchor(title); + let n = ''; + while (this.ownerDocument.getElementById(anchorName + n)) { + n = (n || 1) + 1; + } + this._setAnchorName(anchorName + n); + } + const pathName = this.isModal ? "" : this.ownerDocument.location.pathname; + return `${pathName}#${this.$target[0].id}`; + }, + /** + * Creates a safe id/anchor from text. + * + * @private + * @param {string} text + * @returns {string} + */ + _text2Anchor: function (text) { + return encodeURIComponent(text.trim().replace(/\s+/g, '-')); + }, +}); + +options.registry.HeaderBox = options.registry.Box.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @override + */ + async selectStyle(previewMode, widgetValue, params) { + if ((params.variable || params.color) + && ['border-width', 'border-style', 'border-color', 'border-radius', 'box-shadow'].includes(params.cssProperty)) { + if (previewMode) { + return; + } + if (params.cssProperty === 'border-color') { + return this.customizeWebsiteColor(previewMode, widgetValue, params); + } + return this.customizeWebsiteVariable(previewMode, widgetValue, params); + } + return this._super(...arguments); + }, + /** + * @override + */ + async setShadow(previewMode, widgetValue, params) { + if (params.variable) { + if (previewMode) { + return; + } + const defaultShadow = this._getDefaultShadow(widgetValue, params.shadowClass); + return this.customizeWebsiteVariable(previewMode, defaultShadow, params); + } + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeWidgetState(methodName, params) { + const value = await this._super(...arguments); + if (methodName === "selectStyle" && params.cssProperty === "border-width") { + // One-sided borders return "0px 0px 3px 0px", which prevents the + // option from being displayed properly. We only keep the affected + // border. + return value.replace(/(^|\s)0px/gi, "").trim() || value; + } + return value; + }, +}); + +options.registry.CookiesBar = options.registry.SnippetPopup.extend({ + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Change the cookies bar layout. + * + * @see this.selectClass for parameters + */ + selectLayout: function (previewMode, widgetValue, params) { + let websiteId; + this.trigger_up('context_get', { + callback: function (ctx) { + websiteId = ctx['website_id']; + }, + }); + + const $template = $(renderToElement(`website.cookies_bar.${widgetValue}`, { + websiteId: websiteId, + })); + + const $content = this.$target.find('.modal-content'); + + // The order of selectors is significant since certain selectors may be + // nested within others, and we want to preserve the nested ones. + // For instance, in the case of '.o_cookies_bar_text_policy' nested + // inside '.o_cookies_bar_text_secondary', the parent selector should be + // copied first, followed by the child selector to ensure that the + // content of the nested selector is not overwritten. + const selectorsToKeep = [ + '.o_cookies_bar_text_button', + '.o_cookies_bar_text_button_essential', + '.o_cookies_bar_text_title', + '.o_cookies_bar_text_primary', + '.o_cookies_bar_text_secondary', + '.o_cookies_bar_text_policy' + ]; + + if (this.$savedSelectors === undefined) { + this.$savedSelectors = []; + } + + for (const selector of selectorsToKeep) { + const $currentLayoutEls = $content.find(selector).contents(); + const $newLayoutEl = $template.find(selector); + if ($currentLayoutEls.length) { + // save value before change, eg 'title' is not inside 'discrete' template + // but we want to preserve it in case of select another layout later + this.$savedSelectors[selector] = $currentLayoutEls; + } + const $savedSelector = this.$savedSelectors[selector]; + if ($newLayoutEl.length && $savedSelector && $savedSelector.length) { + $newLayoutEl.empty().append($savedSelector); + } + } + + $content.empty().append($template); + }, +}); + +/** + * Allows edition of 'cover_properties' in website models which have such + * fields (blogs, posts, events, ...). + */ +options.registry.CoverProperties = options.Class.extend({ + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + + this.$image = this.$target.find('.o_record_cover_image'); + this.$filter = this.$target.find('.o_record_cover_filter'); + }, + /** + * @override + */ + start: function () { + this.$filterValueOpts = this.$el.find('[data-filter-value]'); + + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Handles a background change. + * + * @see this.selectClass for parameters + */ + background: async function (previewMode, widgetValue, params) { + if (previewMode === false) { + this.$image[0].classList.remove("o_b64_image_to_save"); + } + if (widgetValue === '') { + this.$image.css('background-image', ''); + this.$target.removeClass('o_record_has_cover'); + } else { + if (previewMode === false) { + const imgEl = document.createElement("img"); + imgEl.src = widgetValue; + await loadImageInfo(imgEl); + if (imgEl.dataset.mimetype && ![ + "image/gif", + "image/svg+xml", + "image/webp", + ].includes(imgEl.dataset.mimetype)) { + // Convert to webp but keep original width. + imgEl.dataset.mimetype = "image/webp"; + const base64src = await applyModifications(imgEl, { + mimetype: "image/webp", + }); + widgetValue = base64src; + this.$image[0].classList.add("o_b64_image_to_save"); + } + } + this.$image.css('background-image', `url('${widgetValue}')`); + this.$target.addClass('o_record_has_cover'); + const $defaultSizeBtn = this.$el.find('.o_record_cover_opt_size_default'); + $defaultSizeBtn.click(); + $defaultSizeBtn.closest('we-select').click(); + } + + if (!previewMode) { + this._updateSavingDataset(); + } + }, + /** + * @see this.selectClass for parameters + */ + filterValue: function (previewMode, widgetValue, params) { + this.$filter.css('opacity', widgetValue || 0); + this.$filter.toggleClass('oe_black', parseFloat(widgetValue) !== 0); + + if (!previewMode) { + this._updateSavingDataset(); + } + }, + /** + * @override + */ + selectStyle: async function (previewMode, widgetValue, params) { + await this._super(...arguments); + + if (!previewMode) { + this._updateSavingDataset(widgetValue); + } + }, + /** + * @override + */ + selectClass: async function (previewMode, widgetValue, params) { + await this._super(...arguments); + + if (!previewMode) { + this._updateSavingDataset(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'filterValue': { + return parseFloat(this.$filter.css('opacity')).toFixed(1); + } + case 'background': { + const background = this.$image.css('background-image'); + if (background && background !== 'none') { + return background.match(/^url\(["']?(.+?)["']?\)$/)[1]; + } + return ''; + } + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility: function (widgetName, params) { + if (params.coverOptName) { + return this.$target.data(`use_${params.coverOptName}`) === 'True'; + } + return this._super(...arguments); + }, + /** + * @private + */ + _updateColorDataset(bgColorStyle = '', bgColorClass = '') { + this.$target[0].dataset.bgColorStyle = bgColorStyle; + this.$target[0].dataset.bgColorClass = bgColorClass; + }, + /** + * Updates the cover properties dataset used for saving. + * + * @private + */ + _updateSavingDataset(colorValue) { + const [colorPickerWidget, sizeWidget, textAlignWidget] = this._requestUserValueWidgets('bg_color_opt', 'size_opt', 'text_align_opt'); + // TODO: `o_record_has_cover` should be handled using model field, not + // resize_class to avoid all of this. + // Get values from DOM (selected values in options are only available + // after updateUI) + const sizeOptValues = sizeWidget.getMethodsParams('selectClass').possibleValues; + let coverClass = [...this.$target[0].classList].filter( + value => sizeOptValues.includes(value) + ).join(' '); + const bg = this.$image.css('background-image'); + if (bg && bg !== 'none') { + coverClass += " o_record_has_cover"; + } + const textAlignOptValues = textAlignWidget.getMethodsParams('selectClass').possibleValues; + const textAlignClass = [...this.$target[0].classList].filter( + value => textAlignOptValues.includes(value) + ).join(' '); + const filterEl = this.$target[0].querySelector('.o_record_cover_filter'); + const filterValue = filterEl && filterEl.style.opacity; + // Update saving dataset + this.$target[0].dataset.coverClass = coverClass; + this.$target[0].dataset.textAlignClass = textAlignClass; + this.$target[0].dataset.filterValue = filterValue || 0.0; + // TODO there is probably a better way and this should be refactored to + // use more standard colorpicker+imagepicker structure + const ccValue = colorPickerWidget._ccValue; + const colorOrGradient = colorPickerWidget._value; + const isGradient = weUtils.isColorGradient(colorOrGradient); + const valueIsCSSColor = !isGradient && isCSSColor(colorOrGradient); + const colorNames = []; + if (ccValue) { + colorNames.push(ccValue); + } + if (colorOrGradient && !isGradient && !valueIsCSSColor) { + colorNames.push(colorOrGradient); + } + const bgColorClass = weUtils.computeColorClasses(colorNames).join(' '); + const bgColorStyle = valueIsCSSColor ? `background-color: ${colorOrGradient};` : + isGradient ? `background-color: rgba(0, 0, 0, 0); background-image: ${colorOrGradient};` : ''; + this._updateColorDataset(bgColorStyle, bgColorClass); + }, +}); + +options.registry.ScrollButton = options.Class.extend({ + /** + * @override + */ + start: async function () { + await this._super(...arguments); + this.$button = this.$('.o_scroll_button'); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + async showScrollButton(previewMode, widgetValue, params) { + if (widgetValue) { + this.$button.show(); + } else { + if (previewMode) { + this.$button.hide(); + } else { + this.$button.detach(); + } + } + }, + /** + * Toggles the scroll down button. + */ + toggleButton: function (previewMode, widgetValue, params) { + if (widgetValue) { + if (!this.$button.length) { + const anchor = document.createElement('a'); + anchor.classList.add( + 'o_scroll_button', + 'mb-3', + 'rounded-circle', + 'align-items-center', + 'justify-content-center', + 'mx-auto', + 'bg-primary', + 'o_not_editable', + ); + anchor.href = '#'; + anchor.contentEditable = "false"; + anchor.title = _t("Scroll down to next section"); + const arrow = document.createElement('i'); + arrow.classList.add('fa', 'fa-angle-down', 'fa-3x'); + anchor.appendChild(arrow); + this.$button = $(anchor); + } + this.$target.append(this.$button); + } else { + this.$button.detach(); + } + }, + /** + * @override + */ + async selectClass(previewMode, widgetValue, params) { + await this._super(...arguments); + // If a "d-lg-block" class exists on the section (e.g., for mobile + // visibility option), it should be replaced with a "d-lg-flex" class. + // This ensures that the section has the "display: flex" property + // applied, which is the default rule for both "height" option classes. + if (params.possibleValues.includes("o_half_screen_height")) { + if (widgetValue) { + this.$target[0].classList.replace("d-lg-block", "d-lg-flex"); + } else if (this.$target[0].classList.contains("d-lg-flex")) { + // There are no known cases, but we still make sure that the + // <section> element doesn't have a "display: flex" originally. + this.$target[0].classList.remove("d-lg-flex"); + const sectionStyle = window.getComputedStyle(this.$target[0]); + const hasDisplayFlex = sectionStyle.getPropertyValue("display") === "flex"; + this.$target[0].classList.add(hasDisplayFlex ? "d-lg-flex" : "d-lg-block"); + } + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _renderCustomXML(uiFragment) { + // TODO We should have a better way to change labels depending on some + // condition (maybe a dedicated way in updateUI...) + if (this.$target[0].dataset.snippet === 's_image_gallery') { + const minHeightEl = uiFragment.querySelector('[data-name="minheight_auto_opt"]'); + minHeightEl.parentElement.setAttribute('string', _t("Min-Height")); + } + }, + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'toggleButton': + return !!this.$button.parent().length; + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'fixed_height_opt') { + return (this.$target[0].dataset.snippet === 's_image_gallery'); + } + return this._super(...arguments); + }, +}); + +options.registry.ConditionalVisibility = options.registry.DeviceVisibility.extend({ + /** + * @constructor + */ + init() { + this._super(...arguments); + this.optionsAttributes = []; + }, + /** + * @override + */ + async start() { + await this._super(...arguments); + + for (const widget of this._userValueWidgets) { + const params = widget.getMethodsParams(); + if (params.saveAttribute) { + this.optionsAttributes.push({ + saveAttribute: params.saveAttribute, + attributeName: params.attributeName, + // If callWith dataAttribute is not specified, the default + // field to check on the record will be .value for values + // coming from another widget than M2M. + callWith: params.callWith || 'value', + }); + } + } + }, + /** + * @override + */ + async onTargetHide() { + await this._super(...arguments); + if (this.$target[0].classList.contains('o_snippet_invisible')) { + this.$target[0].classList.add('o_conditional_hidden'); + } + }, + /** + * @override + */ + async onTargetShow() { + await this._super(...arguments); + this.$target[0].classList.remove('o_conditional_hidden'); + }, + // Todo: remove me in master. + /** + * @override + */ + cleanForSave() {}, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Inserts or deletes record's id and value in target's data-attributes + * if no ids are selected, deletes the attribute. + * + * @see this.selectClass for parameters + */ + selectRecord(previewMode, widgetValue, params) { + const recordsData = JSON.parse(widgetValue); + if (recordsData.length) { + this.$target[0].dataset[params.saveAttribute] = widgetValue; + } else { + delete this.$target[0].dataset[params.saveAttribute]; + } + + this._updateCSSSelectors(); + }, + /** + * Selects a value for target's data-attributes. + * Should be used instead of selectRecord if the visibility is not related + * to database values. + * + * @see this.selectClass for parameters + */ + selectValue(previewMode, widgetValue, params) { + if (widgetValue) { + const widgetValueIndex = params.possibleValues.indexOf(widgetValue); + const value = [{value: widgetValue, id: widgetValueIndex}]; + this.$target[0].dataset[params.saveAttribute] = JSON.stringify(value); + } else { + delete this.$target[0].dataset[params.saveAttribute]; + } + + this._updateCSSSelectors(); + }, + /** + * Opens the toggler when 'conditional' is selected. + * + * @override + */ + async selectDataAttribute(previewMode, widgetValue, params) { + await this._super(...arguments); + + if (params.attributeName === 'visibility') { + const targetEl = this.$target[0]; + if (widgetValue === 'conditional') { + const collapseEl = this.$el.children('we-collapse')[0]; + this._toggleCollapseEl(collapseEl); + } else { + // TODO create a param to allow doing this automatically for genericSelectDataAttribute? + delete targetEl.dataset.visibility; + + for (const attribute of this.optionsAttributes) { + delete targetEl.dataset[attribute.saveAttribute]; + delete targetEl.dataset[`${attribute.saveAttribute}Rule`]; + } + } + this.trigger_up('snippet_option_visibility_update', {show: true}); + } else if (!params.isVisibilityCondition) { + return; + } + + this._updateCSSSelectors(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeWidgetState(methodName, params) { + if (methodName === 'selectRecord') { + return this.$target[0].dataset[params.saveAttribute] || '[]'; + } + if (methodName === 'selectValue') { + const selectedValue = this.$target[0].dataset[params.saveAttribute]; + return selectedValue ? JSON.parse(selectedValue)[0].value : params.attributeDefaultValue; + } + return this._super(...arguments); + }, + /** + * Reads target's attributes and creates CSS selectors. + * Stores them in data-attributes to then be reapplied by + * content/inject_dom.js (ideally we should saved them in a <style> tag + * directly but that would require a new website.page field and would not + * be possible in dynamic (controller) pages... maybe some day). + * + * @private + */ + _updateCSSSelectors() { + // There are 2 data attributes per option: + // - One that stores the current records selected + // - Another that stores the value of the rule "Hide for / Visible for" + const visibilityIDParts = []; + const onlyAttributes = []; + const hideAttributes = []; + const target = this.$target[0]; + for (const attribute of this.optionsAttributes) { + if (target.dataset[attribute.saveAttribute]) { + let records = JSON.parse(target.dataset[attribute.saveAttribute]).map(record => { + return { id: record.id, value: record[attribute.callWith] }; + }); + if (attribute.saveAttribute === 'visibilityValueLang') { + records = records.map(lang => { + lang.value = lang.value.replace(/_/g, '-'); + return lang; + }); + } + const hideFor = target.dataset[`${attribute.saveAttribute}Rule`] === 'hide'; + if (hideFor) { + hideAttributes.push({ name: attribute.attributeName, records: records}); + } else { + onlyAttributes.push({ name: attribute.attributeName, records: records}); + } + // Create a visibilityId based on the options name and their + // values. eg : hide for en_US(id:1) -> lang1h + const type = attribute.attributeName.replace('data-', ''); + const valueIDs = records.map(record => record.id).sort(); + visibilityIDParts.push(`${type}_${hideFor ? 'h' : 'o'}_${valueIDs.join('_')}`); + } + } + const visibilityId = visibilityIDParts.join('_'); + // Creates CSS selectors based on those attributes, the reducers + // combine the attributes' values. + let selectors = ''; + for (const attribute of onlyAttributes) { + // e.g of selector: + // html:not([data-attr-1="valueAttr1"]):not([data-attr-1="valueAttr2"]) [data-visibility-id="ruleId"] + const selector = attribute.records.reduce((acc, record) => { + return acc += `:not([${attribute.name}="${record.value}"])`; + }, 'html') + ` body:not(.editor_enable) [data-visibility-id="${visibilityId}"]`; + selectors += selector + ', '; + } + for (const attribute of hideAttributes) { + // html[data-attr-1="valueAttr1"] [data-visibility-id="ruleId"], + // html[data-attr-1="valueAttr2"] [data-visibility-id="ruleId"] + const selector = attribute.records.reduce((acc, record, i, a) => { + acc += `html[${attribute.name}="${record.value}"] body:not(.editor_enable) [data-visibility-id="${visibilityId}"]`; + return acc + (i !== a.length - 1 ? ',' : ''); + }, ''); + selectors += selector + ', '; + } + selectors = selectors.slice(0, -2); + if (selectors) { + this.$target[0].dataset.visibilitySelectors = selectors; + } else { + delete this.$target[0].dataset.visibilitySelectors; + } + + if (visibilityId) { + this.$target[0].dataset.visibilityId = visibilityId; + } else { + delete this.$target[0].dataset.visibilityId; + } + }, +}); + +options.registry.WebsiteAnimate = options.Class.extend({ + /** + * @override + */ + async start() { + await this._super(...arguments); + // Animations for which the "On Scroll" and "Direction" options are not + // available. + this.limitedAnimations = ['o_anim_flash', 'o_anim_pulse', 'o_anim_shake', 'o_anim_tada', 'o_anim_flip_in_x', 'o_anim_flip_in_y']; + this.isAnimatedText = this.$target.hasClass('o_animated_text'); + this.$optionsSection = this.$overlay.data('$optionsSection'); + this.$scrollingElement = $().getScrollingElement(this.ownerDocument); + this.$overlay[0].querySelector(".o_handles").classList.toggle("pe-none", this.isAnimatedText); + }, + /** + * @override + */ + async onBuilt() { + this.$target[0].classList.toggle('o_animate_preview', this.$target[0].classList.contains('o_animate')); + }, + /** + * @override + */ + onFocus() { + if (this.isAnimatedText) { + // For animated text, the animation options must be in the editor + // toolbar. + this.options.wysiwyg.toolbarEl.append(this.$el[0]); + this.$optionsSection.addClass('d-none'); + } + }, + /** + * @override + */ + onBlur() { + if (this.isAnimatedText) { + // For animated text, the options must be returned to their + // original location as they were moved in the toolbar. + this.$optionsSection.append(this.$el); + } + }, + /** + * @override + */ + cleanForSave() { + if (this.$target[0].closest('.o_animate')) { + // As images may have been added in an animated element, we must + // remove the lazy loading on them. + this._toggleImagesLazyLoading(false); + } + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @override + */ + async selectClass(previewMode, widgetValue, params) { + await this._super(...arguments); + if (params.forceAnimation && params.name !== 'o_anim_no_effect_opt' && previewMode !== 'reset') { + this._forceAnimation(); + } + if (params.isAnimationTypeSelection) { + this.$target[0].classList.toggle("o_animate_preview", this.$target[0].classList.contains("o_animate")); + } + }, + /** + * @override + */ + async selectDataAttribute(previewMode, widgetValue, params) { + await this._super(...arguments); + if (params.forceAnimation) { + this._forceAnimation(); + } + }, + /** + * Sets the animation mode. + * + * @see this.selectClass for parameters + */ + animationMode(previewMode, widgetValue, params) { + const targetClassList = this.$target[0].classList; + this.$scrollingElement[0].classList.remove('o_wanim_overflow_xy_hidden'); + targetClassList.remove('o_animating', 'o_animate_both_scroll', 'o_visible', 'o_animated', 'o_animate_out'); + this.$target[0].style.animationDelay = ''; + this.$target[0].style.animationPlayState = ''; + this.$target[0].style.animationName = ''; + this.$target[0].style.visibility = ''; + if (widgetValue === 'onScroll') { + this.$target[0].dataset.scrollZoneStart = 0; + this.$target[0].dataset.scrollZoneEnd = 100; + } else { + delete this.$target[0].dataset.scrollZoneStart; + delete this.$target[0].dataset.scrollZoneEnd; + } + if (params.activeValue === "o_animate_on_hover") { + this.trigger_up("option_update", { + optionName: "ImageTools", + name: "disable_hover_effect", + }); + } + if ((!params.activeValue || params.activeValue === "o_animate_on_hover") + && widgetValue && widgetValue !== "onHover") { + // If "Animation" was on "None" or "o_animate_on_hover" and it is no + // longer, it is set to "fade_in" by default. + targetClassList.add('o_anim_fade_in'); + this._toggleImagesLazyLoading(false); + } + if (!widgetValue || widgetValue === "onHover") { + const possibleEffects = this._requestUserValueWidgets('animation_effect_opt')[0].getMethodsParams('selectClass').possibleValues; + const possibleDirections = this._requestUserValueWidgets('animation_direction_opt')[0].getMethodsParams('selectClass').possibleValues; + const possibleEffectsAndDirections = possibleEffects.concat(possibleDirections); + // Remove the classes added by "Effect" and "Direction" options if + // "Animation" is "None". + for (const targetClass of targetClassList.value.split(/\s+/g)) { + if (possibleEffectsAndDirections.indexOf(targetClass) >= 0) { + targetClassList.remove(targetClass); + } + } + this.$target[0].style.setProperty('--wanim-intensity', ''); + this.$target[0].style.animationDuration = ''; + this._toggleImagesLazyLoading(true); + } + if (widgetValue === "onHover") { + // Pause the history until the hover effect is applied in + // "setImgShapeHoverEffect". This prevents saving the intermediate + // steps done (in a tricky way) up to that point. + this.options.wysiwyg.odooEditor.historyPauseSteps(); + this.trigger_up("option_update", { + optionName: "ImageTools", + name: "enable_hover_effect", + }); + } + }, + /** + * Sets the animation intensity. + * + * @see this.selectClass for parameters + */ + animationIntensity(previewMode, widgetValue, params) { + this.$target[0].style.setProperty('--wanim-intensity', widgetValue); + this._forceAnimation(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + async _forceAnimation() { + this.$target.css('animation-name', 'dummy'); + + if (this.$target[0].classList.contains('o_animate_on_scroll')) { + // Trigger a DOM reflow. + void this.$target[0].offsetWidth; + this.$target.css('animation-name', ''); + this.ownerDocument.defaultView.dispatchEvent(new Event('resize')); + } else { + // Trigger a DOM reflow (Needed to prevent the animation from + // being launched twice when previewing the "Intensity" option). + await new Promise(resolve => setTimeout(resolve)); + this.$target.addClass('o_animating'); + this.trigger_up('cover_update', { + overlayVisible: true, + }); + this.$scrollingElement[0].classList.add('o_wanim_overflow_xy_hidden'); + this.$target.css('animation-name', ''); + this.$target.one('webkitAnimationEnd oanimationend msAnimationEnd animationend', () => { + this.$scrollingElement[0].classList.remove('o_wanim_overflow_xy_hidden'); + this.$target.removeClass('o_animating'); + }); + } + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + const hasAnimateClass = this.$target[0].classList.contains("o_animate"); + switch (widgetName) { + case 'no_animation_opt': { + return !this.isAnimatedText; + } + case 'animation_effect_opt': { + return hasAnimateClass; + } + case 'animation_trigger_opt': { + return !this.$target[0].closest('.dropdown'); + } + case 'animation_on_scroll_opt': + case 'animation_direction_opt': { + if (widgetName === "animation_direction_opt" && !hasAnimateClass) { + return false; + } + return !this.limitedAnimations.some(className => this.$target[0].classList.contains(className)); + } + case 'animation_intensity_opt': { + if (!hasAnimateClass) { + return false; + } + const possibleDirections = this._requestUserValueWidgets('animation_direction_opt')[0].getMethodsParams('selectClass').possibleValues; + if (this.$target[0].classList.contains('o_anim_fade_in')) { + for (const targetClass of this.$target[0].classList) { + // Show "Intensity" if "Fade in" + direction is not + // "In Place" ... + if (possibleDirections.indexOf(targetClass) >= 0) { + return true; + } + } + // ... but hide if "Fade in" + "In Place" direction. + return false; + } + return true; + } + case 'animation_on_hover_opt': { + const [hoverEffectOverlayWidget] = this._requestUserValueWidgets("hover_effect_overlay_opt"); + if (hoverEffectOverlayWidget) { + const hoverEffectWidget = hoverEffectOverlayWidget.getParent(); + const imageToolsOpt = hoverEffectWidget.getParent(); + return ( + imageToolsOpt._canHaveHoverEffect() + && !await isImageCorsProtected(this.$target[0]) + ); + } + return false; + } + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeVisibility(methodName, params) { + if (this.$target[0].matches('img')) { + return isImageSupportedForStyle(this.$target[0]); + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetState(methodName, params) { + if (methodName === 'animationIntensity') { + return window.getComputedStyle(this.$target[0]).getPropertyValue('--wanim-intensity'); + } + return this._super(...arguments); + }, + /** + * Removes or adds the lazy loading on images because animated images can + * appear before or after their parents and cause bugs in the animations. + * To put "lazy" back on the "loading" attribute, we simply remove the + * attribute as it is automatically added on page load. + * + * @private + * @param {Boolean} lazy + */ + _toggleImagesLazyLoading(lazy) { + const imgEls = this.$target[0].matches('img') + ? [this.$target[0]] + : this.$target[0].querySelectorAll('img'); + for (const imgEl of imgEls) { + if (lazy) { + // Let the automatic system add the loading attribute + imgEl.removeAttribute('loading'); + } else { + imgEl.loading = 'eager'; + } + } + }, +}); + +/** + * Allows edition of text "Highlight Effects" following this generic structure: + * `<span class="o_text_highlight"> + * <span class="o_text_highlight_item"> + * line1-textNode1 [line1-textNode2,...] + * <svg.../> + * </span> + * [<br/>] + * <span class="o_text_highlight_item"> + * line2-textNode1 [line2-textNode2,...] + * <svg.../> + * </span> + * ... + * </span>` + * To correctly adapt each highlight unit when the text content is changed. + */ +options.registry.TextHighlight = options.Class.extend({ + custom_events: Object.assign({}, options.Class.prototype.custom_events, { + "user_value_widget_opening": "_onWidgetOpening", + }), + /** + * @override + */ + async start() { + await this._super(...arguments); + this.leftPanelEl = this.$overlay.data("$optionsSection")[0]; + // Reduce overlay opacity for more highlight visibility on small text. + this.$overlay[0].style.opacity = "0.25"; + this.$overlay[0].querySelector(".o_handles").classList.add("pe-none"); + }, + /** + * Move "Text Effect" options to the editor's toolbar. + * + * @override + */ + onFocus() { + this.options.wysiwyg.toolbarEl.append(this.$el[0]); + }, + /** + * @override + */ + onBlur() { + this.leftPanelEl.appendChild(this.el); + }, + /** + * @override + */ + notify(name, data) { + // Apply the highlight effect DOM structure when added for the first time + // and display the highlight effects grid immediately. + if (name === "new_text_highlight") { + this._autoAdaptHighlights(); + this._requestUserValueWidgets("text_highlight_opt")[0]?.enable(); + } + this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Activates & deactivates the text highlight effect. + * + * @see this.selectClass for parameters + */ + async setTextHighlight(previewMode, widgetValue, params) { + return widgetValue ? this._addTextHighlight(widgetValue) + : removeTextHighlight(this.$target[0]); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Used to add a highlight SVG element to the targeted text node(s). + * This should also take in consideration a situation where many text nodes + * are separate e.g. `<p>first text content<br/>second text content...</p>`. + * To correctly handle those situations, every set of text nodes will be + * wrapped in a `.o_text_highlight_item` that contains its highlight SVG. + * + * @param {String} highlightID + * @private + */ + _addTextHighlight(highlightID) { + const highlightEls = [...this.$target[0].querySelectorAll(".o_text_highlight_item svg")]; + if (highlightEls.length) { + // If the text element has a highlight effect, we only need to + // change the SVG. + highlightEls.forEach(svg => { + svg.after(drawTextHighlightSVG(svg.parentElement, highlightID)); + svg.remove(); + }); + } else { + this._autoAdaptHighlights(); + } + }, + /** + * Used to set the highlight effect DOM structure on the targeted text + * content. + * + * @private + */ + _autoAdaptHighlights() { + this.trigger_up("snippet_edition_request", { exec: async () => + await this._refreshPublicWidgets($(this.options.wysiwyg.odooEditor.editable)) + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * To draw highlight SVGs for `<we-select/>` preview, we need to open the + * widget (we need correct size values from `getBoundingClientRect()`). + * This code will build the highlight preview the first time we open the + * `<we-select/>`. + * + * @private + */ + _onWidgetOpening(ev) { + const target = ev.target; + // Only when there is no highlight SVGs. + if (target.getName() === "text_highlight_opt" && !target.el.querySelector("svg")) { + const weToggler = target.el.querySelector("we-toggler"); + weToggler.classList.add("active"); + [...target.el.querySelectorAll("we-button[data-set-text-highlight] div")].forEach(weBtnEl => { + weBtnEl.textContent = "Text"; + // Get the text highlight linked to each `<we-button/>` + // and apply it to its text content. + weBtnEl.append(drawTextHighlightSVG(weBtnEl, weBtnEl.parentElement.dataset.setTextHighlight)); + }); + } + }, +}); + +/** + * Replaces current target with the specified template layout + */ +options.registry.MegaMenuLayout = options.registry.SelectTemplate.extend({ + /** + * @override + */ + init() { + this._super(...arguments); + this.selectTemplateWidgetName = 'mega_menu_template_opt'; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + notify(name, data) { + if (name === 'reset_template') { + const xmlid = this._getCurrentTemplateXMLID(); + this._getTemplate(xmlid).then(template => { + this.containerEl.insertAdjacentHTML('beforeend', template); + data.onSuccess(); + }); + } else { + this._super(...arguments); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + if (methodName === 'selectTemplate') { + return this._getCurrentTemplateXMLID(); + } + return this._super(...arguments); + }, + /** + * @private + * @returns {string} xmlid of the current template. + */ + _getCurrentTemplateXMLID: function () { + const templateDefiningClass = this.containerEl.querySelector('section') + .classList.value.split(' ').filter(cl => cl.startsWith('s_mega_menu'))[0]; + return `website.${templateDefiningClass}`; + }, +}); + +/** + * Hides delete and clone buttons for Mega Menu block. + */ +options.registry.MegaMenuNoDelete = options.Class.extend({ + forceNoDeleteButton: true, + + /** + * @override + */ + async onRemove() { + await new Promise(resolve => { + this.trigger_up('option_update', { + optionName: 'MegaMenuLayout', + name: 'reset_template', + data: { + onSuccess: () => resolve(), + } + }); + }); + }, +}); + +options.registry.sizing.include({ + /** + * @override + */ + start() { + const defs = this._super(...arguments); + const self = this; + this.$handles.on('mousedown', function (ev) { + // Since website is edited in an iframe, a div that goes over the + // iframe is necessary to catch mousemove and mouseup events, + // otherwise the iframe absorbs them. + const $body = $(this.ownerDocument.body); + if (!self.divEl) { + self.divEl = document.createElement('div'); + self.divEl.style.position = 'absolute'; + self.divEl.style.height = '100%'; + self.divEl.style.width = '100%'; + self.divEl.setAttribute('id', 'iframeEventOverlay'); + $body.append(self.divEl); + } + const documentMouseUp = () => { + // Multiple mouseup can occur if mouse goes out of the window + // while moving. + if (self.divEl) { + self.divEl.remove(); + self.divEl = undefined; + } + $body.off('mouseup', documentMouseUp); + }; + $body.on('mouseup', documentMouseUp); + }); + return defs; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + async updateUIVisibility() { + await this._super(...arguments); + const nonDraggableClasses = [ + 's_table_of_content_navbar_wrap', + 's_table_of_content_main', + ]; + if (nonDraggableClasses.some(c => this.$target[0].classList.contains(c))) { + const moveHandleEl = this.$overlay[0].querySelector('.o_move_handle'); + moveHandleEl.classList.add('d-none'); + } + }, +}); + +options.registry.SwitchableViews = options.Class.extend({ + /** + * @override + */ + async willStart() { + const _super = this._super.bind(this); + this.switchableRelatedViews = await new Promise((resolve, reject) => { + this.trigger_up('get_switchable_related_views', { + onSuccess: resolve, + onFailure: reject, + }); + }); + return _super(...arguments); + }, + /** + * @override + */ + _renderCustomXML(uiFragment) { + for (const view of this.switchableRelatedViews) { + const weCheckboxEl = document.createElement('we-checkbox'); + weCheckboxEl.setAttribute('string', view.name); + weCheckboxEl.setAttribute('data-customize-website-views', view.key); + weCheckboxEl.setAttribute('data-no-preview', 'true'); + weCheckboxEl.setAttribute('data-reload', '/'); + uiFragment.appendChild(weCheckboxEl); + } + }, + /*** + * @override + */ + _computeVisibility() { + return !!this.switchableRelatedViews.length; + }, + /** + * @override + */ + _checkIfWidgetsUpdateNeedReload() { + return true; + } +}); + +options.registry.GridImage = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + changeGridImageMode(previewMode, widgetValue, params) { + const imageGridItemEl = this._getImageGridItem(); + if (imageGridItemEl) { + imageGridItemEl.classList.toggle('o_grid_item_image_contain', widgetValue === 'contain'); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns the parent column if it is marked as a grid item containing an + * image. + * + * @returns {?HTMLElement} + */ + _getImageGridItem() { + return this.$target[0].closest(".o_grid_item_image"); + }, + /** + * @override + */ + _computeVisibility() { + // Special conditions for the hover effects. + const hasSquareShape = this.$target[0].dataset.shape === "web_editor/geometric/geo_square"; + const effectAllowsOption = !["dolly_zoom", "outline", "image_mirror_blur"] + .includes(this.$target[0].dataset.hoverEffect); + + return this._super(...arguments) + && !!this._getImageGridItem() + && (!('shape' in this.$target[0].dataset) + || hasSquareShape && effectAllowsOption); + }, + /** + * @override + */ + _computeWidgetState(methodName, params) { + if (methodName === 'changeGridImageMode') { + const imageGridItemEl = this._getImageGridItem(); + return imageGridItemEl && imageGridItemEl.classList.contains('o_grid_item_image_contain') + ? 'contain' + : 'cover'; + } + return this._super(...arguments); + }, +}); + +options.registry.GalleryElement = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Allows to change the position of an item on the set. + * + * @see this.selectClass for parameters + */ + position(previewMode, widgetValue, params) { + const optionName = this.$target[0].classList.contains("carousel-item") ? "Carousel" + : "GalleryImageList"; + const itemEl = this.$target[0]; + this.trigger_up("option_update", { + optionName: optionName, + name: "reorder_items", + data: { + itemEl: itemEl, + position: widgetValue, + }, + }); + }, +}); + +options.registry.Button = options.Class.extend({ + /** + * @override + */ + init() { + this._super(...arguments); + const isUnremovableButton = this.$target[0].classList.contains("oe_unremovable"); + this.forceDuplicateButton = !isUnremovableButton; + this.forceNoDeleteButton = isUnremovableButton; + }, + /** + * @override + */ + onBuilt(options) { + // Only if the button is built, not if a snippet containing that button + // is built (e.g. true if dropping a button from the snippet menu onto + // the page, false if dropping an "image-text" snippet). + if (options.isCurrent) { + this._adaptButtons(); + } + }, + /** + * @override + */ + onClone(options) { + // Only if the button is cloned, not if a snippet containing that button + // is cloned. + if (options.isCurrent) { + this._adaptButtons(false); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Checks if there are buttons before or after the target element and + * applies appropriate styling. + * + * @private + * @param {Boolean} [adaptAppearance=true] + */ + _adaptButtons(adaptAppearance = true) { + const previousSiblingEl = this.$target[0].previousElementSibling; + const nextSiblingEl = this.$target[0].nextElementSibling; + let siblingButtonEl = null; + // When multiple buttons follow each other, they may break on 2 lines or + // more on mobile, so they need a margin-bottom. Also, if the button is + // dropped next to another button add a space between them. + if (nextSiblingEl?.matches(".btn")) { + nextSiblingEl.classList.add("mb-2"); + this.$target[0].after(' '); + // It is first the next button that we put in this variable because + // we want to copy as a priority the style of the previous button + // if it exists. + siblingButtonEl = nextSiblingEl; + } + if (previousSiblingEl?.matches(".btn")) { + previousSiblingEl.classList.add("mb-2"); + this.$target[0].before(' '); + siblingButtonEl = previousSiblingEl; + } + if (siblingButtonEl) { + this.$target[0].classList.add("mb-2"); + } + if (adaptAppearance) { + if (siblingButtonEl && !this.$target[0].matches(".s_custom_button")) { + // If the dropped button is not a custom button then we adjust + // its appearance to match its sibling. + if (siblingButtonEl.classList.contains("btn-secondary")) { + this.$target[0].classList.remove("btn-primary"); + this.$target[0].classList.add("btn-secondary"); + } + if (siblingButtonEl.classList.contains("btn-sm")) { + this.$target[0].classList.add("btn-sm"); + } else if (siblingButtonEl.classList.contains("btn-lg")) { + this.$target[0].classList.add("btn-lg"); + } + } else { + // To align with the editor's behavior, we need to enclose the + // button in a <p> tag if it's not dropped within a <p> tag. We only + // put the dropped button in a <p> if it's not next to another + // button, because some snippets have buttons that aren't inside a + // <p> (e.g. s_text_cover). + // TODO: this definitely needs to be fixed at web_editor level. + // Nothing should prevent adding buttons outside of a paragraph. + const btnContainerEl = this.$target[0].closest("p"); + if (!btnContainerEl) { + const paragraphEl = document.createElement("p"); + this.$target[0].parentNode.insertBefore(paragraphEl, this.$target[0]); + paragraphEl.appendChild(this.$target[0]); + } + } + this.$target[0].classList.remove("s_custom_button"); + } + }, +}); + +options.registry.layout_column.include({ + /** + * @override + */ + _isMobile() { + return wUtils.isMobile(this); + }, +}); + +options.registry.SnippetMove.include({ + /** + * @override + */ + _isMobile() { + return wUtils.isMobile(this); + }, +}); + +export default { + UrlPickerUserValueWidget: UrlPickerUserValueWidget, + FontFamilyPickerUserValueWidget: FontFamilyPickerUserValueWidget, +}; diff --git a/addons/website/static/src/js/editor/snippets.options.xml b/addons/website/static/src/js/editor/snippets.options.xml new file mode 100644 index 0000000000000..3fb91c7a3f88f --- /dev/null +++ b/addons/website/static/src/js/editor/snippets.options.xml @@ -0,0 +1,904 @@ +<?xml version="1.0" encoding="utf-8"?> +<templates id="template" xml:space="preserve"> +<t t-name="website.anchor"> + <WeButton class="'fa fa-fw fa-link o_we_link'" + tooltip.translate="Create a link to target this section" + noPreview="'true'" + copyAnchorToClipboard="''" + /> +</t> +<t t-name="website.snippet_options_background_options" t-inherit="web_editor.snippet_options_background_options"> + <xpath expr="//WeButton[@toggleBgImage]" position="after"> + <t t-if="option.withVideos"> + <WeButton tooltip.translate="Video" class="'fa fa-fw fa-film'" + name="'bg_video_toggler_opt'" + dependencies="option.imagesDependencies || false" + toggleBgVideo="'true'" + noPreview="'true'"/> + </t> + </xpath> +</t> +<t t-name="website.BackgroundVideo"> + <WeRow class="'o_we_sublevel_1'" title.translate="Video"> + <WeMediapicker tooltip.translate="Edit video" + background="''" + mediaType="'videos'" + name="'bg_video_opt'" + dependencies="'bg_video_opt'"/> + </WeRow> +</t> +<t t-name="website.column_count_option"> + <WeSelect class="with_grid and 'o_grid'" noPreview="'true'" title="title" name="'column_count_opt'" dependencies="with_grid and 'normal_mode'"> + <!-- TODO: @owl-options + This should be working but slots cannot be conditional so instead + make the caller define the title. + <t t-set-slot="title"><t t-if="!with_grid">Columns</t></t> + --> + <WeButton selectCount="'0'" name="'zero_cols_opt'">None</WeButton> + <WeButton selectCount="'1'">1</WeButton> + <WeButton selectCount="'2'">2</WeButton> + <WeButton selectCount="'3'">3</WeButton> + <WeButton selectCount="'4'">4</WeButton> + <WeButton selectCount="'5'">5</WeButton> + <WeButton selectCount="'6'">6</WeButton> + <WeButton selectCount="'custom'" name="'custom_cols_opt'">Custom</WeButton> + </WeSelect> +</t> +<t t-name="website.grid_layout_options"> + <WeButtonGroup class="indent and 'o_we_sublevel_1'" noPreview="'true'" + dependencies="'grid_mode'" title.translate="Add Elements"> + <WeButton class="'o_we_bg_brand_primary o_grid'" addElement="'image'" tooltip.translate="Image" name="'image'">Image</WeButton> + <WeButton class="'o_we_bg_brand_primary o_grid'" addElement="'text'" tooltip.translate="Text" name="'text'">Text</WeButton> + <WeButton class="'o_we_bg_brand_primary o_grid'" addElement="'button'" tooltip.translate="Button" name="'button'">Button</WeButton> + </WeButtonGroup> + <WeRow class="indent and 'o_we_sublevel_1'" title.translate="Spacing (Y, X)"> + <WeInput dependencies="'grid_mode'" selectStyle="''" cssProperty="'row-gap'" unit="'px'" preventImportant="'true'" applyTo="'.row'"/> + <WeInput dependencies="'grid_mode'" selectStyle="''" cssProperty="'column-gap'" unit="'px'" max="'60'" preventImportant="'true'" applyTo="'.row'"/> + </WeRow> +</t> +<t t-name="website.layout_column"> + <WeRow> + <WeButtonGroup noPreview="'true'" title.translate="Layout"> + <WeButton selectLayout="'grid'" name="'grid_mode'">Grid</WeButton> + <WeButton selectLayout="'normal'" name="'normal_mode'">Cols</WeButton> + </WeButtonGroup> + <t t-call="website.column_count_option"> + <t t-set="with_grid" t-value="true"/> + </t> + </WeRow> + <t t-call="website.grid_layout_options"> + <t t-set="indent" t-value="true"/> + </t> +</t> +<t t-name="website.vertical_alignment_option"> + <WeButtonGroup class="'o_we_sublevel_1'" title.translate="Vert. Alignment" + tooltip.translate="Vertical Alignment" dependencies="'normal_mode'"> + <WeButton tooltip.translate="Align Top" + selectClass="'align-items-start'" + img="'/website/static/src/img/snippets_options/align_top.svg'" + /> + <WeButton tooltip.translate="Align Middle" + selectClass="'align-items-center'" + img="'/website/static/src/img/snippets_options/align_middle.svg'" + /> + <WeButton tooltip.translate="Align Bottom" + selectClass="'align-items-end'" + img="'/website/static/src/img/snippets_options/align_bottom.svg'" + /> + <WeButton tooltip.translate="Stretch to Equal Height" + selectClass="'align-items-stretch'" + img="'/website/static/src/img/snippets_options/align_stretch.svg'" + /> + </WeButtonGroup> +</t> +<!-- Scroll to next section button (only for full height) --> +<t t-name="website.scroll_button_option"> + <!-- Min height of section --> + <WeButtonGroup title.translate="Height" showScrollButton="''"> + <WeButton name="'minheight_auto_opt'" selectClass="''" tooltip.translate="Fit content">Auto</WeButton> + <WeButton selectClass="'o_half_screen_height'" tooltip.translate="Half screen">50%</WeButton> + <WeButton selectClass="'o_full_screen_height'" tooltip.translate="Full screen" name="'full_height_opt'" showScrollButton="'true'">100%</WeButton> + </WeButtonGroup> + <WeInput title.translate="Height" class="'o_we_sublevel_1'" name="'fixed_height_opt'" + dependencies="'minheight_auto_opt'" selectStyle="''" unit="'px'" + cssProperty="'height'" forceStyle="''"/> + <!-- ↑ ↑ ↑ + For this setting, we need to always force the style (= if the block + is naturally 800px tall and the user enters 800px for this setting, + we set 800px as inline style anyway). Indeed, this snippet's style + is based on the height that is forced but once the related public + widgets are started, the inner carousel items receive a min-height + which makes it so the snippet "natural" height is equal to the + initially forced height... so if the style is not forced, it would + ultimately be removed by mistake thinking it is not necessary. + Note: this is forced as not important as we still need the height to + be reset to 'auto' in mobile (generic css rules). + --> + <WeCheckbox title.translate="Scroll Down Button" name="'scroll_button_opt'" + dependencies="'full_height_opt'" toggleButton="'true'" noPreview="'true'"/> + <WeRow class="'o_we_sublevel_1'" title.translate="Colors"> + <WeColorpicker dependencies="'scroll_button_opt'" + applyTo="'.o_scroll_button'" + selectStyle="'true'" cssProperty="'background-color'" + colorPrefix="'bg-'" + /> + <WeColorpicker dependencies="'scroll_button_opt'" + applyTo="'.o_scroll_button'" + selectStyle="'true'" cssProperty="'color'" + colorPrefix="'text-'" + /> + </WeRow> + <WeSelect class="'o_we_sublevel_1'" title.translate="Spacing" + applyTo="'.o_scroll_button'" dependencies="'scroll_button_opt'"> + <WeButton selectClass="''">None</WeButton> + <WeButton selectClass="'mb-1'">Extra-Small</WeButton> + <WeButton selectClass="'mb-2'">Small</WeButton> + <WeButton selectClass="'mb-3'">Medium</WeButton> + <WeButton selectClass="'mb-4'">Large</WeButton> + <WeButton selectClass="'mb-5'">Extra-Large</WeButton> + </WeSelect> +</t> +<t t-name="website.snippet_options_border_line_widgets"> + <WeRow class="classname || ''" title="label"> + <WeInput name="'border_width_opt'" + applyTo="apply_to || ''" + selectStyle="'0'" + cssProperty="'border-' + (direction ? direction + '-' : '') + 'width'" + unit="'px'" + extraClass="with_bs_class ? 'border' : ''" + variable="width_variable || ''" + /> + <WeSelect dependencies="'border_width_opt'" + cssProperty="'border-' + (direction ? direction + '-' : '') + 'style'" + applyTo="apply_to || ''" + variable="style_variable || ''"> + <WeButton tooltip.translate="Solid" selectStyle="'solid'"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: solid;"/></WeButton> + <WeButton tooltip.translate="Dashed" selectStyle="'dashed'"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: dashed;"/></WeButton> + <WeButton tooltip.translate="Dotted" selectStyle="'dotted'"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: dotted;"/></WeButton> + <WeButton tooltip.translate="Double" selectStyle="'double'"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: double; border-left: none; border-right: none;"/></WeButton> + </WeSelect> + <WeColorpicker dependencies="'border_width_opt'" + applyTo="apply_to || ''" + selectStyle="'true'" + cssProperty="'border-' + (direction ? direction + '-' : '') + 'color'" + colorPrefix="'border-'" + color="color_variable || ''" + /> + </WeRow> +</t> +<t t-name="website.snippet_options_border_widgets"> + <t t-call="website.snippet_options_border_line_widgets"> + <t t-set="label">Border</t> + <t t-set="with_bs_class" t-value="true"/> + </t> + <WeRow title.translate="Round Corners"> + <WeInput t-if="!no_border_radius" + dependencies="!so_rounded_no_dependencies and 'border_width_opt,bg_color_opt'" + applyTo="apply_to || ''" + selectStyle="'0'" cssProperty="'border-radius'" + unit="'px'" extraClass="'rounded'" + variable="radius_variable || ''"/> + </WeRow> +</t> +<t t-name="website.snippet_options_shadow_widgets"> + <!-- Shadow --> + <!-- TODO: @owl-options what is the variable="shadow_variable" on the button group ? --> + <WeButtonGroup shadowClass="'shadow'" title.translate="Shadow"> + <WeButton setShadow="''">None</WeButton> + <WeButton setShadow="'outset'" img="'/website/static/src/img/snippets_options/shadow_out.svg'" name="'shadow_active_opt'" tooltip.translate="Outset"/> + <WeButton setShadow="'inset'" img="'/website/static/src/img/snippets_options/shadow_in.svg'" name="'shadow_inset_opt'" tooltip.translate="Inset"/> + </WeButtonGroup> + <!-- TODO: @owl-options what is the variable="shadow_variable" on the multi ? --> + <WeMulti cssProperty="'box-shadow'" dependencies="'shadow_active_opt, shadow_inset_opt'"> + <WeRow class="'o_we_sublevel_1'" title.translate="Color"> + <WeColorpicker multiSequence="10" selectStyle="''" cssCompatible="''"/> + </WeRow> + <WeRow class="'o_we_sublevel_1'" title.translate="Offset (X, Y)"> + <WeInput multiSequence="20" unit="'px'" selectStyle="''"/> + <WeInput multiSequence="30" unit="'px'" selectStyle="''"/> + </WeRow> + <WeRow class="'o_we_sublevel_1'" title.translate="Blur"> + <WeInput multiSequence="40" unit="'px'" selectStyle="''"/> + </WeRow> + <WeRow class="'o_we_sublevel_1'" title.translate="Spread"> + <WeInput multiSequence="50" unit="'px'" selectStyle="''"/> + </WeRow> + <!-- Inset parameter always hidden (as controlled above) but needed --> + <!-- for the we-multi widget to work properly. --> + <WeCheckbox multiSequence="60" name="'fake_inset_shadow_opt'" selectStyle="'inset'"/> + </WeMulti> +</t> +<t t-name="website.card_color_border_shadow"> + <!-- COLOR, BORDER, SHADOW | .s_three_columns | .s_comparisons --> + <WeColorpicker title.translate="Colors" + selectStyle="'true'" + cssProperty="'background-color'" + colorPrefix="'bg-'"/> + <t t-call="website.snippet_options_border_widgets"> + <t t-set="so_rounded_no_dependencies" t-value="True"/> + </t> + <t t-call="website.snippet_options_shadow_widgets"/> +</t> +<t t-name="website.card_color"> + <!-- COLOR | .s_cards --> + <WeColorpicker title.translate="Color" + selectStyle="'true'" + cssProperty="'background-color'" + colorPrefix="'bg-'"/> +</t> +<t t-name="website.horizontal_alignment_option"> + <WeButtonGroup title.translate="Alignment"> + <WeButton class="'fa fa-fw fa-align-left'" tooltip.translate="Left" selectClass="'text-start'"/> + <WeButton class="'fa fa-fw fa-align-center'" tooltip.translate="Center" selectClass="'text-center'"/> + <WeButton class="'fa fa-fw fa-align-right'" tooltip.translate="Right" selectClass="'text-end'"/> + </WeButtonGroup> +</t> +<t t-name="website.block_width_option"> + <WeSelect title.translate="Width"> + <WeButton selectClass="'w-25'">25%</WeButton> + <WeButton selectClass="'w-50'">50%</WeButton> + <WeButton selectClass="'w-75'">75%</WeButton> + <WeButton selectClass="'w-100'" name="'so_width_100'">100%</WeButton> + </WeSelect> +</t> +<t t-name="website.block_align_option"> + <WeButtonGroup title.translate="Alignment" dependencies="'!so_width_100'"> + <WeButton class="'fa fa-fw fa-align-left'" tooltip.translate="'Left'" selectClass="'me-auto'"/> + <WeButton class="'fa fa-fw fa-align-center'" tooltip.translate="'Center'" selectClass="'mx-auto'"/> + <WeButton class="'fa fa-fw fa-align-right'" tooltip.translate="'Right'" selectClass="'ms-auto'"/> + </WeButtonGroup> +</t> + +<!-- Header - Borders & Shadows --> +<t t-name="website.HeaderBox"> + <t t-call="website.snippet_options_border_widgets"> + <t t-set="so_rounded_no_dependencies" t-value="True"/> + <t t-set="width_variable" t-value="'menu-border-width'"/> + <t t-set="style_variable" t-value="'menu-border-style'"/> + <t t-set="color_variable" t-value="'menu-border-color'"/> + <t t-set="radius_variable" t-value="'menu-border-radius'"/> + <t t-set="no_border_radius" t-value="this.renderContext.noBorderRadius"/> + </t> + <t t-call="website.snippet_options_shadow_widgets"> + <t t-set="shadow_variable" t-value="'menu-box-shadow'"/> + </t> +</t> +<t t-name="website.TopMenuVisibility"> + <WeSelect title.translate="Header Position" noPreview="'true'" dependencies="'!header_sidebar_opt'"> + <WeButton visibility="'transparent'">Over The Content</WeButton> + <WeButton visibility="'regular'">Regular</WeButton> + <WeButton visibility="'hidden'">Hidden</WeButton> + </WeSelect> +</t> +<t t-name="website.TopMenuColor"> + <WeColorpicker title.translate="Background" + class="'o_we_sublevel_1'" + id="'option_header_transparent_color'" + selectStyle="'true'" + preventImportant="'true'" + cssProperty="'background-color'" + colorPrefix="'bg-'" + excluded="'theme, common'" + pageOptionName="'header_color'"/> + <WeColorpicker title.translate="Text Color" + class="'o_we_sublevel_1'" + id="'option_header_transparent_text_color'" + selectStyle="'true'" + cssProperty="'color'" + colorPrefix="'text-'" + pageOptionName="'header_text_color'"/> +</t> + +<t t-name="website.HeaderElements"> + <WeRow title.translate="Elements" class="'o_we_full_row align-items-start'"> + <WeButton tooltip.translate="Show/hide text element" class="'d-flex justify-content-center flex-grow-1'" + img="'/website/static/src/img/snippets_options/header_extra_element_text.svg'" + customizeWebsiteViews="'website.header_text_element'" + resetViewArch="'true'" + reload="'/'"/> + <WeButton tooltip.translate="Show/hide language selector" class="'fa fa-flag d-flex justify-content-center flex-grow-1'" + name="'header_language_selector_opt'" + customizeWebsiteViews="'website.header_language_selector'" + resetViewArch="'true'" + reload="'/'"/> + <WeButton tooltip.translate="Show/hide search bar" class="'fa fa-search d-flex justify-content-center flex-grow-1'" + customizeWebsiteViews="'website.header_search_box'" + resetViewArch="'true'" + reload="'/'"/> + <WeButton tooltip.translate="Show/hide sign in button" class="'fa fa-sign-in d-flex justify-content-center flex-grow-1'" + customizeWebsiteViews="'portal.user_sign_in'" + reload="'/'" + noPreview="'true'"/> + </WeRow> + <WeRow title="' '" class="'o_we_full_row align-items-start mt-1'"> + <WeButton tooltip.translate="Show/hide social links" class="'flex-grow-1 d-flex justify-content-center'" + img="'/website/static/src/img/snippets_options/header_extra_element_social.svg'" + customizeWebsiteViews="'website.header_social_links'" + resetViewArch="'true'" + reload="'/'"/> + <WeButton tooltip.translate="Show/hide button" class="'flex-grow-1 d-flex justify-content-center'" + img="'/website/static/src/img/snippets_options/header_extra_element_cta.svg'" + customizeWebsiteViews="'website.header_call_to_action'" + resetViewArch="'true'" + reload="'/'"/> + <WeButton tooltip.translate="Show/hide logo" class="'flex-grow-1 d-flex justify-content-center'" + img="'/website/static/src/img/snippets_options/header_extra_element_logo.svg'" + customizeWebsiteViews="'|website.option_header_brand_name|website.option_header_brand_logo'" + resetViewArch="'true'" + reload="'/'"/> + </WeRow> +</t> + +<t t-name="website.HeaderScrollEffect"> + <WeSelect title.translate="Scroll Effect" dependencies="'!header_sidebar_opt'" + class="'o_scroll_effects_selector'" + variable="'header-scroll-effect'"> + <WeButton id="'option_header_visibility_standard'" + customizeWebsiteVariable="`'standard'`" + name="'header_visibility_standard_opt'" + selectClass="'o_header_standard'" + customizeWebsiteViews="'website.header_visibility_standard'" + img="'/website/static/src/img/snippets_options/header_effect_standard.png'" + animateImg="'true'"> + Standard + </WeButton> + <WeButton id="'option_header_effect_scroll'" + customizeWebsiteVariable="`'scroll'`" + name="'header_effect_scroll_opt'" + selectClass="''" + customizeWebsiteViews="''" + img="'/website/static/src/img/snippets_options/header_effect_scroll.png'" + animateImg="'true'"> + Scroll + </WeButton> + <WeButton id="'option_header_effect_fixed'" + customizeWebsiteVariable="`'fixed'`" + name="'header_effect_fixed_opt'" + selectClass="'o_header_fixed'" + customizeWebsiteViews="'website.header_visibility_fixed'" + img="'/website/static/src/img/snippets_options/header_effect_fixed.png'" + animateImg="'true'"> + Fixed + </WeButton> + <WeButton id="'option_header_effect_disappears'" + customizeWebsiteVariable="`'disappears'`" + name="'header_effect_disappears_opt'" + selectClass="'o_header_disappears'" + customizeWebsiteViews="'website.header_visibility_disappears'" + img="'/website/static/src/img/snippets_options/header_effect_disappears.png'" + animateImg="'true'"> + Disappears + </WeButton> + <WeButton id="'option_header_effect_fade_out'" + customizeWebsiteVariable="`'fade-out'`" + name="'header_effect_fade_out_opt'" + selectClass="'o_header_fade_out'" + customizeWebsiteViews="'website.header_visibility_fade_out'" + img="'/website/static/src/img/snippets_options/header_effect_fade_out.png'" + animateImg="'true'"> + Fade Out + </WeButton> + </WeSelect> +</t> + +<t t-name="website.HeaderLanguageSelector"> + <WeSelect title.translate="Style" reload="'/'"> + <WeButton customizeWebsiteViews="'website.header_language_selector'"> + Dropdown + </WeButton> + <WeButton customizeWebsiteViews="'website.header_language_selector, website.header_language_selector_inline'"> + Inline + </WeButton> + </WeSelect> + <WeSelect title.translate="Label" class="'o_we_sublevel_1'" reload="'/'" noPreview="'true'"> + <WeButton customizeWebsiteViews="''"> + Text + </WeButton> + <WeButton customizeWebsiteViews="'website.header_language_selector_flag, website.header_language_selector_no_text'"> + Flag + </WeButton> + <WeButton customizeWebsiteViews="'website.header_language_selector_flag'"> + Flag and Text + </WeButton> + <WeButton customizeWebsiteViews="'website.header_language_selector_code, website.header_language_selector_no_text'"> + Code + </WeButton> + <WeButton customizeWebsiteViews="'website.header_language_selector_flag, website.header_language_selector_code, website.header_language_selector_no_text'"> + Flag and Code + </WeButton> + </WeSelect> +</t> + +<t t-name="website.HeaderBrand"> + <WeSelect title.translate="Logo" reload="'/'"> + <WeButton customizeWebsiteViews="''" name="'option_header_brand_none'">None</WeButton> + <WeButton customizeWebsiteViews="'website.option_header_brand_name'">Text</WeButton> + <WeButton customizeWebsiteViews="'website.option_header_brand_logo'">Image</WeButton> + </WeSelect> + <WeInput title.translate="Height" class="'o_we_sublevel_1'" + dependencies="'!option_header_brand_none'" + customizeWebsiteVariable="'null'" + variable="'logo-height'" + unit="'px'" + saveUnit="'rem'"/> + <WeInput title.translate="Height (Scrolled)" class="'o_we_sublevel_1'" + name="'option_logo_height_scrolled'" + customizeWebsiteVariable="'null'" + variable="'fixed-logo-height'" + unit="'px'" + saveUnit="'rem'" + dependencies="'!header_effect_scroll_opt'"/> <!-- TODO: @owl-options - dependency doesn't work --> +</t> + +<!-- Header > Navbar Options --> +<t t-name="website.HeaderNavbar"> + <WeSelect title.translate="Mobile Alignment" + name="'header_mobile_alignment_opt'" + variable="'hamburger-position-mobile'" + reload="'/'"> + <WeButton customizeWebsiteViews="''" customizeWebsiteVariable="`'left'`">Left</WeButton> + <WeButton customizeWebsiteViews="'website.template_header_mobile_align_center, website.template_header_hamburger_mobile_align_center'" + customizeWebsiteVariable="`'center'`">Center</WeButton> + <WeButton customizeWebsiteViews="'website.template_header_mobile_align_right, website.template_header_hamburger_mobile_align_right'" + customizeWebsiteVariable="`'right'`">Right</WeButton> + </WeSelect> + + <WeRow title.translate="Font"> + <WeFontFamilyPicker variable="'navbar-font'"/> + </WeRow> + + <WeRow title.translate="Format" class="'o_we_header_font_row'"> + <WeInput customizeWebsiteVariable="'null'" variable="'header-font-size'" unit="'px'" saveUnit="'rem'"/> + <WeColorpicker variable="'header-text-color'" customizeWebsiteVariable="''"/> + <!-- Generic alignment option controlling all the template at once. --> + <!-- Currently needed to be this way as the SCSS variable controls --> + <!-- the mobile alignment which is the same for all templates. --> + <WeSelect class="'o_we_icon_select'" + name="'header_alignment_opt'" + variable="'hamburger-position'" + reload="'/'" + tooltip.translate="Alignment" + dependencies="'header_default_opt, header_hamburger_opt, header_boxed_opt, header_stretch_opt, header_search_opt, header_sales_one_opt, header_sales_two_opt, header_sales_four_opt, header_sidebar_opt'"> + <WeButton customizeWebsiteViews="''" + customizeWebsiteVariable="`'left'`" + icon="'fa-align-left'"/> + <WeButton customizeWebsiteViews="'website.template_header_default_align_center, website.template_header_boxed_align_center, website.template_header_stretch_align_center, website.template_header_search_align_center, website.template_header_sales_one_align_center, website.template_header_sales_two_align_center, website.template_header_sales_four_align_center, website.template_header_sidebar_align_center'" + customizeWebsiteVariable="`'center'`" + icon="'fa-align-center'" + dependencies="'!header_hamburger_opt'"/> + <WeButton customizeWebsiteViews="'website.template_header_default_align_right, website.template_header_hamburger_align_right, website.template_header_boxed_align_right, website.template_header_stretch_align_right, website.template_header_search_align_right, website.template_header_sales_one_align_right, website.template_header_sales_two_align_right, website.template_header_sales_four_align_right, website.template_header_sidebar_align_right'" + customizeWebsiteVariable="`'right'`" + icon="'fa-align-right'"/> + </WeSelect> + </WeRow> + + <WeSelect title.translate="Links Style" + variable="'header-links-style'" + reload="'/'" + dependencies="'!header_stretch_opt'"> + <WeButton name="'option_header_navbar_links_default'" + customizeWebsiteViews="''" + customizeWebsiteVariable="`'default'`">Default</WeButton> + <WeButton name="'option_header_navbar_links_fill'" + customizeWebsiteViews="'website.header_navbar_pills_style'" + customizeWebsiteVariable="`'fill'`">Fill</WeButton> + <WeButton name="'option_header_navbar_links_outline'" + customizeWebsiteViews="''" + customizeWebsiteVariable="`'outline'`">Outline</WeButton> + <WeButton name="'option_header_navbar_links_pills'" + customizeWebsiteViews="'website.header_navbar_pills_style'" + customizeWebsiteVariable="`'pills'`">Pills</WeButton> + <WeButton name="'option_header_navbar_block'" + customizeWebsiteViews="'website.header_navbar_pills_style'" + customizeWebsiteVariable="`'block'`">Block</WeButton> + <WeButton name="'option_header_navbar_border_bottom'" + customizeWebsiteViews="''" + customizeWebsiteVariable="`'border-bottom'`">Border Bottom</WeButton> + </WeSelect> + + <WeSelect title.translate="Sub Menus" + id="'option_header_dropdown'" + dependencies="'!header_hamburger_opt'" + noPreview="'true'"> + <WeButton selectClass="'o_hoverable_dropdown'" + customizeWebsiteViews="'website.header_hoverable_dropdown'"> + On Hover + </WeButton> + <WeButton selectClass="''" + name="'header_dropdown_on_click_opt'"> + On Click + </WeButton> + </WeSelect> +</t> + +<!-- Footer - Borders & Shadows --> +<t t-name="website.HideFooter"> + <WeCheckbox title.translate="Page Visibility" visibility="'hidden|shown'" noPreview="'true'"/> +</t> + +<!-- Scroll to Top --> +<t t-name="website.FooterScrolltop"> + <WeRow title.translate="Scroll Top Button"> + <WeCheckbox name="'footer_scrolltop_opt'" + customizeWebsiteViews="'website.option_footer_scrolltop'" + customizeWebsiteVariable="'false|true'" + variable="'footer-scrolltop'" + reload="'/'"/> + <WeSelect dependencies="'footer_scrolltop_opt'" applyTo="'#o_footer_scrolltop_wrapper'"> + <WeButton selectClass="'justify-content-start'">Left</WeButton> + <WeButton selectClass="'justify-content-center'">Center</WeButton> + <WeButton selectClass="'justify-content-end'">Right</WeButton> + </WeSelect> + </WeRow> +</t> + +<t t-name="website.share_social_media_option"> + <WeSelect title.translate="Title Position" applyTo="'.s_share_title, .s_social_media_title'"> + <WeButton selectClass="'d-block'">Top</WeButton> + <WeButton selectClass="''">Left</WeButton> + <WeButton selectClass="'d-none'">None</WeButton> + </WeSelect> + <WeSelect title.translate="Layout" applyTo="'.fa'"> + <WeButton selectClass="'rounded shadow-sm'">Square</WeButton> + <WeButton selectClass="'rounded-empty-circle shadow-sm'">Circle</WeButton> + <WeButton selectClass="'rounded-circle shadow-sm'">Disk</WeButton> + <WeButton selectClass="'fa-stack'">None</WeButton> + </WeSelect> + <WeSelect title.translate="Size" applyTo="'.fa'"> + <WeButton selectClass="''">Small</WeButton> + <WeButton selectClass="'fa-2x'">Medium</WeButton> + <WeButton selectClass="'fa-3x'">Big</WeButton> + </WeSelect> + <!-- Compatibility, keep reverse logical, don't use `icon_color` --> + <WeCheckbox title.translate="Color" selectClass="'no_icon_color|'"/> +</t> +<t t-name="website.cookie_bar_options"> + <WeSelect title.translate="Layout" class="'o_we_inline'"> + <WeButton selectClass="'o_cookies_discrete'" + selectLayout="'discrete'" + trigger="'position_bottom,s_popup_size_full'"> + Discrete + </WeButton> + <WeButton selectClass="'o_cookies_classic'" + selectLayout="'classic'" + trigger="'position_bottom,s_popup_size_full'"> + Classic + </WeButton> + <WeButton name="'layout_popup_opt'" + selectClass="'o_cookies_popup'" + selectLayout="'popup'" + trigger="'position_middle,s_popup_size_md'"> + Popup + </WeButton> + </WeSelect> +</t> +<t t-name="web_editor.mega_menu_layout_options"> + <WeSelect name="'mega_menu_template_opt'" title.translate="Template"> + <t t-set="_label">Multi Menus</t> + <WeButton selectLabel="_label" + selectTemplate="'website.s_mega_menu_multi_menus'" + img="'/website/static/src/img/snippets_thumbs/s_mega_menu_multi_menus.svg'" + t-out="_label"/> + <t t-set="_label">Image Menu</t> + <WeButton selectLabel="_label" + selectTemplate="'website.s_mega_menu_menu_image_menu'" + img="'/website/static/src/img/snippets_thumbs/s_mega_menu_menu_image_menu.svg'" + t-out="_label"/> + <t t-set="_label">Odoo Menu</t> + <WeButton selectLabel="_label" + selectTemplate="'website.s_mega_menu_odoo_menu'" + img="'/website/static/src/img/snippets_thumbs/s_mega_menu_odoo_menu.svg'" + t-out="_label"/> + <t t-set="_label">Little Icons</t> + <WeButton selectLabel="_label" + selectTemplate="'website.s_mega_menu_little_icons'" + img="'/website/static/src/img/snippets_thumbs/s_mega_menu_little_icons.svg'" + t-out="_label"/> + <t t-set="_label">Big Icons Subtitles</t> + <WeButton selectLabel="_label" + selectTemplate="'website.s_mega_menu_big_icons_subtitles'" + img="'/website/static/src/img/snippets_thumbs/s_mega_menu_big_icons_subtitles.svg'" + t-out="_label"/> + <t t-set="_label">Images Subtitles</t> + <WeButton selectLabel="_label" + selectTemplate="'website.s_mega_menu_images_subtitles'" + img="'/website/static/src/img/snippets_thumbs/s_mega_menu_images_subtitles.svg'" + t-out="_label"/> + <t t-set="_label">Logos</t> + <WeButton selectLabel="_label" + selectTemplate="'website.s_mega_menu_menus_logos'" + img="'/website/static/src/img/snippets_thumbs/s_mega_menu_menus_logos.svg'" + t-out="_label"/> + <t t-set="_label">Thumbnails</t> + <WeButton selectLabel="_label" + selectTemplate="'website.s_mega_menu_thumbnails'" + img="'/website/static/src/img/snippets_thumbs/s_mega_menu_thumbnails.svg'" + t-out="_label"/> + <t t-set="_label">Cards</t> + <WeButton selectLabel="_label" + selectTemplate="'website.s_mega_menu_cards'" + img="'/website/static/src/img/snippets_thumbs/s_mega_menu_cards.svg'" + t-out="_label"/> + </WeSelect> + <WeSelect title.translate="Size"> + <WeButton selectClass="''">Full-Width</WeButton> + <WeButton selectClass="'o_mega_menu_container_size'">Narrow</WeButton> + </WeSelect> +</t> + +<t t-name="website.TestSnippetOption" t-inherit="web_editor.TestSnippetOption" t-inherit-mode="extension"> + <xpath expr="//WeRow" position="before"> + <WeRow> + <t t-set-slot="title">URL</t> + <WeUrlPicker selectDataAttribute="''" attributeName="'url'"/> + </WeRow> + <!-- Do not annoy devs with GMap key + <WeRow> + <t t-set-slot="title">Address</t> + <WeGpsPicker selectDataAttribute="''" attributeName="'mapGps'" + setFormattedAddress="''" + placeholder="'e.g. De Brouckere, Brussels, Belgium'" + noPreview="'true'"/> + </WeRow> + --> + <WeRow> + <t t-set-slot="title">Font</t> + <WeFontFamilyPicker selectMethod="'selectDataAttribute'" attributeName="'font'"/> + </WeRow> + </xpath> +</t> + +<t t-name="website.WeUrlPicker"> + <!-- TODO There was no w-100 --> + <we-input class="o_we_user_value_widget o_we_large w-100"> + <div> + <input t-ref="input" type="text" class="text-start" + autocomplete="chrome-off" + t-att-placeholder="this.props.placeholder" + t-on-input="this._onInputInput" + t-on-blur="this._onInputBlur" + t-on-change="this._onUserValueChange" + t-on-keydown="this._onInputKeydown" + t-att-value="this.state.value" + /> + </div> + <we-button class="o_we_redirect_to o_we_link ms-1" + title="Preview this URL in a new tab" + t-on-click="() => { this._onRedirectTo(); }" + > + <i class="fa fa-fw fa-external-link"/> + </we-button> + </we-input> +</t> +<t t-name="website.WeGpsPicker"> + <we-input class="o_we_user_value_widget o_we_large w-100"> + <div> + <input t-ref="input" type="text" class="text-start" + autocomplete="chrome-off" + t-att-placeholder="this.props.placeholder" + t-att-value="this.state.formattedAddress" + /> + </div> + </we-input> +</t> +<t t-name="website.WeFontFamilyPicker"> + <we-select class="o_we_user_value_widget" t-att-class="getAllClasses()" t-on-click="_onClick"> + <div> + <we-toggler t-out="state.toggler.textContent" t-att-class="{ 'active': state.opened }"/> + <we-selection-items t-ref="menu"> + <t t-foreach="state.fonts" t-as="font" t-key="font_index"> + <WeButton class="'o_we_user_value_widget'" t-props="forwardProps(font)"> + <div class="d-flex justify-content-between"> + <span t-attf-style="font-family: {{font.fontFamily}};"> + <i t-if="font.type === 'cloud'" role="button" class="text-info me-2 fa fa-cloud" title="This font is hosted and served to your visitors by Google servers"></i> + <t t-out="font.string"/> + </span> + <div class="text-end"> + <t t-if="font.indexForType >= 0"> + <t t-set="delete_font_title">Delete this font</t> + <i role="button" + t-on-click.prevent="() => this._onDeleteFontClick(font)" + class="link-danger fa fa-trash-o o_we_delete_font_btn" + title="Delete this font" + t-att-aria-label="delete_font_title" + t-att-title="delete_font_title"/> + </t> + </div> + </div> + </WeButton> + </t> + <WeButton class="'o_we_user_value_widget o_we_add_font_btn'" t-on-click="() => this._onAddFontClick()">Add a Custom Font</WeButton> + <t t-slot="default"/> + </we-selection-items> + <span class="o_we_dropdown_caret"/> + </div> + </we-select> +</t> +<t t-name="website.Parallax"> + <WeSelect title.translate="Parallax" + class="'o_we_sublevel_2'" + attributeName="'scrollBackgroundRatio'" + attributeDefaultValue="'0'" + parallaxTypeOpt="'true'" + noPreview="'true'" + dependencies="'bg_image_opt'"> + <WeButton name="'parallax_none_opt'" selectDataAttribute="'0'">None</WeButton> + <WeButton selectDataAttribute="'1'">Fixed</WeButton> + <WeButton name="'parallax_top_opt'" selectDataAttribute="'1.5'">Bottom to Top</WeButton> + <WeButton name="'parallax_bottom_opt'" selectDataAttribute="'-1.5'">Top to Bottom</WeButton> + </WeSelect> + <WeRange title.translate="Intensity" + class="'o_we_sublevel_3'" + dependencies="'parallax_top_opt'" + selectDataAttribute="''" + attributeName="'scrollBackgroundRatio'" + attributeDefaultValue="'0'" + noPreview="'true'" + min="'0'" + max="'3'" + step="'0.15'"/> <!-- Make sure this cannot land on 1 --> + <WeRange title.translate="Intensity" + class="'o_we_sublevel_3'" + dependencies="'parallax_bottom_opt'" + selectDataAttribute="''" + attributeName="'scrollBackgroundRatio'" + attributeDefaultValue="'0'" + noPreview="'true'" + min="'0'" + max="'-3'" + step="'0.15'"/> <!-- Make sure this cannot land on 1 --> +</t> +<t t-name="website.columns_only"> + <t t-call="website.column_count_option"> + <t t-set="title">Columns</t> + </t> +</t> + +<t t-name="website.snippet_options_conditional_visibility"> + <WeSelect title="option_name" class="'o_we_sublevel_1'" dependencies="'visibility_conditional'" + attributeName="attribute_rule" noPreview="'true'" isVisibilityCondition="'true'"> + <WeButton selectDataAttribute="''">Visible for</WeButton> + <WeButton selectDataAttribute="'hide'">Hidden for</WeButton> + </WeSelect> + <WeMany2many title="' '" dependencies="'visibility_conditional'" selectRecord="''" + noPreview="'true'" allowDelete="'true'" fakem2m="'true'" + saveAttribute="save_attribute" attributeName="attribute_name" model="model" + callWith="call_with" fields="data_fields" domain="domain"> + </WeMany2many> +</t> +<t t-name="website._device_visibility_widgets"> + <WeButton class="'o_we_device'" toggleDeviceVisibility="'no_desktop'" tooltip.translate="Show/Hide on Desktop" + noPreview="'true'" img="'/website/static/src/img/snippets_options/desktop_invisible.svg'"/> + <WeButton class="'o_we_device'" toggleDeviceVisibility="'no_mobile'" tooltip.translate="Show/Hide on Mobile" + noPreview="'true'" img="'/website/static/src/img/snippets_options/mobile_invisible.svg'"/> +</t> +<t t-name="website.ConditionalVisibility"> + <we-collapse> + <WeRow title.translate="Visibility"> + <t t-call="website._device_visibility_widgets"/> + <WeSelect class="'o_we_collapse_toggler'" attributeName="'visibility'" noPreview="'true'"> + <WeButton selectDataAttribute="''">No condition</WeButton> + <WeButton selectDataAttribute="'conditional'" selectClass="'o_snippet_invisible'" + name="'visibility_conditional'"> + Conditionally + </WeButton> + </WeSelect> + </WeRow> + + <t t-if="renderContext.countryCode"> + <t t-call="website.snippet_options_conditional_visibility"> + <t t-set="option_name">Country</t> + <t t-set="attribute_rule" t-value="'visibilityValueCountryRule'"/> + <t t-set="save_attribute" t-value="'visibilityValueCountry'"/> + <t t-set="attribute_name" t-value="'data-country'"/> + <t t-set="model" t-value="'res.country'"/> + <t t-set="call_with" t-value="'code'"/> + <t t-set="data_fields" t-value="'["code"]'"/> + </t> + </t> + <t t-if="renderContext.currentWebsite.language_ids.length > 1"> + <t t-call="website.snippet_options_conditional_visibility"> + <t t-set="option_name">Languages</t> + <t t-set="attribute_rule" t-value="'visibilityValueLangRule'"/> + <t t-set="save_attribute" t-value="'visibilityValueLang'"/> + <t t-set="attribute_name" t-value="'lang'"/> + <t t-set="model" t-value="'res.lang'"/> + <t t-set="call_with" t-value="'code'"/> + <t t-set="domain" t-value='`[["id", "in", [${renderContext.currentWebsite.language_ids}]]]`'/> + <t t-set="data_fields" t-value="'["code"]'"/> + </t> + </t> + <t t-call="website.snippet_options_conditional_visibility"> + <t t-set="option_name">UTM Campaign</t> + <t t-set="attribute_rule" t-value="'visibilityValueUtmCampaignRule'"/> + <t t-set="save_attribute" t-value="'visibilityValueUtmCampaign'"/> + <t t-set="attribute_name" t-value="'data-utm-campaign'"/> + <t t-set="model" t-value="'utm.campaign'"/> + <t t-set="call_with" t-value="'display_name'"/> + </t> + <t t-call="website.snippet_options_conditional_visibility"> + <t t-set="option_name">UTM Medium</t> + <t t-set="attribute_rule" t-value="'visibilityValueUtmMediumRule'"/> + <t t-set="save_attribute" t-value="'visibilityValueUtmMedium'"/> + <t t-set="attribute_name" t-value="'data-utm-medium'"/> + <t t-set="model" t-value="'utm.medium'"/> + <t t-set="call_with" t-value="'display_name'"/> + </t> + <t t-call="website.snippet_options_conditional_visibility"> + <t t-set="option_name">UTM Source</t> + <t t-set="attribute_rule" t-value="'visibilityValueUtmSourceRule'"/> + <t t-set="save_attribute" t-value="'visibilityValueUtmSource'"/> + <t t-set="attribute_name" t-value="'data-utm-source'"/> + <t t-set="model" t-value="'utm.source'"/> + <t t-set="call_with" t-value="'display_name'"/> + </t> + <WeSelect title.translate="Users" + class="'o_we_sublevel_1'" dependencies="'visibility_conditional'" + attributeName="'data-logged'" saveAttribute="'visibilityValueLogged'" + noPreview="'true'" attributeDefaultValue="''"> + <WeButton selectValue="'true'">Visible for Logged In</WeButton> + <WeButton selectValue="'false'">Visible for Logged Out</WeButton> + <WeButton selectValue="''">Visible for Everyone</WeButton> + </WeSelect> + </we-collapse> +</t> +<!-- Mobile/Desktop display options --> +<t t-name="website.DeviceVisibility"> + <WeRow title.translate="Visibility"> + <t t-call="website._device_visibility_widgets"/> + </WeRow> +</t> + +<t t-name="website.Carousel"> + <WeRow title.translate="Slide"> + <WeButton addSlide="'true'" noPreview="'true'" class="'o_we_bg_brand_primary'">Add Slide</WeButton> + </WeRow> + <WeSelect title.translate="Style"> + <WeButton selectClass="'s_carousel_default'">Default</WeButton> + <WeButton selectClass="'s_carousel_bordered'">Bordered</WeButton> + <WeButton selectClass="'s_carousel_boxed'">Boxed</WeButton> + <WeButton selectClass="'s_carousel_rounded'">Rounded</WeButton> + </WeSelect> + <WeSelect title.translate="Transition"> + <WeButton selectClass="'slide'">Slide</WeButton> + <WeButton selectClass="'carousel-fade slide'">Fade</WeButton> + <we-divider/> + <WeButton selectClass="''">None</WeButton> + </WeSelect> + <WeInput title.translate="Speed" selectDataAttribute="'0s'" attributeName="'bsInterval'" + unit="'s'" saveUnit="'ms'" step="'0.1'"/> +</t> + +<t t-name="website.CarouselItem"> + <WeButton class="'fa fa-fw fa-angle-left'" switchToSlide="'left'" noPreview="'true'" + tooltip.translate="Move Backward"/> + <WeButton class="'fa fa-fw fa-angle-right me-2'" switchToSlide="'right'" noPreview="'true'" + tooltip.translate="Move Forward"/> + <WeButton class="'fa fa-fw fa-plus o_we_bg_success'" addSlideItem="'true'" noPreview="'true'" + tooltip.translate="Add Slide"/> + <WeButton class="'fa fa-fw fa-minus o_we_bg_danger'" removeSlide="'true'" noPreview="'true'" + tooltip.translate="Remove Slide"/> +</t> + +<t t-name="website.GalleryElement"> +<WeRow title.translate="Re-order"> + <WeButton class="'fa fa-fw fa-angle-double-left'" tooltip.translate="Move to first" + position="'first'" noPreview="'true'"/> + <WeButton class="'fa fa-fw fa-angle-left'" tooltip.translate="Move to previous" + position="'prev'" noPreview="'true'"/> + <WeButton class="'fa fa-fw fa-angle-right'" tooltip.translate="Move to next" + position="'next'" noPreview="'true'"/> + <WeButton class="'fa fa-fw fa-angle-double-right'" tooltip.translate="Move to last" + position="'last'" noPreview="'true'"/> +</WeRow> +</t> + +<!-- Info page ('/website/info') --> +<t t-name="website.InfoPage"> + <WeCheckbox title.translate="Odoo Information" + customizeWebsiteViews="'website.show_website_info'" + noPreview="'true'" + reload="'/'"/> +</t> + +</templates> diff --git a/addons/website/static/src/js/editor/snippets.registry.js b/addons/website/static/src/js/editor/snippets.registry.js new file mode 100644 index 0000000000000..0f1da79a2663b --- /dev/null +++ b/addons/website/static/src/js/editor/snippets.registry.js @@ -0,0 +1,12 @@ +/** @odoo-module **/ + +import { registerOption } from "@web_editor/js/editor/snippets.registry"; + + +export function registerWebsiteOption(name, def, options) { + if (!def.module) { + def.module = "website"; + } + return registerOption(name, def, options); +} + diff --git a/addons/website/static/src/js/tours/tour_utils.js b/addons/website/static/src/js/tours/tour_utils.js index f2c09c97b03d8..787eb00a4c285 100644 --- a/addons/website/static/src/js/tours/tour_utils.js +++ b/addons/website/static/src/js/tours/tour_utils.js @@ -100,7 +100,9 @@ function changeOption(optionName, weName = '', optionTooltipLabel = '', position const noPalette = allowPalette ? '' : '.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened))'; const option_block = `${noPalette} we-customizeblock-option[class='snippet-option-${optionName}']`; return { - trigger: `${option_block} ${weName}, ${option_block} [title='${weName}']`, + // TODO: @owl-options when all options are converted to OWL, remove the + // the first part of the selector. + trigger: `${option_block} ${weName}, ${option_block} [title='${weName}'], ${option_block} [data-title='${weName}']`, content: markup(_t("<b>Click</b> on this option to change the %s of the block.", optionTooltipLabel)), tooltipPosition: position, run: "click", diff --git a/addons/website/static/src/js/utils.js b/addons/website/static/src/js/utils.js index a76dfbd1b3b63..402ab4a4fa717 100644 --- a/addons/website/static/src/js/utils.js +++ b/addons/website/static/src/js/utils.js @@ -197,11 +197,16 @@ function prompt(options, _qweb) { function websiteDomain(self) { var websiteID; - self.trigger_up('context_get', { - callback: function (ctx) { - websiteID = ctx['website_id']; - }, - }); + // TODO: @owl-options remove the trigger_up once the refactoring is done + if (self.trigger_up) { + self.trigger_up('context_get', { + callback: function (ctx) { + websiteID = ctx['website_id']; + }, + }); + } else { + websiteID = self.env.services.website.currentWebsite.id; + } return ['|', ['website_id', '=', false], ['website_id', '=', websiteID]]; } @@ -405,12 +410,16 @@ export function generateGMapLink(dataset) { */ function isMobile(self) { let isMobile; - self.trigger_up("service_context_get", { - callback: (ctx) => { - isMobile = ctx["isMobile"]; - }, - }); - + // TODO: @owl-options remove the trigger_up once the refactoring is done + if (self.trigger_up) { + self.trigger_up("service_context_get", { + callback: (ctx) => { + isMobile = ctx["isMobile"]; + }, + }); + } else { + isMobile = self.env.services.website.context.isMobile; + } return isMobile; } diff --git a/addons/website/static/src/snippets/s_alert/options.js b/addons/website/static/src/snippets/s_alert/options.js new file mode 100644 index 0000000000000..a9a36c96056ca --- /dev/null +++ b/addons/website/static/src/snippets/s_alert/options.js @@ -0,0 +1,6 @@ +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; + +registerWebsiteOption("Alert", { + template: "website.s_alert_options", + selector: ".s_alert", +}); diff --git a/addons/website/static/src/snippets/s_alert/options.xml b/addons/website/static/src/snippets/s_alert/options.xml new file mode 100644 index 0000000000000..38266cec44668 --- /dev/null +++ b/addons/website/static/src/snippets/s_alert/options.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="website.s_alert_options"> + <WeSelect title.translate="Type" applyTo="'.fa.s_alert_icon'" trigger="'alert_colorpicker_opt'"> + <WeButton selectClass="'fa-user-circle'" triggerValue="'primary'">Primary</WeButton> + <WeButton selectClass="'fa-user-circle-o'" triggerValue="'secondary'">Secondary</WeButton> + <WeButton selectClass="'fa-info-circle'" triggerValue="'info'">Info</WeButton> + <WeButton selectClass="'fa-check-circle'" triggerValue="'success'">Success</WeButton> + <WeButton selectClass="'fa-exclamation-triangle'" triggerValue="'warning'">Warning</WeButton> + <WeButton selectClass="'fa-exclamation-circle'" triggerValue="'danger'">Danger</WeButton> + </WeSelect> + + <t t-call="website.block_width_option"/> + + <WeSelect title.translate="Size"> + <WeButton selectClass="'s_alert_sm'">Small</WeButton> + <WeButton selectClass="'s_alert_md'">Medium</WeButton> + <WeButton selectClass="'s_alert_lg'">Large</WeButton> + </WeSelect> + + <WeColorpicker title.translate="Color" + data-name="alert_colorpicker_opt" + selectStyle="'true'" + cssProperty="'background-color'" + colorPrefix="'alert-'"/> + </t> + +</templates> diff --git a/addons/website/static/src/snippets/s_blockquote/options.js b/addons/website/static/src/snippets/s_blockquote/options.js new file mode 100644 index 0000000000000..cc8710cadc2b0 --- /dev/null +++ b/addons/website/static/src/snippets/s_blockquote/options.js @@ -0,0 +1,32 @@ +/** @odoo-module **/ +import { + Box, +} from "@web_editor/js/editor/snippets.options"; +import { + registerWebsiteOption +} from "@website/js/editor/snippets.registry"; +import { + websiteRegisterBackgroundOptions, +} from "@website/js/editor/snippets.options"; + +registerWebsiteOption("Blockquote (layout)", { + template: "website.s_blockquote_option_layout", + selector: ".s_blockquote", +}); +websiteRegisterBackgroundOptions("Blockquote (background)", { + selector: ".s_blockquote", + withColors: true, + withImages: true, + withShapes: true, + withGradients: true, +}); +registerWebsiteOption("Blockquote (border)", { + Class: Box, + template: "website.snippet_options_border_widgets", + selector: ".s_blockquote", +}); +registerWebsiteOption("Blockquote (shadow)", { + Class: Box, + template: "website.snippet_options_shadow_widgets", + selector: ".s_blockquote", +}); diff --git a/addons/website/static/src/snippets/s_blockquote/options.xml b/addons/website/static/src/snippets/s_blockquote/options.xml new file mode 100644 index 0000000000000..dccc1d196b532 --- /dev/null +++ b/addons/website/static/src/snippets/s_blockquote/options.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<templates id="template" xml:space="preserve"> +<t t-name="website.s_blockquote_option_layout"> + <!-- Layout --> + <WeRange title.translate="Edge Spacing" selectClass="'p-1|p-2|p-3|p-4|p-5'"/> + <WeSelect title.translate="Decoration"> + <WeButton selectClass="'s_blockquote_default'">None</WeButton> + <WeButton selectClass="'s_blockquote_with_line'" name="'blockquote_with_line_opt'">Left line</WeButton> + <WeButton selectClass="'s_blockquote_with_icon'">Icon</WeButton> + </WeSelect> + <WeRow title.translate="Style" class="'o_we_sublevel_1'"> + <WeInput cssProperty="'width'" selectStyle="''" unit="'px'" dependencies="'blockquote_with_line_opt'" applyTo="'.s_blockquote_line_elt'"/> + <WeColorpicker tooltip.translate="Color" + name="'bg_color_opt'" + applyTo="'.s_blockquote_line_elt'" + selectStyle="''" + cssProperty="'background-color'" + colorPrefix="'bg-'" + withGradients="'true'" + dependencies="'blockquote_with_line_opt'" + /> + </WeRow> + <WeSelect title.translate="Author Alignment" applyTo="'.s_blockquote_infos'"> + <WeButton selectClass="'flex-row align-items-start justify-content-start text-start'">Left</WeButton> + <WeButton selectClass="'flex-column align-items-center text-center'">Center</WeButton> + <WeButton selectClass="'flex-row-reverse align-items-start justify-content-start text-end'">Right</WeButton> + </WeSelect> +</t> +</templates> diff --git a/addons/website/static/src/snippets/s_card/options.js b/addons/website/static/src/snippets/s_card/options.js index 0af3e0218b612..354d9e4acf175 100644 --- a/addons/website/static/src/snippets/s_card/options.js +++ b/addons/website/static/src/snippets/s_card/options.js @@ -1,10 +1,12 @@ /** @odoo-module **/ import { renderToElement } from "@web/core/utils/render"; -import options from "@web_editor/js/editor/snippets.options"; +import { Box, SnippetOption } from "@web_editor/js/editor/snippets.options"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; +import { websiteRegisterBackgroundOptions } from "@website/js/editor/snippets.options"; -options.registry.CardWidth = options.Class.extend({ +class CardWidth extends SnippetOption { /** * @override */ @@ -17,11 +19,12 @@ options.registry.CardWidth = options.Class.extend({ } } } - return this._super(...arguments); - }, -}); + return super._computeWidgetState(...arguments); + } +} + +class CardImageOptions extends SnippetOption { -options.registry.CardImageOptions = options.Class.extend({ //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- @@ -33,7 +36,7 @@ options.registry.CardImageOptions = options.Class.extend({ const imageWrapperEl = renderToElement("website.s_card.imageWrapper"); this.$target[0].insertAdjacentElement("afterbegin", imageWrapperEl); this.$target[0].classList.add("o_card_img_top"); - }, + } /** * Changes the cover image position. * @@ -73,7 +76,7 @@ options.registry.CardImageOptions = options.Class.extend({ delete this.previousRatio; } } - }, + } /** * Removes the cover image. */ @@ -87,7 +90,7 @@ options.registry.CardImageOptions = options.Class.extend({ this.$target[0].style.removeProperty("--card-img-size-h"); this.$target[0].style.removeProperty("--card-img-ratio-align"); this.$target[0].style.removeProperty("--card-img-aspect-ratio"); - }, + } /** * Aligns the image inside the cover. * @@ -99,7 +102,7 @@ options.registry.CardImageOptions = options.Class.extend({ imageWrapperEl.classList.toggle("o_card_img_adjust_v", ratio > 1); imageWrapperEl.classList.toggle("o_card_img_adjust_h", ratio < 1); - }, + } //-------------------------------------------------------------------------- // Private @@ -122,8 +125,8 @@ options.registry.CardImageOptions = options.Class.extend({ } else if (widgetName === "cover_image_alignment_opt") { return hasCoverImage && hasNonSquareRatio && useRatio && !hasShape; } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * Compares the aspect ratio of the card image to its wrapper. * @@ -144,5 +147,37 @@ options.registry.CardImageOptions = options.Class.extend({ const wrapperRatio = imageWrapperEl.offsetHeight / imageWrapperEl.offsetWidth; return imgRatio / wrapperRatio; - }, + } +} + +registerWebsiteOption("Card (border)", { + Class: Box, + template: "website.snippet_options_border_widgets", + selector: ".s_card", +}); +registerWebsiteOption("Card (shadow)", { + Class: Box, + template: "website.snippet_options_shadow_widgets", + selector: ".s_card", +}); +registerWebsiteOption("Card (Width)", { + Class: CardWidth, + template: "website.CardWidth", + selector: ".s_card", }); +registerWebsiteOption("Card (Image)", { + Class: CardImageOptions, + template: "website.CardImageOptions", + selector: ".s_card", +}) + +websiteRegisterBackgroundOptions("Card (Background)", { + selector: ".s_card", + withColors: true, + withImages: true, + withShapes: true, + withColorCombinations: true, + withGradients: true, +}); + + diff --git a/addons/website/static/src/snippets/s_card/options.xml b/addons/website/static/src/snippets/s_card/options.xml new file mode 100644 index 0000000000000..39c08db1cd7b0 --- /dev/null +++ b/addons/website/static/src/snippets/s_card/options.xml @@ -0,0 +1,85 @@ +<?xml version="1.0" encoding="utf-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="website.CardWidth"> + <WeRange title.translate="Card Width" + selectStyle="'0'" + cssProperty="'max-width'" + unit="'%'" + min="'8'" + max="'100'" + displayRangeValue="'true'" + displayRangeValueUnit="'%'"/> +</t> + +<t t-name="website.CardImageOptions"> + <WeRow title.translate="Cover Image"> + <WeButton tooltip.translate="Add" addCoverImage="''" noPreview="'true'" class="'o_we_bg_success'" name="'add_cover_image_opt'">Add</WeButton> + </WeRow> + + <WeRow title.translate="Cover Image"> + <WeButtonGroup name="'cover_image_position_opt'"> + <WeButton tooltip.translate="Top" + name="'card_img_top_opt'" + selectClass="'o_card_img_top'" + selectImageClass="'card-img-top'" + img="'/website/static/src/img/snippets_options/pos_top.svg'"/> + <WeButton tooltip.translate="Left" + selectClass="'o_card_img_horizontal flex-lg-row'" + selectImageClass="'rounded-start'" + img="'/website/static/src/img/snippets_options/pos_left.svg'"/> + <WeButton tooltip.translate="Right" + selectClass="'o_card_img_horizontal flex-lg-row-reverse'" + selectImageClass="'rounded-end'" + img="'/website/static/src/img/snippets_options/pos_right.svg'"/> + </WeButtonGroup> + <WeButton tooltip.translate="Remove Cover" removeCoverImage="''" noPreview="'true'" class="'fa fa-trash-o fa-fw o_we_bg_danger'" name="'remove_cover_image_opt'"/> + </WeRow> + + <WeSelect title.translate="Ratio" applyTo="'.o_card_img_wrapper'" class="'o_we_sublevel_1'"> + <WeButton selectClass="''">Image default</WeButton> + <WeButton selectClass="'ratio ratio-1x1'">Square</WeButton> + <WeButton selectClass="'ratio ratio-4x3'" dependencies="'card_img_top_opt'">Landscape - 4/3</WeButton> + <WeButton selectClass="'ratio ratio-16x9'" dependencies="'card_img_top_opt'">Wide - 16/9</WeButton> + <WeButton selectClass="'ratio ratio-21x9'" dependencies="'card_img_top_opt'">Ultrawide - 21/9</WeButton> + <WeButton selectClass="'ratio o_card_img_ratio_custom'" dependencies="'card_img_top_opt'" name="'image_ratio_custom_opt'">Custom</WeButton> + </WeSelect> + + <!-- Calculate bootstrap default column size --> + <t t-set="colSize" t-value="8.33333333"/> + <WeRange class="'o_we_sublevel_1'" + title.translate="Width" + tooltip.translate="Adjust the image width" + name="'cover_image_width_opt'" + dependencies="'!card_img_top_opt'" + selectStyle="'0'" + cssProperty="'--card-img-size-h'" + unit="'%'" + min="`${colSize}`" + max="`${colSize * 11}`" + step="`${colSize}`"/> + + <WeRange title.translate="Custom Ratio" + class="'o_we_sublevel_2'" + name="'cover_image_ratio_range_opt'" + dependencies="'image_ratio_custom_opt'" + selectStyle="'0'" + cssProperty="'--card-img-aspect-ratio'" + unit="'%'" + displayRangeValue="'true'" + displayRangeValueUnit="'%'" + min="'8'" + max="'132'" + step="'4'"/> + + <WeRange title.translate="Alignment" + class="'o_we_sublevel_1'" + name="'cover_image_alignment_opt'" + selectStyle="'0'" + alignCoverImage="''" + cssProperty="'--card-img-ratio-align'" + unit="'%'" + min="'0'" + max="'100'"/> +</t> +</templates> diff --git a/addons/website/static/src/snippets/s_chart/options.js b/addons/website/static/src/snippets/s_chart/options.js index 923b32bbdeeda..bec6a371bbcc1 100644 --- a/addons/website/static/src/snippets/s_chart/options.js +++ b/addons/website/static/src/snippets/s_chart/options.js @@ -1,85 +1,125 @@ /** @odoo-module **/ import { _t } from "@web/core/l10n/translation"; -import options from "@web_editor/js/editor/snippets.options"; -import weUtils from "@web_editor/js/common/utils"; import { isCSSColor } from '@web/core/utils/colors'; +import weUtils from "@web_editor/js/common/utils"; +import { + SnippetOption, + SnippetOptionComponent, +} from "@web_editor/js/editor/snippets.options"; +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; + +class InnerChartOptionComponent extends SnippetOptionComponent { + static themeArray = ['o-color-1', 'o-color-2', 'o-color-3', 'o-color-4', 'o-color-5']; + onAddDataset() { + const usedColor = this.renderContext.datasets.map(dataset => dataset.backgroundColor); + const color = this.constructor.themeArray.filter(el => !usedColor.includes(el))[0] || this._randomColor(); + this.renderContext.datasets.push({ + label: "", + data: [], + backgroundColor: color, + borderColor: color, + }); + this.env.snippetOption._reloadGraph(); + } + onAddRow() { + this.renderContext.labels.push(""); + this.env.snippetOption._reloadGraph(); + } + onRemoveDataset(datasetIndex) { + this.renderContext.datasets.splice(datasetIndex, 1); + this.env.snippetOption._reloadGraph(); + this._refreshDatasetPalette() + } + onRemoveRow(labelIndex) { + this.renderContext.labels.splice(labelIndex, 1); + for (const dataset of this.renderContext.datasets) { + dataset.data.splice(labelIndex, 1); + } + this.env.snippetOption._reloadGraph(); + } + onCellFocus(datasetIndex, labelIndex) { + this.renderContext.focusedDatasetIndex = datasetIndex; + this.renderContext.focusedLabelIndex = labelIndex; + this._refreshDatasetPalette() + } + setDatasetLabel(datasetIndex, datasetLabel) { + this.renderContext.datasets[datasetIndex].label = datasetLabel; + this.env.snippetOption._reloadGraph(); + } + setRowLabel(rowIndex, rowLabel) { + this.renderContext.labels[rowIndex] = rowLabel; + this.env.snippetOption._reloadGraph(); + } + setCellValue(datasetIndex, rowIndex, value) { + this.renderContext.datasets[datasetIndex].data[rowIndex] = value; + this.env.snippetOption._reloadGraph(); + } + isCSSColor(color) { + return isCSSColor(color); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- -options.registry.InnerChart = options.Class.extend({ + _refreshDatasetPalette() { + // TODO: @owl-options how am I supposed to refresh those ? + /* + this.env.userValueNotification({ + triggerWidgetsNames: ["chart_bg_color_opt", "chart_border_color_opt"], + }); + */ + this.env.snippetOption.updateUI(); + } + /** + * Return a random hexadecimal color. + * + * @private + * @return {string} + */ + _randomColor() { + return '#' + ('00000' + (Math.random() * (1 << 24) | 0).toString(16)).slice(-6).toUpperCase(); + } +} + +class InnerChart extends SnippetOption { + static themeArray = ['o-color-1', 'o-color-2', 'o-color-3', 'o-color-4', 'o-color-5']; + /* custom_events: Object.assign({}, options.Class.prototype.custom_events, { 'get_custom_colors': '_onGetCustomColors', }), - events: Object.assign({}, options.Class.prototype.events, { - 'click we-button.add_column': '_onAddColumnClick', - 'click we-button.add_row': '_onAddRowClick', - 'click we-button.o_we_matrix_remove_col': '_onRemoveColumnClick', - 'click we-button.o_we_matrix_remove_row': '_onRemoveRowClick', - 'input we-matrix input': '_onMatrixInputInput', - 'focus we-matrix input': '_onMatrixInputFocus', - }), - + */ /** * @override */ - init: function () { - this._super.apply(this, arguments); - this.themeArray = ['o-color-1', 'o-color-2', 'o-color-3', 'o-color-4', 'o-color-5']; + constructor() { + super(...arguments); this.style = window.getComputedStyle(this.$target[0].ownerDocument.documentElement); - }, + } /** * @override */ - start: function () { - this.backSelectEl = this.el.querySelector('[data-name="chart_bg_color_opt"]'); - this.borderSelectEl = this.el.querySelector('[data-name="chart_border_color_opt"]'); + async willStart() { + await super.willStart(...arguments); // Build matrix content - this.tableEl = this.el.querySelector('we-matrix table'); const data = JSON.parse(this.$target[0].dataset.data); - data.labels.forEach(el => { - this._addRow(el); - }); - data.datasets.forEach((el, i) => { + Object.assign(data, { + focusedDatasetIndex: -1, + focusedLabelIndex: -1, + }); + Object.assign(this.renderContext, data); + data.datasets.forEach((dataset, i) => { if (this._isPieChart()) { // Add header colors in case the user changes the type of graph - const headerBackgroundColor = this.themeArray[i] || this._randomColor(); - const headerBorderColor = this.themeArray[i] || this._randomColor(); - this._addColumn(el.label, el.data, headerBackgroundColor, headerBorderColor, el.backgroundColor, el.borderColor); - } else { - this._addColumn(el.label, el.data, el.backgroundColor, el.borderColor); - } - }); - this._displayRemoveColButton(); - this._displayRemoveRowButton(); - this._setDefaultSelectedInput(); - return this._super(...arguments); - }, - /** - * @override - */ - updateUI: async function () { - // Selected input might not be in dom anymore if col/row removed - // Done before _super because _computeWidgetState of colorChange - if (!this.lastEditableSelectedInput.closest('table') || this.colorPaletteSelectedInput && !this.colorPaletteSelectedInput.closest('table')) { - this._setDefaultSelectedInput(); - } - - await this._super(...arguments); - - this.backSelectEl.querySelector('we-title').textContent = this._isPieChart() ? _t("Data Color") : _t("Dataset Color"); - this.borderSelectEl.querySelector('we-title').textContent = this._isPieChart() ? _t("Data Border") : _t("Dataset Border"); - - // Dataset/Cell color - this.tableEl.querySelectorAll('input').forEach(el => el.style.border = ''); - const selector = this._isPieChart() ? 'td input' : 'tr:first-child input'; - this.tableEl.querySelectorAll(selector).forEach(el => { - const color = el.dataset.backgroundColor || el.dataset.borderColor; - if (color) { - el.style.border = '2px solid'; - el.style.borderColor = isCSSColor(color) ? color : weUtils.getCSSVariableValue(color, this.style); + dataset.backgroundColor = this.themeArray[i] || this._randomColor(); + dataset.borderColor = this.themeArray[i] || this._randomColor(); } }); - }, + } //-------------------------------------------------------------------------- // Options @@ -88,34 +128,31 @@ options.registry.InnerChart = options.Class.extend({ /** * Set the color on the selected input. */ - colorChange: async function (previewMode, widgetValue, params) { + async colorChange(previewMode, widgetValue, params) { + const dataset = this.renderContext.datasets[this.renderContext.focusedDatasetIndex]; + if (!dataset) { + return; + } if (widgetValue) { - this.colorPaletteSelectedInput.dataset[params.attributeName] = widgetValue; + dataset[params.attributeName] = widgetValue; } else { - delete this.colorPaletteSelectedInput.dataset[params.attributeName]; + delete dataset[params.attributeName]; } await this._reloadGraph(); - // To focus back the input that is edited we have to wait for the color - // picker to be fully reloaded. - await new Promise(resolve => setTimeout(() => { - this.lastEditableSelectedInput.focus(); - resolve(); - })); - }, + } /** * @override */ - selectDataAttribute: async function (previewMode, widgetValue, params) { - await this._super(...arguments); + async selectDataAttribute(previewMode, widgetValue, params) { + await super.selectDataAttribute(...arguments); // Data might change if going from or to a pieChart. if (params.attributeName === 'type') { - this._setDefaultSelectedInput(); await this._reloadGraph(); } if (params.attributeName === 'minValue' || params.attributeName === 'maxValue') { this._computeTicksMinMaxValue(); } - }, + } //-------------------------------------------------------------------------- // Private @@ -124,27 +161,29 @@ options.registry.InnerChart = options.Class.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { if (methodName === 'colorChange') { - return this.colorPaletteSelectedInput && this.colorPaletteSelectedInput.dataset[params.attributeName] || ''; + const dataset = this.renderContext.datasets[this.renderContext.focusedDatasetIndex]; + return dataset && dataset[params.attributeName] || ''; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ - _computeWidgetVisibility: function (widgetName, params) { + _computeWidgetVisibility(widgetName, params) { switch (widgetName) { case 'stacked_chart_opt': { return this._getColumnCount() > 1; } case 'chart_bg_color_opt': case 'chart_border_color_opt': { - return !!this.colorPaletteSelectedInput; + const dataset = this.renderContext.datasets[this.renderContext.focusedDatasetIndex]; + return !!dataset; } } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * Maintains the gap between the scale axis for the auto fit behavior if we * used min/max config. @@ -186,208 +225,56 @@ options.registry.InnerChart = options.Class.extend({ 'data-ticks-min': minValue, 'data-ticks-max': maxValue, }); - }, + } /** * Sets and reloads the data on the canvas if it has changed. * Used in matrix related method. * * @private */ - _reloadGraph: async function () { + async _reloadGraph() { const jsonValue = this._matrixToChartData(); if (this.$target[0].dataset.data !== jsonValue) { this.$target[0].dataset.data = jsonValue; await this._refreshPublicWidgets(); } - }, + } /** * Return a stringifyed chart.js data object from the matrix * Pie charts have one color per data while other charts have one color per dataset. * * @private */ - _matrixToChartData: function () { - const data = { - labels: [], - datasets: [], - }; - this.tableEl.querySelectorAll('tr:first-child input').forEach(el => { - data.datasets.push({ - label: el.value || '', - data: [], - backgroundColor: this._isPieChart() ? [] : el.dataset.backgroundColor || '', - borderColor: this._isPieChart() ? [] : el.dataset.borderColor || '', - }); - }); - this.tableEl.querySelectorAll('tr:not(:first-child):not(:last-child)').forEach((el) => { - const title = el.querySelector('th input').value || ''; - data.labels.push(title); - el.querySelectorAll('td input').forEach((el, i) => { - data.datasets[i].data.push(el.value || 0); - if (this._isPieChart()) { - data.datasets[i].backgroundColor.push(el.dataset.backgroundColor || ''); - data.datasets[i].borderColor.push(el.dataset.borderColor || ''); - } - }); - }); - return JSON.stringify(data); - }, - /** - * Return a td containing a we-button with minus icon - * - * @param {...string} classes Classes to add to the we-button - * @returns {HTMLElement} - */ - _makeDeleteButton: function (...classes) { - const rmbuttonEl = options.buildElement('we-button', null, { - classes: ['o_we_text_danger', 'o_we_link', 'fa', 'fa-fw', 'fa-minus', ...classes], + _matrixToChartData() { + return JSON.stringify({ + labels: this.renderContext.labels, + datasets: this.renderContext.datasets, }); - rmbuttonEl.title = classes.includes('o_we_matrix_remove_col') ? _t("Remove Serie") : _t("Remove Row"); - const newEl = document.createElement('td'); - newEl.appendChild(rmbuttonEl); - return newEl; - }, - /** - * Add a column to the matrix - * The th (dataset label) of a column hold the colors for the entire dataset if the graph is not a pie chart - * If the graph is a pie chart the color of the td (data) are used. - * - * @private - * @param {String} title The title of the column - * @param {Array} values The values of the column input - * @param {String} heardeBackgroundColor The background color of the dataset - * @param {String} headerBorderColor The border color of the dataset - * @param {string[]} cellBackgroundColors The background colors of the datas inputs, random color if missing - * @param {string[]} cellBorderColors The border color of the datas inputs, no color if missing - */ - _addColumn: function (title, values, heardeBackgroundColor, headerBorderColor, cellBackgroundColors = [], cellBorderColors = []) { - const firstRow = this.tableEl.querySelector('tr:first-child'); - const headerInput = this._makeCell('th', title, heardeBackgroundColor, headerBorderColor); - firstRow.insertBefore(headerInput, firstRow.lastElementChild); - - this.tableEl.querySelectorAll('tr:not(:first-child):not(:last-child)').forEach((el, i) => { - const newCell = this._makeCell('td', values ? values[i] : null, cellBackgroundColors[i] || this._randomColor(), cellBorderColors[i - 1]); - el.insertBefore(newCell, el.lastElementChild); - }); - - const lastRow = this.tableEl.querySelector('tr:last-child'); - const removeButton = this._makeDeleteButton('o_we_matrix_remove_col'); - lastRow.appendChild(removeButton); - }, - /** - * Add a row to the matrix - * The background color of the datas are random - * - * @private - * @param {String} tilte The title of the row - */ - _addRow: function (tilte) { - const trEl = document.createElement('tr'); - trEl.appendChild(this._makeCell('th', tilte)); - this.tableEl.querySelectorAll('tr:first-child input').forEach(() => { - trEl.appendChild(this._makeCell('td', null, this._randomColor())); - }); - trEl.appendChild(this._makeDeleteButton('o_we_matrix_remove_row')); - const tbody = this.tableEl.querySelector('tbody'); - tbody.insertBefore(trEl, tbody.lastElementChild); - }, - /** - * @private - * @param {string} tag tag of the HTML Element (td/th) - * @param {string} value The current value of the cell input - * @param {string} backgroundColor The background Color of the data on the graph - * @param {string} borderColor The border Color of the data on the graph - * @returns {HTMLElement} - */ - _makeCell: function (tag, value, backgroundColor, borderColor) { - const newEl = document.createElement(tag); - const contentEl = document.createElement('input'); - contentEl.type = 'text'; - if (tag === 'td') { - contentEl.type = 'number'; - } - contentEl.value = value || ''; - if (backgroundColor) { - contentEl.dataset.backgroundColor = backgroundColor; - } - if (borderColor) { - contentEl.dataset.borderColor = borderColor; - } - newEl.appendChild(contentEl); - return newEl; - }, - /** - * Display the remove button coresponding to the colIndex - * - * @private - * @param {Int} colIndex Can be undefined, if so the last remove button of the column will be shown - */ - _displayRemoveColButton: function (colIndex) { - if (this._getColumnCount() > 1) { - this._displayRemoveButton(colIndex, 'o_we_matrix_remove_col'); - } - }, - /** - * Display the remove button coresponding to the rowIndex - * - * @private - * @param {Int} rowIndex Can be undefined, if so the last remove button of the row will be shown - */ - _displayRemoveRowButton: function (rowIndex) { - //Nbr of row minus header and button - const rowCount = this.tableEl.rows.length - 2; - if (rowCount > 1) { - this._displayRemoveButton(rowIndex, 'o_we_matrix_remove_row'); - } - }, - /** - * @private - * @param {Int} tdIndex Can be undefined, if so the last remove button will be shown - * @param {String} btnClass Either o_we_matrix_remove_col or o_we_matrix_remove_row - */ - _displayRemoveButton: function (tdIndex, btnClass) { - const removeBtn = this.tableEl.querySelectorAll(`td we-button.${btnClass}`); - removeBtn.forEach(el => el.style.display = ''); //hide all - const index = tdIndex < removeBtn.length ? tdIndex : removeBtn.length - 1; - removeBtn[index].style.display = 'inline-block'; - }, + } /** * @private * @return {boolean} */ - _isPieChart: function () { + _isPieChart() { return ['pie', 'doughnut'].includes(this.$target[0].dataset.type); - }, + } /** * Return the number of column minus header and button * @private * @return {integer} */ - _getColumnCount: function () { - return this.tableEl.rows[0].cells.length - 2; - }, - /** - * Select the first data input - * - * @private - */ - _setDefaultSelectedInput: function () { - this.lastEditableSelectedInput = this.tableEl.querySelector('td input'); - if (this._isPieChart()) { - this.colorPaletteSelectedInput = this.lastEditableSelectedInput; - } else { - this.colorPaletteSelectedInput = this.tableEl.querySelector('th input'); - } - }, + _getColumnCount() { + return this.renderContext.datasets.length; + } /** * Return a random hexadecimal color. * * @private * @return {string} */ - _randomColor: function () { + _randomColor() { return '#' + ('00000' + (Math.random() * (1 << 24) | 0).toString(16)).slice(-6).toUpperCase(); - }, + } //-------------------------------------------------------------------------- // Handlers @@ -400,7 +287,8 @@ options.registry.InnerChart = options.Class.extend({ * * @private */ - _onGetCustomColors: function (ev) { + // TODO: @owl-options what was this mechanism replaced with ? + _onGetCustomColors(ev) { const data = JSON.parse(this.$target[0].dataset.data || ''); let customColors = []; data.datasets.forEach(el => { @@ -415,93 +303,13 @@ options.registry.InnerChart = options.Class.extend({ return !weUtils.getCSSVariableValue(el, this.style) && array.indexOf(el) === i && el !== ''; // unique non class not transparent }); ev.data.onSuccess(customColors); - }, - /** - * Add a row at the end of the matrix and display it's remove button - * Choose the color of the column from the theme array or a random color if they are already used - * - * @private - */ - _onAddColumnClick: function () { - const usedColor = Array.from(this.tableEl.querySelectorAll('tr:first-child input')).map(el => el.dataset.backgroundColor); - const color = this.themeArray.filter(el => !usedColor.includes(el))[0] || this._randomColor(); - this._addColumn(null, null, color, color); - this._reloadGraph().then(() => { - this._displayRemoveColButton(); - this.updateUI(); - }); - }, - /** - * Add a column at the end of the matrix and display it's remove button - * - * @private - */ - _onAddRowClick: function () { - this._addRow(); - this._reloadGraph().then(() => { - this._displayRemoveRowButton(); - this.updateUI(); - }); - }, - /** - * Remove the column and show the remove button of the next column or the last if no next. - * - * @private - * @param {Event} ev - */ - _onRemoveColumnClick: function (ev) { - const cell = ev.currentTarget.parentElement; - const cellIndex = cell.cellIndex; - this.tableEl.querySelectorAll('tr').forEach((el) => { - el.children[cellIndex].remove(); - }); - this._displayRemoveColButton(cellIndex - 1); - this._reloadGraph().then(() => { - this.updateUI(); - }); - }, - /** - * Remove the row and show the remove button of the next row or the last if no next. - * - * @private - * @param {Event} ev - */ - _onRemoveRowClick: function (ev) { - const row = ev.currentTarget.parentElement.parentElement; - const rowIndex = row.rowIndex; - row.remove(); - this._displayRemoveRowButton(rowIndex - 1); - this._reloadGraph().then(() => { - this.updateUI(); - }); - }, - /** - * @private - */ - _onMatrixInputInput() { - this._reloadGraph(); - }, - /** - * Set the selected cell/header and display the related remove button - * - * @private - * @param {Event} ev - */ - _onMatrixInputFocus: function (ev) { - this.lastEditableSelectedInput = ev.target; - const col = ev.target.parentElement.cellIndex; - const row = ev.target.parentElement.parentElement.rowIndex; - if (this._isPieChart()) { - this.colorPaletteSelectedInput = ev.target.parentNode.tagName === 'TD' ? ev.target : null; - } else { - this.colorPaletteSelectedInput = this.tableEl.querySelector(`tr:first-child th:nth-of-type(${col + 1}) input`); - } - if (col > 0) { - this._displayRemoveColButton(col - 1); - } - if (row > 0) { - this._displayRemoveRowButton(row - 1); - } - this.updateUI(); - }, + } +} + +registerWebsiteOption("InnerChart", { + Class: InnerChart, + renderingComponent: InnerChartOptionComponent, + template: "website.s_chart_option", + selector: ".s_chart", + withColorCombinations: false, }); diff --git a/addons/website/static/src/snippets/s_chart/options.xml b/addons/website/static/src/snippets/s_chart/options.xml new file mode 100644 index 0000000000000..769e9726908a6 --- /dev/null +++ b/addons/website/static/src/snippets/s_chart/options.xml @@ -0,0 +1,118 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="website.s_chart_option"> + <WeRow title.translate="Background"> + <t t-set="option" t-value="props.snippetOption"/> + <t t-call="web_editor.snippet_options_background_color_widget"/> + </WeRow> + <WeSelect title.translate="Type" attributeName="'type'" attributeDefaultValue="'bar'"> + <WeButton selectDataAttribute="'bar'" name="'bar_chart_opt'">Bar Vertical</WeButton> + <WeButton selectDataAttribute="'horizontalBar'" name="'horizontal_bar_chart_opt'">Bar Horizontal</WeButton> + <WeButton selectDataAttribute="'line'" name="'line_chart_opt'">Line</WeButton> + <WeButton selectDataAttribute="'pie'">Pie</WeButton> + <WeButton selectDataAttribute="'doughnut'">Doughnut</WeButton> + <WeButton selectDataAttribute="'radar'">Radar</WeButton> + </WeSelect> + <WeCheckbox title.translate="Stacked" + name="'stacked_chart_opt'" + dependencies="'bar_chart_opt, horizontal_bar_chart_opt'" + selectDataAttribute="'false|true'" + attributeName="'stacked'" + noPreview="'true'" + /> + <WeSelect title.translate="Legend" attributeName="'legendPosition'" attributeDefaultValue="'top'"> + <WeButton selectDataAttribute="'none'">None</WeButton> + <WeButton selectDataAttribute="'top'">Top</WeButton> + <WeButton selectDataAttribute="'left'">Left</WeButton> + <WeButton selectDataAttribute="'bottom'">Bottom</WeButton> + <WeButton selectDataAttribute="'right'">Right</WeButton> + </WeSelect> + <WeCheckbox title.translate="Tooltip" + selectDataAttribute="'false|true'" + attributeName="'tooltipDisplay'" + noPreview="'true'" + /> + <we-matrix> + <table> + <tbody> + <tr> + <th/> + <th t-foreach="renderContext.datasets" t-as="dataset" t-key="dataset_index"> + <t t-set="backgroundColor" t-value="dataset.backgroundColor"/> + <t t-if="!isCSSColor(backgroundColor)"> + <t t-set="backgroundColor" t-value="'var(--' + backgroundColor + ')'"/> + </t> + <input t-att-value="dataset.label" type="text" + t-att-style="'border: 2px solid ' + backgroundColor + ';'" + t-on-focus="() => { this.onCellFocus(dataset_index, -1); }" + t-on-change="(ev) => { this.setDatasetLabel(dataset_index, ev.target.value); }" + t-on-input="(ev) => { this.setDatasetLabel(dataset_index, ev.target.value); }" + /> + </th> + <th> + <we-button title="Add Set" + class="add_column fa fa-fw fa-plus o_we_link o_we_text_success d-inline-block" + t-on-click="() => { this.onAddDataset(); }" + /> + </th> + </tr> + <tr t-foreach="renderContext.labels" t-as="label" t-key="label_index"> + <th> + <input t-att-value="label" type="text" + t-on-focus="() => { this.onCellFocus(-1, label_index); }" + t-on-change="(ev) => { this.setRowLabel(label_index, ev.target.value); }" + t-on-input="(ev) => { this.setRowLabel(label_index, ev.target.value); }" + /> + </th> + <td t-foreach="renderContext.datasets" t-as="dataset" t-key="dataset_index"> + <input t-att-value="dataset.data[label_index]" type="number" + t-on-focus="() => { this.onCellFocus(dataset_index, label_index); }" + t-on-change="(ev) => { this.setCellValue(dataset_index, label_index, ev.target.value); }" + t-on-input="(ev) => { this.setCellValue(dataset_index, label_index, ev.target.value); }" + /> + </td> + <td> + <we-button title="Remove Row" + t-if="label_index === renderContext.focusedLabelIndex and renderContext.labels.length > 1" + class="o_we_text_danger o_we_link fa fa-fw fa-minus" + t-on-click="() => { this.onRemoveRow(label_index); }" + /> + </td> + </tr> + <tr> + <th> + <we-button title="Add Row" + class="add_row fa fa-fw fa-plus o_we_link o_we_text_success d-inline-block" + t-on-click="() => { this.onAddRow(); }" + /> + </th> + <td t-foreach="renderContext.datasets" t-as="dataset" t-key="dataset_index"> + <we-button title="Remove Set" + t-if="dataset_index === renderContext.focusedDatasetIndex and renderContext.datasets.length > 1" + class="o_we_text_danger o_we_link fa fa-fw fa-minus" + t-on-click="() => { this.onRemoveDataset(dataset_index); }" + /> + </td> + </tr> + </tbody> + </table> + </we-matrix> + <WeInput title.translate="Min Axis" dependencies="'bar_chart_opt, horizontal_bar_chart_opt, line_chart_opt'" + step="'1'" selectDataAttribute="''" attributeName="'minValue'" + /> + <WeInput title.translate="Max Axis" dependencies="'bar_chart_opt, horizontal_bar_chart_opt, line_chart_opt'" + step="'1'" selectDataAttribute="''" attributeName="'maxValue'" + /> + <WeColorpicker title.translate="Background" name="'chart_bg_color_opt'" + colorChange="''" attributeName="'backgroundColor'" + noPreview="'true'" + /> + <WeColorpicker title.translate="Border" name="'chart_border_color_opt'" + colorChange="''" attributeName="'borderColor'" + noPreview="'true'" + /> + <WeInput title.translate="Border Width" selectDataAttribute="'2px'" attributeName="'borderWidth'" unit="'px'"/> +</t> + +</templates> diff --git a/addons/website/static/src/snippets/s_countdown/options.js b/addons/website/static/src/snippets/s_countdown/options.js index fb851558f716b..0edfc8e0e6a57 100644 --- a/addons/website/static/src/snippets/s_countdown/options.js +++ b/addons/website/static/src/snippets/s_countdown/options.js @@ -1,22 +1,23 @@ /** @odoo-module **/ import { renderToElement } from "@web/core/utils/render"; -import options from "@web_editor/js/editor/snippets.options"; - -options.registry.countdown = options.Class.extend({ - events: Object.assign({}, options.Class.prototype.events || {}, { - 'click .toggle-edit-message': '_onToggleEndMessageClick', - }), +import { + SnippetOption, +} from "@web_editor/js/editor/snippets.options"; +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; +class Countdown extends SnippetOption { /** * Remove any preview classes, if present. * * @override */ - cleanForSave: async function () { + async cleanForSave() { this.$target.find('.s_countdown_canvas_wrapper').removeClass("s_countdown_none"); this.$target.find('.s_countdown_end_message').removeClass("s_countdown_enable_preview"); - }, + } //-------------------------------------------------------------------------- // Options @@ -27,7 +28,7 @@ options.registry.countdown = options.Class.extend({ * * @see this.selectClass for parameters */ - endAction: function (previewMode, widgetValue, params) { + endAction(previewMode, widgetValue, params) { this.$target[0].dataset.endAction = widgetValue; if (widgetValue === 'message' || widgetValue === 'message_no_countdown') { if (!this.$target.find('.s_countdown_end_message').length) { @@ -44,13 +45,13 @@ options.registry.countdown = options.Class.extend({ this.endMessage = $message[0].outerHTML; } } - }, + } /** * Changes the countdown style. * * @see this.selectClass for parameters */ - layout: function (previewMode, widgetValue, params) { + layout(previewMode, widgetValue, params) { switch (widgetValue) { case 'circle': this.$target[0].dataset.progressBarStyle = 'disappear'; @@ -71,7 +72,7 @@ options.registry.countdown = options.Class.extend({ break; } this.$target[0].dataset.layout = widgetValue; - }, + } //-------------------------------------------------------------------------- // Public @@ -80,26 +81,20 @@ options.registry.countdown = options.Class.extend({ /** * @override */ - updateUIVisibility: async function () { - await this._super(...arguments); - const dataset = this.$target[0].dataset; - - // End Action UI - this.$el.find('.toggle-edit-message') - .toggleClass('d-none', dataset.endAction === 'nothing' || dataset.endAction === 'redirect'); - + async updateUIVisibility() { + await super.updateUIVisibility(...arguments); // End Message UI this.updateUIEndMessage(); - }, + } /** * @see this.updateUI */ - updateUIEndMessage: function () { + updateUIEndMessage() { this.$target.find('.s_countdown_canvas_wrapper') .toggleClass("s_countdown_none", this.showEndMessage === true && this.$target.hasClass("hide-countdown")); this.$target.find('.s_countdown_end_message') .toggleClass("s_countdown_enable_preview", this.showEndMessage === true); - }, + } //-------------------------------------------------------------------------- // Private @@ -108,7 +103,7 @@ options.registry.countdown = options.Class.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { switch (methodName) { case 'endAction': case 'layout': @@ -121,8 +116,18 @@ options.registry.countdown = options.Class.extend({ break; } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'show_message_opt') { + const dataset = this.$target[0].dataset; + return !['nothing', 'redirect'].includes(dataset.endAction); + } + return super._computeWidgetVisibility(...arguments); + } //-------------------------------------------------------------------------- // Handlers @@ -131,11 +136,24 @@ options.registry.countdown = options.Class.extend({ /** * @private */ - _onToggleEndMessageClick: function () { + toggleEndMessage() { this.showEndMessage = !this.showEndMessage; - this.$el.find(".toggle-edit-message") - .toggleClass('text-primary', this.showEndMessage); this.updateUIEndMessage(); - this.trigger_up('cover_update'); - }, + this.callbacks.coverUpdate(); + this.renderContext.showEndMessage = this.showEndMessage; + } + /** + * @override + */ + async _getRenderContext() { + return { + showEndMessage: this.showEndMessage, + }; + } +} + +registerWebsiteOption("Countdown", { + Class: Countdown, + template: "website.s_countdown_option", + selector: ".s_countdown", }); diff --git a/addons/website/static/src/snippets/s_countdown/options.xml b/addons/website/static/src/snippets/s_countdown/options.xml index 19eadee6c9df9..13bccacbe0dd4 100644 --- a/addons/website/static/src/snippets/s_countdown/options.xml +++ b/addons/website/static/src/snippets/s_countdown/options.xml @@ -21,4 +21,66 @@ </div> </t> +<t t-name="website.s_countdown_option"> + <WeDatetime title.translate="Due Date" selectDataAttribute="'0'" attributeName="'endTime'"/> + <WeRow title.translate="At The End"> + <WeSelect noPreview="'true'"> + <WeButton endAction="'nothing'" name="'no_end_action_opt'">Nothing</WeButton> + <WeButton endAction="'redirect'" name="'redirect_end_action_opt'">Redirect</WeButton> + <WeButton endAction="'message_no_countdown'">Show Message and hide countdown</WeButton> + <WeButton endAction="'message'">Show Message and keep countdown</WeButton> + </WeSelect> + <WeButton class="'align-self-end' + (renderContext.showEndMessage ? ' text-primary' : '')" + tooltip.translate="The message will be visible once the countdown ends" + toggleEndMessage="''" noPreview="'true'" + name="'show_message_opt'" + > + <i class="fa fa-fw fa-eye"/> + </WeButton> + </WeRow> + <WeRow title.translate="URL"> + <WeUrlPicker dependencies="'redirect_end_action_opt'" + placeholder.translate="e.g. /my-awesome-page" + selectDataAttribute="''" + attributeName="'redirectUrl'" + /> + </WeRow> + <WeSelect title.translate="Size" attributeName="'size'"> + <WeButton selectDataAttribute="'80'">Small</WeButton> + <WeButton selectDataAttribute="'120'">Medium</WeButton> + <WeButton selectDataAttribute="'175'">Large</WeButton> + </WeSelect> + <WeSelect title.translate="Display" attributeName="'display'"> + <WeButton selectDataAttribute="'d'">D</WeButton> + <WeButton selectDataAttribute="'dhm'">D - H - M</WeButton> + <WeButton selectDataAttribute="'dhms'">D - H - M - S</WeButton> + </WeSelect> + <WeColorpicker title.translate="Text Color" attributeName="'textColor'" selectDataAttribute="''"/> + <WeSelect title.translate="Layout"> + <WeButton layout="'circle'" name="'circle_layout_opt'">Circle</WeButton> + <WeButton layout="'boxes'" name="'boxes_layout_opt'">Boxes</WeButton> + <WeButton layout="'clean'">Clean</WeButton> + <WeButton layout="'text'">Text Inline</WeButton> + </WeSelect> + <WeSelect title.translate="Layout Background" attributeName="'layoutBackground'" + dependencies="'circle_layout_opt, boxes_layout_opt'"> + <WeButton selectDataAttribute="'inner'">Inner</WeButton> + <WeButton selectDataAttribute="'plain'">Plain</WeButton> + <WeButton selectDataAttribute="'none'" name="'no_background_layout_opt'">None</WeButton> + </WeSelect> + <WeColorpicker title.translate="Layout Background Color" dependencies="'!no_background_layout_opt'" attributeName="'layoutBackgroundColor'" selectDataAttribute="''"/> + <WeSelect title.translate="Progress Bar Style" attributeName="'progressBarStyle'" + dependencies="'circle_layout_opt, boxes_layout_opt'"> + <WeButton selectDataAttribute="'surrounded'">Surrounded</WeButton> + <WeButton selectDataAttribute="'disappear'">Disappearing</WeButton> + <WeButton selectDataAttribute="'none'" name="'no_progressbar_style_opt'">None</WeButton> + </WeSelect> + <WeSelect title.translate="Progress Bar Weight" attributeName="'progressBarWeight'" + dependencies="'!no_progressbar_style_opt'"> + <WeButton selectDataAttribute="'thin'">Thin</WeButton> + <WeButton selectDataAttribute="'thick'">Thick</WeButton> + </WeSelect> + <WeColorpicker title.translate="Progress Bar Color" dependencies="'!no_progressbar_style_opt'" attributeName="'progressBarColor'" selectDataAttribute="''"/> +</t> + </templates> diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/options.js b/addons/website/static/src/snippets/s_dynamic_snippet/options.js index 88ed1a777a069..37606edef8e19 100644 --- a/addons/website/static/src/snippets/s_dynamic_snippet/options.js +++ b/addons/website/static/src/snippets/s_dynamic_snippet/options.js @@ -1,9 +1,14 @@ /** @odoo-module **/ -import options from "@web_editor/js/editor/snippets.options"; import { rpc } from "@web/core/network/rpc"; +import { + SnippetOption, +} from "@web_editor/js/editor/snippets.options"; +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; -const dynamicSnippetOptions = options.Class.extend({ +export class DynamicSnippetOptions extends SnippetOption { /** * This type defines the template infos retrieved from * @see /website/snippet/filter_templates @@ -22,8 +27,8 @@ const dynamicSnippetOptions = options.Class.extend({ /** * @override */ - init: function () { - this._super.apply(this, arguments); + constructor() { + super(...arguments); // specify model name in subclasses to filter the list of available model record filters this.modelNameFilter = undefined; this.contextualFilterDomain = []; @@ -34,16 +39,18 @@ const dynamicSnippetOptions = options.Class.extend({ this.dynamicFilterTemplates = {}; // Indicates that some current options are a default selection. this.isOptionDefault = {}; - }, + } /** * @override */ async willStart() { - const _super = this._super.bind(this); + // TODO: @owl-options Init makes no sense in Owl options await this._fetchDynamicFilters(); await this._fetchDynamicFilterTemplates(); - return _super(...arguments); - }, + await super.willStart(...arguments); + this.renderContext.dynamicFilters = this.dynamicFilters; + this.renderContext.dynamicFilterTemplates = this.dynamicFilterTemplates; + } /** * * @override @@ -55,7 +62,7 @@ const dynamicSnippetOptions = options.Class.extend({ // The target needs to be restarted when the correct // template values are applied (numberOfElements, rowPerSlide, etc.) return this._refreshPublicWidgets(); - }, + } //-------------------------------------------------------------------------- // Options @@ -65,8 +72,8 @@ const dynamicSnippetOptions = options.Class.extend({ * * @see this.selectClass for parameters */ - selectDataAttribute: function (previewMode, widgetValue, params) { - this._super.apply(this, arguments); + selectDataAttribute(previewMode, widgetValue, params) { + super.selectDataAttribute(...arguments); if (params.attributeName === 'filterId' && previewMode === false) { const filter = this.dynamicFilters[parseInt(widgetValue)]; this.$target.get(0).dataset.numberOfRecords = filter.limit; @@ -75,7 +82,7 @@ const dynamicSnippetOptions = options.Class.extend({ if (params.attributeName === 'templateKey' && previewMode === false) { this._templateUpdated(widgetValue, params.activeValue); } - }, + } //-------------------------------------------------------------------------- // Public @@ -92,8 +99,8 @@ const dynamicSnippetOptions = options.Class.extend({ await this._rerenderXML(); return; } - await this._super(...arguments); - }, + await super.updateUI(...arguments); + } //-------------------------------------------------------------------------- // Private @@ -103,20 +110,20 @@ const dynamicSnippetOptions = options.Class.extend({ * @private * @returns {Template} */ - _getCurrentTemplate: function () { + _getCurrentTemplate() { return this.dynamicFilterTemplates[this.$target.get(0).dataset['templateKey']]; - }, + } - _getTemplateClass: function (templateKey) { + _getTemplateClass(templateKey) { return templateKey.replace(/.*\.dynamic_filter_template_/, "s_"); - }, + } /** * * @override * @private */ - _computeWidgetVisibility: function (widgetName, params) { + _computeWidgetVisibility(widgetName, params) { if (widgetName === 'filter_opt') { // Hide if exaclty one is available: show when none to help understand what is missing return Object.keys(this.dynamicFilters).length !== 1; @@ -127,22 +134,22 @@ const dynamicSnippetOptions = options.Class.extend({ return template && !template.numOfElFetch; } - return this._super.apply(this, arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * @override * @private * @returns {Promise} */ - _refreshPublicWidgets: function () { - return this._super.apply(this, arguments).then(() => { + _refreshPublicWidgets() { + return super._refreshPublicWidgets(...arguments).then(() => { const template = this._getCurrentTemplate(); this.$target.find('.missing_option_warning').toggleClass( 'd-none', !!template ); }); - }, + } /** * Fetches dynamic filters and set them in {@link this.dynamicFilters}. * @@ -162,7 +169,7 @@ const dynamicSnippetOptions = options.Class.extend({ this.dynamicFilters[dynamicFilters[index].id] = dynamicFilters[index]; } this._defaultFilterId = dynamicFilters[0].id; - }, + } /** * Fetch dynamic filters templates and set them in {@link this.dynamicFilterTemplates}. * @@ -182,53 +189,17 @@ const dynamicSnippetOptions = options.Class.extend({ this.dynamicFilterTemplates[dynamicFilterTemplates[index].key] = dynamicFilterTemplates[index]; } this._defaultTemplateKey = dynamicFilterTemplates[0].key; - }, + } /** - * * @override * @private */ - _renderCustomXML: async function (uiFragment) { - await this._renderDynamicFiltersSelector(uiFragment); - await this._renderDynamicFilterTemplatesSelector(uiFragment); - }, - /** - * Renders the dynamic filter option selector content into the provided uiFragment. - * @param {HTMLElement} uiFragment - * @private - */ - _renderDynamicFiltersSelector: async function (uiFragment) { - const filtersSelectorEl = uiFragment.querySelector('[data-name="filter_opt"]'); - return this._renderSelectUserValueWidgetButtons(filtersSelectorEl, this.dynamicFilters); - }, - /** - * Renders we-buttons into a SelectUserValueWidget element according to provided data. - * @param {HTMLElement} selectUserValueWidgetElement the SelectUserValueWidget buttons - * have to be created into. - * @param {Object} data - * @private - */ - _renderSelectUserValueWidgetButtons: async function (selectUserValueWidgetElement, data) { - for (let id in data) { - const button = document.createElement('we-button'); - button.dataset.selectDataAttribute = id; - if (data[id].thumb) { - button.dataset.img = data[id].thumb; - } else { - button.innerText = data[id].name; - } - selectUserValueWidgetElement.appendChild(button); - } - }, - /** - * Renders the template option selector content into the provided uiFragment. - * @param {HTMLElement} uiFragment - * @private - */ - _renderDynamicFilterTemplatesSelector: async function (uiFragment) { - const templatesSelectorEl = uiFragment.querySelector('[data-name="template_opt"]'); - return this._renderSelectUserValueWidgetButtons(templatesSelectorEl, this.dynamicFilterTemplates); - }, + async _getRenderContext() { + return { + dynamicFilter: this.dynamicFilter || {}, + dynamicFilterTemplates: this.dynamicFilterTemplates || {}, + }; + } /** * Sets default options values. * Method to be overridden in child components in order to set additional @@ -236,30 +207,19 @@ const dynamicSnippetOptions = options.Class.extend({ * @private */ async _setOptionsDefaultValues() { - // Unactive the editor observer, otherwise, undo of the editor will undo - // the attribute being changed. In some case of undo, a race condition - // with the public widget that use following property (eg. - // numberOfElements or numberOfElementsSmallDevices) might throw an - // exception by not finding the attribute on the element. - this.options.wysiwyg.odooEditor.observerUnactive(); - const filterKeys = this.$el.find("we-select[data-attribute-name='filterId'] we-selection-items we-button"); - if (filterKeys.length > 0) { - this._setOptionValue('numberOfRecords', this.dynamicFilters[Object.keys(this.dynamicFilters)[0]].limit); + if (!this.$target[0].dataset.numberOfRecords) { + this._setOptionValue("numberOfRecords", + this.dynamicFilters.length ? this.dynamicFilters[Object.keys(this.dynamicFilters)[0]].limit : 4 + ); } - let selectedFilterId = this.$target.get(0).dataset['filterId']; - if (Object.keys(this.dynamicFilters).length > 0) { - if (!this.dynamicFilters[selectedFilterId]) { - this.$target.get(0).dataset['filterId'] = this._defaultFilterId; - this.isOptionDefault['filterId'] = true; - selectedFilterId = this._defaultFilterId; - } + if (!this.$target[0].dataset.filterId) { + this.$target[0].dataset["filterId"] = this._defaultFilterId; } - if (this.dynamicFilters[selectedFilterId] && - !this.dynamicFilterTemplates[this.$target.get(0).dataset['templateKey']]) { + if (!this.$target[0].dataset.templateKey) { + this.$target[0].dataset["templateKey"] = this._defaultTemplateKey; this._setDefaultTemplate(); } - this.options.wysiwyg.odooEditor.observerActive(); - }, + } /** * Take the new filter selection into account * @param filter @@ -269,15 +229,15 @@ const dynamicSnippetOptions = options.Class.extend({ if (filter && this.currentModelName !== filter.model_name) { this.currentModelName = filter.model_name; await this._fetchDynamicFilterTemplates(); + this.renderContext.dynamicFilterTemplates = this.dynamicFilterTemplates; if (Object.keys(this.dynamicFilterTemplates).length > 0) { const selectedTemplateId = this.$target.get(0).dataset['templateKey']; if (!this.dynamicFilterTemplates[selectedTemplateId]) { this._setDefaultTemplate(); } } - this.rerender = true; } - }, + } /** * Sets the default filter template. * @private @@ -288,7 +248,7 @@ const dynamicSnippetOptions = options.Class.extend({ this.isOptionDefault['templateKey'] = true; this._templateUpdated(this._defaultTemplateKey); } - }, + } /** * Take the new template selection into account @@ -321,14 +281,14 @@ const dynamicSnippetOptions = options.Class.extend({ } else { delete this.$target[0].dataset.extraClasses; } - }, + } /** * Sets the option value. * @param optionName * @param value * @private */ - _setOptionValue: function (optionName, value) { + _setOptionValue(optionName, value) { const selectedTemplateId = this.$target.get(0).dataset['templateKey']; if (this.$target.get(0).dataset[optionName] === undefined || this.isOptionDefault[optionName]) { this.$target.get(0).dataset[optionName] = value; @@ -337,9 +297,11 @@ const dynamicSnippetOptions = options.Class.extend({ if (optionName === 'templateKey') { this._templateUpdated(value, selectedTemplateId); } - }, -}); + } +} -options.registry.dynamic_snippet = dynamicSnippetOptions; - -export default dynamicSnippetOptions; +registerWebsiteOption("DynamicSnippetOptions", { + Class: DynamicSnippetOptions, + template: "website.s_dynamic_snippet_option", + selector: "[data-snippet='s_dynamic_snippet']", +}); diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/options.xml b/addons/website/static/src/snippets/s_dynamic_snippet/options.xml new file mode 100644 index 0000000000000..d4b4e5161a11e --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet/options.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="website.s_dynamic_snippet_option"> + <WeRow title.translate="Filter"> + <WeSelect name="'filter_opt'" attributeName="'filterId'" noPreview="'true'"> + <WeButton t-foreach="renderContext.dynamicFilters" t-as="filter_key" t-key="filter_key" + img="renderContext.dynamicFilters[filter_key].thumb" + selectDataAttribute="filter_key" + > + <t t-out="renderContext.dynamicFilters[filter_key].name"/> + </WeButton> + </WeSelect> + </WeRow> + <WeRow title.translate="Template"> + <WeSelect name="'template_opt'" attributeName="'templateKey'" noPreview="'true'"> + <WeButton t-foreach="renderContext.dynamicFilterTemplates" t-as="template_key" t-key="template_key" + img="renderContext.dynamicFilterTemplates[template_key].thumb || ''" + selectDataAttribute="template_key" + > + <t t-out="renderContext.dynamicFilterTemplates[template_key].name || ''"/> + </WeButton> + </WeSelect> + </WeRow> + <WeRow title.translate="Fetched Elements"> + <WeSelect name="'number_of_records_opt'" attributeName="'numberOfRecords'" noPreview="'true'"> + <WeButton t-foreach="[...Array(16).keys()].map(k => (k + 1).toString())" t-as="value" t-key="value_index" selectDataAttribute="value"> + <t t-out="value"/> + </WeButton> + </WeSelect> + </WeRow> + <t t-slot="default"/> +</t> + +</templates> diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.js b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.js index a95e0536d9cc6..250a32d428eea 100644 --- a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.js +++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.js @@ -1,9 +1,11 @@ /** @odoo-module **/ -import options from "@web_editor/js/editor/snippets.options"; -import s_dynamic_snippet_options from "@website/snippets/s_dynamic_snippet/options"; +import { DynamicSnippetOptions } from "@website/snippets/s_dynamic_snippet/options"; +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; -const dynamicSnippetCarouselOptions = s_dynamic_snippet_options.extend({ +export class DynamicSnippetCarouselOptions extends DynamicSnippetOptions { //-------------------------------------------------------------------------- // Options @@ -14,10 +16,10 @@ const dynamicSnippetCarouselOptions = s_dynamic_snippet_options.extend({ * @override * @private */ - _setOptionsDefaultValues: function () { - this._super.apply(this, arguments); + _setOptionsDefaultValues() { + super._setOptionsDefaultValues(this, arguments); this._setOptionValue('carouselInterval', '5000'); - }, + } /** * Take the new template selection into account * @@ -26,7 +28,7 @@ const dynamicSnippetCarouselOptions = s_dynamic_snippet_options.extend({ * @override */ _templateUpdated(newTemplate, oldTemplate) { - this._super(...arguments); + super._templateUpdated(...arguments); const template = this.dynamicFilterTemplates[newTemplate]; if (template.rowPerSlide) { this.$target[0].dataset.rowPerSlide = template.rowPerSlide; @@ -38,10 +40,12 @@ const dynamicSnippetCarouselOptions = s_dynamic_snippet_options.extend({ } else { delete this.$target[0].dataset.arrowPosition; } - }, + } -}); - -options.registry.dynamic_snippet_carousel = dynamicSnippetCarouselOptions; +} -export default dynamicSnippetCarouselOptions; +registerWebsiteOption("DynamicSnippetCarouselOptions", { + Class: DynamicSnippetCarouselOptions, + template: "website.s_dynamic_snippet_carousel_option", + selector: "[data-snippet='s_dynamic_snippet_carousel']", +}); diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.xml b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.xml new file mode 100644 index 0000000000000..6996b43851e49 --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="website.dynamic_snippet_carousel_options_template" t-inherit="website.s_dynamic_snippet_option" t-inherit-mode="primary"> + <xpath expr="//WeRow[.//WeSelect[@name="'number_of_records_opt'"]]" position="after"> + <WeRow title.translate="Slider Speed"> + <WeInput + selectDataAttribute="'1s'" name="'speed_opt'" attributeName="'carouselInterval'" noPreview="'true'" + unit="'s'" saveUnit="'ms'" step="'0.1'"/> + </WeRow> + <t t-slot="default"/> + </xpath> +</t> + +<t t-name="website.s_dynamic_snippet_carousel_option" t-inherit="website.dynamic_snippet_carousel_options_template" t-inherit-mode="primary"> +</t> + +</templates> diff --git a/addons/website/static/src/snippets/s_embed_code/options.js b/addons/website/static/src/snippets/s_embed_code/options.js index c2ca375af7bef..0eb11de819292 100644 --- a/addons/website/static/src/snippets/s_embed_code/options.js +++ b/addons/website/static/src/snippets/s_embed_code/options.js @@ -2,12 +2,13 @@ import { Dialog } from "@web/core/dialog/dialog"; import { CodeEditor } from "@web/core/code_editor/code_editor"; import { useService } from "@web/core/utils/hooks"; -import options from '@web_editor/js/editor/snippets.options'; import { _t } from "@web/core/l10n/translation"; +import { SnippetOption } from "@web_editor/js/editor/snippets.options"; import { EditHeadBodyDialog } from "@website/components/edit_head_body_dialog/edit_head_body_dialog"; import { cloneContentEls } from "@website/js/utils"; import { Component, useState } from "@odoo/owl"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; class CodeEditorDialog extends Component { static template = "website.s_embed_code_dialog"; @@ -36,11 +37,11 @@ class CodeEditorDialog extends Component { } } -options.registry.EmbedCode = options.Class.extend({ - init() { - this._super(...arguments); - this.dialog = this.bindService("dialog"); - }, +class EmbedCode extends SnippetOption { + constructor() { + super(...arguments); + this.dialog = this.env.services.dialog; + } //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- @@ -66,9 +67,13 @@ options.registry.EmbedCode = options.Class.extend({ onClose: resolve, }); }); - }, -}); + } +} -export default { - EmbedCode: options.registry.EmbedCode, -}; +registerWebsiteOption("EmbedCode", { + Class: EmbedCode, + template: "website.s_embed_code", + selector: ".s_embed_code", + // TODO: @owl-options + // position: before data-js='Box' +}); diff --git a/addons/website/static/src/snippets/s_embed_code/options.xml b/addons/website/static/src/snippets/s_embed_code/options.xml new file mode 100644 index 0000000000000..f2f1323fb446d --- /dev/null +++ b/addons/website/static/src/snippets/s_embed_code/options.xml @@ -0,0 +1,16 @@ +<templates id="template" xml:space="preserve"> +<t t-name="website.s_embed_code"> + <WeRow title.translate="Code"> + <WeButton editCode="''" noPreview="'true'" + class="'o_we_edit_code o_we_no_toggle o_we_bg_success active'" + tooltip.translate="Edit embedded code"> + Edit + </WeButton> + </WeRow> + <WeButtonGroup title.translate="Alignment"> + <WeButton class="'fa fa-fw fa-align-left'" tooltip.translate="Left" selectClass="'text-start'"/> + <WeButton class="'fa fa-fw fa-align-center'" tooltip.translate="Center" selectClass="'text-center'"/> + <WeButton class="'fa fa-fw fa-align-right'" tooltip.translate="Right" selectClass="'text-end'"/> + </WeButtonGroup> +</t> +</templates> diff --git a/addons/website/static/src/snippets/s_facebook_page/options.js b/addons/website/static/src/snippets/s_facebook_page/options.js index e060f08b2d834..17e3cecf01ec4 100644 --- a/addons/website/static/src/snippets/s_facebook_page/options.js +++ b/addons/website/static/src/snippets/s_facebook_page/options.js @@ -2,22 +2,23 @@ import { _t } from "@web/core/l10n/translation"; import { pick } from "@web/core/utils/objects"; -import options from "@web_editor/js/editor/snippets.options"; +import { SnippetOption } from "@web_editor/js/editor/snippets.options"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; -options.registry.facebookPage = options.Class.extend({ - init() { - this._super(...arguments); - this.orm = this.bindService("orm"); - this.notification = this.bindService("notification"); - }, +export class FacebookPage extends SnippetOption { + constructor() { + super(...arguments); + this.orm = this.env.services.orm; + this.notification = this.env.services.notification; + } /** * Initializes the required facebook page data to create the iframe. * * @override */ - willStart: function () { - var defs = [this._super.apply(this, arguments)]; + async willStart() { + const defs = [await super.willStart(...arguments)]; var defaults = { href: '', @@ -42,13 +43,13 @@ options.registry.facebookPage = options.Class.extend({ } return Promise.all(defs).then(() => this._markFbElement()).then(() => this._refreshPublicWidgets()); - }, + } /** * @override */ onBuilt() { this.$target[0].querySelector('.o_facebook_page_preview')?.remove(); - }, + } //-------------------------------------------------------------------------- // Options @@ -60,7 +61,7 @@ options.registry.facebookPage = options.Class.extend({ * @see this.selectClass for parameters * @param {String} optionName the name of the option to toggle */ - toggleOption: function (previewMode, widgetValue, params) { + toggleOption(previewMode, widgetValue, params) { let optionName = params.optionName; if (optionName.startsWith('tab.')) { optionName = optionName.replace('tab.', ''); @@ -84,16 +85,16 @@ options.registry.facebookPage = options.Class.extend({ } } return this._markFbElement(); - }, + } /** * Sets the facebook page's URL. * * @see this.selectClass for parameters */ - pageUrl: function (previewMode, widgetValue, params) { + pageUrl(previewMode, widgetValue, params) { this.fbData.href = widgetValue; return this._markFbElement(); - }, + } //-------------------------------------------------------------------------- // Private @@ -104,7 +105,7 @@ options.registry.facebookPage = options.Class.extend({ * * @see this.selectClass for parameters */ - _markFbElement: function () { + _markFbElement() { return this._checkURL().then(() => { // Managing height based on options if (this.fbData.tabs) { @@ -118,11 +119,11 @@ options.registry.facebookPage = options.Class.extend({ this.$target[0].dataset[key] = value; } }); - }, + } /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { const optionName = params.optionName; switch (methodName) { case 'toggleOption': { @@ -139,12 +140,12 @@ options.registry.facebookPage = options.Class.extend({ return this._checkURL().then(() => this.fbData.href); } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @private */ - _checkURL: function () { + _checkURL() { const defaultURL = 'https://www.facebook.com/Odoo'; // Patterns matched by the regex (all relate to existing pages, // in spite of the URLs containing "profile.php" or "people"): @@ -181,5 +182,11 @@ options.registry.facebookPage = options.Class.extend({ type: "warning", }); return Promise.resolve(); - }, + } +} + +registerWebsiteOption("FacebookPage", { + Class: FacebookPage, + template: "website.s_facebook_page_options", + selector: ".o_facebook_page", }); diff --git a/addons/website/static/src/snippets/s_facebook_page/options.xml b/addons/website/static/src/snippets/s_facebook_page/options.xml new file mode 100644 index 0000000000000..fe0cc3d440af4 --- /dev/null +++ b/addons/website/static/src/snippets/s_facebook_page/options.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="website.s_facebook_page_options"> + <WeInput title.translate="Page URL" pageUrl="'https://www.facebook.com/Odoo'" noPreview="'true'"/> + <WeCheckbox title.translate="Cover Photo" optionName="'show_cover'" toggleOption="'true'" noPreview="'true'"/> + <WeCheckbox title.translate="Timeline" optionName="'tab.timeline'" toggleOption="'true'" noPreview="'true'"/> + <WeCheckbox title.translate="Events" optionName="'tab.events'" toggleOption="'true'" noPreview="'true'"/> + <WeCheckbox title.translate="Messages" optionName="'tab.messages'" toggleOption="'true'" noPreview="'true'"/> + <WeCheckbox title.translate="Small Header" optionName="'small_header'" toggleOption="'true'" noPreview="'true'"/> + </t> + +</templates> diff --git a/addons/website/static/src/snippets/s_google_map/options.js b/addons/website/static/src/snippets/s_google_map/options.js index 17121e050f3d4..122a3e07e3dc3 100644 --- a/addons/website/static/src/snippets/s_google_map/options.js +++ b/addons/website/static/src/snippets/s_google_map/options.js @@ -1,9 +1,25 @@ /** @odoo-module **/ import { _t } from "@web/core/l10n/translation"; -import options from "@web_editor/js/editor/snippets.options"; +import { SnippetOption } from "@web_editor/js/editor/snippets.options"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; -options.registry.GoogleMap = options.Class.extend({ +class GoogleMap extends SnippetOption { + + /** + * @override + */ + async onBuilt(options) { + this.env.gmapApiRequest({ + data: { + editableMode: true, + configureIfNecessary: true, + onSuccess: (key) => key, + }, + stopPropagation: () => {}, + }); + await super.onBuilt(...arguments); + } //-------------------------------------------------------------------------- // Options @@ -14,13 +30,13 @@ options.registry.GoogleMap = options.Class.extend({ */ resetMapColor(previewMode, widgetValue, params) { this.$target[0].dataset.mapColor = ''; - }, + } /** * @see this.selectClass for parameters */ setFormattedAddress(previewMode, widgetValue, params) { this.$target[0].dataset.pinAddress = params.gmapPlace.formatted_address; - }, + } /** * @see this.selectClass for parameters */ @@ -36,7 +52,7 @@ options.registry.GoogleMap = options.Class.extend({ } else if (!widgetValue && descriptionEl) { descriptionEl.remove(); } - }, + } //-------------------------------------------------------------------------- // Private @@ -49,6 +65,11 @@ options.registry.GoogleMap = options.Class.extend({ if (methodName === 'showDescription') { return this.$target[0].querySelector('.description') ? 'true' : ''; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } +} +registerWebsiteOption("GoogleMap", { + Class: GoogleMap, + template: "website.s_google_map_options", + selector: ".s_google_map", }); diff --git a/addons/website/static/src/snippets/s_google_map/options.xml b/addons/website/static/src/snippets/s_google_map/options.xml new file mode 100644 index 0000000000000..da17014c91267 --- /dev/null +++ b/addons/website/static/src/snippets/s_google_map/options.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="website.s_google_map_options"> + <WeGpsPicker title.translate="Address" + selectDataAttribute="''" attributeName="'mapGps'" + setFormattedAddress="''" + placeholder.translate="e.g. De Brouckere, Brussels, Belgium" + noPreview="'true'"/> + <WeSelect title.translate="Marker Style" attributeName="'pinStyle'"> + <WeButton selectDataAttribute="''">Default</WeButton> + <WeButton selectDataAttribute="'flat'">Flat</WeButton> + </WeSelect> + <WeSelect title.translate="Type" attributeName="'mapType'" noPreview="'true'" resetMapColor="''"> + <WeButton name="'roadmap_opt'" selectDataAttribute="'ROADMAP'">RoadMap</WeButton> + <WeButton selectDataAttribute="'TERRAIN'">Terrain</WeButton> + <WeButton selectDataAttribute="'SATELLITE'">Satellite</WeButton> + <WeButton selectDataAttribute="'HYBRID'">Hybrid</WeButton> + </WeSelect> + <WeSelect class="'o_we_sublevel_1'" title.translate="Style" dependencies="'roadmap_opt'" attributeName="'mapColor'" noPreview="'true'"> + <WeButton selectDataAttribute="''" tooltip.translate="Default" img="'/website/static/src/snippets/s_google_map/img/thumbs/map-default.jpg'"/> + <WeButton selectDataAttribute="'lightMonoMap'" tooltip.translate="Light Mono" img="'/website/static/src/snippets/s_google_map/img/thumbs/map-lightMono.jpg'"/> + <WeButton selectDataAttribute="'cupertinoMap'" tooltip.translate="Cupertino" img="'/website/static/src/snippets/s_google_map/img/thumbs/map-cupertino.jpg'"/> + <WeButton selectDataAttribute="'retroMap'" tooltip.translate="Retro" img="'/website/static/src/snippets/s_google_map/img/thumbs/map-retro.jpg'"/> + <WeButton selectDataAttribute="'cobaltMap'" tooltip.translate="Cobalt" img="'/website/static/src/snippets/s_google_map/img/thumbs/map-cobalt.jpg'"/> + <WeButton selectDataAttribute="'flatMap'" tooltip.translate="Flat" img="'/website/static/src/snippets/s_google_map/img/thumbs/map-flat.jpg'"/> + <WeButton selectDataAttribute="'blueMap'" tooltip.translate="Blue" img="'/website/static/src/snippets/s_google_map/img/thumbs/map-blue.jpg'"/> + <WeButton selectDataAttribute="'lillaMap'" tooltip.translate="Lilla" img="'/website/static/src/snippets/s_google_map/img/thumbs/map-lilla.jpg'"/> + <WeButton selectDataAttribute="'carMap'" tooltip.translate="Caramello" img="'/website/static/src/snippets/s_google_map/img/thumbs/map-caramello.jpg'"/> + <WeButton selectDataAttribute="'bwMap'" tooltip.translate="BW" img="'/website/static/src/snippets/s_google_map/img/thumbs/map-bw.jpg'"/> + </WeSelect> + <WeInput title.translate="Zoom" selectDataAttribute="'12'" noPreview="'true'" attributeName="'mapZoom'" step="'1'"/> + <WeCheckbox title.translate="Description" showDescription="'true'" noPreview="'true'"/> + </t> + +</templates> diff --git a/addons/website/static/src/snippets/s_image/options.js b/addons/website/static/src/snippets/s_image/options.js index 08b9e8092adb4..934fe792c8a82 100644 --- a/addons/website/static/src/snippets/s_image/options.js +++ b/addons/website/static/src/snippets/s_image/options.js @@ -1,9 +1,16 @@ /** @odoo-module **/ -import options from "@web_editor/js/editor/snippets.options"; +import { SnippetOption } from "@web_editor/js/editor/snippets.options"; import { MediaDialog } from "@web_editor/components/media_dialog/media_dialog"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; + +export class ImageSnippet extends SnippetOption { + + constructor() { + super(...arguments); + this.dialog = this.env.services.dialog; + } -options.registry.ImageSnippet = options.Class.extend({ /** * @override */ @@ -12,7 +19,7 @@ options.registry.ImageSnippet = options.Class.extend({ // dialog. await new Promise(resolve => { let isImageSaved = false; - this.call("dialog", "add", MediaDialog, { + this.dialog.add(MediaDialog, { onlyImages: true, save: imageEl => { isImageSaved = true; @@ -34,9 +41,10 @@ options.registry.ImageSnippet = options.Class.extend({ } }); }); - }, -}); + } +} -export default { - ImageSnippet: options.registry.ImageSnippet, -}; +registerWebsiteOption("Image", { + Class: ImageSnippet, + selector: '.s_image', +}); diff --git a/addons/website/static/src/snippets/s_image_gallery/options.js b/addons/website/static/src/snippets/s_image_gallery/options.js index b49515e5eec4d..ed2f665d65e10 100644 --- a/addons/website/static/src/snippets/s_image_gallery/options.js +++ b/addons/website/static/src/snippets/s_image_gallery/options.js @@ -1,7 +1,8 @@ /** @odoo-module **/ import { MediaDialog } from "@web_editor/components/media_dialog/media_dialog"; -import options from "@web_editor/js/editor/snippets.options"; +import { CarouselHandler, SnippetOption } from "@web_editor/js/editor/snippets.options"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; import wUtils from '@website/js/utils'; import { _t } from "@web/core/l10n/translation"; import { renderToElement } from "@web/core/utils/render"; @@ -16,8 +17,7 @@ import { * This is typically the case when adding/removing/moving images, changing the * layout mode and changing the number of columns. */ -options.registry.GalleryLayout = options.registry.CarouselHandler.extend({ - +class GalleryLayout extends CarouselHandler { //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -40,7 +40,7 @@ options.registry.GalleryLayout = options.registry.CarouselHandler.extend({ mode = 'nomode'; } return mode; - }, + } /** * Displays the images with the "grid" layout. * @@ -63,7 +63,7 @@ options.registry.GalleryLayout = options.registry.CarouselHandler.extend({ } }); this.$target.css('height', ''); - }, + } /** * Displays the images with the "masonry" layout. * @@ -109,7 +109,7 @@ options.registry.GalleryLayout = options.registry.CarouselHandler.extend({ } resolve(); }); - }, + } /** * Allows to change the images layout. @see grid, masonry, nomode, slideshow * @@ -131,9 +131,9 @@ options.registry.GalleryLayout = options.registry.CarouselHandler.extend({ this.options.wysiwyg.odooEditor.unbreakableStepUnactive(); } await this[`_${modeName}`](); - this.trigger_up('cover_update'); + this.callbacks.coverUpdate(); await this._refreshPublicWidgets(); - }, + } /** * Displays the images with the standard layout: floating images. * @@ -154,7 +154,7 @@ options.registry.GalleryLayout = options.registry.CarouselHandler.extend({ var $wrap = $('<div/>', {class: wrapClass}).append(imgHolderEls[index]); $row.append($wrap); }); - }, + } /** * Displays the images with a "slideshow" layout. * @@ -188,7 +188,7 @@ options.registry.GalleryLayout = options.registry.CarouselHandler.extend({ this.$target.off('slide.bs.carousel').off('slid.bs.carousel'); this._slideshowStart(); this.$('li.fa').off('click'); - }, + } /** * @override */ @@ -196,7 +196,7 @@ options.registry.GalleryLayout = options.registry.CarouselHandler.extend({ const imgs = this.$('img').get(); imgs.sort((a, b) => this._getIndex(a) - this._getIndex(b)); return imgs; - }, + } /** * Returns the images, or the images holder if this holder is an anchor, * sorted by index. @@ -204,10 +204,10 @@ options.registry.GalleryLayout = options.registry.CarouselHandler.extend({ * @private * @returns {Array.<HTMLImageElement|HTMLAnchorElement>} */ - _getImgHolderEls: function () { + _getImgHolderEls() { const imgEls = this._getItemsGallery(); return imgEls.map(imgEl => imgEl.closest("a") || imgEl); - }, + } /** * Returns the index associated to a given image. * @@ -215,18 +215,18 @@ options.registry.GalleryLayout = options.registry.CarouselHandler.extend({ * @param {DOMElement} img * @returns {integer} */ - _getIndex: function (img) { + _getIndex(img) { return img.dataset.index || 0; - }, + } /** * Returns the currently selected column option. * * @private * @returns {integer} */ - _getColumns: function () { + _getColumns() { return parseInt(this.$target.attr('data-columns')) || 3; - }, + } /** * @override */ @@ -234,19 +234,16 @@ options.registry.GalleryLayout = options.registry.CarouselHandler.extend({ itemsEls.forEach((img, index) => { img.dataset.index = index; }); - this.trigger_up('snippet_edition_request', {exec: async () => { + this.options.snippetEditionRequest({exec: async () => { await this._relayout(); if (this._getMode() === "slideshow") { this._updateIndicatorAndActivateSnippet(newItemPosition); } else { const imageEl = this.$target[0].querySelector(`[data-index='${newItemPosition}']`); - this.trigger_up("activate_snippet", { - $snippet: $(imageEl), - ifInactiveOptions: true, - }); + this.env.activateSnippet($(imageEl), false, true); } }}); - }, + } /** * Empties the container, adds the given content and returns the container. * @@ -254,11 +251,11 @@ options.registry.GalleryLayout = options.registry.CarouselHandler.extend({ * @param {jQuery} $content * @returns {jQuery} the main container of the snippet */ - _replaceContent: function ($content) { + _replaceContent($content) { var $container = this.$('> .container, > .container-fluid, > .o_container_small'); $container.empty().append($content); return $container; - }, + } /** * Redraws the current layout. * @@ -266,7 +263,7 @@ options.registry.GalleryLayout = options.registry.CarouselHandler.extend({ */ _relayout() { return this._setMode(this._getMode()); - }, + } /** * Sets up listeners on slideshow to activate selected image. */ @@ -284,13 +281,13 @@ options.registry.GalleryLayout = options.registry.CarouselHandler.extend({ $carousel.on("slide.bs.carousel.image_gallery", (ev) => { lastSlideTimeStamp = ev.timeStamp; const activeImageEl = this.$target[0].querySelector(".carousel-item.active img"); - this.trigger_up("is_element_selected", { + this.env.isElementSelected({ el: activeImageEl, callback: () => { _previousEditor = true; }, }); - this.trigger_up("hide_overlay"); + this.env.hideOverlay(); }); $carousel.on("slid.bs.carousel.image_gallery", (ev) => { if (!_previousEditor && !_miniatureClicked) { @@ -305,21 +302,20 @@ options.registry.GalleryLayout = options.registry.CarouselHandler.extend({ const _slideDuration = new Date().getTime() - lastSlideTimeStamp; setTimeout(() => { const activeImageEl = this.$target[0].querySelector(".carousel-item.active img"); - this.trigger_up("activate_snippet", { - $snippet: $(activeImageEl), - ifInactiveOptions: true, - }); + this.env.activateSnippet($(activeImageEl), false, true); }, 0.2 * _slideDuration); }); - }, -}); + } +} -options.registry.gallery = options.registry.GalleryLayout.extend({ + +class Gallery extends GalleryLayout { /** * @override */ - start() { - const _super = this._super.bind(this); + constructor() { + super(...arguments); + let layoutPromise; const containerEl = this.$target[0].querySelector(":scope > .container, :scope > .container-fluid, :scope > .o_container_small"); if (containerEl.querySelector(":scope > *:not(div)")) { @@ -327,14 +323,14 @@ options.registry.gallery = options.registry.GalleryLayout.extend({ } else { layoutPromise = Promise.resolve(); } - return layoutPromise.then(() => _super.apply(this, arguments).then(() => { + layoutPromise.then(() => { // Call specific mode's start if defined (e.g. _slideshowStart) const startMode = this[`_${this._getMode()}Start`]; if (startMode) { startMode.bind(this)(); } - })); - }, + }); + } /** * @override */ @@ -342,7 +338,7 @@ options.registry.gallery = options.registry.GalleryLayout.extend({ if (this.$target.hasClass('slideshow')) { this.$target.removeAttr('style'); } - }, + } //-------------------------------------------------------------------------- // Options @@ -359,7 +355,7 @@ options.registry.gallery = options.registry.GalleryLayout.extend({ this.$target.attr('data-columns', nbColumns); return this._relayout(); - }, + } /** * Allows to change the images layout. @see grid, masonry, nomode, slideshow * @@ -367,7 +363,7 @@ options.registry.gallery = options.registry.GalleryLayout.extend({ */ mode(previewMode, widgetValue, params) { return this._setMode(widgetValue); - }, + } //-------------------------------------------------------------------------- // Private @@ -393,8 +389,8 @@ options.registry.gallery = options.registry.GalleryLayout.extend({ return `${this._getColumns()}`; } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @private */ @@ -402,15 +398,15 @@ options.registry.gallery = options.registry.GalleryLayout.extend({ if (widgetName === 'slideshow_mode_opt') { return false; } - return this._super(...arguments); - }, -}); + return super._computeWidgetVisibility(...arguments); + } +} + +class GalleryImageList extends GalleryLayout { + constructor() { + super(...arguments); + this.dialog = this.env.services.dialog; -options.registry.GalleryImageList = options.registry.GalleryLayout.extend({ - /** - * @override - */ - start() { // Make sure image previews are updated if images are changed this.$target.on('image_changed.gallery', 'img', ev => { const $img = $(ev.currentTarget); @@ -431,38 +427,36 @@ options.registry.GalleryImageList = options.registry.GalleryLayout.extend({ if (!ev.target.height) { $(ev.target).one('load', () => { setTimeout(() => { - this.trigger_up('cover_update'); + this.callbacks.coverUpdate(); }); }); } }); - - return this._super.apply(this, arguments); - }, + } /** * @override */ async onBuilt() { - await this._super(...arguments); + await super.onBuilt(...arguments); if (this.$target.find('.o_add_images').length) { await this.addImages(false); } // TODO should consider the async parts this._adaptNavigationIDs(); - }, + } /** * @override */ onClone() { this._adaptNavigationIDs(); - }, + } /** * @override */ destroy() { - this._super(...arguments); + super.destroy(...arguments); this.$target.off('.gallery'); - }, + } //-------------------------------------------------------------------------- // Options @@ -522,17 +516,17 @@ options.registry.GalleryImageList = options.registry.GalleryLayout.extend({ savedPromise = savedPromise.then(async () => { await this._relayout(); }); - this.trigger_up('cover_update'); + this.callbacks.coverUpdate(); } }, }; - this.call("dialog", "add", MediaDialog, props, { + this.dialog.add(MediaDialog, props, { onClose: () => { savedPromise.then(resolve); }, }); }); - }, + } /** * Allows to remove all images. Restores the snippet to the way it was when * it was added in the page. @@ -552,7 +546,7 @@ options.registry.GalleryImageList = options.registry.GalleryLayout.extend({ class: ' fa fa-plus-circle', }); this._replaceContent($addImg.append($icon).append($text)); - }, + } //-------------------------------------------------------------------------- // Public @@ -564,14 +558,14 @@ options.registry.GalleryImageList = options.registry.GalleryLayout.extend({ * @override */ notify(name, data) { - this._super(...arguments); + super.notify(...arguments); if (name === 'image_removed') { data.$image.remove(); // Force the removal of the image before reset - this.trigger_up('snippet_edition_request', {exec: () => { + this.options.snippetEditionRequest({exec: () => { return this._relayout(); }}); } - }, + } //-------------------------------------------------------------------------- // Private @@ -591,22 +585,38 @@ options.registry.GalleryImageList = options.registry.GalleryLayout.extend({ $el.attr('href', '#slideshow_' + uuid); } }); - }, -}); + } +} -options.registry.gallery_img = options.Class.extend({ +class GalleryImage extends SnippetOption { /** * Rebuilds the whole gallery when one image is removed. * * @override */ - onRemove: function () { - this.trigger_up('option_update', { + onRemove() { + this.callbacks.notifyOptions({ optionName: 'GalleryImageList', name: 'image_removed', data: { $image: this.$target, }, }); - }, + } +} + +registerWebsiteOption("GalleryImageList", { + Class: GalleryImageList, + template: "website.GalleryImageList", + selector: ".s_image_gallery", +}, { sequence: 24 }); // Before website.snippet_options_background_options +registerWebsiteOption("Gallery", { + Class: Gallery, + template: "website.Gallery", + selector: ".s_image_gallery", +}); +registerWebsiteOption("GalleryImage", { + Class: GalleryImage, + template: "website.GalleryImage", + selector: ".s_image_gallery img", }); diff --git a/addons/website/static/src/snippets/s_image_gallery/options.xml b/addons/website/static/src/snippets/s_image_gallery/options.xml new file mode 100644 index 0000000000000..27b31db46ddc6 --- /dev/null +++ b/addons/website/static/src/snippets/s_image_gallery/options.xml @@ -0,0 +1,51 @@ +<templates id="template" xml:space="preserve"> +<t t-name="website.GalleryImageList"> + <WeRow title.translate="Images"> + <WeButton class="'o_we_bg_success'" addImages="'true'" noPreview="'true'">Add</WeButton> + <WeButton class="'o_we_bg_danger'" removeAllImages="'true'" noPreview="'true'">Remove all</WeButton> + </WeRow> +</t> +<t t-name="website.Gallery"> + <WeSelect title.translate="Mode" dependencies="'!slideshow_mode_opt'"> + <WeButton mode="'grid'" name="'grid_mode_opt'">Grid</WeButton> + <WeButton mode="'masonry'" name="'masonry_mode_opt'">Masonry</WeButton> + <WeButton mode="'nomode'">Float</WeButton> + + <!-- Hidden option --> + <WeButton mode="'slideshow'" name="'slideshow_mode_opt'">Slideshow</WeButton> + </WeSelect> + <WeInput title.translate="Speed" + dependencies="'slideshow_mode_opt'" + applyTo="'.carousel:first'" + selectDataAttribute="'0s'" attributeName="'bsInterval'" + unit="'s'" saveUnit="'ms'" step="'0.1'"/> + <WeSelect title.translate="Columns" dependencies="'masonry_mode_opt, grid_mode_opt'"> + <WeButton columns="'1'">1</WeButton> + <WeButton columns="'2'">2</WeButton> + <WeButton columns="'3'">3</WeButton> + <WeButton columns="'4'">4</WeButton> + <WeButton columns="'6'">6</WeButton> + <WeButton columns="'12'">12</WeButton> + </WeSelect> + <WeRange title.translate="Images Spacing" + dependencies="'!slideshow_mode_opt'" + selectClass="'o_spc-none|o_spc-small|o_spc-medium|o_spc-big'"/> + <WeSelect title.translate="Arrows" dependencies="'slideshow_mode_opt'"> + <WeButton selectClass="''">Standard</WeButton> + <WeButton selectClass="'s_image_gallery_indicators_arrows_boxed'">Boxed</WeButton> + <WeButton selectClass="'s_image_gallery_indicators_arrows_rounded'">Rounded</WeButton> + </WeSelect> + <WeCheckbox title.translate="Image Cover" selectClass="'s_image_gallery_cover'" dependencies="'slideshow_mode_opt'"/> + <WeSelect title.translate="Indicators" dependencies="'slideshow_mode_opt'"> + <WeButton selectClass="''">None</WeButton> + <WeButton selectClass="'s_image_gallery_show_indicators s_image_gallery_indicators_dots'">Dots</WeButton> + <WeButton selectClass="'s_image_gallery_show_indicators'">Squared Miniatures</WeButton> + <WeButton selectClass="'s_image_gallery_show_indicators s_image_gallery_indicators_rounded'">Rounded Miniatures</WeButton> + </WeSelect> + <t t-call="website.snippet_options_border_widgets"> + <t t-set="apply_to" t-value="'img'"/> + <t t-set="so_rounded_no_dependencies" t-value="true"/> + </t> +</t> +<t t-name="website.GalleryImage"/> +</templates> diff --git a/addons/website/static/src/snippets/s_instagram_page/options.js b/addons/website/static/src/snippets/s_instagram_page/options.js index e081d19822533..199c0e5621655 100644 --- a/addons/website/static/src/snippets/s_instagram_page/options.js +++ b/addons/website/static/src/snippets/s_instagram_page/options.js @@ -1,36 +1,30 @@ /** @odoo-module **/ import {_t} from "@web/core/l10n/translation"; -import options from "@web_editor/js/editor/snippets.options"; +import { SnippetOption } from "@web_editor/js/editor/snippets.options"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; import SocialMediaOption from "@website/snippets/s_social_media/options"; -options.registry.InstagramPage = options.Class.extend({ - /** - * @override - */ - init() { - this._super(...arguments); - this.orm = this.bindService("orm"); - this.notification = this.bindService("notification"); +export class InstagramPage extends SnippetOption { + + constructor() { + super(...arguments); + this.orm = this.env.services.orm; + this.notification = this.env.services.notification; + this.website = this.env.services.website; this.instagramUrlStr = "instagram.com/"; - }, + } /** * @override */ async onBuilt() { // First we check if the user has changed his instagram during the // current edition (via the social media options). - const dbSocialValuesCache = SocialMediaOption.getDbSocialValuesCache(); + const dbSocialValuesCache = SocialMediaOption.getDbSocialValuesCache().dbSocialValues; let socialInstagram = dbSocialValuesCache && dbSocialValuesCache["social_instagram"]; // If not, we check the value in the DB. if (!socialInstagram) { - let websiteId; - this.trigger_up("context_get", { - callback: function (ctx) { - websiteId = ctx["website_id"]; - }, - }); - const values = await this.orm.read("website", [websiteId], ["social_instagram"]); + const values = await this.orm.read("website", [this.website.currentWebsite.id], ["social_instagram"]); socialInstagram = values[0]["social_instagram"]; } if (socialInstagram) { @@ -39,7 +33,7 @@ options.registry.InstagramPage = options.Class.extend({ this.$target[0].dataset.instagramPage = pageName; } } - }, + } //-------------------------------------------------------------------------- // Options @@ -62,11 +56,15 @@ options.registry.InstagramPage = options.Class.extend({ this.$target[0].dataset.instagramPage = widgetValue || ""; // As the public widget restart is disabled for instagram, we have to // manually restart the widget. - await this.trigger_up("widgets_start_request", { - $target: this.$target, - editableMode: true, + await new Promise((resolve, reject) => { + this.website.websiteRootInstance.trigger_up("widgets_start_request", { + $target: this.$target, + editableMode: true, + onSuccess: () => resolve(), + onFailure: () => reject(), + }); }); - }, + } //-------------------------------------------------------------------------- // Private @@ -79,8 +77,8 @@ options.registry.InstagramPage = options.Class.extend({ if (widgetName === "setInstagramPage") { return this.$target[0].dataset.instagramPage; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * Returns the instagram page name from the given url. * @@ -95,9 +93,11 @@ options.registry.InstagramPage = options.Class.extend({ return; } return pageName.split("/")[0]; - }, -}); + } +} -export default { - InstagramPage: options.registry.InstagramPage, -}; +registerWebsiteOption("InstagramPage", { + Class: InstagramPage, + template: "website.s_instagram_page_options", + selector: ".s_instagram_page", +}); diff --git a/addons/website/static/src/snippets/s_instagram_page/options.xml b/addons/website/static/src/snippets/s_instagram_page/options.xml new file mode 100644 index 0000000000000..bd88a3ce93a52 --- /dev/null +++ b/addons/website/static/src/snippets/s_instagram_page/options.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="website.s_instagram_page_options"> + <we-alert class="mt-2"> + Your instagram page must be public to be integrated into an Odoo website. + </we-alert> + <WeInput title.translate="Instagram Page" placeholder="'odoo.official'" setInstagramPage="''" noPreview="'true'"/> + </t> + +</templates> diff --git a/addons/website/static/src/snippets/s_map/options.js b/addons/website/static/src/snippets/s_map/options.js index 25611cfe329d9..721d2e9950d7d 100644 --- a/addons/website/static/src/snippets/s_map/options.js +++ b/addons/website/static/src/snippets/s_map/options.js @@ -1,10 +1,11 @@ /** @odoo-module **/ import { _t } from "@web/core/l10n/translation"; -import options from '@web_editor/js/editor/snippets.options'; -import {generateGMapIframe, generateGMapLink} from '@website/js/utils'; +import { SnippetOption } from "@web_editor/js/editor/snippets.options"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; +import { generateGMapIframe, generateGMapLink } from '@website/js/utils'; -options.registry.Map = options.Class.extend({ +export class Map extends SnippetOption { /** * @override */ @@ -18,7 +19,7 @@ options.registry.Map = options.Class.extend({ this.$target[0].querySelector('.s_map_color_filter').before(iframeEl); this._updateSource(); } - }, + } //-------------------------------------------------------------------------- // Options @@ -28,11 +29,11 @@ options.registry.Map = options.Class.extend({ * @see this.selectClass for parameters */ async selectDataAttribute(previewMode, widgetValue, params) { - await this._super(...arguments); + await super.selectDataAttribute(...arguments); if (['mapAddress', 'mapType', 'mapZoom'].includes(params.attributeName)) { this._updateSource(); } - }, + } /** * @see this.selectClass for parameters */ @@ -48,7 +49,7 @@ options.registry.Map = options.Class.extend({ } else if (!widgetValue && descriptionEl) { descriptionEl.remove(); } - }, + } //-------------------------------------------------------------------------- // Private @@ -61,8 +62,8 @@ options.registry.Map = options.Class.extend({ if (methodName === 'showDescription') { return !!this.$target[0].querySelector('.description'); } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @private */ @@ -82,9 +83,11 @@ options.registry.Map = options.Class.extend({ $embedded.addClass('d-none'); $info.removeClass('d-none'); } - }, -}); + } +} -export default { - Map: options.registry.Map, -}; +registerWebsiteOption("Map", { + Class: Map, + template: "website.s_map_options", + selector: ".s_map", +}); diff --git a/addons/website/static/src/snippets/s_map/options.xml b/addons/website/static/src/snippets/s_map/options.xml new file mode 100644 index 0000000000000..3775cb58a815f --- /dev/null +++ b/addons/website/static/src/snippets/s_map/options.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="website.s_map_options"> + <WeInput title.translate="Address" attributeName="'mapAddress'" selectDataAttribute="''" + noPreview="'true'" placeholder.translate="e.g. De Brouckere, Brussels, Belgium"/> + <WeSelect title.translate="Type" attributeName="'mapType'" noPreview="'true'"> + <WeButton selectDataAttribute="'m'">Road</WeButton> + <WeButton selectDataAttribute="'k'">Satellite</WeButton> + </WeSelect> + <WeSelect title.translate="Zoom" attributeName="'mapZoom'" selectDataAttribute="'12'" noPreview="'true'"> + <WeButton selectDataAttribute="'21'">2.5 m</WeButton> + <WeButton selectDataAttribute="'20'">5 m</WeButton> + <WeButton selectDataAttribute="'19'">10 m</WeButton> + <WeButton selectDataAttribute="'18'">20 m</WeButton> + <WeButton selectDataAttribute="'17'">50 m</WeButton> + <WeButton selectDataAttribute="'16'">100 m</WeButton> + <WeButton selectDataAttribute="'15'">200 m</WeButton> + <WeButton selectDataAttribute="'14'">400 m</WeButton> + <WeButton selectDataAttribute="'13'">1 km</WeButton> + <WeButton selectDataAttribute="'12'">2 km</WeButton> + <WeButton selectDataAttribute="'11'">4 km</WeButton> + <WeButton selectDataAttribute="'10'">8 km</WeButton> + <WeButton selectDataAttribute="'9'">15 km</WeButton> + <WeButton selectDataAttribute="'8'">30 km</WeButton> + <WeButton selectDataAttribute="'7'">50 km</WeButton> + <WeButton selectDataAttribute="'6'">100 km</WeButton> + <WeButton selectDataAttribute="'5'">200 km</WeButton> + <WeButton selectDataAttribute="'4'">400 km</WeButton> + <WeButton selectDataAttribute="'3'">1000 km</WeButton> + <WeButton selectDataAttribute="'2'">2000 km</WeButton> + </WeSelect> + <WeColorpicker title.translate="Color Filter" selectStyle="'true'" + cssProperty="'background-color'" colorPrefix="'bg-'" applyTo="'.s_map_color_filter'"/> + <WeCheckbox title.translate="Description" noPreview="'true'" showDescription="'true'"/> + </t> + +</templates> diff --git a/addons/website/static/src/snippets/s_masonry_block/options.js b/addons/website/static/src/snippets/s_masonry_block/options.js index 9c1f329662b93..b4ae0b3d30aea 100644 --- a/addons/website/static/src/snippets/s_masonry_block/options.js +++ b/addons/website/static/src/snippets/s_masonry_block/options.js @@ -1,14 +1,18 @@ -/** @odoo-module */ +import { SelectTemplate } from "@web_editor/js/editor/snippets.options"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; -import options from '@web_editor/js/editor/snippets.options'; - -options.registry.MasonryLayout = options.registry.SelectTemplate.extend({ - /** - * @constructor - */ - init() { - this._super(...arguments); +export class MasonryLayout extends SelectTemplate { + constructor() { + super(...arguments); this.containerSelector = '> .container, > .container-fluid, > .o_container_small'; this.selectTemplateWidgetName = 'masonry_template_opt'; - }, + } +} + +registerWebsiteOption("MasonryLayout", { + Class: MasonryLayout, + template: "website.s_masonry_block_options", + selector: ".s_masonry_block", +}, { + sequence: 10, }); diff --git a/addons/website/static/src/snippets/s_masonry_block/options.xml b/addons/website/static/src/snippets/s_masonry_block/options.xml new file mode 100644 index 0000000000000..39244ca0f40c6 --- /dev/null +++ b/addons/website/static/src/snippets/s_masonry_block/options.xml @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="website.s_masonry_block_options"> + <WeSelect title.translate="Template" + name="'masonry_template_opt'" + attributeName="'masonryTemplate'" + attributeDefaultValue="'default'"> + <WeButton tooltip.translate="Default" + selectTemplate="'website.s_masonry_block_default_template'" + selectDataAttribute="'default'" + img="'/website/static/src/img/snippets_options/masonry_template_default.svg'"/> + <WeButton tooltip.translate="Default Reversed" + selectTemplate="'website.s_masonry_block_reversed_template'" + selectDataAttribute="'default_reversed'" + img="'/website/static/src/img/snippets_options/masonry_template_reversed.svg'"/> + <WeButton tooltip.translate="Images" + selectTemplate="'website.s_masonry_block_images_template'" + selectDataAttribute="'images'" + img="'/website/static/src/img/snippets_options/masonry_template_images.svg'"/> + <WeButton tooltip.translate="Image Text Image" + selectTemplate="'website.s_masonry_block_image_texts_image_template'" + selectDataAttribute="'image_text_image'" + img="'/website/static/src/img/snippets_options/masonry_template_image_texts_image.svg'"/> + <WeButton tooltip.translate="Mosaic" + selectTemplate="'website.s_masonry_block_mosaic_template'" + selectDataAttribute="'mosaic'" + img="'/website/static/src/img/snippets_options/masonry_template_mosaic.svg'"/> + <WeButton tooltip.translate="Text Image Text" + selectTemplate="'website.s_masonry_block_texts_image_texts_template'" + selectDataAttribute="'text_image_text'" + img="'/website/static/src/img/snippets_options/masonry_template_texts_image_texts.svg'"/> + <WeButton tooltip.translate="Alternate Text" + selectTemplate="'website.s_masonry_block_alternation_text_template'" + selectDataAttribute="'alternate_text'" + img="'/website/static/src/img/snippets_options/masonry_template_alternate_texts.svg'"/> + <WeButton tooltip.translate="Alternate Text Image" + selectTemplate="'website.s_masonry_block_alternation_text_image_template'" + selectDataAttribute="'alternate_text_image'" + img="'/website/static/src/img/snippets_options/masonry_template_alternate_text_image.svg'"/> + <WeButton tooltip.translate="Alternate Image Text" + selectTemplate="'website.s_masonry_block_alternation_image_text_template'" + selectDataAttribute="'alternate_image_text'" + img="'/website/static/src/img/snippets_options/masonry_template_alternate_image_text.svg'"/> + <WeButton tooltip.translate="Alternate Text Image Text" + selectTemplate="'website.s_masonry_block_alternation_text_image_text_template'" + selectDataAttribute="'alternate_text_image_text'" + img="'/website/static/src/img/snippets_options/masonry_template_alternate_text_image_text.svg'"/> + </WeSelect> + </t> + +</templates> diff --git a/addons/website/static/src/snippets/s_media_list/options.js b/addons/website/static/src/snippets/s_media_list/options.js index 66030ae3bbf32..3f6f7814e5ed4 100644 --- a/addons/website/static/src/snippets/s_media_list/options.js +++ b/addons/website/static/src/snippets/s_media_list/options.js @@ -1,8 +1,18 @@ /** @odoo-module **/ -import options from "@web_editor/js/editor/snippets.options"; +import { + MultipleItems, + Box, + SnippetOption, +} from "@web_editor/js/editor/snippets.options"; +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; +import { + websiteRegisterBackgroundOptions, +} from "@website/js/editor/snippets.options"; -options.registry.MediaItemLayout = options.Class.extend({ +export class MediaItemLayoutOption extends SnippetOption { //-------------------------------------------------------------------------- // Options @@ -13,7 +23,7 @@ options.registry.MediaItemLayout = options.Class.extend({ * * @see this.selectClass for parameters */ - layout: function (previewMode, widgetValue, params) { + layout(previewMode, widgetValue, params) { const $image = this.$target.find('.s_media_list_img_wrapper'); const $content = this.$target.find('.s_media_list_body'); @@ -23,7 +33,7 @@ options.registry.MediaItemLayout = options.Class.extend({ } $image.addClass(`col-lg-${widgetValue}`); $content.addClass(`col-lg-${12 - widgetValue}`); - }, + } //-------------------------------------------------------------------------- // Private @@ -43,6 +53,44 @@ options.registry.MediaItemLayout = options.Class.extend({ } } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } +} + +registerWebsiteOption("Media List (Multiple items)", { + Class: MultipleItems, + template: "website.s_media_list_option_add_media", + selector: ".s_media_list", +}, { sequence: 24 }); +websiteRegisterBackgroundOptions("Media List (background)", { + selector: ".s_media_list_item", + target: "> .row", + withColors: true, + withImages: false, + withColorCombinations: true, + withGradients: true, +}); +registerWebsiteOption("Media List (border & shadow)", { + Class: Box, + template: "website.card_color_border_shadow", + selector: ".s_media_list_item", + target: "> .row", +}); +registerWebsiteOption("Media List (layout)", { + Class: SnippetOption, + template: "website.s_media_list_option_layout", + selector: ".s_media_list_item", + target: "> .row", }); +registerWebsiteOption("Media List (item layout)", { + Class: MediaItemLayoutOption, + template: "website.s_media_list_option_item_layout", + selector: ".s_media_list_item", +}); +registerWebsiteOption("Media List (vertical alignment)", { + Class: SnippetOption, + template: "website.s_media_list_option_vertical_alignment", + selector: ".s_media_list_item", + target: "> .row", +}); + diff --git a/addons/website/static/src/snippets/s_media_list/options.xml b/addons/website/static/src/snippets/s_media_list/options.xml new file mode 100644 index 0000000000000..07fc9eab42912 --- /dev/null +++ b/addons/website/static/src/snippets/s_media_list/options.xml @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="website.s_media_list_option_add_media"> + <WeRow title.translate="Media"> + <WeButton addItem="''" item="'.s_media_list_item:last'" selectItem="''" noPreview="'true'" class="'o_we_bg_brand_primary'"> + Add Media + </WeButton> + </WeRow> +</t> +<t t-name="website.s_media_list_option_layout"> + <WeRow title.translate="Layout"> + <WeButtonGroup> + <WeButton tooltip.translate="Left" name="'media_left_opt'" selectClass="''" img="'/website/static/src/img/snippets_options/image_left.svg'"/> + <WeButton tooltip.translate="Right" selectClass="'flex-row-reverse'" img="'/website/static/src/img/snippets_options/image_right.svg'"/> + </WeButtonGroup> + </WeRow> +</t> +<t t-name="website.s_media_list_option_item_layout"> + <WeRow title.translate="Image Size"> + <WeButtonGroup dependencies="'media_left_opt'"> + <WeButton layout="'3'" img="'/website/static/src/img/snippets_options/media_layout_1_4.svg'" tooltip="'1/4 - 3/4'"/> + <WeButton layout="'4'" img="'/website/static/src/img/snippets_options/media_layout_1_3.svg'" tooltip="'1/3 - 2/3'"/> + <WeButton layout="'6'" img="'/website/static/src/img/snippets_options/media_layout_1_2.svg'" tooltip="'1/2 - 1/2'"/> + </WeButtonGroup> + <WeButtonGroup dependencies="'!media_left_opt'"> + <WeButton layout="'3'" img="'/website/static/src/img/snippets_options/media_layout_1_4_right.svg'" tooltip="'1/4 - 3/4'"/> + <WeButton layout="'4'" img="'/website/static/src/img/snippets_options/media_layout_1_3_right.svg'" tooltip="'1/3 - 2/3'"/> + <WeButton layout="'6'" img="'/website/static/src/img/snippets_options/media_layout_1_2_right.svg'" tooltip="'1/2 - 1/2'"/> + </WeButtonGroup> + </WeRow> +</t> +<t t-name="website.s_media_list_option_vertical_alignment"> + <!-- Don't use the standard Vert. Alignement option to not suggest + Equal Height, which is useless for this snippet. --> + <WeRow title.translate="Text Position"> + <WeButtonGroup dependencies="'media_left_opt'"> + <WeButton tooltip.translate="Align Top" selectClass="'align-items-start'" img="'/website/static/src/img/snippets_options/align_top_right.svg'"/> + <WeButton tooltip.translate="Align Middle" selectClass="'align-items-center'" img="'/website/static/src/img/snippets_options/align_middle_right.svg'"/> + <WeButton tooltip.translate="Align Bottom" selectClass="'align-items-end'" img="'/website/static/src/img/snippets_options/align_bottom_right.svg'"/> + </WeButtonGroup> + <WeButtonGroup dependencies="'!media_left_opt'"> + <WeButton tooltip.translate="Align Top" selectClass="'align-items-start'" img="'/website/static/src/img/snippets_options/align_top.svg'"/> + <WeButton tooltip.translate="Align Middle" selectClass="'align-items-center'" img="'/website/static/src/img/snippets_options/align_middle.svg'"/> + <WeButton tooltip.translate="Align Bottom" selectClass="'align-items-end'" img="'/website/static/src/img/snippets_options/align_bottom.svg'"/> + </WeButtonGroup> + </WeRow> +</t> + +</templates> diff --git a/addons/website/static/src/snippets/s_popup/options.js b/addons/website/static/src/snippets/s_popup/options.js index 11b7bf8bffff1..eb7aceb6a30bf 100644 --- a/addons/website/static/src/snippets/s_popup/options.js +++ b/addons/website/static/src/snippets/s_popup/options.js @@ -1,21 +1,29 @@ -/** @odoo-module **/ +import { SnippetOption } from "@web_editor/js/editor/snippets.options"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; -import options from "@web_editor/js/editor/snippets.options"; - -options.registry.SnippetPopup = options.Class.extend({ +export class SnippetPopup extends SnippetOption { + constructor({ callbacks }) { + super(...arguments); + this.$bsTarget = this.ownerDocument.defaultView.$(this.$target[0]); + this.website = this.env.services.website; + this.notifyOptions = callbacks.notifyOptions; + this.updateSnippetOptionVisibility = callbacks.updateSnippetOptionVisibility; + } /** * @override */ - start: function () { + async willStart() { + await super.willStart(); + // Note: the link are excluded here so that internal modal buttons do // not close the popup as we want to allow edition of those buttons. this.$bsTarget.on('click.SnippetPopup', '.js_close_popup:not(a, .btn)', ev => { ev.stopPropagation(); this.onTargetHide(); - this.trigger_up('snippet_option_visibility_update', {show: false}); + this.updateSnippetOptionVisibility(false); }); this.$bsTarget.on('shown.bs.modal.SnippetPopup', () => { - this.trigger_up('snippet_option_visibility_update', {show: true}); + this.updateSnippetOptionVisibility(true); // TODO duplicated code from the popup public widget, this should // be moved to a *video* public widget and be reviewed in master this.$target[0].querySelectorAll('.media_iframe_video').forEach(media => { @@ -24,38 +32,28 @@ options.registry.SnippetPopup = options.Class.extend({ }); }); this.$bsTarget.on('hide.bs.modal.SnippetPopup', () => { - this.trigger_up('snippet_option_visibility_update', {show: false}); + this.updateSnippetOptionVisibility(false); this._removeIframeSrc(); }); // The video might be playing before entering edit mode (possibly with // sound). Stop the video, as the user can't do it (no button on video // in edit mode). this._removeIframeSrc(); - if (!this.$target[0].parentElement.matches("#website_cookies_bar")) { - this.trigger_up("option_update", { - optionName: "anchor", - name: "modalAnchor", - data: { - buttonEl: this._requestUserValueWidgets("onclick_opt")[0].el, - }, - }); - } - return this._super(...arguments); - }, + } /** * @override */ - destroy: function () { - this._super(...arguments); + async onRemove() { + await super.onRemove(); // The video should not start before the modal opens, remove it from the // DOM. It will be added back on modal open to start the video. this._removeIframeSrc(); this.$bsTarget.off('.SnippetPopup'); - }, + } /** * @override */ - onBuilt: function () { + async onBuilt() { this._assignUniqueID(); // Fix in stable to convert the data-focus bootstrap option from version 4.0 to // 5.1 (renamed to data-bs-focus). @@ -64,25 +62,25 @@ options.registry.SnippetPopup = options.Class.extend({ popup.attr('data-bs-focus', popup.attr('data-focus')); popup[0].removeAttribute('data-focus'); } - }, + } /** * @override */ - onClone: function () { + onClone() { this._assignUniqueID(); - }, + } /** * @override */ - onTargetShow: async function () { + async onTargetShow() { this.$bsTarget.modal('show'); $(this.$target[0].ownerDocument.body).children('.modal-backdrop:last').addClass('d-none'); - }, + } /** * @override */ - onTargetHide: async function () { - return new Promise(resolve => { + async onTargetHide() { + await new Promise(resolve => { const timeoutID = setTimeout(() => { this.$bsTarget.off('hidden.bs.modal.popup_on_target_hide'); resolve(); @@ -96,32 +94,44 @@ options.registry.SnippetPopup = options.Class.extend({ this.$target[0].closest('.s_popup').classList.add('d-none'); this.$bsTarget.modal('hide'); }); - }, + } //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- + /** + * @override + */ + async selectDataAttribute(previewMode, widgetValue, params) { + await super.selectDataAttribute(...arguments); + if (!previewMode && params.attributeName === "display" && widgetValue === "onClick") { + this.notifyOptions({ + optionName: "Anchor", + name: "modalAnchor", + }); + } + } /** * Moves the snippet in #o_shared_blocks to be common to all pages or inside * the first editable oe_structure in the main to be on current page only. * * @see this.selectClass for parameters */ - moveBlock: function (previewMode, widgetValue, params) { + moveBlock(previewMode, widgetValue, params) { const selector = widgetValue === 'allPages' ? '#o_shared_blocks' : 'main .oe_structure:o_editable'; const whereEl = $(this.$target[0].ownerDocument).find(selector)[0]; const popupEl = this.$target[0].closest('.s_popup'); whereEl.prepend(popupEl); - }, + } /** * @see this.selectClass for parameters */ setBackdrop(previewMode, widgetValue, params) { const color = widgetValue ? 'var(--black-50)' : ''; this.$target[0].style.setProperty('background-color', color, 'important'); - }, + } //-------------------------------------------------------------------------- // Private @@ -132,19 +142,19 @@ options.registry.SnippetPopup = options.Class.extend({ * * @private */ - _assignUniqueID: function () { + _assignUniqueID() { this.$target.closest('.s_popup').attr('id', 'sPopup' + Date.now()); - }, + } /** * @override */ - _computeWidgetState: function (methodName, params) { + async _computeWidgetState(methodName, params) { switch (methodName) { case 'moveBlock': return this.$target[0].closest('#o_shared_blocks') ? 'allPages' : 'currentPage'; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * Removes the iframe `src` attribute (a copy of the src is already on the * parent `oe-expression` attribute). @@ -155,5 +165,23 @@ options.registry.SnippetPopup = options.Class.extend({ this.$target.find('.media_iframe_video iframe').each((i, iframe) => { iframe.src = ''; }); - }, + } +} + +registerWebsiteOption("SnippetPopup", { + Class: SnippetPopup, + template: "website.s_popup_options", + selector: ".s_popup", + exclude: "#website_cookies_bar", + target: ".modal", + // TODO: Should this be a snippet addition selector? (see + // registerSnippetAdditionSelector) + dropIn: ":not(p).oe_structure:not(.oe_structure_solo):not([data-snippet] *), :not(.o_mega_menu):not(p)[data-oe-type=html]:not([data-snippet] *)", +}); + +registerWebsiteOption("SnippetPopupCookieBar", { + Class: SnippetPopup, + template: "website.s_popup_cookie_bar_options", + selector: ".s_popup#website_cookies_bar", + target: ".modal", }); diff --git a/addons/website/static/src/snippets/s_popup/options.legacy.js b/addons/website/static/src/snippets/s_popup/options.legacy.js new file mode 100644 index 0000000000000..b6fa76d5385fa --- /dev/null +++ b/addons/website/static/src/snippets/s_popup/options.legacy.js @@ -0,0 +1,159 @@ +/** @odoo-module **/ + +import options from "@web_editor/js/editor/snippets.options.legacy"; + +options.registry.SnippetPopup = options.Class.extend({ + /** + * @override + */ + start: function () { + // Note: the link are excluded here so that internal modal buttons do + // not close the popup as we want to allow edition of those buttons. + this.$bsTarget.on('click.SnippetPopup', '.js_close_popup:not(a, .btn)', ev => { + ev.stopPropagation(); + this.onTargetHide(); + this.trigger_up('snippet_option_visibility_update', {show: false}); + }); + this.$bsTarget.on('shown.bs.modal.SnippetPopup', () => { + this.trigger_up('snippet_option_visibility_update', {show: true}); + // TODO duplicated code from the popup public widget, this should + // be moved to a *video* public widget and be reviewed in master + this.$target[0].querySelectorAll('.media_iframe_video').forEach(media => { + const iframe = media.querySelector('iframe'); + iframe.src = media.dataset.oeExpression || media.dataset.src; // TODO still oeExpression to remove someday + }); + }); + this.$bsTarget.on('hide.bs.modal.SnippetPopup', () => { + this.trigger_up('snippet_option_visibility_update', {show: false}); + this._removeIframeSrc(); + }); + // The video might be playing before entering edit mode (possibly with + // sound). Stop the video, as the user can't do it (no button on video + // in edit mode). + this._removeIframeSrc(); + if (!this.$target[0].parentElement.matches("#website_cookies_bar")) { + this.trigger_up("option_update", { + optionName: "anchor", + name: "modalAnchor", + data: { + buttonEl: this._requestUserValueWidgets("onclick_opt")[0].el, + }, + }); + } + return this._super(...arguments); + }, + /** + * @override + */ + destroy: function () { + this._super(...arguments); + // The video should not start before the modal opens, remove it from the + // DOM. It will be added back on modal open to start the video. + this._removeIframeSrc(); + this.$bsTarget.off('.SnippetPopup'); + }, + /** + * @override + */ + onBuilt: function () { + this._assignUniqueID(); + // Fix in stable to convert the data-focus bootstrap option from version 4.0 to + // 5.1 (renamed to data-bs-focus). + const popup = this.$target.closest('.s_popup_middle'); + if (popup && popup.attr('data-focus')) { + popup.attr('data-bs-focus', popup.attr('data-focus')); + popup[0].removeAttribute('data-focus'); + } + }, + /** + * @override + */ + onClone: function () { + this._assignUniqueID(); + }, + /** + * @override + */ + onTargetShow: async function () { + this.$bsTarget.modal('show'); + $(this.$target[0].ownerDocument.body).children('.modal-backdrop:last').addClass('d-none'); + }, + /** + * @override + */ + onTargetHide: async function () { + return new Promise(resolve => { + const timeoutID = setTimeout(() => { + this.$bsTarget.off('hidden.bs.modal.popup_on_target_hide'); + resolve(); + }, 500); + this.$bsTarget.one('hidden.bs.modal.popup_on_target_hide', () => { + clearTimeout(timeoutID); + resolve(); + }); + // The following line is in charge of hiding .s_popup at the same + // time the modal is closed when the page is saved in edit mode. + this.$target[0].closest('.s_popup').classList.add('d-none'); + this.$bsTarget.modal('hide'); + }); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Moves the snippet in #o_shared_blocks to be common to all pages or inside + * the first editable oe_structure in the main to be on current page only. + * + * @see this.selectClass for parameters + */ + moveBlock: function (previewMode, widgetValue, params) { + const selector = widgetValue === 'allPages' ? + '#o_shared_blocks' : 'main .oe_structure:o_editable'; + const whereEl = $(this.$target[0].ownerDocument).find(selector)[0]; + const popupEl = this.$target[0].closest('.s_popup'); + whereEl.prepend(popupEl); + }, + /** + * @see this.selectClass for parameters + */ + setBackdrop(previewMode, widgetValue, params) { + const color = widgetValue ? 'var(--black-50)' : ''; + this.$target[0].style.setProperty('background-color', color, 'important'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Creates a unique ID. + * + * @private + */ + _assignUniqueID: function () { + this.$target.closest('.s_popup').attr('id', 'sPopup' + Date.now()); + }, + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'moveBlock': + return this.$target[0].closest('#o_shared_blocks') ? 'allPages' : 'currentPage'; + } + return this._super(...arguments); + }, + /** + * Removes the iframe `src` attribute (a copy of the src is already on the + * parent `oe-expression` attribute). + * + * @private + */ + _removeIframeSrc() { + this.$target.find('.media_iframe_video iframe').each((i, iframe) => { + iframe.src = ''; + }); + }, +}); diff --git a/addons/website/static/src/snippets/s_popup/options.xml b/addons/website/static/src/snippets/s_popup/options.xml new file mode 100644 index 0000000000000..eb426c33c20bb --- /dev/null +++ b/addons/website/static/src/snippets/s_popup/options.xml @@ -0,0 +1,70 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="website.s_popup_options.base"> + <WeSelect title.translate="Position" dependencies="'layout_popup_opt'"> <!-- When cookie configuration only display this for popup mode --> + <WeButton selectClass="'s_popup_top'" name="'position_top'">Top</WeButton> + <WeButton selectClass="'s_popup_middle'" name="'position_middle'">Middle</WeButton> + <WeButton selectClass="'s_popup_bottom'" name="'position_bottom'">Bottom</WeButton> + </WeSelect> + <WeSelect title.translate="Size" applyTo="'.modal-dialog'" name="'s_popup_size_opt'"> + <WeButton selectClass="'modal-sm'">Small</WeButton> + <WeButton selectClass="''" name="'s_popup_size_md'">Medium</WeButton> + <WeButton selectClass="'modal-lg'">Large</WeButton> + <WeButton selectClass="'modal-xl'">Extra Large</WeButton> + <WeButton selectClass="'s_popup_size_full'" name="'s_popup_size_full'">Full</WeButton> + </WeSelect> + <WeRow title.translate="Backdrop"> + <WeCheckbox name="'popup_backdrop_opt'" + selectClass="'s_popup_no_backdrop|'" + setBackdrop="'true'" + noPreview="'true'"/> + <WeColorpicker dependencies="'popup_backdrop_opt'" + selectStyle="'true'" + cssProperty="'background-color'" + colorPrefix="'bg-'" + cssCompatible="'true'"/> + </WeRow> + </t> + + <t t-name="website.s_popup_options.extra"> + <WeColorpicker title.translate="Close Button Color" + selectStyle="'true'" + cssProperty="'color'" + colorPrefix="'text-'" + applyTo="'.s_popup_close'"/> + <WeSelect title.translate="Display" attributeName="'display'" attributeDefaultValue="always"> + <WeButton selectDataAttribute="'afterDelay'" name="'show_delay'">Delay</WeButton> + <WeButton selectDataAttribute="'mouseExit'">On Exit</WeButton> + <WeButton selectDataAttribute="'onClick'" name="'onclick_opt'">On Click (via link)</WeButton> + </WeSelect> + <WeInput title.translate="⌙ Delay" + tooltip.translate="Automatically opens the pop-up if the user stays on a page longer than the specified time." + selectDataAttribute="''" + attributeName="'showAfter'" + unit="'s'" + saveUnit="'ms'" + dependencies="'show_delay'"/> + <t t-set="unit_popup_duration">days</t> + <WeInput title.translate="Hide For" + tooltip.translate="Once the user closes the popup, it won't be shown again for that period of time." + selectDataAttribute="`7${unit_popup_duration}`" + attributeName="'consentsDuration'" + unit="unit_popup_duration" + dependencies="'!onclick_opt'"/> + <WeSelect title.translate="Show on" noPreview="'true'"> + <WeButton moveBlock="'currentPage'">This page</WeButton> + <WeButton moveBlock="'allPages'">All pages</WeButton> + </WeSelect> + </t> + + <t t-name="website.s_popup_options"> + <t t-call="website.s_popup_options.base"/> + <t t-call="website.s_popup_options.extra"/> + </t> + + <t t-name="website.s_popup_cookie_bar_options"> + <t t-call="website.s_popup_options.base"/> + </t> + +</templates> diff --git a/addons/website/static/src/snippets/s_process_steps/options.js b/addons/website/static/src/snippets/s_process_steps/options.js index 3e9937999105c..9c65658942005 100644 --- a/addons/website/static/src/snippets/s_process_steps/options.js +++ b/addons/website/static/src/snippets/s_process_steps/options.js @@ -1,23 +1,28 @@ /** @odoo-module **/ -import options from '@web_editor/js/editor/snippets.options'; +import { + SnippetOption, +} from "@web_editor/js/editor/snippets.options"; import weUtils from '@web_editor/js/common/utils'; +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; -options.registry.StepsConnector = options.Class.extend({ +export class StepsConnector extends SnippetOption { /** * @override */ start() { this.$target.on('content_changed.StepsConnector', () => this._reloadConnectors()); - return this._super(...arguments); - }, + return super.start(...arguments); + } /** * @override */ destroy() { - this._super(...arguments); + super.destroy(...arguments); this.$target.off('.StepsConnector'); - }, + } //-------------------------------------------------------------------------- // Options @@ -26,8 +31,8 @@ options.registry.StepsConnector = options.Class.extend({ /** * @override */ - selectClass: function (previewMode, value, params) { - this._super(...arguments); + selectClass(previewMode, value, params) { + super.selectClass(...arguments); if (params.name === 'connector_type') { this._reloadConnectors(); let markerEnd = ''; @@ -41,7 +46,7 @@ options.registry.StepsConnector = options.Class.extend({ } this.$target[0].querySelectorAll('.s_process_step_connector path').forEach(path => path.setAttribute('marker-end', markerEnd)); } - }, + } /** * Changes arrow heads' fill color. * @@ -51,7 +56,7 @@ options.registry.StepsConnector = options.Class.extend({ const htmlPropColor = weUtils.getCSSVariableValue(widgetValue); const arrowHeadEl = this.$target[0].closest('.s_process_steps').querySelector('.s_process_steps_arrow_head'); arrowHeadEl.querySelector('path').style.fill = htmlPropColor || widgetValue; - }, + } //-------------------------------------------------------------------------- // Public @@ -64,9 +69,9 @@ options.registry.StepsConnector = options.Class.extend({ if (['change_column_size', 'change_container_width', 'change_columns', 'move_snippet'].includes(name)) { this._reloadConnectors(); } else { - this._super(...arguments); + super.notify(...arguments); } - }, + } //-------------------------------------------------------------------------- // Private @@ -80,8 +85,8 @@ options.registry.StepsConnector = options.Class.extend({ // connectors are hidden as soon as the page is smaller than 992px // (the BS lg breakpoint). const isMobileView = weUtils.isMobileView(this.$target[0]); - return !isMobileView && this._super(...arguments); - }, + return !isMobileView && super._computeVisibility(...arguments); + } /** * Width and position of the connectors should be updated when one of the * steps is modified. @@ -126,7 +131,7 @@ options.registry.StepsConnector = options.Class.extend({ connectorEl.setAttribute('viewBox', `0 0 ${width} ${height}`); connectorEl.querySelector('path').setAttribute('d', this._getPath(type, width, height)); } - }, + } /** * Returns the number suffixed to the class given in parameter. * @@ -138,7 +143,7 @@ options.registry.StepsConnector = options.Class.extend({ _getClassSuffixedInteger(el, classNamePrefix) { const className = [...el.classList].find(cl => cl.startsWith(classNamePrefix)); return className ? parseInt(className.replace(classNamePrefix, '')) : 0; - }, + } /** * Returns the step's icon or content bounding rectangle. * @@ -165,7 +170,7 @@ options.registry.StepsConnector = options.Class.extend({ }); } return {}; - }, + } /** * Returns the svg path based on the type of connector. * @@ -189,5 +194,11 @@ options.registry.StepsConnector = options.Class.extend({ } } return ''; - }, + } +} + +registerWebsiteOption("Steps Connector", { + Class: StepsConnector, + template: "website.s_process_steps_option", + selector: ".s_process_steps", }); diff --git a/addons/website/static/src/snippets/s_process_steps/options.xml b/addons/website/static/src/snippets/s_process_steps/options.xml new file mode 100644 index 0000000000000..1159b437d66d4 --- /dev/null +++ b/addons/website/static/src/snippets/s_process_steps/options.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="website.s_process_steps_option"> + <WeRow title.translate="Connector"> + <WeSelect name="'connector_type'"> + <WeButton selectClass="''" name="'no_connector_opt'">None</WeButton> + <WeButton selectClass="'s_process_steps_connector_line'">Line</WeButton> + <WeButton selectClass="'s_process_steps_connector_arrow'">Straight arrow</WeButton> + <WeButton selectClass="'s_process_steps_connector_curved_arrow'">Curved arrow</WeButton> + </WeSelect> + <WeColorpicker + selectStyle="'true'" + name="'connector_color_opt'" + dependencies="'!no_connector_opt'" + applyTo="'.s_process_step_connector path'" + cssProperty="'stroke'" changeColor="'true'"/> + </WeRow> +</t> + +</templates> diff --git a/addons/website/static/src/snippets/s_product_catalog/options.js b/addons/website/static/src/snippets/s_product_catalog/options.js index a9faddc400f43..bc43385a171bc 100644 --- a/addons/website/static/src/snippets/s_product_catalog/options.js +++ b/addons/website/static/src/snippets/s_product_catalog/options.js @@ -1,9 +1,17 @@ /** @odoo-module **/ import { _t } from "@web/core/l10n/translation"; -import options from "@web_editor/js/editor/snippets.options"; +import { registry } from "@web/core/registry"; +import { + MultipleItems, + SnippetMove, + SnippetOption, +} from "@web_editor/js/editor/snippets.options"; +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; -options.registry.ProductCatalog = options.Class.extend({ +export class ProductCatalogOption extends SnippetOption { //-------------------------------------------------------------------------- // Options @@ -14,7 +22,7 @@ options.registry.ProductCatalog = options.Class.extend({ * * @see this.selectClass for parameters */ - toggleDescription: function (previewMode, widgetValue, params) { + toggleDescription(previewMode, widgetValue, params) { const $dishes = this.$('.s_product_catalog_dish'); const $name = $dishes.find('.s_product_catalog_dish_name'); $name.toggleClass('s_product_catalog_dish_dot_leaders', !widgetValue); @@ -42,7 +50,7 @@ options.registry.ProductCatalog = options.Class.extend({ } }); } - }, + } //-------------------------------------------------------------------------- // Private @@ -51,11 +59,36 @@ options.registry.ProductCatalog = options.Class.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { if (methodName === 'toggleDescription') { const $description = this.$('.s_product_catalog_dish_description'); return $description.length && !$description.hasClass('d-none'); } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } +} + +registerWebsiteOption("Product Catalog (multiple item add product)", { + Class: MultipleItems, + template: "website.s_product_catalog_add_product_option", + selector: ".s_product_catalog", + applyTo: "> :has(.s_product_catalog_dish):not(:has(.row > div:has(.s_product_catalog_dish)))", +}); +registerWebsiteOption("Product Catalog (multiple item add product in row)", { + Class: MultipleItems, + template: "website.s_product_catalog_add_product_option", + selector: ".s_product_catalog .row > div", + applyTo: "> :has(.s_product_catalog_dish)", +}); +registerWebsiteOption("Product Catalog (Description)", { + Class: ProductCatalogOption, + template: "website.s_product_catalog_option", + selector: ".s_product_catalog", +}); +registerWebsiteOption("Product Catalog (Drop)", { + selector: ".s_product_catalog_dish", + dropNear: ".s_product_catalog_dish", }); +const SnippetMoveOption = registry.category("snippet_options").get("SnippetMove (Vertical)"); +SnippetMoveOption.selector = SnippetMoveOption.selector + ", .s_product_catalog_dish"; +registry.category("snippet_options").add("SnippetMove (Vertical)", SnippetMoveOption, { force: true }); diff --git a/addons/website/static/src/snippets/s_product_catalog/options.xml b/addons/website/static/src/snippets/s_product_catalog/options.xml new file mode 100644 index 0000000000000..52368ede05d29 --- /dev/null +++ b/addons/website/static/src/snippets/s_product_catalog/options.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="website.s_product_catalog_add_product_option"> + <WeRow title.translate="Product"> + <WeButton addItem="''" item="'.s_product_catalog_dish:last'" selectItem="''" + noPreview="'true'" class="'o_we_bg_brand_primary'"> + Add Product + </WeButton> + </WeRow> +</t> + +<t t-name="website.s_product_catalog_option"> + <WeCheckbox title.translate="Descriptions" toggleDescription="'true'" noPreview="'true'"/> + <t t-call="website.snippet_options_border_line_widgets"> + <t t-set="classname" t-value="'o_we_sublevel_1'"/> + <t t-set="label">Separator</t> + <t t-set="direction" t-value="'top'"/> + <t t-set="apply_to" t-value="'.s_product_catalog_dish_description'"/> + <!-- Note: no need of extra dependency thanks to the apply-to --> + </t> +</t> + +</templates> diff --git a/addons/website/static/src/snippets/s_progress_bar/options.js b/addons/website/static/src/snippets/s_progress_bar/options.js index 46dffcebacf5c..b93e6eec157c4 100644 --- a/addons/website/static/src/snippets/s_progress_bar/options.js +++ b/addons/website/static/src/snippets/s_progress_bar/options.js @@ -1,9 +1,14 @@ /** @odoo-module **/ import { clamp } from "@web/core/utils/numbers"; -import options from "@web_editor/js/editor/snippets.options"; +import { + SnippetOption, +} from "@web_editor/js/editor/snippets.options"; +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; -options.registry.progress = options.Class.extend({ +export class ProgressBarOption extends SnippetOption { //-------------------------------------------------------------------------- // Options @@ -14,7 +19,7 @@ options.registry.progress = options.Class.extend({ * * @see this.selectClass for parameters */ - display: function (previewMode, widgetValue, params) { + display(previewMode, widgetValue, params) { // retro-compatibility if (this.$target.hasClass('progress')) { this.$target.removeClass('progress'); @@ -43,13 +48,13 @@ options.registry.progress = options.Class.extend({ // Temporary hide the label. It's effectively removed in cleanForSave // if the option is confirmed progressLabel.classList.toggle('d-none', widgetValue === 'none'); - }, + } /** * Sets the progress bar value. * * @see this.selectClass for parameters */ - progressBarValue: function (previewMode, widgetValue, params) { + progressBarValue(previewMode, widgetValue, params) { let value = parseInt(widgetValue); value = clamp(value, 0, 100); const $progressBar = this.$target.find('.progress-bar'); @@ -60,7 +65,7 @@ options.registry.progress = options.Class.extend({ $progressBarText.text($progressBarText.text().replace(/[0-9]+%/, value + '%')); progressMain.setAttribute('aria-valuenow', value); $progressBar.css("width", value + "%"); - }, + } /** * @override */ @@ -75,7 +80,7 @@ options.registry.progress = options.Class.extend({ if (progressLabel && progressLabel.classList.contains('d-none')) { progressLabel.remove(); } - }, + } //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -83,12 +88,18 @@ options.registry.progress = options.Class.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { switch (methodName) { case 'progressBarValue': { return this.$target[0].querySelector(".progress").getAttribute("aria-valuenow") + "%"; } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } +} + +registerWebsiteOption("ProgressBar", { + Class: ProgressBarOption, + template: "website.s_progress_bar_option", + selector: ".s_progress_bar", }); diff --git a/addons/website/static/src/snippets/s_progress_bar/options.xml b/addons/website/static/src/snippets/s_progress_bar/options.xml new file mode 100644 index 0000000000000..3525ac57a6a89 --- /dev/null +++ b/addons/website/static/src/snippets/s_progress_bar/options.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="website.s_progress_bar_option"> + <WeRow title.translate="Value"> + <WeInput progressBarValue="''" unit="'%'"/> + </WeRow> + <WeRow title.translate="Display"> + <WeSelect> + <WeButton display="'inline'" selectClass="'s_progress_bar_label_inline'">Display Inside</WeButton> + <WeButton display="'below'" selectClass="'s_progress_bar_label_below'">Display Below</WeButton> + <WeButton display="'after'" selectClass="'s_progress_bar_label_after'">Display After</WeButton> + <WeButton display="'none'" selectClass="'s_progress_bar_label_hidden'">Hide</WeButton> + </WeSelect> + </WeRow> + <WeRow title.translate="Colors"> + <WeColorpicker applyTo="'.progress-bar'" + selectStyle="'true'" + cssProperty="'background-color'" + colorPrefix="'bg-'"/> + </WeRow> + <WeCheckbox title.translate="Striped" name="'progress_striped_opt'" selectClass="'progress-bar-striped'" applyTo="'.progress-bar'" noPreview="'true'"/> + <WeCheckbox title.translate="Animated" class="'o_we_sublevel_1'" dependencies="'progress_striped_opt'" selectClass="'progress-bar-animated'" applyTo="'.progress-bar'" noPreview="'true'"/> +</t> + +</templates> diff --git a/addons/website/static/src/snippets/s_rating/options.js b/addons/website/static/src/snippets/s_rating/options.js index 8c07dd1dae791..57273fc686e32 100644 --- a/addons/website/static/src/snippets/s_rating/options.js +++ b/addons/website/static/src/snippets/s_rating/options.js @@ -2,18 +2,23 @@ import { MediaDialog } from "@web_editor/components/media_dialog/media_dialog"; -import options from "@web_editor/js/editor/snippets.options"; +import { + SnippetOption, +} from "@web_editor/js/editor/snippets.options"; +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; -options.registry.Rating = options.Class.extend({ +export class RatingOption extends SnippetOption { /** * @override */ - start: function () { + willStart() { this.iconType = this.$target[0].dataset.icon; this.faClassActiveCustomIcons = this.$target[0].dataset.activeCustomIcon || ''; this.faClassInactiveCustomIcons = this.$target[0].dataset.inactiveCustomIcon || ''; - return this._super.apply(this, arguments); - }, + return super.willStart(...arguments); + } //-------------------------------------------------------------------------- // Options @@ -24,22 +29,22 @@ options.registry.Rating = options.Class.extend({ * * @see this.selectClass for parameters */ - setIcons: function (previewMode, widgetValue, params) { + setIcons(previewMode, widgetValue, params) { this.iconType = widgetValue; this._renderIcons(); this.$target[0].dataset.icon = widgetValue; delete this.$target[0].dataset.activeCustomIcon; delete this.$target[0].dataset.inactiveCustomIcon; - }, + } /** * Allows to select a font awesome icon with media dialog. * * @see this.selectClass for parameters */ - customIcon: async function (previewMode, widgetValue, params) { + async customIcon(previewMode, widgetValue, params) { const media = document.createElement('i'); media.className = params.customActiveIcon === 'true' ? this.faClassActiveCustomIcons : this.faClassInactiveCustomIcons; - this.call("dialog", "add", MediaDialog, { + this.env.services.dialog.add(MediaDialog, { noImages: true, noDocuments: true, noVideos: true, @@ -58,25 +63,25 @@ options.registry.Rating = options.Class.extend({ this.iconType = 'custom'; } }); - }, + } /** * Sets the number of active icons. * * @see this.selectClass for parameters */ - activeIconsNumber: function (previewMode, widgetValue, params) { + activeIconsNumber(previewMode, widgetValue, params) { this.nbActiveIcons = parseInt(widgetValue); this._createIcons(); - }, + } /** * Sets the total number of icons. * * @see this.selectClass for parameters */ - totalIconsNumber: function (previewMode, widgetValue, params) { + totalIconsNumber(previewMode, widgetValue, params) { this.nbTotalIcons = Math.max(parseInt(widgetValue), 1); this._createIcons(); - }, + } //-------------------------------------------------------------------------- // Private @@ -85,7 +90,7 @@ options.registry.Rating = options.Class.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { switch (methodName) { case 'setIcons': { return this.$target[0].dataset.icon; @@ -99,14 +104,14 @@ options.registry.Rating = options.Class.extend({ return this.nbTotalIcons; } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * Creates the icons. * * @private */ - _createIcons: function () { + _createIcons() { const $activeIcons = this.$target.find('.s_rating_active_icons'); const $inactiveIcons = this.$target.find('.s_rating_inactive_icons'); this.$target.find('.s_rating_icons i').remove(); @@ -118,13 +123,13 @@ options.registry.Rating = options.Class.extend({ } } this._renderIcons(); - }, + } /** * Renders icons with selected fonts. * * @private */ - _renderIcons: function () { + _renderIcons() { const icons = { 'fa-star': 'fa-star-o', 'fa-thumbs-up': 'fa-thumbs-o-up', @@ -138,5 +143,11 @@ options.registry.Rating = options.Class.extend({ const $inactiveIcons = this.$target.find('.s_rating_inactive_icons > i'); $activeIcons.removeClass().addClass(faClassActiveIcons); $inactiveIcons.removeClass().addClass(faClassInactiveIcons); - }, + } +} + +registerWebsiteOption("Rating", { + Class: RatingOption, + template: "website.s_rating_option", + selector: ".s_rating", }); diff --git a/addons/website/static/src/snippets/s_rating/options.xml b/addons/website/static/src/snippets/s_rating/options.xml new file mode 100644 index 0000000000000..3b2a09d4de77b --- /dev/null +++ b/addons/website/static/src/snippets/s_rating/options.xml @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="website.s_rating_option"> + <WeRow title.translate="Icon"> + <WeSelect> + <WeButton setIcons="'fa-star'"><i class="fa fa-fw fa-star"/> Stars</WeButton> + <WeButton setIcons="'fa-thumbs-up'"><i class="fa fa-fw fa-thumbs-up"/> Thumbs</WeButton> + <WeButton setIcons="'fa-circle'"><i class="fa fa-fw fa-circle"/> Circles</WeButton> + <WeButton setIcons="'fa-square'"><i class="fa fa-fw fa-square"/> Squares</WeButton> + <WeButton setIcons="'fa-heart'"><i class="fa fa-fw fa-heart"/> Hearts</WeButton> + <WeButton setIcons="'custom'" class="'d-none'">Custom</WeButton> + </WeSelect> + </WeRow> + <WeRow title.translate="Active" class="'o_we_sublevel_1'"> + <WeColorpicker selectStyle="''" applyTo="'.s_rating_active_icons'" cssProperty="'color'" colorPrefix="'text-'"/> + <WeButton customIcon="'true'" customActiveIcon="'true'" noPreview="'true'"> + <i class="fa fa-fw fa-refresh me-1"/> Replace Icon + </WeButton> + </WeRow> + <WeRow title.translate="Inactive" class="'o_we_sublevel_1'"> + <WeColorpicker selectStyle="''" applyTo="'.s_rating_inactive_icons'" cssProperty="'color'" colorPrefix="'text-'"/> + <WeButton customIcon="'true'" customActiveIcon="'false'" noPreview="'true'"> + <i class="fa fa-fw fa-refresh me-1"/> Replace Icon + </WeButton> + </WeRow> + <WeRow title.translate="Score"> + <WeInput activeIconsNumber="'true'" step="'1'"/> + <span class="mx-2">/</span> + <WeInput totalIconsNumber="'true'" step="'1'"/> + </WeRow> + <WeRow title.translate="Size"> + <WeButtonGroup applyTo="'.s_rating_icons'"> + <WeButton selectClass="''" tooltip.translate="Small" img="'/website/static/src/img/snippets_options/size_small.svg'"/> + <WeButton selectClass="'fa-2x'" tooltip.translate="Medium" img="'/website/static/src/img/snippets_options/size_medium.svg'"/> + <WeButton selectClass="'fa-3x'" tooltip.translate="Large" img="'/website/static/src/img/snippets_options/size_large.svg'"/> + </WeButtonGroup> + </WeRow> + <WeRow title.translate="Title Position"> + <WeSelect> + <WeButton selectClass="''">Top</WeButton> + <WeButton selectClass="'s_rating_inline'">Left</WeButton> + <WeButton selectClass="'s_rating_no_title'">None</WeButton> + </WeSelect> + </WeRow> +</t> + +</templates> diff --git a/addons/website/static/src/snippets/s_searchbar/options.js b/addons/website/static/src/snippets/s_searchbar/options.js index e5a87b41f59f1..f23012a1a1fa2 100644 --- a/addons/website/static/src/snippets/s_searchbar/options.js +++ b/addons/website/static/src/snippets/s_searchbar/options.js @@ -1,55 +1,77 @@ -/** @odoo-module **/ +import { SnippetOption } from "@web_editor/js/editor/snippets.options"; +import { registerContentAdditionSelector } from "@web_editor/js/editor/snippets.registry"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; -import options from '@web_editor/js/editor/snippets.options'; +export class SearchBar extends SnippetOption { + constructor({ callbacks }) { + super(...arguments); + this.requestUserValue = callbacks.requestUserValue; + this._constructor(); + } + + /** + * Allows patching the constructor. + * + * @protected + */ + _constructor() { + } -options.registry.SearchBar = options.Class.extend({ /** * @override */ - start() { + async willStart() { + await super.willStart(...arguments); this.searchInputEl = this.$target[0].querySelector(".oe_search_box"); this.searchButtonEl = this.$target[0].querySelector(".oe_search_button"); - return this._super(...arguments); - }, + } //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- - setSearchType: function (previewMode, widgetValue, params) { + setSearchType(previewMode, widgetValue, params) { const form = this.$target.parents('form'); form.attr('action', params.formAction); if (!previewMode) { - this.trigger_up('snippet_edition_request', {exec: () => { - const widget = this._requestUserValueWidgets('order_opt')[0]; - const orderBy = widget.getValue("selectDataAttribute"); - const order = widget.$el.find("we-button[data-select-data-attribute='" + orderBy + "']")[0]; - if (order.classList.contains("d-none")) { - const defaultOrder = widget.$el.find("we-button[data-name='order_name_asc_opt']")[0]; - defaultOrder.click(); // open - defaultOrder.click(); // close + this.env.snippetEditionRequest(async () => { + // Reset orderBy if current value is not present in searchType + const orderBySelect = this.requestUserValue({ name: "order_opt" }); + const currentOrderBy = orderBySelect.getValue("selectDataAttribute"); + const currentOrderByButton = Object.values(orderBySelect._subValues) + .find(userValue => userValue._data.selectDataAttribute === currentOrderBy); + if (!currentOrderByButton.show) { + const defaultOrderByWidget = orderBySelect.findWidget("order_name_asc_opt"); + defaultOrderByWidget.enable(); } - }}); - // Reset display options. - const displayOptions = new Set(); - for (const optionEl of this.$el[0].querySelectorAll('[data-dependencies="limit_opt"] [data-attribute-name^="display"]')) { - displayOptions.add(optionEl.dataset.attributeName); - } - const scopeName = this.$el[0].querySelector(`[data-set-search-type="${widgetValue}"]`).dataset.name; - for (const displayOption of displayOptions) { - this.$target[0].dataset[displayOption] = this.$el[0].querySelector( - `[data-attribute-name="${displayOption}"][data-dependencies="${scopeName}"]` - ) ? 'true' : ''; - } - } - }, + // Reset display options. + const displayOptions = Object.values(this._userValues) + .filter(userValue => userValue._data.attributeName?.startsWith("display")); + const displayOptionsNames = new Set( + displayOptions.map(userValue => userValue._data.attributeName) + ); + const shownDisplayOptions = displayOptions.filter(userValue => userValue.show); + for (const displayOptionName of displayOptionsNames) { + const shownUserValue = shownDisplayOptions.find( + userValue => userValue._data.attributeName === displayOptionName + ); + const isEnabled = !!shownUserValue; + this.$target[0].dataset[displayOptionName] = isEnabled ? "true" : ""; - setOrderBy: function (previewMode, widgetValue, params) { + if (shownUserValue) { + // TODO: @owl-options Should this be needed ? + await shownUserValue.setValue("true"); + } + } + }); + } + } + setOrderBy(previewMode, widgetValue, params) { const form = this.$target.parents('form'); form.find(".o_search_order_by").attr("value", widgetValue); - }, + } /** * Sets the style of the searchbar. * @@ -61,7 +83,7 @@ options.registry.SearchBar = options.Class.extend({ this.searchInputEl.classList.toggle("bg-light", isLight); this.searchButtonEl.classList.toggle("btn-light", isLight); this.searchButtonEl.classList.toggle("btn-primary", !isLight); - }, + } //-------------------------------------------------------------------------- // Private @@ -70,16 +92,19 @@ options.registry.SearchBar = options.Class.extend({ /** * @override */ - _computeWidgetState(methodName, params) { + async _computeWidgetState(methodName, params) { if (methodName === "setSearchbarStyle") { const searchInputIsLight = this.searchInputEl.matches(".border-0.bg-light"); const searchButtonIsLight = this.searchButtonEl.matches(".btn-light"); return searchInputIsLight && searchButtonIsLight ? "light" : "default"; } - return this._super(...arguments); - }, -}); + return super._computeWidgetState(...arguments); + } +} -export default { - SearchBar: options.registry.SearchBar, -}; +registerWebsiteOption("SearchBar", { + Class: SearchBar, + template: "website.s_searchbar_options", + selector: ".s_searchbar_input", +}); +registerContentAdditionSelector(".s_searchbar_input"); diff --git a/addons/website/static/src/snippets/s_searchbar/options.xml b/addons/website/static/src/snippets/s_searchbar/options.xml new file mode 100644 index 0000000000000..86eea70b3e42b --- /dev/null +++ b/addons/website/static/src/snippets/s_searchbar/options.xml @@ -0,0 +1,72 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="website.s_searchbar_options"> + <WeSelect title.translate="Search within" + name="'scope_opt'" + attributeName="'searchType'" + applyTo="'.search-query'"> + <WeButton setSearchType="'all'" + selectDataAttribute="'all'" + name="'search_all_opt'" + formAction="'/website/search'"> + Everything + </WeButton> + <WeButton setSearchType="'pages'" + selectDataAttribute="'pages'" + name="'search_pages_opt'" + formAction="'/pages'"> + Pages + </WeButton> + </WeSelect> + <WeSelect title.translate="Order by" name="'order_opt'" attributeName="'orderBy'" applyTo="'.search-query'"> + <WeButton setOrderBy="'name asc'" selectDataAttribute="'name asc'" name="'order_name_asc_opt'"> + Name (A-Z) + </WeButton> + </WeSelect> + <WeInput title.translate="Suggestions" + name="'limit_opt'" + attributeName="'limit'" + applyTo="'.search-query'" + selectDataAttribute="''" + unit="'results'"/> + <div dependencies="'limit_opt'"> + <WeRow title.translate="Description"> + <WeCheckbox dependencies="'search_all_opt'" + selectDataAttribute="'true'" + attributeName="'displayDescription'" + applyTo="'.search-query'"/> + </WeRow> + <WeRow title.translate="Extra Link"> + <WeCheckbox dependencies="'search_all_opt'" + selectDataAttribute="'true'" + attributeName="'displayExtraLink'" + applyTo="'.search-query'"/> + </WeRow> + <WeRow title.translate="Detail"> + <WeCheckbox dependencies="'search_all_opt'" + selectDataAttribute="'true'" + attributeName="'displayDetail'" + applyTo="'.search-query'"/> + </WeRow> + <WeRow title.translate="Image"> + <WeCheckbox dependencies="'search_all_opt'" + selectDataAttribute="'true'" + attributeName="'displayImage'" + applyTo="'.search-query'"/> + </WeRow> + + <WeRow title.translate="Content"> + <WeCheckbox dependencies="'search_pages_opt'" + selectDataAttribute="'true'" + attributeName="'displayDescription'" + applyTo="'.search-query'"/> + </WeRow> + </div> + <WeSelect title.translate="Style"> + <WeButton setSearchbarStyle="'default'">Default Input Style</WeButton> + <WeButton setSearchbarStyle="'light'">Light</WeButton> + </WeSelect> + </t> + +</templates> diff --git a/addons/website/static/src/snippets/s_showcase/options.js b/addons/website/static/src/snippets/s_showcase/options.js new file mode 100644 index 0000000000000..ebbc9bb9a8b9c --- /dev/null +++ b/addons/website/static/src/snippets/s_showcase/options.js @@ -0,0 +1,9 @@ +/** @odoo-module **/ + +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; + +registerWebsiteOption("Showcase", { + selector: ".s_showcase .row > div:has(> .s_showcase_title)", +}); diff --git a/addons/website/static/src/snippets/s_social_media/options.js b/addons/website/static/src/snippets/s_social_media/options.js index 082e2504a4550..325e166a6818d 100644 --- a/addons/website/static/src/snippets/s_social_media/options.js +++ b/addons/website/static/src/snippets/s_social_media/options.js @@ -2,36 +2,34 @@ import fonts from '@web_editor/js/wysiwyg/fonts'; import weUtils from '@web_editor/js/common/utils'; -import options from '@web_editor/js/editor/snippets.options'; import { _t } from "@web/core/l10n/translation"; import { ICON_SELECTOR } from "@web_editor/js/editor/odoo-editor/src/utils/utils"; +import { SnippetOption } from '@web_editor/js/editor/snippets.options'; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; let dbSocialValues; let dbSocialValuesProm; +let tmpSocialsIds; const clearDbSocialValuesCache = () => { dbSocialValuesProm = undefined; dbSocialValues = undefined; + tmpSocialsIds = undefined; }; const getDbSocialValuesCache = () => { - return dbSocialValues; + return {dbSocialValues, tmpSocialsIds}; }; -options.registry.SocialMedia = options.Class.extend({ - init() { - this._super(...arguments); - this.orm = this.bindService("orm"); - }, +class SocialMedia extends SnippetOption { + constructor() { + super(...arguments); + this.orm = this.env.services.orm; + this.website = this.env.services.website; - /** - * @override - */ - start() { // When the alert is clicked, focus the first media input in the editor. this.__onSetupBannerClick = this._onSetupBannerClick.bind(this); this.$target[0].addEventListener('click', this.__onSetupBannerClick); this.entriesNotInDom = []; - return this._super(...arguments); - }, + } /** * @override */ @@ -46,7 +44,7 @@ options.registry.SocialMedia = options.Class.extend({ } // Ensure we do not drop a blank block. this._handleNoMediaAlert(); - }, + } /** * @override */ @@ -58,21 +56,15 @@ options.registry.SocialMedia = options.Class.extend({ return; } // Update the DB links. - let websiteId; - this.trigger_up('context_get', { - callback: function (ctx) { - websiteId = ctx['website_id']; - }, - }); - await this.orm.write("website", [websiteId], dbSocialValues); - }, + await this.orm.write("website", [this.website.currentWebsite.id], dbSocialValues); + } /** * @override */ destroy() { - this._super(...arguments); + super.destroy(...arguments); this.$target[0].removeEventListener('click', this.__onSetupBannerClick); - }, + } //-------------------------------------------------------------------------- // Options @@ -176,6 +168,11 @@ options.registry.SocialMedia = options.Class.extend({ if (!isDbField) { // Handle URL change for custom links. const href = anchorEl.getAttribute('href'); + // Update the ID cache + tmpSocialsIds[entry.display_name] ||= entry.id; + if (tmpSocialsIds[href] === entry.id) { + delete tmpSocialsIds[href]; + } if (href !== entry.display_name) { let socialMedia = null; if (this._isValidURL(entry.display_name)) { @@ -209,7 +206,7 @@ options.registry.SocialMedia = options.Class.extend({ } this._handleNoMediaAlert(); - }, + } //-------------------------------------------------------------------------- // Private @@ -220,7 +217,7 @@ options.registry.SocialMedia = options.Class.extend({ */ async _computeWidgetState(methodName, params) { if (methodName !== 'renderListItems') { - return this._super(methodName, params); + return super._computeWidgetState(methodName, params); } await this._fetchSocialMedia(); let listPosition = 0; @@ -232,8 +229,11 @@ options.registry.SocialMedia = options.Class.extend({ while (this.entriesNotInDom.find(entry => entry.listPosition === listPosition)) { listPosition++; } + const id = media + ? tmpSocialsIds[`social_${media}`] + : tmpSocialsIds[el.getAttribute("href")] || weUtils.generateHTMLId(); return { - id: weUtils.generateHTMLId(), + id: id, display_name: media ? dbSocialValues[`social_${media}`] : el.getAttribute('href'), placeholder: `https://${encodeURIComponent(media) || 'example'}.com/yourPage`, undeletable: !!media, @@ -251,7 +251,7 @@ options.registry.SocialMedia = options.Class.extend({ const entryNotInDom = this.entriesNotInDom.find(entry => entry.media === media); if (!entryNotInDom) { this.entriesNotInDom.push({ - id: weUtils.generateHTMLId(), + id: tmpSocialsIds[`social_${media}`], display_name: link, placeholder: `https://${encodeURIComponent(media)}.com/yourPage`, undeletable: true, @@ -274,20 +274,14 @@ options.registry.SocialMedia = options.Class.extend({ return a.listPosition - b.listPosition; }); return JSON.stringify(entries); - }, + } /** * Fetches the urls of the social networks that are in the database. */ async _fetchSocialMedia() { if (!dbSocialValuesProm) { - let websiteId; - this.trigger_up('context_get', { - callback: function (ctx) { - websiteId = ctx['website_id']; - }, - }); // Fetch URLs for db links. - dbSocialValuesProm = this.orm.read("website", [websiteId], [ + dbSocialValuesProm = this.orm.read("website", [this.website.currentWebsite.id], [ "social_facebook", "social_twitter", "social_linkedin", @@ -298,10 +292,13 @@ options.registry.SocialMedia = options.Class.extend({ ]).then(function (values) { [dbSocialValues] = values; delete dbSocialValues.id; + tmpSocialsIds = Object.fromEntries( + Object.keys(dbSocialValues).map((key) => [key, weUtils.generateHTMLId()]) + ); }); } await dbSocialValuesProm; - }, + } /** * Finds the social network for the given url. * @@ -331,7 +328,7 @@ options.registry.SocialMedia = options.Class.extend({ } catch { return false; } - }, + } /** * Adds a warning banner to alert that there are no social networks. */ @@ -352,7 +349,7 @@ options.registry.SocialMedia = options.Class.extend({ this.$target[0].appendChild(divEl).append(spanEl); } } - }, + } /** * @param {String} str * @returns {boolean} is the string a valid URL. @@ -365,7 +362,7 @@ options.registry.SocialMedia = options.Class.extend({ return false; } return url.protocol.startsWith('http'); - }, + } /** * Removes social media classes from the given element. * @@ -380,7 +377,7 @@ options.registry.SocialMedia = options.Class.extend({ // Remove every fa classes except fa-x sizes. iEl.className = iEl.className.replace(regx, ''); } - }, + } //-------------------------------------------------------------------------- // Handlers @@ -396,11 +393,16 @@ options.registry.SocialMedia = options.Class.extend({ // work of course this._requestUserValueWidgets('social_media_list')[0].focus(); } - }, + } +} +registerWebsiteOption("SocialMedia", { + Class: SocialMedia, + template: "website.s_social_media_options", + selector: ".s_social_media", }); export default { - SocialMedia: options.registry.SocialMedia, + SocialMedia, clearDbSocialValuesCache, getDbSocialValuesCache, }; diff --git a/addons/website/static/src/snippets/s_social_media/options.xml b/addons/website/static/src/snippets/s_social_media/options.xml new file mode 100644 index 0000000000000..3f000419db956 --- /dev/null +++ b/addons/website/static/src/snippets/s_social_media/options.xml @@ -0,0 +1,10 @@ +<templates id="template" xml:space="preserve"> +<t t-name="website.s_social_media_options"> + <WeList title.translate="Social Networks" + addItemTitle.translate="Add New Social Network" + renderListItems="''" hasDefault="'multiple'" name="'social_media_list'" + defaultValue="'https://www.example.com'" + newElementsNotToggleable="'true'" noPreview="'true'" + renderOnInputBlur="'true'"/> +</t> +</templates> diff --git a/addons/website/static/src/snippets/s_table_of_content/options.js b/addons/website/static/src/snippets/s_table_of_content/options.js index 1b15cf441a7cb..fec7efa95713e 100644 --- a/addons/website/static/src/snippets/s_table_of_content/options.js +++ b/addons/website/static/src/snippets/s_table_of_content/options.js @@ -1,7 +1,7 @@ /** @odoo-module **/ import { uniqueId } from "@web/core/utils/functions"; -import options from "@web_editor/js/editor/snippets.options"; +import options from "@web_editor/js/editor/snippets.options.legacy"; options.registry.TableOfContent = options.Class.extend({ /** diff --git a/addons/website/static/src/snippets/s_tabs/options.js b/addons/website/static/src/snippets/s_tabs/options.js index c8ba074eea993..2fd5b2af0427b 100644 --- a/addons/website/static/src/snippets/s_tabs/options.js +++ b/addons/website/static/src/snippets/s_tabs/options.js @@ -1,30 +1,36 @@ /** @odoo-module **/ import { uniqueId } from "@web/core/utils/functions"; -import options from "@web_editor/js/editor/snippets.options"; +import { + MultipleItems, + SnippetOption, +} from "@web_editor/js/editor/snippets.options"; +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; -options.registry.NavTabs = options.registry.MultipleItems.extend({ - isTopOption: true, +export class TabsOption extends MultipleItems { + static isTopOption = true; /** * @override */ - start: function () { + willStart() { this._findLinksAndPanes(); - return this._super.apply(this, arguments); - }, + return super.willStart(...arguments); + } /** * @override */ - onBuilt: function () { + onBuilt() { this._generateUniqueIDs(); - }, + } /** * @override */ - onClone: function () { + onClone() { this._generateUniqueIDs(); - }, + } //-------------------------------------------------------------------------- // Private @@ -33,23 +39,23 @@ options.registry.NavTabs = options.registry.MultipleItems.extend({ /** * @override */ - _computeWidgetVisibility: async function (widgetName, params) { + async _computeWidgetVisibility(widgetName, params) { if (widgetName === 'remove_tab_opt') { return (this.$tabPanes.length > 2); } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * @private */ - _findLinksAndPanes: function () { + _findLinksAndPanes() { this.$navLinks = this.$target.find('.nav:first .nav-link'); this.$tabPanes = this.$target.find(".tab-content:first > .tab-pane"); - }, + } /** * @private */ - _generateUniqueIDs: function () { + _generateUniqueIDs() { for (var i = 0; i < this.$navLinks.length; i++) { var id = uniqueId(new Date().getTime() + "_"); var idLink = 'nav_tabs_link_' + id; @@ -64,7 +70,7 @@ options.registry.NavTabs = options.registry.MultipleItems.extend({ 'aria-labelledby': idLink, }); } - }, + } /** * @override */ @@ -77,7 +83,7 @@ options.registry.NavTabs = options.registry.MultipleItems.extend({ this._findLinksAndPanes(); this._generateUniqueIDs(); $navLink.tab('show'); - }, + } /** * @override */ @@ -87,9 +93,17 @@ options.registry.NavTabs = options.registry.MultipleItems.extend({ $targetNavLink.parent().remove(); this._findLinksAndPanes(); $navLinkToShow.tab('show'); - }, + } +} + +registerWebsiteOption("Tabs", { + Class: TabsOption, + template: "website.s_tabs_option", + selector: "section.s_tabs", }); -options.registry.NavTabsStyle = options.Class.extend({ + + +export class TabsStyleOption extends SnippetOption { //-------------------------------------------------------------------------- // Options @@ -100,7 +114,7 @@ options.registry.NavTabsStyle = options.Class.extend({ * * @see this.selectClass for parameters */ - setStyle: function (previewMode, widgetValue, params) { + setStyle(previewMode, widgetValue, params) { const $nav = this.$target.find('.s_tabs_nav:first .nav'); const isPills = widgetValue === 'pills'; $nav.toggleClass('nav-tabs card-header-tabs', !isPills); @@ -108,20 +122,20 @@ options.registry.NavTabsStyle = options.Class.extend({ this.$target.find('.s_tabs_nav:first').toggleClass('card-header', !isPills).toggleClass('mb-3', isPills); this.$target.toggleClass('card', !isPills); this.$target.find('.s_tabs_content:first').toggleClass('card-body', !isPills); - }, + } /** * Horizontal/vertical nav. * * @see this.selectClass for parameters */ - setDirection: function (previewMode, widgetValue, params) { + setDirection(previewMode, widgetValue, params) { const isVertical = widgetValue === 'vertical'; this.$target.toggleClass('row s_col_no_resize s_col_no_bgcolor', isVertical); this.$target.find('.s_tabs_nav:first .nav').toggleClass('flex-column', isVertical); this.$target.find('.s_tabs_nav:first > .nav-link').toggleClass('py-2', isVertical); this.$target.find('.s_tabs_nav:first').toggleClass('col-md-3', isVertical); this.$target.find('.s_tabs_content:first').toggleClass('col-md-9', isVertical); - }, + } //-------------------------------------------------------------------------- // Private @@ -130,13 +144,20 @@ options.registry.NavTabsStyle = options.Class.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { switch (methodName) { case 'setStyle': return this.$target.find('.s_tabs_nav:first .nav').hasClass('nav-pills') ? 'pills' : 'tabs'; case 'setDirection': return this.$target.find('.s_tabs_nav:first .nav').hasClass('flex-column') ? 'vertical' : 'horizontal'; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } +} + +registerWebsiteOption("TabsStyle", { + Class: TabsStyleOption, + template: "website.s_tabs_style_option", + selector: "section", + target: ".s_tabs_main", }); diff --git a/addons/website/static/src/snippets/s_tabs/options.xml b/addons/website/static/src/snippets/s_tabs/options.xml new file mode 100644 index 0000000000000..a4c5e3012b26c --- /dev/null +++ b/addons/website/static/src/snippets/s_tabs/options.xml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="website.s_tabs_option"> + <t t-set="tooltip">Add Tab</t> + <WeButton addItem="''" item="'.tab-content:first > .tab-pane.active'" noPreview="'true'" class="'o_we_text_success ms-0'" tooltip="tooltip" ariaLabel="tooltip"> + <i class="fa fa-fw fa-plus"/> + </WeButton> + <t t-set="tooltip">Remove Tab</t> + <WeButton removeItem="''" item="'.tab-content:first > .tab-pane.active'" name="'remove_tab_opt'" noPreview="'true'" class="'o_we_text_danger me-3'" tooltip="tooltip" ariaLabel="tooltip"> + <i class="fa fa-fw fa-minus"/> + </WeButton> +</t> + +<t t-name="website.s_tabs_style_option"> + <WeRow title.translate="Style"> + <WeSelect> + <WeButton setStyle="'tabs'" name="'tabs_opt'" trigger="'horizontal_opt'">Tabs</WeButton> + <WeButton setStyle="'pills'" name="'pills_opt'" trigger="'tabs_color_opt'" triggerValue="''">Buttons</WeButton> + </WeSelect> + </WeRow> + <WeRow title.translate="Tabs Color"> + <WeColorpicker dependencies="'tabs_opt'" name="'tabs_color_opt'" selectStyle="'true'" cssProperty="'background-color'" colorPrefix="'bg-'"/> + </WeRow> + <WeRow title.translate="Direction" dependencies="'pills_opt'"> + <WeSelect> + <WeButton setDirection="'horizontal'" name="'horizontal_opt'">Horizontal</WeButton> + <WeButton setDirection="'vertical'" trigger="'left_alignment_opt'">Vertical</WeButton> + </WeSelect> + </WeRow> + <WeRow title.translate="Alignment" dependencies="'horizontal_opt'"> + <WeSelect applyTo="'.s_tabs_nav:first .nav'"> + <WeButton selectClass="''" name="'left_alignment_opt'">Left</WeButton> + <WeButton selectClass="'justify-content-center'">Center</WeButton> + <WeButton selectClass="'justify-content-end'">Right</WeButton> + </WeSelect> + </WeRow> + <WeRow title.translate="Fill and Justify" dependencies="'horizontal_opt'"> + <WeSelect applyTo="'.s_tabs_nav:first .nav'"> + <WeButton selectClass="''">Regular</WeButton> + <WeButton selectClass="'nav-fill'">Full Width</WeButton> + <WeButton selectClass="'nav-justified'">Equal Widths</WeButton> + </WeSelect> + </WeRow> + <we-divider/> + <WeRow title.translate="Slide"> + <WeButtonGroup applyTo="'.s_tabs_content:first'"> + <WeButton class="'fa fa-fw fa-long-arrow-left'" tooltip.translate="Slide Left" selectClass="'s_tabs_slide_left'"/> + <WeButton class="'fa fa-fw fa-long-arrow-up'" tooltip.translate="Slide Up" selectClass="'s_tabs_slide_up'"/> + <WeButton class="'fa fa-fw fa-long-arrow-down'" tooltip.translate="Slide Down" selectClass="'s_tabs_slide_down'"/> + <WeButton class="'fa fa-fw fa-long-arrow-right'" tooltip.translate="Slide Right" selectClass="'s_tabs_slide_right'"/> + <WeButton class="'fa fa-fw fa-ban'" tooltip.translate="No Slide Effect" selectClass="''"/> + </WeButtonGroup> + </WeRow> +</t> + +</templates> diff --git a/addons/website/static/src/snippets/s_timeline/options.js b/addons/website/static/src/snippets/s_timeline/options.js index 5f3bd16f8b4f8..141e907456301 100644 --- a/addons/website/static/src/snippets/s_timeline/options.js +++ b/addons/website/static/src/snippets/s_timeline/options.js @@ -1,20 +1,16 @@ /** @odoo-module **/ -import options from "@web_editor/js/editor/snippets.options"; +import { registry } from "@web/core/registry"; +import { + MultipleItems, + SnippetOption, +} from "@web_editor/js/editor/snippets.options"; +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; -options.registry.Timeline = options.Class.extend({ - displayOverlayOptions: true, - - /** - * @override - */ - start: function () { - var $buttons = this.$el.find('we-button.o_we_overlay_opt'); - var $overlayArea = this.$overlay.find('.o_overlay_options_wrap'); - $overlayArea.append($buttons); - - return this._super(...arguments); - }, +export class TimelineOption extends SnippetOption { + static displayOverlayOptions = true; //-------------------------------------------------------------------------- // Options @@ -25,8 +21,31 @@ options.registry.Timeline = options.Class.extend({ * * @see this.selectClass for parameters */ - timelineCard: function (previewMode, widgetValue, params) { + timelineCard(previewMode, widgetValue, params) { const $timelineRow = this.$target.closest('.s_timeline_row'); $timelineRow.toggleClass('flex-row-reverse flex-row'); - }, + } +} + +registerWebsiteOption("Timeline (Multiple)", { + Class: MultipleItems, + template: "website.s_timeline_multiple_option", + selector: ".s_timeline", }); +registerWebsiteOption("Timeline (Move row)", { + selector: ".s_timeline_row", + "drop-near": ".s_timeline_row", +}); +registerWebsiteOption("Timeline (Overlay)", { + Class: TimelineOption, + template: "website.s_timeline_overlay_option", + selector: ".s_timeline_card", +}); +registerWebsiteOption("Timeline (Color)", { + template: "website.s_timeline_color_option", + selector: ".s_timeline", +}); + +const SnippetMoveOption = registry.category("snippet_options").get("SnippetMove (Vertical)"); +SnippetMoveOption.selector = SnippetMoveOption.selector + ", .s_timeline_row"; +registry.category("snippet_options").add("SnippetMove (Vertical)", SnippetMoveOption, { force: true }); diff --git a/addons/website/static/src/snippets/s_timeline/options.xml b/addons/website/static/src/snippets/s_timeline/options.xml new file mode 100644 index 0000000000000..a6575925bc81d --- /dev/null +++ b/addons/website/static/src/snippets/s_timeline/options.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="website.s_timeline_multiple_option"> + <WeRow title.translate="Year"> + <WeButton addItem="''" item="'.s_timeline_row:first'" selectItem="''" addBefore="'true'" noPreview="'true'" class="'o_we_bg_brand_primary'"> + Add Year + </WeButton> + </WeRow> +</t> + +<t t-name="website.s_timeline_overlay_option"> + <Overlay targetEl="$overlay[0].querySelector('.o_overlay_options_wrap')"> + <WeButton timelineCard="''" noPreview="'true'" class="'o_we_overlay_opt'"><i class="fa fa-fw fa-angle-left"/><i class="fa fa-fw fa-angle-right"/></WeButton> + </Overlay> +</t> + +<t t-name="website.s_timeline_color_option"> + <WeRow title.translate="Line Color"> + <WeColorpicker selectStyle="'true'" cssProperty="'border-color'" colorPrefix="'border-'" applyTo="'.s_timeline_line'"/> + </WeRow> +</t> + +</templates> diff --git a/addons/website/static/src/snippets/s_website_controller_page_listing_layout/options.js b/addons/website/static/src/snippets/s_website_controller_page_listing_layout/options.js index a25578d4b03c0..07a91b7d9fd02 100644 --- a/addons/website/static/src/snippets/s_website_controller_page_listing_layout/options.js +++ b/addons/website/static/src/snippets/s_website_controller_page_listing_layout/options.js @@ -1,22 +1,28 @@ /** @odoo-module **/ -import options from "@web_editor/js/editor/snippets.options"; +import { _t } from "@web/core/l10n/translation"; +import { + SnippetOption, +} from "@web_editor/js/editor/snippets.options"; import { rpc } from "@web/core/network/rpc"; +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; const mainObjectRe = /website\.controller\.page\(((\d+,?)*)\)/; -options.registry.WebsiteControllerPageListingLayout = options.Class.extend({ - init() { - this._super(...arguments); - this.orm = this.bindService("orm"); +export class WebsiteControllerPageListingLayoutOption extends SnippetOption { + + constructor() { + super(...arguments); + this.orm = this.env.services.orm; this.resModel = "website.controller.page"; - }, + } /** * @override */ async willStart() { - const _super = this._super.bind(this); const mainObjectRepr = this.$target[0].ownerDocument.documentElement.getAttribute("data-main-object"); const match = mainObjectRe.exec(mainObjectRepr); if (match && match[1]) { @@ -31,8 +37,8 @@ options.registry.WebsiteControllerPageListingLayout = options.Class.extend({ const results = await this.orm.read(this.resModel, this.resIds, ["default_layout"]); this.layout = results[0]["default_layout"]; - return _super(...arguments); - }, + return super.willStart(...arguments); + } //-------------------------------------------------------------------------- // Options @@ -48,7 +54,7 @@ options.registry.WebsiteControllerPageListingLayout = options.Class.extend({ this.orm.write(this.resModel, this.resIds, { default_layout: widgetValue }), rpc("/website/save_session_layout_mode", params), ]); - }, + } //-------------------------------------------------------------------------- // Private @@ -68,5 +74,17 @@ options.registry.WebsiteControllerPageListingLayout = options.Class.extend({ } } return this._super(...arguments); - }, + } +} + +registerWebsiteOption("WebsiteControllerPageListingLayout", { + Class: WebsiteControllerPageListingLayoutOption, + template: "website.s_website_controller_page_listing_layout_option", + selector: ".listing_layout_switcher", + noCheck: true, + data: { + string: _t("Layout"), + pageOptions:true, + groups: ["website.group_website_designer"] + }, }); diff --git a/addons/website/static/src/snippets/s_website_controller_page_listing_layout/options.xml b/addons/website/static/src/snippets/s_website_controller_page_listing_layout/options.xml new file mode 100644 index 0000000000000..b41e5296eab4c --- /dev/null +++ b/addons/website/static/src/snippets/s_website_controller_page_listing_layout/options.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="website.s_website_controller_page_listing_layout_option"> + <!-- All records page --> + <WeRow title.translate="Default Layout"> + <WeSelect name="'default_listing_layout'" noPreview="'true'" reload="'/'"> + <WeButton setLayout="'grid'" name="'grid_view_opt'">Grid</WeButton> + <WeButton setLayout="'list'" name="'list_view_opt'">List</WeButton> + </WeSelect> + </WeRow> +</t> + +</templates> diff --git a/addons/website/static/src/snippets/s_website_form/options.js b/addons/website/static/src/snippets/s_website_form/options.js index 5abdb531d2f4a..803113e2c1632 100644 --- a/addons/website/static/src/snippets/s_website_form/options.js +++ b/addons/website/static/src/snippets/s_website_form/options.js @@ -1,7 +1,13 @@ /** @odoo-module **/ import FormEditorRegistry from "@website/js/form_editor_registry"; -import options from "@web_editor/js/editor/snippets.options"; +import { + SnippetOption, +} from "@web_editor/js/editor/snippets.options"; +import { registerContentAdditionSelector } from "@web_editor/js/editor/snippets.registry"; +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; import weUtils from "@web_editor/js/common/utils"; import "@website/js/editor/snippets.options"; @@ -9,8 +15,11 @@ import { unique } from "@web/core/utils/arrays"; import { _t } from "@web/core/l10n/translation"; import { renderToElement } from "@web/core/utils/render"; import { formatDate, formatDateTime } from "@web/core/l10n/dates"; +import { session } from "@web/session"; import wUtils from '@website/js/utils'; +import { reactive } from "@odoo/owl"; + let currentActionName; const allFormsInfo = new Map(); @@ -73,11 +82,7 @@ const authorizedFieldsCache = { }; -const FormEditor = options.Class.extend({ - init() { - this._super(...arguments); - this.orm = this.bindService("orm"); - }, +export class FormEditor extends SnippetOption { //---------------------------------------------------------------------- // Private @@ -91,7 +96,7 @@ const FormEditor = options.Class.extend({ * @param {Object} field * @returns {Promise<Object>} */ - _fetchFieldRecords: async function (field) { + async _fetchFieldRecords(field) { // Convert the required boolean to a value directly usable // in qweb js to avoid duplicating this in the templates field.required = field.required ? 1 : null; @@ -108,7 +113,7 @@ const FormEditor = options.Class.extend({ display_name: tag[1], })); } else if (field._property && field.comodel) { - field.records = await this.orm.searchRead(field.comodel, field.domain || [], ["display_name"]); + field.records = await this.env.services.orm.searchRead(field.comodel, field.domain || [], ["display_name"]); } else if (field.type === "selection") { // Set selection as records to avoid added complexity. field.records = field.selection.map(el => ({ @@ -117,13 +122,13 @@ const FormEditor = options.Class.extend({ })); } else if (field.relation && field.relation !== 'ir.attachment') { const fieldNames = field.fieldName ? [field.fieldName] : ["display_name"]; - field.records = await this.orm.searchRead(field.relation, field.domain || [], fieldNames); + field.records = await this.env.services.orm.searchRead(field.relation, field.domain || [], fieldNames); if (field.fieldName) { field.records.forEach(r => r["display_name"] = r[field.fieldName]); } } return field.records; - }, + } /** * Returns a field object * @@ -132,7 +137,7 @@ const FormEditor = options.Class.extend({ * @param {string} name The name of the field used also as label * @returns {Object} */ - _getCustomField: function (type, name) { + _getCustomField(type, name) { return { name: name, string: name, @@ -150,14 +155,14 @@ const FormEditor = options.Class.extend({ display_name: _t('Option 3'), }], }; - }, + } /** * Returns the default formatInfos of a field. * * @private * @returns {Object} */ - _getDefaultFormat: function () { + _getDefaultFormat() { return { labelWidth: this.$target[0].querySelector('.s_website_form_label').style.width, labelPosition: 'left', @@ -166,14 +171,14 @@ const FormEditor = options.Class.extend({ optionalMark: this._isOptionalMark(), mark: this._getMark(), }; - }, + } /** * @private * @returns {string} */ - _getMark: function () { + _getMark() { return this.$target[0].dataset.mark; - }, + } /** * Replace all `"` character by `"`. * @@ -193,27 +198,27 @@ const FormEditor = options.Class.extend({ // assign random field names (as we do for IDs) and send a mapping // with the labels, as values (TODO ?). return name.replaceAll(/"/g, character => `"`); - }, + } /** * @private * @returns {boolean} */ - _isOptionalMark: function () { + _isOptionalMark() { return this.$target[0].classList.contains('o_mark_optional'); - }, + } /** * @private * @returns {boolean} */ - _isRequiredMark: function () { + _isRequiredMark() { return this.$target[0].classList.contains('o_mark_required'); - }, + } /** * @private * @param {Object} field * @returns {HTMLElement} */ - _renderField: function (field, resetId = false) { + _renderField(field, resetId = false) { if (!field.id) { field.id = weUtils.generateHTMLId(); } @@ -249,19 +254,19 @@ const FormEditor = options.Class.extend({ el.dataset.name = this._getQuotesEncodedName(el.dataset.name); }); return template.content.firstElementChild; - }, -}); + } +} -const FieldEditor = FormEditor.extend({ - VISIBILITY_DATASET: ['visibilityDependency', 'visibilityCondition', 'visibilityComparator', 'visibilityBetween'], +export class FieldEditor extends FormEditor { + static VISIBILITY_DATASET = ['visibilityDependency', 'visibilityCondition', 'visibilityComparator', 'visibilityBetween']; /** * @override */ - init: function () { - this._super.apply(this, arguments); + willStart() { this.formEl = this.$target[0].closest('form'); - }, + return super.willStart(...arguments); + } //-------------------------------------------------------------------------- // Private @@ -274,7 +279,7 @@ const FieldEditor = FormEditor.extend({ * @param {boolean} noRecords * @returns {Object} */ - _getActiveField: function (noRecords) { + _getActiveField(noRecords) { let field; const labelText = this.$target.find('.s_website_form_label_content').text(); if (this._isFieldCustom()) { @@ -289,7 +294,7 @@ const FieldEditor = FormEditor.extend({ } this._setActiveProperties(field); return field; - }, + } /** * Returns the format object of a field containing * the position, labelWidth and bootstrap col class @@ -297,7 +302,7 @@ const FieldEditor = FormEditor.extend({ * @private * @returns {Object} */ - _getFieldFormat: function () { + _getFieldFormat() { let requiredMark, optionalMark; const mark = this.$target[0].querySelector('.s_website_form_mark'); if (mark) { @@ -315,7 +320,7 @@ const FieldEditor = FormEditor.extend({ mark: mark && mark.textContent, }; return format; - }, + } /** * Returns the name of the field * @@ -323,59 +328,59 @@ const FieldEditor = FormEditor.extend({ * @param {HTMLElement} fieldEl * @returns {string} */ - _getFieldName: function (fieldEl = this.$target[0]) { + _getFieldName(fieldEl = this.$target[0]) { const multipleName = fieldEl.querySelector('.s_website_form_multiple'); return multipleName ? multipleName.dataset.name : fieldEl.querySelector('.s_website_form_input').name; - }, + } /** * Returns the type of the field, can be used for both custom and existing fields * * @private * @returns {string} */ - _getFieldType: function () { + _getFieldType() { return this.$target[0].dataset.type; - }, + } /** * @private * @returns {string} */ - _getLabelPosition: function () { + _getLabelPosition() { const label = this.$target[0].querySelector('.s_website_form_label'); if (this.$target[0].querySelector('.row:not(.s_website_form_multiple)')) { return label.classList.contains('text-end') ? 'right' : 'left'; } else { return label.classList.contains('d-none') ? 'none' : 'top'; } - }, + } /** * Returns the multiple checkbox/radio element if it exist else null * * @private * @returns {HTMLElement} */ - _getMultipleInputs: function () { + _getMultipleInputs() { return this.$target[0].querySelector('.s_website_form_multiple'); - }, + } /** * Returns true if the field is a custom field, false if it is an existing field * * @private * @returns {boolean} */ - _isFieldCustom: function () { + _isFieldCustom() { return !!this.$target[0].classList.contains('s_website_form_custom'); - }, + } /** * Returns true if the field is required by the model or by the user. * * @private * @returns {boolean} */ - _isFieldRequired: function () { + _isFieldRequired() { const classList = this.$target[0].classList; return classList.contains('s_website_form_required') || classList.contains('s_website_form_model_required'); - }, + } /** * Set the active field properties on the field Object * @@ -405,60 +410,35 @@ const FieldEditor = FormEditor.extend({ field.modelRequired = classList.contains('s_website_form_model_required'); field.hidden = classList.contains('s_website_form_field_hidden'); field.formatInfo = this._getFieldFormat(); - }, -}); - -options.registry.WebsiteFormEditor = FormEditor.extend({ - events: Object.assign({}, options.Class.prototype.events || {}, { - 'click .toggle-edit-message': '_onToggleEndMessageClick', - }), + } +} +export class WebsiteFormEditor extends FormEditor { /** * @override */ - init() { - this._super(...arguments); - this.notification = this.bindService("notification"); - this.dialog = this.bindService("dialog"); - }, - /** - * @override - */ - willStart: async function () { - const _super = this._super.bind(this); - + async willStart() { // Hide change form parameters option for forms // e.g. User should not be enable to change existing job application form // to opportunity form in 'Apply job' page. this.modelCantChange = this.$target.attr('hide-change-model') !== undefined; // Get list of website_form compatible models. - this.models = await this.orm.call("ir.model", "get_compatible_form_models"); + this.models = await this.env.services.orm.call("ir.model", "get_compatible_form_models"); const targetModelName = this.$target[0].dataset.model_name || 'mail.mail'; this.activeForm = this.models.find(m => m.model === targetModelName); currentActionName = this.activeForm && this.activeForm.website_form_label; - if (!this.modelCantChange) { - // Create the Form Action select - this.selectActionEl = document.createElement('we-select'); - this.selectActionEl.setAttribute('string', 'Action'); - this.selectActionEl.dataset.noPreview = 'true'; - this.models.forEach(el => { - const option = document.createElement('we-button'); - option.textContent = el.website_form_label; - option.dataset.selectAction = el.id; - this.selectActionEl.append(option); - }); - } - - return _super(...arguments); - }, + return super.willStart(...arguments).then(() => { + return this.start(); + }); + } /** * @override */ - start: function () { - const proms = [this._super(...arguments)]; + start() { + const proms = []; // Disable text edition this.$target.attr('contentEditable', false); // Identify editable elements of the form: buttons, description, @@ -489,11 +469,11 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ } this.defaultEmailToValue = "info@yourcompany.example.com"; return Promise.all(proms); - }, + } /** * @override */ - cleanForSave: function () { + cleanForSave() { const model = this.$target[0].dataset.model_name; // because apparently this can be called on the wrong widget and // we may not have a model, or fields... @@ -504,43 +484,52 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ if (fields.length) { // ideally we'd only do this if saving the form // succeeds... but no idea how to do that - this.orm.call("ir.model.fields", "formbuilder_whitelist", [model, unique(fields)]); + this.env.services.orm.call("ir.model.fields", "formbuilder_whitelist", [model, unique(fields)]); } } if (this.$message.length) { this.$target.removeClass('d-none'); this.$message.addClass("d-none"); } - }, + } /** * @override */ - updateUI: async function () { - // If we want to rerender the xml we need to avoid the updateUI - // as they are asynchronous and the ui might try to update while - // we are building the UserValueWidgets. - if (this.rerender) { - this.rerender = false; - await this._rerenderXML(); - return; - } - await this._super.apply(this, arguments); + async updateUI() { + await super.updateUI(...arguments); // End Message UI this.updateUIEndMessage(); - }, + + const formKey = this.activeForm.website_form_key; + const formInfo = FormEditorRegistry.get(formKey, null); + formInfo.fields.forEach(field => { + if (field.required) { + // Try to retrieve hidden value in form, else, + // get default value or for many2one fields the first option. + const currentValue = this.$target.find(`.s_website_form_dnone input[name="${field.name}"]`).val(); + const defaultValue = field.defaultValue || field.records[0].id; + // TODO this code is not rightfully placed (even maybe + // from the original form feature in older versions). It + // changes the $target while this method is only about + // declaring the option UI. This for example forces the + // 'email_to' value to a dummy value on contact us form just + // by clicking on it. + this._addHiddenField(currentValue || defaultValue, field.name); + } + }); + } /** * @see this.updateUI */ - updateUIEndMessage: function () { + updateUIEndMessage() { this.$target.toggleClass("d-none", this.showEndMessage); this.$message.toggleClass("d-none", !this.showEndMessage); - this.$el.find(".toggle-edit-message").toggleClass('text-primary', this.showEndMessage); - }, + } /** * @override */ - notify: function (name, data) { - this._super(...arguments); + notify(name, data) { + super.notify(...arguments); if (name === 'field_mark') { this._setLabelsMark(); } else if (name === 'add_field') { @@ -551,11 +540,9 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ field.formatInfo.mark = this._getMark(); const fieldEl = this._renderField(field); data.$target.after(fieldEl); - this.trigger_up('activate_snippet', { - $snippet: $(fieldEl), - }); + this.env.activateSnippet($(fieldEl)); } - }, + } //-------------------------------------------------------------------------- // Options @@ -565,9 +552,9 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ * Select the value of a field (hidden) that will be used on the model as a preset. * ie: The Job you apply for if the form is on that job's page. */ - addActionField: function (previewMode, value, params) { + addActionField(previewMode, value, params) { // Remove old property fields. - authorizedFieldsCache.get(this.$target[0], this.orm).then((fields) => { + authorizedFieldsCache.get(this.$target[0], this.env.services.orm).then((fields) => { for (const [fieldName, field] of Object.entries(fields)) { if (field._property) { for (const inputEl of this.$target[0].querySelectorAll(`[name="${fieldName}"]`)) { @@ -583,24 +570,22 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ this._addHiddenField(value, fieldName); // Existing field editors need to be rebuilt with the correct list of // available fields. - this.trigger_up('activate_snippet', { - $snippet: this.$target, - }); - }, + this.env.activateSnippet(this.$target); + } /** * Prompts the user to save changes before being redirected * towards an action specified in value. * * @see this.selectClass for parameters */ - promptSaveRedirect: function (name, value, widgetValue) { + promptSaveRedirect(name, value, widgetValue) { return new Promise((resolve, reject) => { const message = _t("Would you like to save before being redirected? Unsaved changes will be discarded."); this.dialog.add(ConfirmationDialog, { body: message, confirmLabel: _t("Save"), confirm: () => { - this.trigger_up('request_save', { + this.env.requestSave({ reload: false, onSuccess: () => { this._redirectToAction(value); @@ -610,18 +595,17 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ type: 'danger', sticky: true, }); - reject(); }, }); }, cancel: () => resolve(), }); }); - }, + } /** * Changes the onSuccess event. */ - onSuccess: function (previewMode, value, params) { + onSuccess(previewMode, value, params) { this.$target[0].dataset.successMode = value; if (value === 'message') { if (!this.$message.length) { @@ -632,37 +616,36 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ this.showEndMessage = false; this.$message.remove(); } - }, + } /** * Select the model to create with the form. */ - selectAction: async function (previewMode, value, params) { + async selectAction(previewMode, value, params) { if (this.modelCantChange) { return; } await this._applyFormModel(parseInt(value)); - this.rerender = true; - }, + } /** * @override */ - selectClass: function (previewMode, value, params) { - this._super(...arguments); + selectClass(previewMode, value, params) { + super.selectClass(...arguments); if (params.name === 'field_mark_select') { this._setLabelsMark(); } - }, + } /** * Set the mark string on the form */ - setMark: function (previewMode, value, params) { + setMark(previewMode, value, params) { this.$target[0].dataset.mark = value.trim(); this._setLabelsMark(); - }, + } /** * Toggle the recaptcha legal terms */ - toggleRecaptchaLegal: function (previewMode, value, params) { + toggleRecaptchaLegal(previewMode, value, params) { const recaptchaLegalEl = this.$target[0].querySelector('.s_website_form_recaptcha'); if (recaptchaLegalEl) { recaptchaLegalEl.remove(); @@ -674,7 +657,7 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ legal.setAttribute('contentEditable', true); this.$target.find('.s_website_form_submit').before(legal); } - }, + } //-------------------------------------------------------------------------- // Private @@ -683,7 +666,7 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { switch (methodName) { case 'selectAction': return this.activeForm.id; @@ -712,55 +695,8 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ case 'toggleRecaptchaLegal': return !this.$target[0].querySelector('.s_website_form_recaptcha') || ''; } - return this._super(...arguments); - }, - /** - * @override - */ - _renderCustomXML: function (uiFragment) { - if (this.modelCantChange) { - return; - } - // Add Action select - const firstOption = uiFragment.childNodes[0]; - uiFragment.insertBefore(this.selectActionEl.cloneNode(true), firstOption); - - // Add Action related options - const formKey = this.activeForm.website_form_key; - const formInfo = FormEditorRegistry.get(formKey, null); - if (!formInfo || !formInfo.fields) { - return; - } - allFormsInfo.set(this.$target[0], formInfo); - const proms = formInfo.fields.map(field => this._fetchFieldRecords(field)); - return Promise.all(proms).then(() => { - formInfo.fields.forEach(field => { - let option; - switch (field.type) { - case 'many2one': - option = this._buildSelect(field); - break; - case 'char': - option = this._buildInput(field); - break; - } - if (field.required) { - // Try to retrieve hidden value in form, else, - // get default value or for many2one fields the first option. - const currentValue = this.$target.find(`.s_website_form_dnone input[name="${field.name}"]`).val(); - const defaultValue = field.defaultValue || field.records[0].id; - // TODO this code is not rightfully placed (even maybe - // from the original form feature in older versions). It - // changes the $target while this method is only about - // declaring the option UI. This for example forces the - // 'email_to' value to a dummy value on contact us form just - // by clicking on it. - this._addHiddenField(currentValue || defaultValue, field.name); - } - uiFragment.insertBefore(option, firstOption); - }); - }); - }, + return super._computeWidgetState(...arguments); + } /** * Add a hidden field to the form * @@ -768,7 +704,7 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ * @param {string} value * @param {string} fieldName */ - _addHiddenField: function (value, fieldName) { + _addHiddenField(value, fieldName) { this.$target.find(`.s_website_form_dnone:has(input[name="${fieldName}"])`).remove(); // For the email_to field, we keep the field even if it has no value so // that the email is sent to data-for value or to the default email. @@ -786,80 +722,14 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ }); this.$target.find('.s_website_form_submit').before(hiddenField); } - }, - /** - * Returns a we-input element from the field - * - * @private - * @param {Object} field - * @returns {HTMLElement} - */ - _buildInput: function (field) { - const inputEl = document.createElement('we-input'); - inputEl.dataset.noPreview = 'true'; - inputEl.dataset.fieldName = field.name; - inputEl.dataset.addActionField = ''; - inputEl.setAttribute('string', field.string); - inputEl.classList.add('o_we_large'); - return inputEl; - }, - /** - * Returns a we-select element with field's records as it's options - * - * @private - * @param {Object} field - * @return {HTMLElement} - */ - _buildSelect: function (field) { - const selectEl = document.createElement('we-select'); - selectEl.dataset.noPreview = 'true'; - selectEl.dataset.fieldName = field.name; - selectEl.dataset.isSelect = 'true'; - selectEl.setAttribute('string', field.string); - if (!field.required) { - const noneButton = document.createElement('we-button'); - noneButton.textContent = 'None'; - noneButton.dataset.addActionField = 0; - selectEl.append(noneButton); - } - field.records.forEach(el => { - const button = document.createElement('we-button'); - button.textContent = el.display_name; - button.dataset.addActionField = el.id; - selectEl.append(button); - }); - if (field.createAction) { - return this._addCreateButton(selectEl, field.createAction); - } - return selectEl; - }, - /** - * Wraps an HTML element in a we-row element, and adds a - * we-button linking to the given action. - * - * @private - * @param {HTMLElement} element - * @param {String} action - * @returns {HTMLElement} - */ - _addCreateButton: function (element, action) { - const linkButtonEl = document.createElement('we-button'); - linkButtonEl.title = _t("Create new"); - linkButtonEl.dataset.noPreview = 'true'; - linkButtonEl.dataset.promptSaveRedirect = action; - linkButtonEl.classList.add('fa', 'fa-fw', 'fa-plus'); - const projectRowEl = document.createElement('we-row'); - projectRowEl.append(element); - projectRowEl.append(linkButtonEl); - return projectRowEl; - }, + } /** * Apply the model on the form changing it's fields * * @private * @param {Integer} modelId */ - _applyFormModel: async function (modelId) { + async _applyFormModel(modelId) { let oldFormInfo; if (modelId) { const oldFormKey = this.activeForm.website_form_key; @@ -894,14 +764,41 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ await this._fetchFieldRecords(field); this.$target.find('.s_website_form_submit, .s_website_form_recaptcha').first().before(this._renderField(field)); }); + await this._fetchFormInfoFields(formInfo); + this.renderContext.formInfo = formInfo; } - }, + } + /** + * Ensures formInfo fields are fetched. + * + * @private + */ + async _fetchFormInfoFields(formInfo) { + if (formInfo.fields) { + const proms = formInfo.fields.map(field => this._fetchFieldRecords(field)); + await Promise.all(proms); + } + } + /** + * @override + */ + async _getRenderContext() { + const formKey = this.activeForm.website_form_key; + const formInfo = FormEditorRegistry.get(formKey, null); + await this._fetchFormInfoFields(formInfo); + return { + hasRecaptchaKey: !!session.recaptcha_public_key, + modelCantChange: this.modelCantChange, + models: this.models, + formInfo: formInfo, + }; + } /** * Set the correct mark on all fields. * * @private */ - _setLabelsMark: function () { + _setLabelsMark() { this.$target[0].querySelectorAll('.s_website_form_mark').forEach(el => el.remove()); const mark = this._getMark(); if (!mark) { @@ -921,16 +818,16 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ span.textContent = ` ${mark}`; field.querySelector('.s_website_form_label').appendChild(span); }); - }, + } /** * Redirects the user to the page of a specified action. * * @private * @param {string} action */ - _redirectToAction: function (action) { + _redirectToAction(action) { window.location.replace(`/web#action=${encodeURIComponent(action)}`); - }, + } //-------------------------------------------------------------------------- // Handlers @@ -939,56 +836,41 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ /** * @private */ - _onToggleEndMessageClick: function () { + _onToggleEndMessageClick() { this.showEndMessage = !this.showEndMessage; this.updateUIEndMessage(); - this.trigger_up('activate_snippet', { - $snippet: this.showEndMessage ? this.$message : this.$target, - }); - }, -}); + this.env.activateSnippet(this.showEndMessage ? this.$message : this.$target); + } +} -options.registry.WebsiteFieldEditor = FieldEditor.extend({ +export class WebsiteFieldEditor extends FieldEditor { /** * @override */ - init: function () { - this._super.apply(this, arguments); - this.rerender = true; - }, - /** - * @override - */ - start: async function () { - const _super = this._super.bind(this); + async start() { // Build the custom select const select = this._getSelect(); if (select) { const field = this._getActiveField(); await this._replaceField(field); } - return _super(...arguments); - }, + return super.start(...arguments); + } /** * @override */ - updateUI: async function () { - // See Form updateUI + async updateUI() { if (this.rerender) { this.rerender = false; - await this._rerenderXML(); - return; + await this._updateRenderContext(); } - await this._super.apply(this, arguments); - }, - /** - * @override - */ - onFocus: function () { + await super.updateUI(...arguments); + } + onFocus() { // Other fields type might have change to an existing type. // We need to reload the existing type list. this.rerender = true; - }, + } /** * Rerenders the clone to avoid id duplicates. * @@ -999,7 +881,7 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ delete field.id; const fieldEl = this._renderField(field); this._replaceFieldElement(fieldEl); - }, + } /** * Removes the visibility conditions concerned by the deleted field * @@ -1015,7 +897,7 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ for (const fieldContainerEl of dependentFieldContainerEl) { this._deleteConditionalVisibility(fieldContainerEl); } - }, + } //---------------------------------------------------------------------- // Options @@ -1024,15 +906,15 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ /** * Add/remove a description to the field input */ - toggleDescription: async function (previewMode, value, params) { + async toggleDescription(previewMode, value, params) { const field = this._getActiveField(); field.description = !!value; // Will be changed to default description in qweb await this._replaceField(field); - }, + } /** * Replace the current field with the custom field selected. */ - customField: async function (previewMode, value, params) { + async customField(previewMode, value, params) { // Both custom Field and existingField are called when selecting an option // value is '' for the method that should not be called. if (!value) { @@ -1043,11 +925,11 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ this._setActiveProperties(field); await this._replaceField(field); this.rerender = true; - }, + } /** * Replace the current field with the existing field selected. */ - existingField: async function (previewMode, value, params) { + async existingField(previewMode, value, params) { // see customField if (!value) { return; @@ -1056,7 +938,7 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ this._setActiveProperties(field); await this._replaceField(field); this.rerender = true; - }, + } /** * Set the the selction type of existing fields (radio or dropdown). * @@ -1066,11 +948,11 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ const field = this._getActiveField(); field.type = value; await this._replaceField(field); - }, + } /** * Set the name of the field on the label */ - setLabelText: function (previewMode, value, params) { + setLabelText(previewMode, value, params) { this.$target.find('.s_website_form_label_content').text(value); if (this._isFieldCustom()) { value = this._getQuotesEncodedName(value); @@ -1098,46 +980,47 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ } if (!previewMode) { + // TODO: @owl-options is this still true ? // As the field label changed, the list of available visibility // dependencies needs to be updated in order to not propose a // field that would create a circular dependency. this.rerender = true; } } - }, + } /** * Replace the field with the same field having the label in a different position. */ - selectLabelPosition: async function (previewMode, value, params) { + async selectLabelPosition(previewMode, value, params) { const field = this._getActiveField(); field.formatInfo.labelPosition = value; await this._replaceField(field); - }, - selectType: async function (previewMode, value, params) { + } + async selectType(previewMode, value, params) { const field = this._getActiveField(); field.type = value; await this._replaceField(field); - }, + } /** * Select the textarea default value */ - selectTextareaValue: function (previewMode, value, params) { + selectTextareaValue(previewMode, value, params) { this.$target[0].textContent = value; this.$target[0].value = value; - }, + } /** * Select the date as value property and convert it to the right format */ - selectValueProperty: function (previewMode, value, params) { + selectValueProperty(previewMode, value, params) { const [target] = this.$target; const field = target.closest(".s_website_form_date, .s_website_form_datetime"); const format = field.matches(".s_website_form_date") ? formatDate : formatDateTime; target.value = value ? format(luxon.DateTime.fromSeconds(parseInt(value))) : ""; - }, + } /** * Select the display of the multicheckbox field (vertical & horizontal) */ - multiCheckboxDisplay: function (previewMode, value, params) { + multiCheckboxDisplay(previewMode, value, params) { const target = this._getMultipleInputs(); target.querySelectorAll('.checkbox, .radio').forEach(el => { if (value === 'horizontal') { @@ -1147,23 +1030,23 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ } }); target.dataset.display = value; - }, + } /** * Set the field as required or not */ - toggleRequired: function (previewMode, value, params) { + toggleRequired(previewMode, value, params) { const isRequired = this.$target[0].classList.contains(params.activeValue); this.$target[0].classList.toggle(params.activeValue, !isRequired); this.$target[0].querySelectorAll('input, select, textarea').forEach(el => el.toggleAttribute('required', !isRequired)); - this.trigger_up('option_update', { + this.callbacks.notifyOptions({ optionName: 'WebsiteFormEditor', name: 'field_mark', }); - }, + } /** * Apply the we-list on the target and rebuild the input(s) */ - renderListItems: async function (previewMode, value, params) { + async renderListItems(previewMode, value, params) { const valueList = JSON.parse(value); // Synchronize the possible values with the fields whose visibility @@ -1185,7 +1068,10 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ const field = this._getActiveField(true); field.records = valueList; await this._replaceField(field); - }, + if (this.valueList) { + this.valueList.newRecordId = this._isFieldCustom() ? this._getNewRecordId() : ''; + } + } /** * Sets the visibility of the field. * @@ -1205,23 +1091,23 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ }); } this._deleteConditionalVisibility(this.$target[0]); - }, + } /** * @see this.selectClass for parameters */ setVisibilityDependency(previewMode, widgetValue, params) { this._setVisibilityDependency(widgetValue); - }, + } /** * @override */ async selectDataAttribute(previewMode, widgetValue, params) { - await this._super(...arguments); + await super.selectDataAttribute(...arguments); if (params.attributeName === "maxFilesNumber") { const allowMultipleFiles = params.activeValue > 1; this.$target[0].toggleAttribute("multiple", allowMultipleFiles); } - }, + } //---------------------------------------------------------------------- // Private @@ -1230,7 +1116,7 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { switch (methodName) { case 'toggleDescription': { const description = this.$target[0].querySelector('.s_website_form_field_description'); @@ -1262,12 +1148,12 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ case 'setVisibilityDependency': return this.$target[0].dataset.visibilityDependency || ''; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ - _computeWidgetVisibility: function (widgetName, params) { + _computeWidgetVisibility(widgetName, params) { const dependencyEl = this._getDependencyEl(); switch (widgetName) { case 'hidden_condition_time_comparators_opt': @@ -1337,19 +1223,19 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ ["one2many", "many2many"].includes(fieldEl.dataset.type); } } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * Deletes all attributes related to conditional visibility. * * @param {HTMLElement} fieldEl */ _deleteConditionalVisibility(fieldEl) { - for (const name of this.VISIBILITY_DATASET) { + for (const name of FieldEditor.VISIBILITY_DATASET) { delete fieldEl.dataset[name]; } fieldEl.classList.remove('s_website_form_field_hidden_if', 'd-none'); - }, + } /** * @param {HTMLElement} [fieldEl] * @returns {HTMLElement} The visibility dependency of the field @@ -1357,7 +1243,7 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ _getDependencyEl(fieldEl = this.$target[0]) { const dependencyName = fieldEl.dataset.visibilityDependency; return this.formEl.querySelector(`.s_website_form_input[name="${CSS.escape(dependencyName)}"]`); - }, + } /** * @param {HTMLElement} dependentFieldEl * @param {HTMLElement} targetFieldEl @@ -1398,15 +1284,34 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ return false; }; return recursiveFindCircular(dependentFieldEl, targetFieldEl); - }, + } + /** + * @override + */ + async _getRenderContext() { + await this._loadRenderContextData(); + return { + existingField: this.existingField, + availableFields: this.availableFields, + valueList: this.valueList, + conditionInputs: this.conditionInputs, + conditionValueList: this.conditionValueList, + }; + } /** * @override */ - _renderCustomXML: async function (uiFragment) { + async _updateRenderContext() { + Object.assign(this.renderContext, await this._getRenderContext()); + } + /** + * Loads render context data + */ + async _loadRenderContextData() { // Get the authorized existing fields for the form model // Do it on each render because of custom property fields which can // change depending on the project selected. - this.existingFields = await authorizedFieldsCache.get(this.formEl, this.orm).then((fields) => { + this.existingFields = await authorizedFieldsCache.get(this.formEl, this.env.services.orm).then((fields) => { this.fields = {}; for (const [fieldName, field] of Object.entries(fields)) { field.name = fieldName; @@ -1414,27 +1319,26 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ field.domain = fieldDomain || field.domain || []; this.fields[fieldName] = field; } - // Create the buttons for the type we-select return Object.keys(fields).map(key => { const field = fields[key]; - const button = document.createElement('we-button'); - button.textContent = field.string; - button.dataset.existingField = field.name; - return button; - }).sort((a, b) => a.textContent.localeCompare(b.textContent, undefined, { numeric: true, sensitivity: "base" })); + return { + name: field.name, + string: field.string, + }; + }).sort((a, b) => a.string.localeCompare(b.string, undefined, { numeric: true, sensitivity: "base" })); }); // Update available visibility dependencies - const selectDependencyEl = uiFragment.querySelector('we-select[data-name="hidden_condition_opt"]'); const existingDependencyNames = []; + this.conditionInputs = []; for (const el of this.formEl.querySelectorAll('.s_website_form_field:not(.s_website_form_dnone)')) { const inputEl = el.querySelector('.s_website_form_input'); if (el.querySelector('.s_website_form_label_content') && inputEl && inputEl.name && inputEl.name !== this.$target[0].querySelector('.s_website_form_input').name && !existingDependencyNames.includes(inputEl.name) && !this._findCircular(el)) { - const button = document.createElement('we-button'); - button.textContent = el.querySelector('.s_website_form_label_content').textContent; - button.dataset.setVisibilityDependency = inputEl.name; - selectDependencyEl.append(button); + this.conditionInputs.push({ + name: inputEl.name, + textContent: el.querySelector('.s_website_form_label_content').textContent, + }); existingDependencyNames.push(inputEl.name); } } @@ -1444,27 +1348,27 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ if (dependencyEl) { if ((['radio', 'checkbox'].includes(dependencyEl.type) || dependencyEl.nodeName === 'SELECT')) { // Update available visibility options - const selectOptEl = uiFragment.querySelectorAll('we-select[data-name="hidden_condition_no_text_opt"]')[1]; + this.conditionValueList = []; const inputContainerEl = this.$target[0]; - const dependencyEl = this._getDependencyEl(); if (dependencyEl.nodeName === 'SELECT') { for (const option of dependencyEl.querySelectorAll('option')) { - const button = document.createElement('we-button'); - button.textContent = option.textContent || `<${_t("no value")}>`; - button.dataset.selectDataAttribute = option.value; - selectOptEl.append(button); + this.conditionValueList.push({ + value: option.value, + textContent: option.textContent || `<${_t("no value")}>`, + }); } if (!inputContainerEl.dataset.visibilityCondition) { inputContainerEl.dataset.visibilityCondition = dependencyEl.querySelector('option').value; } - } else { // DependecyEl is a radio or a checkbox + } else { // DependencyEl is a radio or a checkbox const dependencyContainerEl = dependencyEl.closest('.s_website_form_field'); const inputsInDependencyContainer = dependencyContainerEl.querySelectorAll('.s_website_form_input'); + // TODO: @owl-options already wrong in master for e.g. Project/Tags for (const el of inputsInDependencyContainer) { - const button = document.createElement('we-button'); - button.textContent = el.value; - button.dataset.selectDataAttribute = el.value; - selectOptEl.append(button); + this.conditionValueList.push({ + value: el.value, + textContent: el.value, + }); } if (!inputContainerEl.dataset.visibilityCondition) { inputContainerEl.dataset.visibilityCondition = inputsInDependencyContainer[0].value; @@ -1473,7 +1377,6 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ if (!inputContainerEl.dataset.visibilityComparator) { inputContainerEl.dataset.visibilityComparator = 'selected'; } - this.rerender = comparator ? this.rerender : true; } if (!comparator) { // Set a default comparator according to the type of dependency @@ -1488,44 +1391,38 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ } } - const selectEl = uiFragment.querySelector('we-select[data-name="type_opt"]'); const currentFieldName = this._getFieldName(); const fieldsInForm = Array.from(this.formEl.querySelectorAll('.s_website_form_field:not(.s_website_form_custom) .s_website_form_input')).map(el => el.name).filter(el => el !== currentFieldName); - const availableFields = this.existingFields.filter(el => !fieldsInForm.includes(el.dataset.existingField)); - if (availableFields.length) { - const title = document.createElement('we-title'); - title.textContent = 'Existing fields'; - availableFields.unshift(title); - availableFields.forEach(option => selectEl.append(option.cloneNode(true))); - } + this.availableFields = this.existingFields.filter(field => !fieldsInForm.includes(field.name)); const select = this._getSelect(); const multipleInputs = this._getMultipleInputs(); if (!select && !multipleInputs) { + this.valueList = undefined; return; } const field = Object.assign({}, this.fields[this._getFieldName()]); const type = this._getFieldType(); - const list = document.createElement('we-list'); const optionText = select ? 'Option' : type === 'selection' ? 'Radio' : 'Checkbox'; - list.setAttribute('string', `${optionText} List`); - list.dataset.addItemTitle = _t("Add new %s", optionText); - list.dataset.renderListItems = ''; - - list.dataset.hasDefault = ['one2many', 'many2many'].includes(type) ? 'multiple' : 'unique'; const defaults = [...this.$target[0].querySelectorAll('[checked], [selected]')].map(el => { return /^-?[0-9]{1,15}$/.test(el.value) ? parseInt(el.value) : el.value; }); - list.dataset.defaults = JSON.stringify(defaults); - + let availableRecords = undefined; if (!this._isFieldCustom()) { await this._fetchFieldRecords(field); - list.dataset.availableRecords = JSON.stringify(field.records); - } - uiFragment.insertBefore(list, uiFragment.querySelector('we-select[string="Visibility"]')); - }, + availableRecords = JSON.stringify(field.records); + } + this.valueList = reactive({ + title: `${optionText} List`, + addItemTitle: _t("Add new %s", optionText), + hasDefault: ['one2many', 'many2many'].includes(type) ? 'multiple' : 'unique', + defaults: JSON.stringify(defaults), + availableRecords: availableRecords, + newRecordId: this._isFieldCustom() ? this._getNewRecordId() : '', + }); + } /** * Replaces the target content with the field provided. * @@ -1533,15 +1430,24 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ * @param {Object} field * @returns {Promise} */ - _replaceField: async function (field) { + async _replaceField(field) { await this._fetchFieldRecords(field); const activeField = this._getActiveField(); if (activeField.type !== field.type) { field.value = ''; } + const targetEl = this.$target[0].querySelector(".s_website_form_input"); + if (targetEl) { + if (["checkbox", "radio"].includes(targetEl.getAttribute("type"))) { + // Remove first checkbox/radio's id's final '0'. + field.id = targetEl.id.slice(0, -1); + } else { + field.id = targetEl.id; + } + } const fieldEl = this._renderField(field); this._replaceFieldElement(fieldEl); - }, + } /** * Replaces the target with provided field. * @@ -1579,22 +1485,23 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ if (newInputEl) { newInputEl.dataset.fillWith = dataFillWith; } - }, + } /** * Sets the visibility dependency of the field. * * @param {string} value name of the dependency input */ - _setVisibilityDependency(value) { + _setVisibilityDependency(value) { delete this.$target[0].dataset.visibilityCondition; delete this.$target[0].dataset.visibilityComparator; + // TODO: @owl-options somehow does not re-trigger the rendering this.rerender = true; this.$target[0].dataset.visibilityDependency = value; - }, + } /** * @private */ - _getListItems: function () { + _getListItems() { const select = this._getSelect(); const multipleInputs = this._getMultipleInputs(); let options = []; @@ -1603,29 +1510,53 @@ options.registry.WebsiteFieldEditor = FieldEditor.extend({ } else if (multipleInputs) { options = [...multipleInputs.querySelectorAll('.checkbox input, .radio input')]; } + const isFieldCustom = this._isFieldCustom(); return options.map(opt => { const name = select ? opt : opt.nextElementSibling; return { - id: /^-?[0-9]{1,15}$/.test(opt.value) ? parseInt(opt.value) : opt.value, + id: isFieldCustom ? opt.id : /^-?[0-9]{1,15}$/.test(opt.value) ? parseInt(opt.value) : opt.value, display_name: name.textContent.trim(), selected: select ? opt.selected : opt.checked, }; }); - }, + } + /** + * Returns the next new record id. + */ + _getNewRecordId() { + const select = this._getSelect(); + const multipleInputs = this._getMultipleInputs(); + let options = []; + if (select) { + options = [...select.querySelectorAll('option')]; + } else if (multipleInputs) { + options = [...multipleInputs.querySelectorAll('.checkbox input, .radio input')]; + } + // TODO: @owl-option factorize code above + const targetEl = this.$target[0].querySelector(".s_website_form_input"); + let id; + if (["checkbox", "radio"].includes(targetEl.getAttribute("type"))) { + // Remove first checkbox/radio's id's final '0'. + id = targetEl.id.slice(0, -1); + } else { + id = targetEl.id; + } + return id + options.length; + } /** * Returns the select element if it exist else null * * @private * @returns {HTMLElement} */ - _getSelect: function () { + _getSelect() { return this.$target[0].querySelector('select'); - }, -}); + } +} -options.registry.AddFieldForm = FormEditor.extend({ - isTopOption: true, - isTopFirstOption: true, +export class AddFieldForm extends FormEditor { + static isTopOption = true; + static isTopFirstOption = true; //-------------------------------------------------------------------------- // Options @@ -1635,20 +1566,18 @@ options.registry.AddFieldForm = FormEditor.extend({ * Add a char field at the end of the form. * New field is set as active */ - addField: async function (previewMode, value, params) { + async addField(previewMode, value, params) { const field = this._getCustomField('char', 'Custom Text'); field.formatInfo = this._getDefaultFormat(); const fieldEl = this._renderField(field); this.$target.find('.s_website_form_submit, .s_website_form_recaptcha').first().before(fieldEl); - this.trigger_up('activate_snippet', { - $snippet: $(fieldEl), - }); - }, -}); + this.env.activateSnippet($(fieldEl)); + } +} -options.registry.AddField = FieldEditor.extend({ - isTopOption: true, - isTopFirstOption: true, +export class AddField extends FieldEditor { + static isTopOption = true; + static isTopFirstOption = true; //-------------------------------------------------------------------------- // Options @@ -1658,8 +1587,8 @@ options.registry.AddField = FieldEditor.extend({ * Add a char field with active field properties after the active field. * New field is set as active */ - addField: async function (previewMode, value, params) { - this.trigger_up('option_update', { + async addField(previewMode, value, params) { + this.callbacks.notifyOptions({ optionName: 'WebsiteFormEditor', name: 'add_field', data: { @@ -1667,13 +1596,13 @@ options.registry.AddField = FieldEditor.extend({ $target: this.$target, }, }); - }, -}); + } +} // Superclass for options that need to disable a button from the snippet overlay -const DisableOverlayButtonOption = options.Class.extend({ +export class DisableOverlayButtonOption extends SnippetOption { // Disable a button of the snippet overlay - disableButton: function (buttonName, message) { + disableButton(buttonName, message) { // TODO refactor in master const className = 'oe_snippet_' + buttonName; this.$overlay.add(this.$overlay.data('$optionsSection')).on('click', '.' + className, this.preventButton); @@ -1689,33 +1618,33 @@ const DisableOverlayButtonOption = options.Class.extend({ spanEl.appendChild(buttonEl); Tooltip.getOrCreateInstance(spanEl, {delay: 0}); } - }, + } - preventButton: function (event) { + preventButton(event) { // Snippet options bind their functions before the editor, so we // can't cleanly unbind the editor onRemove function from here event.preventDefault(); event.stopImmediatePropagation(); } -}); +} // Disable duplicate button for model fields -options.registry.WebsiteFormFieldModel = DisableOverlayButtonOption.extend({ - start: function () { +export class WebsiteFormFieldModel extends DisableOverlayButtonOption { + start() { this.disableButton('clone', _t('You cannot duplicate this field.')); - return this._super.apply(this, arguments); + return super.start(...arguments); } -}); +} // Disable delete button for model required fields -options.registry.WebsiteFormFieldRequired = DisableOverlayButtonOption.extend({ - start: function () { +export class WebsiteFormFieldRequired extends DisableOverlayButtonOption { + start() { this.disableButton("remove", _t( "This field is mandatory for this action. You cannot remove it. Try hiding it with the" + " 'Visibility' option instead and add it a default value." )); - return this._super.apply(this, arguments); - }, + return super.start(...arguments); + } //-------------------------------------------------------------------------- // Private @@ -1737,19 +1666,21 @@ options.registry.WebsiteFormFieldRequired = DisableOverlayButtonOption.extend({ action: currentActionName, }); uiFragment.querySelector("we-alert").appendChild(spanEl); - }, -}); + } +} // Disable delete and duplicate button for submit -options.registry.WebsiteFormSubmitRequired = DisableOverlayButtonOption.extend({ - start: function () { +export class WebsiteFormSubmitRequired extends DisableOverlayButtonOption { + start() { this.disableButton('remove', _t('You can\'t remove the submit button of the form')); this.disableButton('clone', _t('You can\'t duplicate the submit button of the form.')); - return this._super.apply(this, arguments); + return super.start(...arguments); } -}); +} // Disable "Shown on Mobile/Desktop" option if for an hidden field +// TODO: @owl-options adapt when DeviceVisibility is converted +/* options.registry.DeviceVisibility.include({ //-------------------------------------------------------------------------- @@ -1758,13 +1689,73 @@ options.registry.DeviceVisibility.include({ /** * @override - */ + * / async _computeVisibility() { // Same as default but overridden by other apps - return await this._super(...arguments) + return await super.computeVisibility(...arguments) && !this.$target.hasClass('s_website_form_field_hidden'); }, }); +*/ + +registerWebsiteOption("WebsiteFormEditor", { + Class: WebsiteFormEditor, + template: "website.s_website_form_form_option", + selector: ".s_website_form", + target: "form", +}, { sequence: 10 }); +registerContentAdditionSelector(".s_website_form"); + +registerWebsiteOption("AddFieldForm", { + Class: AddFieldForm, + template: "website.s_website_form_add_field_form_option", + selector: ".s_website_form", + target: "form", +}, { sequence: 11 }); + +registerWebsiteOption("AddField", { + Class: AddField, + template: "website.s_website_form_add_field_option", + selector: ".s_website_form_field", + exclude: ".s_website_form_dnone", +}, { sequence: 12 }); + +registerWebsiteOption("WebsiteFormFieldRequired", { + Class: WebsiteFormFieldRequired, + template: "website.s_website_form_field_required_option", + selector: ".s_website_form .s_website_form_model_required", +}, { sequence: 13 }); + +registerWebsiteOption("WebsiteFieldEditor", { + Class: WebsiteFieldEditor, + template: "website.s_website_form_field_option", + selector: ".s_website_form_field", + exclude: ".s_website_form_dnone", + "drop-near": ".s_website_form_field", + dropLockWithin: "form", +}, { sequence: 14 }); + +registerWebsiteOption("WebsiteFormSubmitRequired", { + Class: WebsiteFormSubmitRequired, + template: "website.s_website_form_submit_option", + selector: ".s_website_form_submit", + exclude: ".s_website_form_no_submit_options", +}, { sequence: 15 }); + +/* + TODO: @owl-options adapt when so_content_addition is "merged" + <!-- Extend drop locations to columns --> + <xpath expr="//t[@t-set='so_content_addition_selector']" position="inside">, .s_website_form</xpath> + + <xpath expr="//div" position="after"> + <!-- Remove the duplicate option of model fields --> + <div data-js="WebsiteFormFieldModel" data-selector=".s_website_form .s_website_form_field:not(.s_website_form_custom)"/> + + <!-- Remove the delete and duplicate option of the submit button --> + <div data-js="WebsiteFormSubmitRequired" data-selector=".s_website_form .s_website_form_submit"/> + </xpath> + +*/ export default { clearAllFormsInfo, diff --git a/addons/website/static/src/snippets/s_website_form/options.xml b/addons/website/static/src/snippets/s_website_form/options.xml new file mode 100644 index 0000000000000..d35e1f5e9eddb --- /dev/null +++ b/addons/website/static/src/snippets/s_website_form/options.xml @@ -0,0 +1,320 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="website.s_website_form_form_option"> + <WeRow t-if="!renderContext.modelCantChange and renderContext.models" + title.translate="Action"> + <WeSelect noPreview="'true'"> + <t t-foreach="renderContext.models" t-as="model" t-key="model.name"> + <WeButton t-out="model.website_form_label" selectAction="model.id.toString()"/> + </t> + </WeSelect> + </WeRow> + <t t-if="renderContext.formInfo.fields"> + <t t-foreach="renderContext.formInfo.fields" t-as="field" t-key="field.name"> + <WeRow> + <t t-set-slot="title"><t t-out="field.string"/></t> + <t t-if="field.type === 'many2one'"> + <WeSelect + noPreview="'true'" + fieldName="field.name" + isSelect="'true'" + > + <WeButton t-if="!field.required" addActionField="'0'"> + None + </WeButton> + <WeButton t-foreach="field.records" t-as="record" t-key="record.id" + addActionField="record.id.toString()" + > + <t t-out="record.display_name"/> + </WeButton> + </WeSelect> + <WeButton t-if="field.createAction" class="'fa fa-fw fa-plus'" + tooltip.translate="Create New" noPreview="'true'" + promptSaveRedirect="field.createAction" + /> + </t> + <WeInput t-if="field.type === 'char'" class="'o_we_large'" + noPreview="'true'" + fieldName="field.name" + addActionField="''" + /> + </WeRow> + </t> + </t> + <WeRow title.translate="Marked Fields"> + <WeSelect name="'field_mark_select'"> + <WeButton selectClass="''">None</WeButton> + <WeButton selectClass="'o_mark_required'" name="'form_required_opt'">Required</WeButton> + <WeButton selectClass="'o_mark_optional'" name="'form_optional_opt'">Optional</WeButton> + </WeSelect> + </WeRow> + <WeRow title.translate="Mark Text"> + <WeInput setMark="''" dependencies="'form_required_opt, form_optional_opt'"/> + </WeRow> + <WeRow title.translate="Labels Width"> + <WeInput + selectStyle="''" cssProperty="'width'" + unit="'px'" applyTo="'.s_website_form_label'"/> + </WeRow> + <WeRow title.translate="On Success"> + <WeSelect noPreview="'true'"> + <WeButton onSuccess="'nothing'">Nothing</WeButton> + <WeButton onSuccess="'redirect'" name="'show_redirect_opt'">Redirect</WeButton> + <WeButton onSuccess="'message'" name="'show_message_opt'">Show Message</WeButton> + </WeSelect> + <WeButton class="'fa fa-fw fa-eye align-self-end toggle-edit-message'" tooltip.translate="Edit Message" name="'message_opt'" dependencies="'show_message_opt'"/> + </WeRow> + <WeRow title.translate="URL"> + <WeUrlPicker selectDataAttribute="'/contactus-thank-you'" attributeName="'successPage'" name="'url_opt'" dependencies="'show_redirect_opt'"/> + </WeRow> + <WeCheckbox t-if="renderContext.hasRecaptchaKey" title.translate="Show reCaptcha Policy" toggleRecaptchaLegal="''" noPreview="'true'"/> +</t> + +<t t-name="website.s_website_form_add_field_form_option"> + <t t-set="tooltip">Add a new field at the end</t> + <WeButton class="'o_we_bg_brand_primary'" + tooltip="tooltip" + addField="''" + noPreview="'true'"> + + Field + </WeButton> +</t> + +<t t-name="website.s_website_form_add_field_option"> + <t t-set="tooltip">Add a new field after this one</t> + <WeButton class="'o_we_bg_brand_primary'" + tooltip="tooltip" + addField="''" + noPreview="'true'"> + + Field + </WeButton> +</t> + +<t t-name="website.s_website_form_field_required_option"> + <!-- Disable the delete option of model required fields and show + alert and tooltip on delete buttons --> + <we-alert class="mt-2"></we-alert> +</t> + +<t t-name="website.s_website_form_field_option"> + <WeRow title.translate="Type"> + <WeSelect name="'type_opt'" noPreview="'true'"> + <we-title>Custom Field</we-title> + <WeButton customField="'char'">Text</WeButton> + <WeButton customField="'text'">Long Text</WeButton> + <WeButton customField="'email'">Email</WeButton> + <WeButton customField="'tel'">Telephone</WeButton> + <WeButton customField="'url'">Url</WeButton> + <WeButton customField="'integer'">Number</WeButton> + <WeButton customField="'float'">Decimal Number</WeButton> + <WeButton customField="'boolean'">Checkbox</WeButton> + <WeButton customField="'one2many'">Multiple Checkboxes</WeButton> + <WeButton customField="'selection'">Radio Buttons</WeButton> + <WeButton customField="'many2one'">Selection</WeButton> + <WeButton customField="'date'">Date</WeButton> + <WeButton customField="'datetime'">Date & Time</WeButton> + <WeButton customField="'binary'">File Upload</WeButton> + <t t-if="renderContext.availableFields.length"> + <we-title>Existing fields</we-title> + <WeButton t-foreach="renderContext.availableFields" t-as="field" t-key="field.name" + existingField="field.name" + > + <t t-out="field.string"/> + </WeButton> + </t> + </WeSelect> + </WeRow> + <WeRow title.translate="Input Type"> + <WeSelect name="'char_input_type_opt'" noPreview="'true'"> + <WeButton selectType="'char'">Text</WeButton> + <WeButton selectType="'email'">Email</WeButton> + <WeButton selectType="'tel'">Telephone</WeButton> + <WeButton selectType="'url'">Url</WeButton> + </WeSelect> + </WeRow> + <WeRow title.translate="Selection type"> + <WeSelect name="'existing_field_select_type_opt'" noPreview="'true'"> + <WeButton existingFieldSelectType="'many2one'">Dropdown List</WeButton> + <WeButton existingFieldSelectType="'selection'">Radio</WeButton> + </WeSelect> + </WeRow> + <WeRow title.translate="Display" class="'o_we_sublevel_1'"> + <WeSelect name="'multi_check_display_opt'" noPreview="'true'"> + <WeButton multiCheckboxDisplay="'horizontal'">Horizontal</WeButton> + <WeButton multiCheckboxDisplay="'vertical'">Vertical</WeButton> + </WeSelect> + </WeRow> + <t t-set="unit_textarea_height">rows</t> + <WeRow title.translate="Height" class="'o_we_sublevel_1'"> + <WeInput + applyTo="'textarea'" + unit="unit_textarea_height" step="'1'" + selectAttribute="'3#' + unit_textarea_height" + attributeName="'rows'"/> + </WeRow> + <WeRow title.translate="Label"> + <WeInput class="'o_we_large'" setLabelText="''"/> + </WeRow> + <WeRow title.translate="Position" class="'o_we_sublevel_1'"> + <WeButtonGroup> + <WeButton tooltip.translate="Hide" selectLabelPosition="'none'"> + <i class="fa fa-eye-slash"/> + </WeButton> + <WeButton tooltip.translate="Top" + selectLabelPosition="'top'" + img="'/website/static/src/img/snippets_options/pos_top.svg'"/> + <WeButton tooltip.translate="Left" + selectLabelPosition="'left'" + img="'/website/static/src/img/snippets_options/pos_left.svg'"/> + <WeButton tooltip.translate="Right" + selectLabelPosition="'right'" + img="'/website/static/src/img/snippets_options/pos_right.svg'"/> + </WeButtonGroup> + </WeRow> + <WeCheckbox title.translate="Description" toggleDescription="'true'" noPreview="'true'"/> + <WeRow title.translate="Placeholder"> + <t t-set="apply_to" t-translation="off"></t> + <WeInput class="'o_we_large'" + selectAttribute="''" attributeName="'placeholder'" + applyTo="`input[type='text'], input[type='email'], input[type='number'], input[type='tel'], input[type='url'], textarea`"/> + </WeRow> + <!-- TODO: @owl-options see if all these cannot be inside the same row --> + <t t-set="default_value_label">Default Value</t> + <WeRow title="default_value_label"> + <WeInput class="'o_we_large'" selectTextareaValue="''" applyTo="'textarea'"/> + </WeRow> + <WeCheckbox title="default_value_label" selectAttribute="'checked'" attributeName="'checked'" + applyTo="`.col-sm > * > input[type='checkbox']`" noPreview="'true'"/> + <WeRow title="default_value_label"> + <WeInput class="'o_we_large'" selectAttribute="''" attributeName="'value'" selectProperty="''" + propertyName="'value'" applyTo="`input[type='text']:not(.datetimepicker-input), input[type='email'], input[type='tel'], input[type='url']`"/> + </WeRow> + <WeRow title="default_value_label"> + <WeInput class="'o_we_large'" selectAttribute="''" attributeName="'value'" selectProperty="''" + step="'1'" propertyName="'value'" applyTo="`input[type='number']`"/> + </WeRow> + <WeRow title="default_value_label"> + <WeDatetime selectAttribute="''" attributeName="'value'" selectValueProperty="''" + applyTo="'.s_website_form_datetime input'"/> + </WeRow> + <WeRow title="default_value_label"> + <WeDatetime selectAttribute="''" attributeName="'value'" selectValueProperty="''" + pickerType="'date'" + applyTo="'.s_website_form_date input'"/> + </WeRow> + <WeRow title.translate="Required"> + <WeCheckbox name="'required_opt'" noPreview="'true'" + toggleRequired="'s_website_form_required'"/> + </WeRow> + <WeRow title.translate="Max # of Files"> + <WeInput name="'max_files_number_opt'" + tooltip.translate="The maximum number of files that can be uploaded." + attributeName="'maxFilesNumber'" + selectDataAttribute="'1'" + applyTo="`input[type='file']`" + step="'1'"/> + </WeRow> + <WeRow title.translate="Max File Size"> + <WeInput + tooltip.translate="The maximum size (in MB) an uploaded file can have." + attributeName="'maxFileSize'" + selectDataAttribute="'1MB'" + applyTo="`input[type='file']`" + unit="'MB'"/> + </WeRow> + <WeList t-if="renderContext.valueList" + title="renderContext.valueList.title" + addItemTitle="renderContext.valueList.addItemTitle" + renderListItems="''" + hasDefault="renderContext.valueList.hasDefault" + defaults="renderContext.valueList.defaults" + availableRecords="renderContext.valueList.availableRecords" + newRecordId="renderContext.valueList.newRecordId" + /> + <WeSelect title.translate="Visibility" noPreview="'true'"> + <WeButton setVisibility="'visible'" selectClass="''">Always Visible</WeButton> + <WeButton setVisibility="'hidden'" selectClass="'s_website_form_field_hidden'">Hidden</WeButton> + <WeButton setVisibility="'conditional'" selectClass="'s_website_form_field_hidden_if d-none'">Visible only if</WeButton> + </WeSelect> + <WeRow name="'hidden_condition_opt'"> + <WeSelect name="'hidden_condition_opt'" noPreview="'true'"> + <!-- Load every existing form input --> + <WeButton t-foreach="renderContext.conditionInputs" t-as="input" t-key="input.name" + setVisibilityDependency="input.name" + > + <t t-out="input.textContent"/> + </WeButton> + </WeSelect> + <WeSelect name="'hidden_condition_no_text_opt'" attributeName="'visibilityComparator'" noPreview="'true'"> + <WeButton selectDataAttribute="'selected'">Is equal to</WeButton> + <WeButton selectDataAttribute="'!selected'">Is not equal to</WeButton> + <WeButton selectDataAttribute="'contains'">Contains</WeButton> + <WeButton selectDataAttribute="'!contains'">Doesn't contain</WeButton> + </WeSelect> + <WeSelect name="'hidden_condition_text_opt'" attributeName="'visibilityComparator'" noPreview="'true'"> + <!-- str comparator possibilities --> + <WeButton selectDataAttribute="'contains'">Contains</WeButton> + <WeButton selectDataAttribute="'!contains'">Doesn't contain</WeButton> + <WeButton selectDataAttribute="'equal'">Is equal to</WeButton> + <WeButton selectDataAttribute="'!equal'">Is not equal to</WeButton> + <WeButton selectDataAttribute="'set'">Is set</WeButton> + <WeButton selectDataAttribute="'!set'">Is not set</WeButton> + </WeSelect> + <WeSelect name="'hidden_condition_num_opt'" attributeName="'visibilityComparator'" noPreview="'true'"> + <!-- number comparator possibilities --> + <WeButton selectDataAttribute="'equal'">Is equal to</WeButton> + <WeButton selectDataAttribute="'!equal'">Is not equal to</WeButton> + <WeButton selectDataAttribute="'greater'">Is greater than</WeButton> + <WeButton selectDataAttribute="'less'">Is less than</WeButton> + <WeButton selectDataAttribute="'greater or equal'">Is greater than or equal to</WeButton> + <WeButton selectDataAttribute="'less or equal'">Is less than or equal to</WeButton> + <WeButton selectDataAttribute="'set'">Is set</WeButton> + <WeButton selectDataAttribute="'!set'">Is not set</WeButton> + </WeSelect> + <WeSelect name="'hidden_condition_time_comparators_opt'" attributeName="'visibilityComparator'" noPreview="'true'"> + <!-- date & datetime comparator possibilities --> + <WeButton selectDataAttribute="'dateEqual'">Is equal to</WeButton> + <WeButton selectDataAttribute="'date!equal'">Is not equal to</WeButton> + <WeButton selectDataAttribute="'after'">Is after</WeButton> + <WeButton selectDataAttribute="'before'">Is before</WeButton> + <WeButton selectDataAttribute="'equal or after'">Is after or equal to</WeButton> + <WeButton selectDataAttribute="'equal or before'">Is before or equal to</WeButton> + <WeButton selectDataAttribute="'set'">Is set</WeButton> + <WeButton selectDataAttribute="'!set'">Is not set</WeButton> + <WeButton selectDataAttribute="'between'">Is between (included)</WeButton> + <WeButton selectDataAttribute="'!between'">Is not between (excluded)</WeButton> + </WeSelect> + <WeSelect name="'hidden_condition_file_opt'" attributeName="'visibilityComparator'" noPreview="'true'"> + <!-- file comparator possibilities --> + <WeButton selectDataAttribute="'fileSet'">Is set</WeButton> + <WeButton selectDataAttribute="'!fileSet'">Is not set</WeButton> + </WeSelect> + </WeRow> + <WeSelect t-if="renderContext.conditionValueList" class="'o_we_large'" name="'hidden_condition_no_text_opt'" attributeName="'visibilityCondition'" noPreview="'true'"> + <!-- checkbox, select, radio possible values --> + <WeButton t-foreach="renderContext.conditionValueList" t-as="record" t-key="record.value" + selectDataAttribute="record.value" + > + <t t-out="record.textContent"/> + </WeButton> + </WeSelect> + <WeInput class="'o_we_large'" name="'hidden_condition_additional_text'" attributeName="'visibilityCondition'" selectDataAttribute="''"/> + <WeDatetime name="'hidden_condition_additional_datetime'" attributeName="'visibilityCondition'" selectDataAttribute="''" /> + <WeDatetime name="'hidden_condition_additional_date'" attributeName="'visibilityCondition'" selectDataAttribute="''" pickerType="'date'"/> + <WeDatetime name="'hidden_condition_datetime_between'" attributeName="'visibilityBetween'" selectDataAttribute="''"/> + <WeDatetime name="'hidden_condition_date_between'" attributeName="'visibilityBetween'" selectDataAttribute="''" pickerType="'date'"/> +</t> + +<t t-name="website.s_website_form_submit_option"> + <WeRow title.translate="Button Position"> + <WeSelect> + <WeButton selectClass="'text-start s_website_form_no_submit_label'">Left</WeButton> + <WeButton selectClass="'text-center s_website_form_no_submit_label'">Center</WeButton> + <WeButton selectClass="'text-end s_website_form_no_submit_label'">Right</WeButton> + <WeButton selectClass="''">Input Aligned</WeButton> + </WeSelect> + </WeRow> +</t> + +</templates> diff --git a/addons/website/static/src/xml/website_form_editor.xml b/addons/website/static/src/xml/website_form_editor.xml index cdcd76966dd03..de68f400093a3 100644 --- a/addons/website/static/src/xml/website_form_editor.xml +++ b/addons/website/static/src/xml/website_form_editor.xml @@ -283,7 +283,7 @@ <t t-call="website.form_field"> <select class="form-select s_website_form_input" t-att-name="field.name" t-att-required="field.required || field.modelRequired || None" t-att-id="field.id"> <t t-foreach="field.records" t-as="record" t-key="record_index"> - <option t-esc="record.display_name" t-att-id="field.id + record_index" t-att-value="record.id" t-att="{selected: record.selected and 'selected' or None}"/> + <option t-esc="record.display_name" t-att-id="field.id + record_index" t-att-value="field.custom ? record.display_name : record.id" t-att="{selected: record.selected and 'selected' or None}"/> </t> </select> </t> diff --git a/addons/website/static/tests/tour_utils/focus_blur_snippets_options.js b/addons/website/static/tests/tour_utils/focus_blur_snippets_options.js index 578182e4121d4..99918de442ddb 100644 --- a/addons/website/static/tests/tour_utils/focus_blur_snippets_options.js +++ b/addons/website/static/tests/tour_utils/focus_blur_snippets_options.js @@ -1,7 +1,7 @@ /** @odoo-module **/ odoo.loader.bus.addEventListener("module-started", (e) => { - if (e.detail.moduleName === "@web_editor/js/editor/snippets.options") { + if (e.detail.moduleName === "@web_editor/js/editor/snippets.options.legacy") { const options = e.detail.module[Symbol.for("default")]; const FocusBlur = options.Class.extend({ onFocus() { diff --git a/addons/website/static/tests/tours/conditional_visibility.js b/addons/website/static/tests/tours/conditional_visibility.js index 3baa71caa8337..2464afabfd887 100644 --- a/addons/website/static/tests/tours/conditional_visibility.js +++ b/addons/website/static/tests/tours/conditional_visibility.js @@ -57,16 +57,16 @@ wTourUtils.changeOption('ConditionalVisibility', 'we-toggler'), }, { content: 'click on utm medium toggler', - trigger: '[data-save-attribute="visibilityValueUtmMedium"] we-toggler', + trigger: '[data-attribute-name="visibilityValueUtmMediumRule"] + .o_we_m2m we-toggler', run: 'click', }, { - trigger: '[data-save-attribute="visibilityValueUtmMedium"] we-selection-items .o_we_m2o_search input', + trigger: '[data-attribute-name="visibilityValueUtmMediumRule"] + .o_we_m2m we-selection-items .o_we_m2o_search input', content: 'Search for Email', run: "edit Email", }, { - trigger: '[data-save-attribute="visibilityValueUtmMedium"] we-selection-items [data-add-record="Email"]', + trigger: '[data-attribute-name="visibilityValueUtmMediumRule"] + .o_we_m2m we-selection-items [data-add-record="Email"]', content: 'click on Email', run: 'click', }, diff --git a/addons/website/static/tests/tours/grid_layout.js b/addons/website/static/tests/tours/grid_layout.js index 54c70187c4394..3fa6105dc5fa5 100644 --- a/addons/website/static/tests/tours/grid_layout.js +++ b/addons/website/static/tests/tours/grid_layout.js @@ -64,9 +64,9 @@ wTourUtils.registerWebsitePreviewTour("scroll_to_new_grid_item", { ...wTourUtils.dragNDrop({id: "s_image_text", name: "Image - Text", groupName: "Content"}), // Toggle the first snippet to grid mode. ...wTourUtils.clickOnSnippet({id: "s_text_image", name: "Text - Image"}), - wTourUtils.changeOption("layout_column", 'we-button[data-name="grid_mode"]'), + wTourUtils.changeOption("WebsiteLayoutColumn", 'we-button[data-name="grid_mode"]'), // Add a new grid item. - wTourUtils.changeOption("layout_column", 'we-button[data-add-element="image"]'), + wTourUtils.changeOption("WebsiteLayoutColumn", 'we-button[data-add-element="image"]'), { content: "Select the new image in the media dialog", trigger: '.o_select_media_dialog img[title="s_banner_default_image.jpg"]', diff --git a/addons/website/static/tests/tours/snippet_countdown.js b/addons/website/static/tests/tours/snippet_countdown.js index a98aca3a75b4b..460b7d7a62704 100644 --- a/addons/website/static/tests/tours/snippet_countdown.js +++ b/addons/website/static/tests/tours/snippet_countdown.js @@ -8,10 +8,10 @@ wTourUtils.registerWebsitePreviewTour('snippet_countdown', { edition: true, }, () => [ ...wTourUtils.dragNDrop({id: "s_countdown", name: "Countdown", groupName: "Content"}), - ...wTourUtils.clickOnSnippet({id: 's_countdown', name: 'Countdown'}), - wTourUtils.changeOption('countdown', 'we-select:has([data-end-action]) we-toggler', 'end action'), - wTourUtils.changeOption('countdown', 'we-button[data-end-action="message"]', 'end action'), - wTourUtils.changeOption('countdown', 'we-button.toggle-edit-message', 'message preview'), + ...wTourUtils.clickOnSnippet({id: 's_countdown', name: "Countdown"}), + wTourUtils.changeOption("Countdown", 'we-select:has([data-end-action]) we-toggler', 'end action'), + wTourUtils.changeOption("Countdown", 'we-button[data-end-action="message"]', 'end action'), + wTourUtils.changeOption("Countdown", 'we-button[data-toggle-end-message]', 'message preview'), // The next two steps check that the end message does not disappear when a // widgets_start_request is triggered. { @@ -41,8 +41,8 @@ wTourUtils.registerWebsitePreviewTour('snippet_countdown', { }, // Next, we change the end action to message and no countdown while the edit // message toggle is still activated. It should hide the countdown - wTourUtils.changeOption('countdown', 'we-select:has([data-end-action]) we-toggler', 'end action'), - wTourUtils.changeOption('countdown', 'we-button[data-end-action="message_no_countdown"]', 'end action'), + wTourUtils.changeOption("Countdown", 'we-select:has([data-end-action]) we-toggler', 'end action'), + wTourUtils.changeOption("Countdown", 'we-button[data-end-action="message_no_countdown"]', 'end action'), { content: "Check that the countdown is not displayed", trigger: ':iframe .s_countdown:has(.s_countdown_canvas_wrapper:not(:visible))', diff --git a/addons/website/static/tests/tours/snippet_editor_panel_options.js b/addons/website/static/tests/tours/snippet_editor_panel_options.js index 2c6fc207c6b57..61e7f7e3947f5 100644 --- a/addons/website/static/tests/tours/snippet_editor_panel_options.js +++ b/addons/website/static/tests/tours/snippet_editor_panel_options.js @@ -102,12 +102,12 @@ wTourUtils.goBackToBlocks(), }, }, { content: "Click on the columns option.", - trigger: '.snippet-option-layout_column we-select', + trigger: '.snippet-option-WebsiteLayoutColumn we-select', run: "click", }, { content: "Change the number of columns.", - trigger: '.snippet-option-layout_column [data-select-count="3"]', + trigger: '.snippet-option-WebsiteLayoutColumn [data-select-count="3"]', run: "click", }, { content: "The snippet should have the correct number of columns.", @@ -132,17 +132,16 @@ wTourUtils.goBackToBlocks(), // Test keeping the text selection when removing all columns of a snippet. { content: "Click on the columns option.", - trigger: '.snippet-option-layout_column we-select', + trigger: '.snippet-option-WebsiteLayoutColumn we-select', run: "click", }, { content: "Change the number of columns.", - trigger: '.snippet-option-layout_column [data-select-count="0"]', + trigger: '.snippet-option-WebsiteLayoutColumn [data-select-count="0"]', run: "click", }, { content: "The snippet should have the correct number of columns.", trigger: ':iframe .s_text_block .container:not(:has(.row))', - run: "click", }, { content: "The text toolbar should still be visible, and the text still selected.", trigger: '#oe_snippets .o_we_customize_panel > #o_we_editor_toolbar_container', @@ -156,7 +155,7 @@ wTourUtils.goBackToBlocks(), }, }, // Test keeping the text selection when toggling the grid mode. -wTourUtils.changeOption("layout_column", 'we-button[data-name="grid_mode"]'), +wTourUtils.changeOption("WebsiteLayoutColumn", 'we-button[data-name="grid_mode"]'), { content: "The snippet row should have the grid mode class.", trigger: ":iframe .s_text_block .row.o_grid_mode", @@ -173,7 +172,7 @@ wTourUtils.changeOption("layout_column", 'we-button[data-name="grid_mode"]'), }, }, // Test keeping the text selection when toggling back the normal mode. -wTourUtils.changeOption("layout_column", 'we-button[data-name="normal_mode"]'), +wTourUtils.changeOption("WebsiteLayoutColumn", 'we-button[data-name="normal_mode"]'), { content: "The snippet row should not have the grid mode class anymore.", trigger: ":iframe .s_text_block .row:not(.o_grid_mode)", diff --git a/addons/website/static/tests/tours/snippet_empty_parent_autoremove.js b/addons/website/static/tests/tours/snippet_empty_parent_autoremove.js index a7e2a9bc30c9e..8257bf149f0c7 100644 --- a/addons/website/static/tests/tours/snippet_empty_parent_autoremove.js +++ b/addons/website/static/tests/tours/snippet_empty_parent_autoremove.js @@ -63,8 +63,8 @@ wTourUtils.registerWebsitePreviewTour('snippet_empty_parent_autoremove', { trigger: ':iframe #wrap .s_cover .o_we_shape', }, // Add a column - wTourUtils.changeOption('layout_column', 'we-toggler'), - wTourUtils.changeOption('layout_column', '[data-select-count="1"]'), + wTourUtils.changeOption('WebsiteLayoutColumn', 'we-toggler'), + wTourUtils.changeOption('WebsiteLayoutColumn', '[data-select-count="1"]'), { content: "Click on the created column", trigger: ':iframe #wrap .s_cover .row > :first-child', diff --git a/addons/website/static/tests/tours/snippet_social_media.js b/addons/website/static/tests/tours/snippet_social_media.js index 4dede13481f08..02df3f1f14ad0 100644 --- a/addons/website/static/tests/tours/snippet_social_media.js +++ b/addons/website/static/tests/tours/snippet_social_media.js @@ -58,7 +58,7 @@ const addNewSocialNetwork = function (optionIndex, linkIndex, url, replaceIcon = }, { content: "Ensure new option is found", - trigger: `we-list table input:eq(${optionIndex})[data-list-position="${optionIndex}"][data-dom-position="${linkIndex}"][data-undeletable=false]`, + trigger: `we-list table input:eq(${optionIndex})[data-list-position="${optionIndex}"][data-dom-position="${linkIndex}"]:not([data-undeletable])`, }, { content: "Ensure new link is found", diff --git a/addons/website/static/tests/tours/website_form_editor.js b/addons/website/static/tests/tours/website_form_editor.js index b8a9b55c88469..dae331f104cac 100644 --- a/addons/website/static/tests/tours/website_form_editor.js +++ b/addons/website/static/tests/tours/website_form_editor.js @@ -272,31 +272,31 @@ ...addCustomField('one2many', 'checkbox', 'Products', true), { content: "Change Option 1 label", - trigger: 'we-list table input:eq(0)', + trigger: 'we-list:visible table input:eq(0)', run: "edit Iphone", }, { content: "Change Option 2 label", - trigger: 'we-list table input:eq(1)', + trigger: 'we-list:visible table input:eq(1)', run: "edit Galaxy S", },{ content: "Change first Option 3 label", - trigger: 'we-list table input:eq(2)', + trigger: 'we-list:visible table input:eq(2)', run: "edit Xperia", }, { // TODO: Fix code to avoid this behavior content: "Click outside focused element before click on add new checkbox otherwise button does'nt work", - trigger: "we-list we-title", + trigger: "we-list:visible we-title", run: "click", }, { content: "Click on Add new Checkbox", - trigger: 'we-list we-button.o_we_list_add_optional', + trigger: 'we-list:visible we-button.o_we_list_add_optional', run: "click", }, { content: "Change added Option label", - trigger: 'we-list table input:eq(3)', + trigger: 'we-list:visible table input:eq(3)', run: "edit Wiko Stairway", }, { content: "Check the resulting field", @@ -321,15 +321,15 @@ ...addCustomField('selection', 'radio', 'Service', true), { content: "Change Option 1 label", - trigger: 'we-list table input:eq(0)', + trigger: 'we-list:visible table input:eq(0)', run: "edit After-sales Service", }, { content: "Change Option 2 label", - trigger: 'we-list table input:eq(1)', + trigger: 'we-list:visible table input:eq(1)', run: "edit Invoicing Service", }, { content: "Change first Option 3 label", - trigger: 'we-list table input:eq(2)', + trigger: 'we-list:visible table input:eq(2)', run: "edit Development Service", }, { @@ -344,7 +344,7 @@ run: "click", }, { content: "Change last Option label", - trigger: 'we-list table input:eq(3)', + trigger: 'we-list:visible table input:eq(3)', run: "edit Management Service", }, { content: "Mark the field as not required", @@ -373,15 +373,15 @@ // Customize custom selection field { content: "Change Option 1 Label", - trigger: 'we-list table input:eq(0)', + trigger: 'we-list:visible table input:eq(0)', run: "edit Germany", }, { content: "Change Option 2 Label", - trigger: 'we-list table input:eq(1)', + trigger: 'we-list:visible table input:eq(1)', run: "edit Belgium", }, { content: "Change first Option 3 label", - trigger: 'we-list table input:eq(2)', + trigger: 'we-list:visible table input:eq(2)', run: "edit France", }, { @@ -403,12 +403,12 @@ }, { content: "Change last Option label", - trigger: 'we-list table input:eq(3)', + trigger: 'we-list:visible table input:eq(3)', // TODO: Fix code to avoid blur event run: "edit Canada", }, { content: "Remove Germany Option", - trigger: '.o_we_select_remove_option:eq(0)', + trigger: '.o_we_select_remove_option:visible:eq(0)', run: "click", }, { @@ -423,22 +423,11 @@ run: "click", }, { content: "Change last option label with a number", - trigger: 'we-list table input:eq(3)', + trigger: 'we-list:visible table input:eq(3)', run: "edit 44 - UK", }, { content: "Check that the input value is the full option value", - trigger: 'we-list table input:eq(3)', - run: () => { - // We need this 'setTimeout' to ensure that the 'input' event of - // the input has enough time to be executed (see the - // '_onListItemBlurInput' function of the 'we-list' widget). - setTimeout(() => { - const addedOptionEl = document.querySelector('iframe.o_iframe').contentDocument.querySelector('.s_website_form_field select option[value="44 - UK"]'); - if (!addedOptionEl) { - console.error('The number option was not correctly added'); - } - }, 500); - }, + trigger: ':iframe .s_website_form_field select:has(option[value="44 - UK"])', }, { content: "Check the resulting snippet", trigger: ":iframe .s_website_form_field.s_website_form_custom.s_website_form_required" + diff --git a/addons/website/static/tests/tours/website_update_column_count.js b/addons/website/static/tests/tours/website_update_column_count.js index 6b3b695469f81..b4ea177977467 100644 --- a/addons/website/static/tests/tours/website_update_column_count.js +++ b/addons/website/static/tests/tours/website_update_column_count.js @@ -2,7 +2,7 @@ import wTourUtils from "@website/js/tours/tour_utils"; -const columnCountOptSelector = ".snippet-option-layout_column we-select[data-name='column_count_opt']"; +const columnCountOptSelector = ".snippet-option-WebsiteLayoutColumn we-select[data-name='column_count_opt']"; const columnsSnippetRow = ":iframe .s_three_columns .row"; const textImageSnippetRow = ":iframe .s_text_image .row"; const changeFirstAndSecondColumnsMobileOrder = (snippetRowSelector, snippetName) => { diff --git a/addons/website/views/snippets/s_alert.xml b/addons/website/views/snippets/s_alert.xml index 9cb6733a286f8..4f994ec949149 100644 --- a/addons/website/views/snippets/s_alert.xml +++ b/addons/website/views/snippets/s_alert.xml @@ -10,35 +10,6 @@ </div> </template> -<template id="s_alert_options" inherit_id="website.snippet_options"> - <xpath expr="//div[@id='so_width']" position="before"> - <div data-selector=".s_alert" data-js="Alert"> - <we-select string="Type" data-apply-to=".fa.s_alert_icon" data-trigger="alert_colorpicker_opt"> - <we-button data-select-class="fa-user-circle" data-trigger-value="primary">Primary</we-button> - <we-button data-select-class="fa-user-circle-o" data-trigger-value="secondary">Secondary</we-button> - <we-button data-select-class="fa-info-circle" data-trigger-value="info">Info</we-button> - <we-button data-select-class="fa-check-circle" data-trigger-value="success">Success</we-button> - <we-button data-select-class="fa-exclamation-triangle" data-trigger-value="warning">Warning</we-button> - <we-button data-select-class="fa-exclamation-circle" data-trigger-value="danger">Danger</we-button> - </we-select> - </div> - </xpath> - <!-- Keep those options in separate xpath for options order --> - <xpath expr="//div[@id='so_width']" position="after"> - <div data-selector=".s_alert"> - <we-select string="Size"> - <we-button data-select-class="s_alert_sm">Small</we-button> - <we-button data-select-class="s_alert_md">Medium</we-button> - <we-button data-select-class="s_alert_lg">Large</we-button> - </we-select> - <we-colorpicker string="Color" data-name="alert_colorpicker_opt" - data-select-style="true" - data-css-property="background-color" - data-color-prefix="alert-"/> - </div> - </xpath> -</template> - <record id="website.s_alert_000_scss" model="ir.asset"> <field name="name">Alert 000 SCSS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_blockquote.xml b/addons/website/views/snippets/s_blockquote.xml index 30da2c10f1e12..a301c14ec8061 100644 --- a/addons/website/views/snippets/s_blockquote.xml +++ b/addons/website/views/snippets/s_blockquote.xml @@ -20,58 +20,6 @@ </blockquote> </template> -<template id="s_blockquote_options" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <!-- Color and images --> - <t t-call="website.snippet_options_background_options"> - <t t-set="selector" t-value="'.s_blockquote'"/> - <t t-set="with_colors" t-value="True"/> - <t t-set="with_images" t-value="True"/> - <t t-set="with_shapes" t-value="True"/> - <t t-set="with_gradients" t-value="True"/> - <t t-set="with_color_combinations" t-value="True"/> - </t> - - <!-- Layout --> - <div data-selector=".s_blockquote"> - <we-range string="Edge Spacing" data-select-class="p-1|p-2|p-3|p-4|p-5"/> - <we-select string="Decoration"> - <we-button data-select-class="s_blockquote_default">None</we-button> - <we-button data-select-class="s_blockquote_with_line" data-name="blockquote_with_line_opt">Left line</we-button> - <we-button data-select-class="s_blockquote_with_icon">Icon</we-button> - </we-select> - </div> - - <div data-selector=".s_blockquote" data-target=".s_blockquote_line_elt"> - <we-row string="Style" class="o_we_sublevel_1"> - <we-input data-css-property="width" data-select-style="" data-unit="px" data-dependencies="blockquote_with_line_opt"/> - <we-colorpicker title="Color" - data-name="bg_color_opt" - data-select-style="" - data-css-property="background-color" - data-color-prefix="bg-" - t-att-data-with-gradients="True" - data-dependencies="blockquote_with_line_opt" - /> - </we-row> - </div> - - <div data-selector=".s_blockquote" data-target=".s_blockquote_infos"> - <we-select string="Author Alignment"> - <we-button data-select-class="flex-row align-items-start justify-content-start text-start">Left</we-button> - <we-button data-select-class="flex-column align-items-center text-center">Center</we-button> - <we-button data-select-class="flex-row-reverse align-items-start justify-content-start text-end">Right</we-button> - </we-select> - </div> - - <!-- Border and Shadow --> - <div data-js="Box" data-selector=".s_blockquote"> - <t t-call="website.snippet_options_border_widgets"/> - <t t-call="website.snippet_options_shadow_widgets"/> - </div> - </xpath> -</template> - <record id="website.s_blockquote_001_scss" model="ir.asset"> <field name="name">Blockquote 001 SCSS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_card.xml b/addons/website/views/snippets/s_card.xml index 5f90f3aef1c4a..dbd7b49cde322 100644 --- a/addons/website/views/snippets/s_card.xml +++ b/addons/website/views/snippets/s_card.xml @@ -14,105 +14,6 @@ </div> </template> -<template id="s_card_options" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <t t-call="website.snippet_options_background_options"> - <t t-set="selector" t-value="'.s_card'"/> - <t t-set="with_colors" t-value="True"/> - <t t-set="with_images" t-value="True"/> - <t t-set="with_shapes" t-value="True"/> - <t t-set="with_color_combinations" t-value="True"/> - <t t-set="with_gradients" t-value="True"/> - </t> - - <div data-js="Box" data-selector=".s_card"> - <t t-call="website.snippet_options_border_widgets"/> - <t t-call="website.snippet_options_shadow_widgets"/> - </div> - - <div data-js="CardWidth" data-selector=".s_card"> - <we-range string="Card Width" - data-select-style="0" - data-css-property="max-width" - data-unit="%" - data-min="8" - data-max="100" - data-display-range-value="true" - data-display-range-value-unit="%"/> - </div> - - <div data-js="CardImageOptions" data-selector=".s_card"> - <we-row string="Cover Image"> - <we-button title="Add" data-add-cover-image="" data-no-preview="true" class="o_we_bg_success" data-name="add_cover_image_opt">Add</we-button> - </we-row> - - <we-row string="Cover Image"> - <we-button-group data-name="cover_image_position_opt"> - <we-button title="Top" - data-name="card_img_top_opt" - data-select-class="o_card_img_top" - data-select-image-class="card-img-top" - data-img="/website/static/src/img/snippets_options/pos_top.svg"/> - <we-button title="Left" - data-select-class="o_card_img_horizontal flex-lg-row" - data-select-image-class="rounded-start" - data-img="/website/static/src/img/snippets_options/pos_left.svg"/> - <we-button title="Right" - data-select-class="o_card_img_horizontal flex-lg-row-reverse" - data-select-image-class="rounded-end" - data-img="/website/static/src/img/snippets_options/pos_right.svg"/> - </we-button-group> - <we-button title="Remove Cover" data-remove-cover-image="" data-no-preview="true" class="fa fa-trash-o fa-fw o_we_bg_danger" data-name="remove_cover_image_opt"/> - </we-row> - - <we-select string="Ratio" data-apply-to=".o_card_img_wrapper" class="o_we_sublevel_1"> - <we-button data-select-class="">Image default</we-button> - <we-button data-select-class="ratio ratio-1x1">Square</we-button> - <we-button data-select-class="ratio ratio-4x3" data-dependencies="card_img_top_opt">Landscape - 4/3</we-button> - <we-button data-select-class="ratio ratio-16x9" data-dependencies="card_img_top_opt">Wide - 16/9</we-button> - <we-button data-select-class="ratio ratio-21x9" data-dependencies="card_img_top_opt">Ultrawide - 21/9</we-button> - <we-button data-select-class="ratio o_card_img_ratio_custom" data-dependencies="card_img_top_opt" data-name="image_ratio_custom_opt">Custom</we-button> - </we-select> - - <!-- Calculate bootstrap default column size --> - <t t-set="colSize" t-value="8.33333333"/> - <we-range string="Width" class="o_we_sublevel_1" - title="Adjust the image width" - data-name="cover_image_width_opt" - data-dependencies="!card_img_top_opt" - data-select-style="0" - data-css-property="--card-img-size-h" - data-unit="%" - t-att-data-min="colSize" - t-att-data-max="colSize * 11" - t-att-data-step="colSize"/> - - <we-range - string="Custom Ratio" class="o_we_sublevel_2" - data-name="cover_image_ratio_range_opt" - data-dependencies="image_ratio_custom_opt" - data-select-style="0" - data-css-property="--card-img-aspect-ratio" - data-unit="%" - data-display-range-value="true" - data-display-range-value-unit="%" - data-min="8" - data-max="132" - data-step="4"/> - - <we-range - string="Alignment" class="o_we_sublevel_1" - data-name="cover_image_alignment_opt" - data-select-style="0" - data-align-cover-image="" - data-css-property="--card-img-ratio-align" - data-unit="%" - data-min="0" - data-max="100"/> - </div> - </xpath> -</template> - <record id="website.s_card_000_scss" model="ir.asset"> <field name="name">Card 000 SCSS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_chart.xml b/addons/website/views/snippets/s_chart.xml index 80c0783cb03a1..ee46297b1cbe6 100644 --- a/addons/website/views/snippets/s_chart.xml +++ b/addons/website/views/snippets/s_chart.xml @@ -19,55 +19,6 @@ </div> </template> -<template id="s_chart_options" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <div data-js="InnerChart" string="Chart" data-selector=".s_chart"> - <we-row string="Background"> - <t t-call="web_editor.snippet_options_background_color_widget"/> - </we-row> - <we-select string="Type" data-attribute-name="type" data-attribute-default-value="bar"> - <we-button data-select-data-attribute="bar" data-name="bar_chart_opt">Bar Vertical</we-button> - <we-button data-select-data-attribute="horizontalBar" data-name="horizontal_bar_chart_opt">Bar Horizontal</we-button> - <we-button data-select-data-attribute="line" data-name="line_chart_opt">Line</we-button> - <we-button data-select-data-attribute="pie">Pie</we-button> - <we-button data-select-data-attribute="doughnut">Doughnut</we-button> - <we-button data-select-data-attribute="radar">Radar</we-button> - </we-select> - <we-checkbox string="Stacked" data-name="stacked_chart_opt" data-dependencies="bar_chart_opt, horizontal_bar_chart_opt" data-select-data-attribute="false|true" data-attribute-name="stacked" data-no-preview="true"/> - <we-select string="Legend" data-attribute-name="legendPosition" data-attribute-default-value="top"> - <we-button data-select-data-attribute="none">None</we-button> - <we-button data-select-data-attribute="top">Top</we-button> - <we-button data-select-data-attribute="left">Left</we-button> - <we-button data-select-data-attribute="bottom">Bottom</we-button> - <we-button data-select-data-attribute="right">Right</we-button> - </we-select> - <we-checkbox string="Tooltip" data-select-data-attribute="false|true" data-attribute-name="tooltipDisplay" data-no-preview="true"/> - <we-matrix> - <table> - <tr> - <th/> - <th><we-button class="add_column fa fa-fw fa-plus o_we_link o_we_text_success d-inline-block" title="Add Serie"/></th> - </tr> - <tr> - <th><we-button class="add_row fa fa-fw fa-plus o_we_link o_we_text_success d-inline-block" title="Add Row"/></th> - </tr> - </table> - </we-matrix> - <we-input string="Min Axis" data-step="1" data-select-data-attribute="" data-dependencies="bar_chart_opt, horizontal_bar_chart_opt, line_chart_opt" data-attribute-name="minValue"/> - <we-input string="Max Axis" data-step="1" data-select-data-attribute="" data-dependencies="bar_chart_opt, horizontal_bar_chart_opt, line_chart_opt" data-attribute-name="maxValue"/> - <we-colorpicker string="Background" data-name="chart_bg_color_opt" - data-color-change="" - data-attribute-name="backgroundColor" - data-no-preview="true"/> - <we-colorpicker string="Border" data-name="chart_border_color_opt" - data-color-change="" - data-attribute-name="borderColor" - data-no-preview="true"/> - <we-input string="Border Width" data-select-data-attribute="2px" data-attribute-name="borderWidth" data-unit="px"/> - </div> - </xpath> -</template> - <record id="website.s_chart_000_js" model="ir.asset"> <field name="name">Chart 000 JS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_countdown.xml b/addons/website/views/snippets/s_countdown.xml index 2903ce96b880d..15911e780a1a7 100644 --- a/addons/website/views/snippets/s_countdown.xml +++ b/addons/website/views/snippets/s_countdown.xml @@ -27,59 +27,6 @@ </section> </template> -<template id="s_countdown_options" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <div data-js="countdown" data-selector=".s_countdown"> - <we-datetimepicker string="Due Date" data-select-data-attribute="0" data-attribute-name="endTime"/> - <we-row> - <we-select string="At The End" data-no-preview="true"> - <we-button data-end-action="nothing" data-name="no_end_action_opt">Nothing</we-button> - <we-button data-end-action="redirect" data-name="redirect_end_action_opt">Redirect</we-button> - <we-button data-end-action="message_no_countdown">Show Message and hide countdown</we-button> - <we-button data-end-action="message">Show Message and keep countdown</we-button> - </we-select> - <we-button class="align-self-end toggle-edit-message" title="The message will be visible once the countdown ends"> - <i class="fa fa-fw fa-eye"/> - </we-button> - </we-row> - <we-urlpicker string="URL" data-dependencies="redirect_end_action_opt" data-select-data-attribute="" placeholder="e.g. /my-awesome-page" data-attribute-name="redirectUrl"/> - <we-select string="Size" data-attribute-name="size"> - <we-button data-select-data-attribute="80">Small</we-button> - <we-button data-select-data-attribute="120">Medium</we-button> - <we-button data-select-data-attribute="175">Large</we-button> - </we-select> - <we-select string="Display" data-attribute-name="display"> - <we-button data-select-data-attribute="d">D</we-button> - <we-button data-select-data-attribute="dhm">D - H - M</we-button> - <we-button data-select-data-attribute="dhms">D - H - M - S</we-button> - </we-select> - <we-colorpicker string="Text Color" data-attribute-name="textColor" data-select-data-attribute=""/> - <we-select string="Layout"> - <we-button data-layout="circle" data-name="circle_layout_opt">Circle</we-button> - <we-button data-layout="boxes" data-name="boxes_layout_opt">Boxes</we-button> - <we-button data-layout="clean">Clean</we-button> - <we-button data-layout="text">Text Inline</we-button> - </we-select> - <we-select string="Layout Background" data-attribute-name="layoutBackground" data-dependencies="circle_layout_opt, boxes_layout_opt"> - <we-button data-select-data-attribute="inner">Inner</we-button> - <we-button data-select-data-attribute="plain">Plain</we-button> - <we-button data-select-data-attribute="none" data-name="no_background_layout_opt">None</we-button> - </we-select> - <we-colorpicker string="Layout Background Color" data-dependencies="!no_background_layout_opt" data-attribute-name="layoutBackgroundColor" data-select-data-attribute=""/> - <we-select string="Progress Bar Style" data-attribute-name="progressBarStyle" data-dependencies="circle_layout_opt, boxes_layout_opt"> - <we-button data-select-data-attribute="surrounded">Surrounded</we-button> - <we-button data-select-data-attribute="disappear">Disappearing</we-button> - <we-button data-select-data-attribute="none" data-name="no_progressbar_style_opt">None</we-button> - </we-select> - <we-select string="Progress Bar Weight" data-attribute-name="progressBarWeight" data-dependencies="!no_progressbar_style_opt"> - <we-button data-select-data-attribute="thin">Thin</we-button> - <we-button data-select-data-attribute="thick">Thick</we-button> - </we-select> - <we-colorpicker string="Progress Bar Color" data-dependencies="!no_progressbar_style_opt" data-attribute-name="progressBarColor" data-select-data-attribute=""/> - </div> - </xpath> -</template> - <record id="website.s_countdown_000_js" model="ir.asset"> <field name="name">Countdown 000 JS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_embed_code.xml b/addons/website/views/snippets/s_embed_code.xml index 39beffdbb410f..c85dd78a760a6 100644 --- a/addons/website/views/snippets/s_embed_code.xml +++ b/addons/website/views/snippets/s_embed_code.xml @@ -18,24 +18,6 @@ </section> </template> -<!-- Snippet's Options --> -<template id="s_embed_code_options" inherit_id="website.snippet_options"> - <xpath expr="//div[@data-js='Box']" position="before"> - <div data-js="EmbedCode" data-selector=".s_embed_code"> - <we-row string="Code"> - <we-button data-edit-code="" data-no-preview="true" - class="o_we_edit_code o_we_no_toggle o_we_bg_success active" - title="Edit embedded code">Edit</we-button> - </we-row> - <we-button-group string="Alignment"> - <we-button class="fa fa-fw fa-align-left" title="Left" data-select-class="text-start"/> - <we-button class="fa fa-fw fa-align-center" title="Center" data-select-class="text-center"/> - <we-button class="fa fa-fw fa-align-right" title="Right" data-select-class="text-end"/> - </we-button-group> - </div> - </xpath> -</template> - <!-- Snippet assets --> <record id="website.s_embed_code_000_js" model="ir.asset"> <field name="name">Embed Code 000 JS</field> diff --git a/addons/website/views/snippets/s_facebook_page.xml b/addons/website/views/snippets/s_facebook_page.xml index ba6d03a215a9f..aab5f7a02274b 100644 --- a/addons/website/views/snippets/s_facebook_page.xml +++ b/addons/website/views/snippets/s_facebook_page.xml @@ -8,20 +8,6 @@ </div> </template> -<!-- Snippet options--> -<template id="s_facebook_page_options" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <div string="Page URL" data-js="facebookPage" data-selector=".o_facebook_page"> - <we-input class="o_we_large" data-page-url="https://www.facebook.com/Odoo" data-no-preview="true"/> - <we-checkbox string="Cover Photo" data-option-name="show_cover" data-toggle-option="true" data-no-preview="true"/> - <we-checkbox string="Timeline" data-option-name="tab.timeline" data-toggle-option="true" data-no-preview="true"/> - <we-checkbox string="Events" data-option-name="tab.events" data-toggle-option="true" data-no-preview="true"/> - <we-checkbox string="Messages" data-option-name="tab.messages" data-toggle-option="true" data-no-preview="true"/> - <we-checkbox string="Small Header" data-option-name="small_header" data-toggle-option="true" data-no-preview="true"/> - </div> - </xpath> -</template> - <!-- Snippet assets --> <record id="website.s_facebook_page_000_js" model="ir.asset"> <field name="name">Facebook page 000 JS</field> diff --git a/addons/website/views/snippets/s_google_map.xml b/addons/website/views/snippets/s_google_map.xml index 684a8964f3026..053eaadf6556e 100644 --- a/addons/website/views/snippets/s_google_map.xml +++ b/addons/website/views/snippets/s_google_map.xml @@ -7,43 +7,6 @@ </section> </template> -<!-- Snippet's Options --> -<template id="s_google_map_options" inherit_id="website.snippet_options"> - <xpath expr="//div[@data-js='Box']" position="before"> - <div data-js="GoogleMap" data-selector=".s_google_map"> - <we-gpspicker string="Address" - data-select-data-attribute="" data-attribute-name="mapGps" - data-set-formatted-address="" - placeholder="e.g. De Brouckere, Brussels, Belgium" - data-no-preview="true"/> - <we-select string="Marker Style" data-attribute-name="pinStyle"> - <we-button data-select-data-attribute="">Default</we-button> - <we-button data-select-data-attribute="flat">Flat</we-button> - </we-select> - <we-select string="Type" data-no-preview="true" data-reset-map-color="" data-attribute-name="mapType"> - <we-button data-name="roadmap_opt" data-select-data-attribute="ROADMAP">RoadMap</we-button> - <we-button data-select-data-attribute="TERRAIN">Terrain</we-button> - <we-button data-select-data-attribute="SATELLITE">Satellite</we-button> - <we-button data-select-data-attribute="HYBRID">Hybrid</we-button> - </we-select> - <we-select string="⌙ Style" data-dependencies="roadmap_opt" data-no-preview="true" data-attribute-name="mapColor"> - <we-button data-select-data-attribute="" data-img="/website/static/src/snippets/s_google_map/img/thumbs/map-default.jpg"/> - <we-button data-select-data-attribute="lightMonoMap" data-img="/website/static/src/snippets/s_google_map/img/thumbs/map-lightMono.jpg"/> - <we-button data-select-data-attribute="cupertinoMap" data-img="/website/static/src/snippets/s_google_map/img/thumbs/map-cupertino.jpg"/> - <we-button data-select-data-attribute="retroMap" data-img="/website/static/src/snippets/s_google_map/img/thumbs/map-retro.jpg"/> - <we-button data-select-data-attribute="cobaltMap" data-img="/website/static/src/snippets/s_google_map/img/thumbs/map-cobalt.jpg"/> - <we-button data-select-data-attribute="flatMap" data-img="/website/static/src/snippets/s_google_map/img/thumbs/map-flat.jpg"/> - <we-button data-select-data-attribute="blueMap" data-img="/website/static/src/snippets/s_google_map/img/thumbs/map-blue.jpg"/> - <we-button data-select-data-attribute="lillaMap" data-img="/website/static/src/snippets/s_google_map/img/thumbs/map-lilla.jpg"/> - <we-button data-select-data-attribute="carMap" data-img="/website/static/src/snippets/s_google_map/img/thumbs/map-caramello.jpg"/> - <we-button data-select-data-attribute="bwMap" data-img="/website/static/src/snippets/s_google_map/img/thumbs/map-bw.jpg"/> - </we-select> - <we-input string="Zoom" data-select-data-attribute="12" data-no-preview="true" data-attribute-name="mapZoom" data-step="1"/> - <we-checkbox string="Description" data-no-preview="true" data-show-description="true"/> - </div> - </xpath> -</template> - <record id="website.s_google_map_000_scss" model="ir.asset"> <field name="name">Google map 000 SCSS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_image.xml b/addons/website/views/snippets/s_image.xml index 55ec8634312d3..0e886017add53 100644 --- a/addons/website/views/snippets/s_image.xml +++ b/addons/website/views/snippets/s_image.xml @@ -10,10 +10,4 @@ </div> </template> -<template id="s_image_options" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <div data-js="ImageSnippet" data-selector=".s_image"/> - </xpath> -</template> - </odoo> diff --git a/addons/website/views/snippets/s_image_gallery.xml b/addons/website/views/snippets/s_image_gallery.xml index b6c1f90d0c4fd..c2354591e36ad 100644 --- a/addons/website/views/snippets/s_image_gallery.xml +++ b/addons/website/views/snippets/s_image_gallery.xml @@ -61,62 +61,6 @@ </section> </template> -<template id="s_image_gallery_options" inherit_id="website.snippet_options"> - <xpath expr="//t[@t-call='website.snippet_options_background_options']" position="before"> - <div data-js="GalleryImageList" data-selector=".s_image_gallery"> - <we-row string="Images"> - <we-button class="o_we_bg_success" data-add-images="true" data-no-preview="true">Add</we-button> - <we-button class="o_we_bg_danger" data-remove-all-images="true" data-no-preview="true">Remove all</we-button> - </we-row> - </div> - </xpath> - <xpath expr="." position="inside"> - <div data-js="gallery" data-selector=".s_image_gallery"> - <we-select string="Mode" data-dependencies="!slideshow_mode_opt"> - <we-button data-mode="grid" data-name="grid_mode_opt">Grid</we-button> - <we-button data-mode="masonry" data-name="masonry_mode_opt">Masonry</we-button> - <we-button data-mode="nomode">Float</we-button> - - <!-- Hidden option --> - <we-button data-mode="slideshow" data-name="slideshow_mode_opt">Slideshow</we-button> - </we-select> - <we-input string="Speed" - data-dependencies="slideshow_mode_opt" - data-apply-to=".carousel:first" - data-select-data-attribute="0s" data-attribute-name="bsInterval" - data-unit="s" data-save-unit="ms" data-step="0.1"/> - <we-select string="Columns" data-dependencies="masonry_mode_opt, grid_mode_opt"> - <we-button data-columns="1">1</we-button> - <we-button data-columns="2">2</we-button> - <we-button data-columns="3">3</we-button> - <we-button data-columns="4">4</we-button> - <we-button data-columns="6">6</we-button> - <we-button data-columns="12">12</we-button> - </we-select> - <we-range string="Images Spacing" - data-dependencies="!slideshow_mode_opt" - data-select-class="o_spc-none|o_spc-small|o_spc-medium|o_spc-big"/> - <we-select string="Arrows" data-dependencies="slideshow_mode_opt"> - <we-button data-select-class="">Standard</we-button> - <we-button data-select-class="s_image_gallery_indicators_arrows_boxed">Boxed</we-button> - <we-button data-select-class="s_image_gallery_indicators_arrows_rounded">Rounded</we-button> - </we-select> - <we-checkbox string="Image Cover" data-select-class="s_image_gallery_cover" data-dependencies="slideshow_mode_opt"/> - <we-select string="Indicators" data-dependencies="slideshow_mode_opt"> - <we-button data-select-class="">None</we-button> - <we-button data-select-class="s_image_gallery_show_indicators s_image_gallery_indicators_dots">Dots</we-button> - <we-button data-select-class="s_image_gallery_show_indicators">Squared Miniatures</we-button> - <we-button data-select-class="s_image_gallery_show_indicators s_image_gallery_indicators_rounded">Rounded Miniatures</we-button> - </we-select> - <t t-call="website.snippet_options_border_widgets"> - <t t-set="apply_to" t-valuef="img"/> - <t t-set="so_rounded_no_dependencies" t-value="true"/> - </t> - </div> - <div data-js="gallery_img" data-selector=".s_image_gallery img"></div> - </xpath> -</template> - <record id="website.s_image_gallery_000_js" model="ir.asset"> <field name="name">Image gallery 000 JS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_instagram_page.xml b/addons/website/views/snippets/s_instagram_page.xml index 03246bb38019e..7499f3d49194f 100644 --- a/addons/website/views/snippets/s_instagram_page.xml +++ b/addons/website/views/snippets/s_instagram_page.xml @@ -9,22 +9,6 @@ </section> </template> - -<template id="s_instagram_page_options" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <div data-js="InstagramPage" data-selector=".s_instagram_page"> - <we-alert class="mt-2"> - Your instagram page must be public to be integrated into an Odoo website. - </we-alert> - <we-input string="Instagram Page" - class="o_we_large" - placeholder="odoo.official" - data-set-instagram-page="" - data-no-preview="true"/> - </div> - </xpath> -</template> - <record id="website.s_instagram_page_000_js" model="ir.asset"> <field name="name">Instagram Page 000 JS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_map.xml b/addons/website/views/snippets/s_map.xml index 78dc2adf8b567..cc869e795a84a 100644 --- a/addons/website/views/snippets/s_map.xml +++ b/addons/website/views/snippets/s_map.xml @@ -15,44 +15,6 @@ </section> </template> -<!-- Snippet's Options --> -<template id="s_map_options" inherit_id="website.snippet_options"> - <xpath expr="//div[@data-js='Box']" position="before"> - <div data-js="Map" data-selector=".s_map"> - <we-input class="o_we_large" string="Address" data-select-data-attribute="" data-no-preview="true" data-attribute-name="mapAddress" placeholder="e.g. De Brouckere, Brussels, Belgium"/> - <we-select string="Type" data-no-preview="true" data-attribute-name="mapType"> - <we-button data-select-data-attribute="m">Road</we-button> - <we-button data-select-data-attribute="k">Satellite</we-button> - </we-select> - <we-select string="Zoom" data-select-data-attribute="12" data-no-preview="true" data-attribute-name="mapZoom"> - <we-button data-select-data-attribute="21">2.5 m</we-button> - <we-button data-select-data-attribute="20">5 m</we-button> - <we-button data-select-data-attribute="19">10 m</we-button> - <we-button data-select-data-attribute="18">20 m</we-button> - <we-button data-select-data-attribute="17">50 m</we-button> - <we-button data-select-data-attribute="16">100 m</we-button> - <we-button data-select-data-attribute="15">200 m</we-button> - <we-button data-select-data-attribute="14">400 m</we-button> - <we-button data-select-data-attribute="13">1 km</we-button> - <we-button data-select-data-attribute="12">2 km</we-button> - <we-button data-select-data-attribute="11">4 km</we-button> - <we-button data-select-data-attribute="10">8 km</we-button> - <we-button data-select-data-attribute="9">15 km</we-button> - <we-button data-select-data-attribute="8">30 km</we-button> - <we-button data-select-data-attribute="7">50 km</we-button> - <we-button data-select-data-attribute="6">100 km</we-button> - <we-button data-select-data-attribute="5">200 km</we-button> - <we-button data-select-data-attribute="4">400 km</we-button> - <we-button data-select-data-attribute="3">1000 km</we-button> - <we-button data-select-data-attribute="2">2000 km</we-button> - </we-select> - <we-colorpicker string="Color Filter" data-select-style="true" - data-css-property="background-color" data-color-prefix="bg-" data-apply-to=".s_map_color_filter"/> - <we-checkbox string="Description" data-no-preview="true" data-show-description="true"/> - </div> - </xpath> -</template> - <record id="website.s_map_000_scss" model="ir.asset"> <field name="name">Map 000 SCSS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_masonry_block.xml b/addons/website/views/snippets/s_masonry_block.xml index 2c17dab7ad2a6..28d3d59aa9309 100644 --- a/addons/website/views/snippets/s_masonry_block.xml +++ b/addons/website/views/snippets/s_masonry_block.xml @@ -258,59 +258,6 @@ </div> </template> -<!-- Options --> -<template id="s_masonry_block_options" inherit_id="website.snippet_options"> - <xpath expr="//div[@data-js='layout_column']" position="after"> - <div data-js="MasonryLayout" data-selector=".s_masonry_block"> - <we-select string="Template" - data-name="masonry_template_opt" - data-attribute-name="masonryTemplate" - data-attribute-default-value="default"> - <we-button title="Default" - data-select-template="website.s_masonry_block_default_template" - data-select-data-attribute="default" - data-img="/website/static/src/img/snippets_options/masonry_template_default.svg"/> - <we-button title="Default Reversed" - data-select-template="website.s_masonry_block_reversed_template" - data-select-data-attribute="default_reversed" - data-img="/website/static/src/img/snippets_options/masonry_template_reversed.svg"/> - <we-button title="Images" - data-select-template="website.s_masonry_block_images_template" - data-select-data-attribute="images" - data-img="/website/static/src/img/snippets_options/masonry_template_images.svg"/> - <we-button title="Image Text Image" - data-select-template="website.s_masonry_block_image_texts_image_template" - data-select-data-attribute="image_text_image" - data-img="/website/static/src/img/snippets_options/masonry_template_image_texts_image.svg"/> - <we-button title="Mosaic" - data-select-template="website.s_masonry_block_mosaic_template" - data-select-data-attribute="mosaic" - data-img="/website/static/src/img/snippets_options/masonry_template_mosaic.svg"/> - <we-button title="Text Image Text" - data-select-template="website.s_masonry_block_texts_image_texts_template" - data-select-data-attribute="text_image_text" - data-img="/website/static/src/img/snippets_options/masonry_template_texts_image_texts.svg"/> - <we-button title="Alternate Text" - data-select-template="website.s_masonry_block_alternation_text_template" - data-select-data-attribute="alternate_text" - data-img="/website/static/src/img/snippets_options/masonry_template_alternate_texts.svg"/> - <we-button title="Alternate Text Image" - data-select-template="website.s_masonry_block_alternation_text_image_template" - data-select-data-attribute="alternate_text_image" - data-img="/website/static/src/img/snippets_options/masonry_template_alternate_text_image.svg"/> - <we-button title="Alternate Image Text" - data-select-template="website.s_masonry_block_alternation_image_text_template" - data-select-data-attribute="alternate_image_text" - data-img="/website/static/src/img/snippets_options/masonry_template_alternate_image_text.svg"/> - <we-button title="Alternate Text Image Text" - data-select-template="website.s_masonry_block_alternation_text_image_text_template" - data-select-data-attribute="alternate_text_image_text" - data-img="/website/static/src/img/snippets_options/masonry_template_alternate_text_image_text.svg"/> - </we-select> - </div> - </xpath> -</template> - <!-- Assets --> <record id="website.s_masonry_block_000_scss" model="ir.asset"> <field name="name">Masonry block 000 SCSS</field> diff --git a/addons/website/views/snippets/s_media_list.xml b/addons/website/views/snippets/s_media_list.xml index 9e815db3fdaba..17a9a34d00cb0 100644 --- a/addons/website/views/snippets/s_media_list.xml +++ b/addons/website/views/snippets/s_media_list.xml @@ -45,65 +45,6 @@ </section> </template> -<template id="s_media_list_options" inherit_id="website.snippet_options"> - <xpath expr="//t[@t-call='website.snippet_options_background_options']" position="before"> - <div data-js="MultipleItems" data-selector=".s_media_list"> - <we-row string="Media"> - <we-button data-add-item="" data-item=".s_media_list_item:last" data-select-item="" data-no-preview="true" class="o_we_bg_brand_primary"> - Add Media - </we-button> - </we-row> - </div> - </xpath> - <xpath expr="." position="inside"> - <t t-call="website.snippet_options_background_options"> - <t t-set="selector" t-value="'.s_media_list_item'"/> - <t t-set="target" t-value="'> .row'"/> - <t t-set="with_colors" t-value="True"/> - <t t-set="with_images" t-value="False"/> - <t t-set="with_color_combinations" t-value="True"/> - <t t-set="with_gradients" t-value="True"/> - </t> - <div data-js="Box" data-selector=".s_media_list_item" data-target="> .row"> - <t t-call="website.snippet_options_border_widgets"/> - <t t-call="website.snippet_options_shadow_widgets"/> - </div> - <div data-selector=".s_media_list_item" data-target="> .row"> - <we-button-group string="Layout"> - <we-button title="Left" data-name="media_left_opt" data-select-class="" data-img="/website/static/src/img/snippets_options/image_left.svg"/> - <we-button title="Right" data-select-class="flex-row-reverse" data-img="/website/static/src/img/snippets_options/image_right.svg"/> - </we-button-group> - </div> - <div data-js="MediaItemLayout" data-selector=".s_media_list_item"> - <we-button-group string="Image Size" data-dependencies="media_left_opt"> - <we-button data-layout="3" data-img="/website/static/src/img/snippets_options/media_layout_1_4.svg" title="1/4 - 3/4"/> - <we-button data-layout="4" data-img="/website/static/src/img/snippets_options/media_layout_1_3.svg" title="1/3 - 2/3"/> - <we-button data-layout="6" data-img="/website/static/src/img/snippets_options/media_layout_1_2.svg" title="1/2 - 1/2"/> - </we-button-group> - <we-button-group string="Image Size" data-dependencies="!media_left_opt"> - <we-button data-layout="3" data-img="/website/static/src/img/snippets_options/media_layout_1_4_right.svg" title="1/4 - 3/4"/> - <we-button data-layout="4" data-img="/website/static/src/img/snippets_options/media_layout_1_3_right.svg" title="1/3 - 2/3"/> - <we-button data-layout="6" data-img="/website/static/src/img/snippets_options/media_layout_1_2_right.svg" title="1/2 - 1/2"/> - </we-button-group> - </div> - - <div data-selector=".s_media_list_item" data-target="> .row"> - <!-- Don't use the standard Vert. Alignement option to not suggest - Equal Height, which is useless for this snippet. --> - <we-button-group string="Text Position" title="Text Position" data-dependencies="media_left_opt"> - <we-button title="Align Top" data-select-class="align-items-start" data-img="/website/static/src/img/snippets_options/align_top_right.svg"/> - <we-button title="Align Middle" data-select-class="align-items-center" data-img="/website/static/src/img/snippets_options/align_middle_right.svg"/> - <we-button title="Align Bottom" data-select-class="align-items-end" data-img="/website/static/src/img/snippets_options/align_bottom_right.svg"/> - </we-button-group> - <we-button-group string="Text Position" title="Text Position" data-dependencies="!media_left_opt"> - <we-button title="Align Top" data-select-class="align-items-start" data-img="/website/static/src/img/snippets_options/align_top.svg"/> - <we-button title="Align Middle" data-select-class="align-items-center" data-img="/website/static/src/img/snippets_options/align_middle.svg"/> - <we-button title="Align Bottom" data-select-class="align-items-end" data-img="/website/static/src/img/snippets_options/align_bottom.svg"/> - </we-button-group> - </div> - </xpath> -</template> - <record id="website.s_media_list_000_scss" model="ir.asset"> <field name="name">Media list 000 SCSS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_popup.xml b/addons/website/views/snippets/s_popup.xml index 012bcb7b64138..d5827acbc350f 100644 --- a/addons/website/views/snippets/s_popup.xml +++ b/addons/website/views/snippets/s_popup.xml @@ -28,54 +28,6 @@ </div> </template> -<template id="s_popup_options" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <t t-set="base_popup_options"> - <we-select string="Position" data-dependencies="layout_popup_opt"> <!-- When cookie configuration only display this for popup mode --> - <we-button data-select-class="s_popup_top" data-name="position_top">Top</we-button> - <we-button data-select-class="s_popup_middle" data-name="position_middle">Middle</we-button> - <we-button data-select-class="s_popup_bottom" data-name="position_bottom">Bottom</we-button> - </we-select> - <we-select string="Size" data-apply-to=".modal-dialog" data-name="s_popup_size_opt"> - <we-button data-select-class="modal-sm">Small</we-button> - <we-button data-select-class="" data-name="s_popup_size_md">Medium</we-button> - <we-button data-select-class="modal-lg">Large</we-button> - <we-button data-select-class="modal-xl">Extra Large</we-button> - <we-button data-select-class="s_popup_size_full" data-name="s_popup_size_full">Full</we-button> - </we-select> - <we-row string="Backdrop"> - <we-checkbox data-name="popup_backdrop_opt" data-select-class="s_popup_no_backdrop|" data-set-backdrop="true" data-no-preview="true"/> - <we-colorpicker data-dependencies="popup_backdrop_opt" data-select-style="true" data-css-property="background-color" data-color-prefix="bg-" data-css-compatible="true"/> - </we-row> - </t> - <t t-set="extra_popup_options"> - <we-colorpicker string="Close Button Color" data-select-style="true" data-css-property="color" data-color-prefix="text-" data-apply-to=".s_popup_close"/> - <we-select string="Display" data-attribute-name="display" data-attribute-default-value="always"> - <we-button data-select-data-attribute="afterDelay" data-name="show_delay">Delay</we-button> - <we-button data-select-data-attribute="mouseExit">On Exit</we-button> - <we-button data-select-data-attribute="onClick" data-name="onclick_opt">On Click (via link)</we-button> - </we-select> - <we-input string="⌙ Delay" title="Automatically opens the pop-up if the user stays on a page longer than the specified time." data-select-data-attribute="" data-attribute-name="showAfter" data-unit="s" data-save-unit="ms" data-dependencies="show_delay"/> - <t t-set="unit_popup_duration">days</t> - <we-input string="Hide For" title="Once the user closes the popup, it won't be shown again for that period of time." t-attf-data-select-data-attribute="7#{unit_popup_duration}" data-attribute-name="consentsDuration" t-att-data-unit="unit_popup_duration" data-dependencies="!onclick_opt"/> - <we-select string="Show on" data-no-preview="true"> - <we-button data-move-block="currentPage">This page</we-button> - <we-button data-move-block="allPages">All pages</we-button> - </we-select> - </t> - <div data-js="SnippetPopup" - data-selector=".s_popup" data-exclude="#website_cookies_bar" - data-target=".modal" - data-drop-in=":not(p).oe_structure:not(.oe_structure_solo):not([data-snippet] *), :not(.o_mega_menu):not(p)[data-oe-type=html]:not([data-snippet] *)"> - <t t-out="base_popup_options"/> - <t t-out="extra_popup_options"/> - </div> - <div data-js="SnippetPopup" data-selector=".s_popup#website_cookies_bar" data-target=".modal"> - <t t-out="base_popup_options"/> - </div> - </xpath> -</template> - <record id="website.s_popup_000_scss" model="ir.asset"> <field name="name">Popup 000 SCSS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_process_steps.xml b/addons/website/views/snippets/s_process_steps.xml index 2991fb097cd41..e3021d1d89aac 100644 --- a/addons/website/views/snippets/s_process_steps.xml +++ b/addons/website/views/snippets/s_process_steps.xml @@ -65,27 +65,6 @@ </section> </template> -<template id="s_process_steps_options" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <div data-js="StepsConnector" data-selector=".s_process_steps"> - <we-row string="Connector"> - <we-select data-name="connector_type"> - <we-button data-select-class="" data-name="no_connector_opt">None</we-button> - <we-button data-select-class="s_process_steps_connector_line">Line</we-button> - <we-button data-select-class="s_process_steps_connector_arrow">Straight arrow</we-button> - <we-button data-select-class="s_process_steps_connector_curved_arrow">Curved arrow</we-button> - </we-select> - <we-colorpicker - data-select-style="true" - data-name="connector_color_opt" - data-dependencies="!no_connector_opt" - data-apply-to=".s_process_step_connector path" - data-css-property="stroke" data-change-color="true"/> - </we-row> - </div> - </xpath> -</template> - <record id="website.s_process_steps_000_scss" model="ir.asset"> <field name="name">Process steps 000 SCSS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_product_catalog.xml b/addons/website/views/snippets/s_product_catalog.xml index 3a820652da7fe..03404c1a62f4d 100644 --- a/addons/website/views/snippets/s_product_catalog.xml +++ b/addons/website/views/snippets/s_product_catalog.xml @@ -65,38 +65,6 @@ </we-row> </template> -<template id="s_product_catalog_options" inherit_id="website.snippet_options"> - <xpath expr="//t[@t-call='website.snippet_options_background_options']" position="before"> - <div data-js="MultipleItems" - data-selector=".s_product_catalog"> - <t t-call="website.s_product_catalog_add_product_widget"> - <t t-set="apply_to" t-valuef="> :has(.s_product_catalog_dish):not(:has(.row > div:has(.s_product_catalog_dish)))"/> - </t> - </div> - <div data-js="MultipleItems" - data-selector=".s_product_catalog .row > div"> - <t t-call="website.s_product_catalog_add_product_widget"> - <t t-set="apply_to" t-valuef="> :has(.s_product_catalog_dish)"/> - </t> - </div> - </xpath> - <xpath expr="." position="inside"> - <div data-js="ProductCatalog" data-selector=".s_product_catalog"> - <we-checkbox string="Descriptions" data-toggle-description="true" data-no-preview="true"/> - <t t-call="website.snippet_options_border_line_widgets"> - <t t-set="label">⌙ Separator</t> - <t t-set="direction" t-value="'top'"/> - <t t-set="apply_to" t-value="'.s_product_catalog_dish_description'"/> - <!-- Note: no need of extra dependency thanks to the apply-to --> - </t> - </div> - <div data-selector=".s_product_catalog_dish" data-drop-near=".s_product_catalog_dish"/> - </xpath> - <xpath expr="//div[@data-js='SnippetMove']" position="attributes"> - <attribute name="data-selector" add=".s_product_catalog_dish" separator=","/> - </xpath> -</template> - <record id="website.s_product_catalog_001_scss" model="ir.asset"> <field name="name">Product catalog 001 SCSS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_progress_bar.xml b/addons/website/views/snippets/s_progress_bar.xml index 82406bc2ff3e7..62ece643bc82a 100644 --- a/addons/website/views/snippets/s_progress_bar.xml +++ b/addons/website/views/snippets/s_progress_bar.xml @@ -14,35 +14,5 @@ </div> </template> -<template id="s_progress_bar_options" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <div data-js="progress" data-selector=".s_progress_bar" > - <we-input string="Value" data-progress-bar-value="" data-unit="%"/> - <we-select string="Label"> - <we-button data-display="inline" data-select-class="s_progress_bar_label_inline">Display Inside</we-button> - <we-button data-display="below" data-select-class="s_progress_bar_label_below">Display Below</we-button> - <we-button data-display="after" data-select-class="s_progress_bar_label_after">Display After</we-button> - <we-button data-display="none" data-select-class="s_progress_bar_label_hidden">Hide</we-button> - </we-select> - <we-colorpicker string="Colors" data-apply-to=".progress-bar" - data-select-style="true" - data-css-property="background-color" - data-color-prefix="bg-"/> - <we-checkbox string="Striped" data-name="progress_striped_opt" data-select-class="progress-bar-striped" data-apply-to=".progress-bar" data-no-preview="true"/> - <we-checkbox string="Animated" class="o_we_sublevel_1" data-dependencies="progress_striped_opt" data-select-class="progress-bar-animated" data-apply-to=".progress-bar" data-no-preview="true"/> - </div> - </xpath> - <xpath expr="//div[@id='so_content_addition']" position="attributes"> - <attribute name="data-selector" add=".s_progress_bar" separator=","/> - <attribute name="data-drop-near" add=".s_progress_bar" separator=","/> - </xpath> -</template> - - -<record id="website.s_progress_bar_001_scss" model="ir.asset"> - <field name="name">Progress bar 001 SCSS</field> - <field name="bundle">web.assets_frontend</field> - <field name="path">website/static/src/snippets/s_progress_bar/001.scss</field> -</record> - +<!-- No related CSS but there is some in theme overrides --> </odoo> diff --git a/addons/website/views/snippets/s_rating.xml b/addons/website/views/snippets/s_rating.xml index e737191ce82e7..e095ea506c42b 100644 --- a/addons/website/views/snippets/s_rating.xml +++ b/addons/website/views/snippets/s_rating.xml @@ -18,48 +18,6 @@ </div> </template> -<template id="s_rating_options" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <div data-js="Rating" data-selector=".s_rating"> - <we-select string="Icon"> - <we-button data-set-icons="fa-star"><i class="fa fa-fw fa-star"/> Stars</we-button> - <we-button data-set-icons="fa-thumbs-up"><i class="fa fa-fw fa-thumbs-up"/> Thumbs</we-button> - <we-button data-set-icons="fa-circle"><i class="fa fa-fw fa-circle"/> Circles</we-button> - <we-button data-set-icons="fa-square"><i class="fa fa-fw fa-square"/> Squares</we-button> - <we-button data-set-icons="fa-heart"><i class="fa fa-fw fa-heart"/> Hearts</we-button> - <we-button data-set-icons="custom" class="d-none">Custom</we-button> - </we-select> - <we-row string="⌙ Active"> - <we-colorpicker data-select-style="" data-apply-to=".s_rating_active_icons" data-css-property="color" data-color-prefix="text-"/> - <we-button data-custom-icon="true" data-custom-active-icon="true" data-no-preview="true"> - <i class="fa fa-fw fa-refresh me-1"/> Replace Icon - </we-button> - </we-row> - <we-row string="⌙ Inactive"> - <we-colorpicker data-select-style="" data-apply-to=".s_rating_inactive_icons" data-css-property="color" data-color-prefix="text-"/> - <we-button data-custom-icon="true" data-custom-active-icon="false" data-no-preview="true"> - <i class="fa fa-fw fa-refresh me-1"/> Replace Icon - </we-button> - </we-row> - <we-row string="Score"> - <we-input data-active-icons-number="true" data-step="1"/> - <span class="mx-2">/</span> - <we-input data-total-icons-number="true" data-step="1"/> - </we-row> - <we-button-group string="Size" data-apply-to=".s_rating_icons"> - <we-button data-select-class="" title="Small" data-img="/website/static/src/img/snippets_options/size_small.svg"/> - <we-button data-select-class="fa-2x" title="Medium" data-img="/website/static/src/img/snippets_options/size_medium.svg"/> - <we-button data-select-class="fa-3x" title="Large" data-img="/website/static/src/img/snippets_options/size_large.svg"/> - </we-button-group> - <we-select string="Title Position"> - <we-button data-select-class="">Top</we-button> - <we-button data-select-class="s_rating_inline">Left</we-button> - <we-button data-select-class="s_rating_no_title">None</we-button> - </we-select> - </div> - </xpath> -</template> - <record id="website.s_rating_000_scss" model="ir.asset"> <field name="name">Rating 000 SCSS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_searchbar.xml b/addons/website/views/snippets/s_searchbar.xml index a4bd936a0e56b..16d41747629bd 100644 --- a/addons/website/views/snippets/s_searchbar.xml +++ b/addons/website/views/snippets/s_searchbar.xml @@ -27,42 +27,6 @@ </section> </template> -<template id="searchbar_input_snippet_options" inherit_id="website.snippet_options" name="search bar snippet options"> - <xpath expr="." position="inside"> - <div data-js="SearchBar" data-selector=".s_searchbar_input"> - <we-select string="Search within" data-name="scope_opt" - data-attribute-name="searchType" data-apply-to=".search-query"> - <we-button data-set-search-type="all" data-select-data-attribute="all" data-name="search_all_opt" data-form-action="/website/search">Everything</we-button> - <we-button data-set-search-type="pages" data-select-data-attribute="pages" data-name="search_pages_opt" data-form-action="/pages">Pages</we-button> - </we-select> - <we-select string="Order by" data-name="order_opt" data-attribute-name="orderBy" data-apply-to=".search-query"> - <we-button data-set-order-by="name asc" data-select-data-attribute="name asc" data-name="order_name_asc_opt">Name (A-Z)</we-button> - </we-select> - <t t-set="unit">results</t> - <we-input string="Suggestions" data-name="limit_opt" data-attribute-name="limit" - data-apply-to=".search-query" data-select-data-attribute="" t-att-data-unit="unit"/> - <div data-dependencies="limit_opt"> - <we-checkbox string="Description" data-dependencies="search_all_opt" data-select-data-attribute="true" data-attribute-name="displayDescription" - data-apply-to=".search-query"/> - <we-checkbox string="Extra Link" data-dependencies="search_all_opt" data-select-data-attribute="true" data-attribute-name="displayExtraLink" - data-apply-to=".search-query"/> - <we-checkbox string="Detail" data-dependencies="search_all_opt" data-select-data-attribute="true" data-attribute-name="displayDetail" - data-apply-to=".search-query"/> - <we-checkbox string="Image" data-dependencies="search_all_opt" data-select-data-attribute="true" data-attribute-name="displayImage" - data-apply-to=".search-query"/> - - <we-checkbox string="Content" data-dependencies="search_pages_opt" data-select-data-attribute="true" data-attribute-name="displayDescription" - data-apply-to=".search-query"/> - </div> - <we-select string="Style"> - <we-button data-set-searchbar-style="default">Default Input Style</we-button> - <we-button data-set-searchbar-style="light">Light</we-button> - </we-select> - </div> - </xpath> - <xpath expr="//*[@t-set='so_content_addition_selector']" position="inside">, .s_searchbar_input</xpath> -</template> - <record id="website.s_searchbar_000_js" model="ir.asset"> <field name="name">Searchbar 000 JS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_showcase.xml b/addons/website/views/snippets/s_showcase.xml index ec5aca7e18dd8..52b7f248d7bff 100644 --- a/addons/website/views/snippets/s_showcase.xml +++ b/addons/website/views/snippets/s_showcase.xml @@ -49,13 +49,6 @@ </section> </template> -<!-- Options --> -<template id="s_showcase_options" inherit_id="website.snippet_options"> - <xpath expr="//div[@data-js='Box']" position="before"> - <div data-js="Showcase" data-selector=".s_showcase .row > div:has(> .s_showcase_title)"/> - </xpath> -</template> - <!-- Assets --> <record id="website.s_showcase_000_scss" model="ir.asset"> <field name="name">Showcase 000 SCSS</field> diff --git a/addons/website/views/snippets/s_social_media.xml b/addons/website/views/snippets/s_social_media.xml index 22e96aaf1a217..07ff20eb689de 100644 --- a/addons/website/views/snippets/s_social_media.xml +++ b/addons/website/views/snippets/s_social_media.xml @@ -32,19 +32,6 @@ title stay editable after a save (see SOCIAL_MEDIA_TITLE_CONTENTEDITABLE). </div> </template> -<template id="s_social_media_options" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <div data-js="SocialMedia" data-selector=".s_social_media"> - <t t-set="add_item_title">Add New Social Network</t> - <we-list string="Social Networks" t-att-data-add-item-title="add_item_title" - data-render-list-items="" data-has-default="multiple" data-name="social_media_list" - data-default-value="https://www.example.com" data-id-mode="name" - data-new-elements-not-toggleable="true" data-no-preview="true" - data-render-on-input-blur="true"/> - </div> - </xpath> -</template> - <record id="website.s_social_media_000_scss" model="ir.asset"> <field name="name">SocialMedia 000 SCSS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_tabs.xml b/addons/website/views/snippets/s_tabs.xml index 87899e7a5a253..06e5a0fef98d0 100644 --- a/addons/website/views/snippets/s_tabs.xml +++ b/addons/website/views/snippets/s_tabs.xml @@ -52,48 +52,6 @@ </section> </template> -<template id="s_tabs_options" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <div data-js="NavTabs" data-selector="section.s_tabs"> - <we-button data-add-item="" data-item=".tab-content:first > .tab-pane.active" data-no-preview="true" class="o_we_text_success ms-0" title="Add Tab" aria-label="Add Tab"> - <i class="fa fa-fw fa-plus"/> - </we-button> - <we-button data-remove-item="" data-item=".tab-content:first > .tab-pane.active" data-name="remove_tab_opt" data-no-preview="true" class="o_we_text_danger me-3" title="Remove Tab" aria-label="Remove Tab"> - <i class="fa fa-fw fa-minus"/> - </we-button> - </div> - <div data-js="NavTabsStyle" data-selector="section" data-target=".s_tabs_main"> - <we-select string="Style"> - <we-button data-set-style="tabs" data-name="tabs_opt" data-trigger="horizontal_opt">Tabs</we-button> - <we-button data-set-style="pills" data-name="pills_opt" data-trigger="tabs_color_opt" data-trigger-value="">Buttons</we-button> - </we-select> - <we-colorpicker string="Tabs Color" data-dependencies="tabs_opt" data-name="tabs_color_opt" data-select-style="true" data-css-property="background-color" data-color-prefix="bg-"/> - <we-select string="Direction" data-dependencies="pills_opt"> - <we-button data-set-direction="horizontal" data-name="horizontal_opt">Horizontal</we-button> - <we-button data-set-direction="vertical" data-trigger="left_alignment_opt">Vertical</we-button> - </we-select> - <we-select string="Alignment" data-apply-to=".s_tabs_nav:first .nav" data-dependencies="horizontal_opt"> - <we-button data-select-class="" data-name="left_alignment_opt">Left</we-button> - <we-button data-select-class="justify-content-center">Center</we-button> - <we-button data-select-class="justify-content-end">Right</we-button> - </we-select> - <we-select string="Fill and Justify" data-apply-to=".s_tabs_nav:first .nav" data-dependencies="horizontal_opt"> - <we-button data-select-class="">Regular</we-button> - <we-button data-select-class="nav-fill">Full Width</we-button> - <we-button data-select-class="nav-justified">Equal Widths</we-button> - </we-select> - <we-divider/> - <we-button-group string="Slide" data-apply-to=".s_tabs_content:first"> - <we-button class="fa fa-fw fa-long-arrow-left" title="Slide Left" data-select-class="s_tabs_slide_left"/> - <we-button class="fa fa-fw fa-long-arrow-up" title="Slide Up" data-select-class="s_tabs_slide_up"/> - <we-button class="fa fa-fw fa-long-arrow-down" title="Slide Down" data-select-class="s_tabs_slide_down"/> - <we-button class="fa fa-fw fa-long-arrow-right" title="Slide Right" data-select-class="s_tabs_slide_right"/> - <we-button class="fa fa-fw fa-ban" title="No Slide Effect" data-select-class=""/> - </we-button-group> - </div> - </xpath> -</template> - <record id="website.s_tabs_001_scss" model="ir.asset"> <field name="name">Tabs 001 SCSS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_timeline.xml b/addons/website/views/snippets/s_timeline.xml index 882925aad0558..cce353aacdc76 100644 --- a/addons/website/views/snippets/s_timeline.xml +++ b/addons/website/views/snippets/s_timeline.xml @@ -52,30 +52,6 @@ </section> </template> -<template id="s_timeline_options" inherit_id="website.snippet_options"> - <xpath expr="//t[@t-call='website.snippet_options_background_options']" position="before"> - <div data-js="MultipleItems" data-selector=".s_timeline"> - <we-row string="Year"> - <we-button data-add-item="" data-item=".s_timeline_row:first" data-select-item="" data-add-before="true" data-no-preview="true" class="o_we_bg_brand_primary"> - Add Year - </we-button> - </we-row> - </div> - </xpath> - <xpath expr="." position="inside"> - <div data-selector=".s_timeline_row" data-drop-near=".s_timeline_row"/> - <div data-js="Timeline" data-selector=".s_timeline_card"> - <we-button data-timeline-card="" data-no-preview="true" class="o_we_overlay_opt"><i class="fa fa-fw fa-angle-left"/><i class="fa fa-fw fa-angle-right"/></we-button> - </div> - <div data-selector=".s_timeline"> - <we-colorpicker string="Line Color" data-select-style="true" data-css-property="border-color" data-color-prefix="border-" data-apply-to=".s_timeline_line"/> - </div> - </xpath> - <xpath expr="//div[@data-js='SnippetMove'][contains(@data-selector,'section')]" position="attributes"> - <attribute name="data-selector" add=".s_timeline_row" separator=","/> - </xpath> -</template> - <record id="website.s_timeline_000_scss" model="ir.asset"> <field name="name">Timeline 000 SCSS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website/views/snippets/s_website_controller_page_listing_layout.xml b/addons/website/views/snippets/s_website_controller_page_listing_layout.xml deleted file mode 100644 index aeb1ea095d0f7..0000000000000 --- a/addons/website/views/snippets/s_website_controller_page_listing_layout.xml +++ /dev/null @@ -1,17 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" ?> -<odoo> - -<template id="s_website_controller_page_listing_layout" name="Default layout" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <!-- All records page --> - <div data-js="WebsiteControllerPageListingLayout" data-page-options="true" groups="website.group_website_designer" data-selector=".listing_layout_switcher" data-no-check="true" - string="Layout"> - <we-select data-name="default_listing_layout" string="Default Layout" data-no-preview="true" data-reload="/"> - <we-button data-set-layout="grid" data-name="grid_view_opt">Grid</we-button> - <we-button data-set-layout="list" data-name="list_view_opt">List</we-button> - </we-select> - </div> - </xpath> -</template> - -</odoo> diff --git a/addons/website/views/snippets/s_website_form.xml b/addons/website/views/snippets/s_website_form.xml index c34b1edde5046..c265c64e1b6b7 100644 --- a/addons/website/views/snippets/s_website_form.xml +++ b/addons/website/views/snippets/s_website_form.xml @@ -97,234 +97,6 @@ </section> </template> -<template id="s_website_form_options" inherit_id="website.snippet_options"> - <!-- Extend drop locations to columns --> - <xpath expr="//t[@t-set='so_content_addition_selector']" position="inside">, .s_website_form</xpath> - - <xpath expr="//div" position="after"> - <!-- Form --> - <div data-js="WebsiteFormEditor" data-selector=".s_website_form" data-target="form"> - <we-select string="Marked Fields" data-name="field_mark_select"> - <we-button data-select-class="">None</we-button> - <we-button data-select-class="o_mark_required" data-name="form_required_opt">Required</we-button> - <we-button data-select-class="o_mark_optional" data-name="form_optional_opt">Optional</we-button> - </we-select> - <we-input string="Mark Text" data-set-mark="" data-dependencies="form_required_opt, form_optional_opt"/> - <we-input string="Labels Width" - data-select-style="" data-css-property="width" - data-unit="px" data-apply-to=".s_website_form_label"/> - <we-row> - <we-select string="On Success" data-no-preview="true"> - <we-button data-on-success="nothing">Nothing</we-button> - <we-button data-on-success="redirect" data-name="show_redirect_opt">Redirect</we-button> - <we-button data-on-success="message" data-name="show_message_opt">Show Message</we-button> - </we-select> - <we-button class="fa fa-fw fa-eye align-self-end toggle-edit-message" title="Edit Message" data-name="message_opt" data-dependencies="show_message_opt"/> - </we-row> - <we-urlpicker string="URL" data-select-data-attribute="/contactus-thank-you" data-attribute-name="successPage" data-name="url_opt" data-dependencies="show_redirect_opt"/> - <t t-set="recaptcha_public_key" t-value="request.env['ir.config_parameter'].sudo().get_param('recaptcha_public_key')"/> - <we-checkbox t-if="recaptcha_public_key" string="Show reCaptcha Policy" data-toggle-recaptcha-legal="" data-no-preview="true"/> - </div> - - <!-- Add Field Form --> - <div data-js="AddFieldForm" data-selector=".s_website_form" data-target="form"> - <we-button class="o_we_bg_brand_primary" - title="Add a new field at the end" - data-add-field="" - data-no-preview="true"> - + Field - </we-button> - </div> - - <!-- Add Field --> - <div data-js="AddField" data-selector=".s_website_form_field" data-exclude=".s_website_form_dnone"> - <we-button class="o_we_bg_brand_primary" - title="Add a new field after this one" - data-add-field="" - data-no-preview="true"> - + Field - </we-button> - </div> - - <!-- Field --> - <div data-js="WebsiteFormFieldRequired" data-selector=".s_website_form .s_website_form_model_required"> - <!-- Disable the delete option of model required fields and show - alert and tooltip on delete buttons --> - <we-alert class="mt-2"></we-alert> - </div> - - <div data-js='WebsiteFieldEditor' data-selector=".s_website_form_field" - data-exclude=".s_website_form_dnone" data-drop-near=".s_website_form_field" - data-drop-lock-within="form"> - <we-select data-name="type_opt" string="Type" data-no-preview="true"> - <we-title>Custom Field</we-title> - <we-button data-custom-field="char">Text</we-button> - <we-button data-custom-field="text">Long Text</we-button> - <we-button data-custom-field="email">Email</we-button> - <we-button data-custom-field="tel">Telephone</we-button> - <we-button data-custom-field="url">Url</we-button> - <we-button data-custom-field="integer">Number</we-button> - <we-button data-custom-field="float">Decimal Number</we-button> - <we-button data-custom-field="boolean">Checkbox</we-button> - <we-button data-custom-field="one2many">Multiple Checkboxes</we-button> - <we-button data-custom-field="selection">Radio Buttons</we-button> - <we-button data-custom-field="many2one">Selection</we-button> - <we-button data-custom-field="date">Date</we-button> - <we-button data-custom-field="datetime">Date & Time</we-button> - <we-button data-custom-field="binary">File Upload</we-button> - </we-select> - <we-select data-name="char_input_type_opt" string="Input Type" data-no-preview="true"> - <we-button data-select-type="char">Text</we-button> - <we-button data-select-type="email">Email</we-button> - <we-button data-select-type="tel">Telephone</we-button> - <we-button data-select-type="url">Url</we-button> - </we-select> - <we-select string="Selection type" data-name="existing_field_select_type_opt" data-no-preview="true"> - <we-button data-existing-field-select-type="many2one">Dropdown List</we-button> - <we-button data-existing-field-select-type="selection">Radio</we-button> - </we-select> - <we-select string="Display" class="o_we_sublevel_1" data-name="multi_check_display_opt" data-no-preview="true"> - <we-button data-multi-checkbox-display="horizontal">Horizontal</we-button> - <we-button data-multi-checkbox-display="vertical">Vertical</we-button> - </we-select> - <t t-set="unit_textarea_height">rows</t> - <we-input string="Height" - class="o_we_sublevel_1" - data-apply-to="textarea" - t-att-data-unit="unit_textarea_height" data-step="1" - t-attf-data-select-attribute="3#{unit_textarea_height}" - data-attribute-name="rows"/> - <we-input string="Label" class="o_we_large" data-set-label-text=""/> - <we-button-group string="Position" class="o_we_sublevel_1"> - <we-button title="Hide" - data-select-label-position="none"> - <i class="fa fa-eye-slash"/> - </we-button> - <we-button title="Top" - data-select-label-position="top" - data-img="/website/static/src/img/snippets_options/pos_top.svg"/> - <we-button title="Left" - data-select-label-position="left" - data-img="/website/static/src/img/snippets_options/pos_left.svg"/> - <we-button title="Right" - data-select-label-position="right" - data-img="/website/static/src/img/snippets_options/pos_right.svg"/> - </we-button-group> - <we-checkbox string="Description" data-toggle-description="true" data-no-preview="true"/> - <we-input string="Placeholder" class="o_we_large" - data-select-attribute="" data-attribute-name="placeholder" - data-apply-to="input[type='text'], input[type='email'], input[type='number'], input[type='tel'], input[type='url'], textarea"/> - <t t-set="default_value_label">Default Value</t> - <we-input t-att-string="default_value_label" class="o_we_large" data-select-textarea-value="" data-apply-to="textarea"/> - <we-checkbox t-att-string="default_value_label" data-select-attribute="checked" data-attribute-name="checked" - data-apply-to=".col-sm > * > input[type='checkbox']" data-no-preview="true"/> - <we-input t-att-string="default_value_label" class="o_we_large" data-select-attribute="" data-attribute-name="value" data-select-property="" - data-property-name="value" data-apply-to="input[type='text']:not(.datetimepicker-input), input[type='email'], input[type='tel'], input[type='url']"/> - <we-input t-att-string="default_value_label" class="o_we_large" data-select-attribute="" data-attribute-name="value" data-select-property="" - data-step="1" data-property-name="value" data-apply-to="input[type='number']"/> - <we-datetimepicker t-att-string="default_value_label" data-select-attribute="" data-attribute-name="value" data-select-value-property="" - data-apply-to=".s_website_form_datetime input"/> - <we-datepicker t-att-string="default_value_label" data-select-attribute="" data-attribute-name="value" data-select-value-property="" - data-apply-to=".s_website_form_date input"/> - <we-checkbox string="Required" data-name="required_opt" data-no-preview="true" - data-toggle-required="s_website_form_required"/> - - <we-input data-name="max_files_number_opt" - string="Max # of Files" - title="The maximum number of files that can be uploaded." - data-attribute-name="maxFilesNumber" - data-select-data-attribute="1" - data-apply-to="input[type='file']" - data-step="1"/> - - <we-input string="Max File Size" - title="The maximum size (in MB) an uploaded file can have." - data-attribute-name="maxFileSize" - data-select-data-attribute="1MB" - data-apply-to="input[type='file']" - data-unit="MB"/> - - <we-select string="Visibility" data-no-preview="true"> - <we-button data-set-visibility="visible" data-select-class="">Always Visible</we-button> - <we-button data-set-visibility="hidden" data-select-class="s_website_form_field_hidden">Hidden</we-button> - <we-button data-set-visibility="conditional" data-select-class="s_website_form_field_hidden_if d-none">Visible only if</we-button> - </we-select> - <we-row data-name="hidden_condition_opt" string=""> - <we-select data-name="hidden_condition_opt" data-no-preview="true"> - <!-- Load every existing form input --> - </we-select> - <we-select data-name="hidden_condition_no_text_opt" data-attribute-name="visibilityComparator" data-no-preview="true"> - <we-button data-select-data-attribute="selected">Is equal to</we-button> - <we-button data-select-data-attribute="!selected">Is not equal to</we-button> - <we-button data-select-data-attribute="contains">Contains</we-button> - <we-button data-select-data-attribute="!contains">Doesn't contain</we-button> - </we-select> - <we-select data-name="hidden_condition_text_opt" data-attribute-name="visibilityComparator" data-no-preview="true"> - <!-- str comparator possibilities --> - <we-button data-select-data-attribute="contains">Contains</we-button> - <we-button data-select-data-attribute="!contains">Doesn't contain</we-button> - <we-button data-select-data-attribute="equal">Is equal to</we-button> - <we-button data-select-data-attribute="!equal">Is not equal to</we-button> - <we-button data-select-data-attribute="set">Is set</we-button> - <we-button data-select-data-attribute="!set">Is not set</we-button> - </we-select> - <we-select data-name="hidden_condition_num_opt" data-attribute-name="visibilityComparator" data-no-preview="true"> - <!-- number comparator possibilities --> - <we-button data-select-data-attribute="equal">Is equal to</we-button> - <we-button data-select-data-attribute="!equal">Is not equal to</we-button> - <we-button data-select-data-attribute="greater">Is greater than</we-button> - <we-button data-select-data-attribute="less">Is less than</we-button> - <we-button data-select-data-attribute="greater or equal">Is greater than or equal to</we-button> - <we-button data-select-data-attribute="less or equal">Is less than or equal to</we-button> - <we-button data-select-data-attribute="set">Is set</we-button> - <we-button data-select-data-attribute="!set">Is not set</we-button> - </we-select> - <we-select data-name="hidden_condition_time_comparators_opt" data-attribute-name="visibilityComparator" data-no-preview="true"> - <!-- date & datetime comparator possibilities --> - <we-button data-select-data-attribute="dateEqual">Is equal to</we-button> - <we-button data-select-data-attribute="date!equal">Is not equal to</we-button> - <we-button data-select-data-attribute="after">Is after</we-button> - <we-button data-select-data-attribute="before">Is before</we-button> - <we-button data-select-data-attribute="equal or after">Is after or equal to</we-button> - <we-button data-select-data-attribute="equal or before">Is before or equal to</we-button> - <we-button data-select-data-attribute="set">Is set</we-button> - <we-button data-select-data-attribute="!set">Is not set</we-button> - <we-button data-select-data-attribute='between'>Is between (included)</we-button> - <we-button data-select-data-attribute='!between'>Is not between (excluded)</we-button> - </we-select> - <we-select data-name="hidden_condition_file_opt" data-attribute-name="visibilityComparator" data-no-preview="true"> - <!-- file comparator possibilities --> - <we-button data-select-data-attribute="fileSet">Is set</we-button> - <we-button data-select-data-attribute="!fileSet">Is not set</we-button> - </we-select> - </we-row> - <we-select class="o_we_large" data-name="hidden_condition_no_text_opt" data-attribute-name="visibilityCondition" data-no-preview="true"> - <!-- checkbox, select, radio possible values --> - </we-select> - <we-input class="o_we_large" data-name="hidden_condition_additional_text" data-attribute-name="visibilityCondition" data-select-data-attribute=""/> - <we-datetimepicker data-name="hidden_condition_additional_datetime" data-attribute-name="visibilityCondition" data-select-data-attribute="" /> - <we-datepicker data-name="hidden_condition_additional_date" data-attribute-name="visibilityCondition" data-select-data-attribute=""/> - <we-datetimepicker data-name="hidden_condition_datetime_between" data-attribute-name="visibilityBetween" data-select-data-attribute=""/> - <we-datepicker data-name="hidden_condition_date_between" data-attribute-name="visibilityBetween" data-select-data-attribute="" /> - </div> - - <div data-js="WebsiteFormSubmit" data-selector=".s_website_form_submit" data-exclude=".s_website_form_no_submit_options"> - <we-select string="Button Position"> - <we-button data-select-class="text-start s_website_form_no_submit_label">Left</we-button> - <we-button data-select-class="text-center s_website_form_no_submit_label">Center</we-button> - <we-button data-select-class="text-end s_website_form_no_submit_label">Right</we-button> - <we-button data-select-class="">Input Aligned</we-button> - </we-select> - </div> - - <!-- Remove the duplicate option of model fields --> - <div data-js="WebsiteFormFieldModel" data-selector=".s_website_form .s_website_form_field:not(.s_website_form_custom)"/> - - <!-- Remove the delete and duplicate option of the submit button --> - <div data-js="WebsiteFormSubmitRequired" data-selector=".s_website_form .s_website_form_submit"/> - </xpath> -</template> - <record id="website.s_website_form_000_scss" model="ir.asset"> <field name="name">Website form 000 SCSS</field> diff --git a/addons/website/views/snippets/snippets.xml b/addons/website/views/snippets/snippets.xml index 135c5ee0eeb03..bca964149e186 100644 --- a/addons/website/views/snippets/snippets.xml +++ b/addons/website/views/snippets/snippets.xml @@ -378,38 +378,6 @@ </we-multi> </template> -<template id="snippet_options_header_box"> - <t t-call="website.snippet_options_border_widgets"> - <t t-set="so_rounded_no_dependencies" t-value="True"/> - <t t-set="width_variable" t-value="'menu-border-width'"/> - <t t-set="style_variable" t-value="'menu-border-style'"/> - <t t-set="color_variable" t-value="'menu-border-color'"/> - <t t-set="radius_variable" t-value="'menu-border-radius'"/> - <t t-set="no_border_radius" t-value="_no_border_radius"/> - </t> - <t t-call="website.snippet_options_shadow_widgets"> - <t t-set="shadow_variable" t-value="'menu-box-shadow'"/> - </t> -</template> - -<template id="snippet_options_conditional_visibility"> - <we-select t-att-string="option_name" class="o_we_sublevel_1" - data-dependencies="visibility_conditional" - t-att-data-attribute-name="attribute_rule" - data-no-preview="true" data-is-visibility-condition="true"> - <we-button data-select-data-attribute="">Visible for</we-button> - <we-button data-select-data-attribute="hide">Hidden for</we-button> - </we-select> - <we-many2many string=" " - data-dependencies="visibility_conditional" - t-att-data-save-attribute="save_attribute" - t-att-data-attribute-name="attribute_name" - data-no-preview="true" - t-att-data-model="model" t-att-data-call-with="call_with" data-select-record="" t-att-data-fields="data_fields" t-att-data-domain="domain" - data-allow-delete="true" data-fakem2m="true"/> -</template> - -<!-- Column count option --> <template id="column_count_option"> <we-select t-att-string="not with_grid and 'Columns'" t-att-class="with_grid and 'o_grid'" data-no-preview="true" data-name="column_count_opt" t-att-data-dependencies="with_grid and 'normal_mode'"> <we-button data-select-count="0" data-name="zero_cols_opt">None</we-button> @@ -475,75 +443,6 @@ <xpath expr="." position="inside"> - <!-- COLOR, BORDER, SHADOW | .s_three_columns | .s_comparisons --> - <div data-js="Box" - data-selector=".s_three_columns .row > div, .s_comparisons .row > div" - data-target=".card"> - <we-colorpicker string="Colors" - data-select-style="true" - data-css-property="background-color" - data-color-prefix="bg-"/> - <t t-call="website.snippet_options_border_widgets"> - <t t-set="so_rounded_no_dependencies" t-value="True"/> - </t> - <t t-call="website.snippet_options_shadow_widgets"/> - </div> - - <!-- COLOR | .s_cards --> - <div data-selector=".accordion .card"> - <we-colorpicker string="Color" - data-select-style="true" - data-css-property="background-color" - data-color-prefix="bg-"/> - </div> - - <!-- H-ALIGN --> - <div id="so_text_align" data-selector=".s_share, .s_text_highlight, .s_social_media"> - <we-button-group string="Alignment"> - <we-button class="fa fa-fw fa-align-left" title="Left" data-select-class="text-start"/> - <we-button class="fa fa-fw fa-align-center" title="Center" data-select-class="text-center"/> - <we-button class="fa fa-fw fa-align-right" title="Right" data-select-class="text-end"/> - </we-button-group> - </div> - - <div data-selector=".s_share, .s_social_media"> - <we-select string="Title Position" data-apply-to=".s_share_title, .s_social_media_title"> - <we-button data-select-class="d-block">Top</we-button> - <we-button data-select-class="">Left</we-button> - <we-button data-select-class="d-none">None</we-button> - </we-select> - <we-select string="Layout" data-apply-to=".fa"> - <we-button data-select-class="rounded shadow-sm">Square</we-button> - <we-button data-select-class="rounded-empty-circle shadow-sm">Circle</we-button> - <we-button data-select-class="rounded-circle shadow-sm">Disk</we-button> - <we-button data-select-class="fa-stack">None</we-button> - </we-select> - <we-select string="Size" data-apply-to=".fa"> - <we-button data-select-class="">Small</we-button> - <we-button data-select-class="fa-2x">Medium</we-button> - <we-button data-select-class="fa-3x">Big</we-button> - </we-select> - <!-- Compatibility, keep reverse logical, don't use `icon_color` --> - <we-checkbox string="Color" data-select-class="no_icon_color|"/> - </div> - - <div id="so_width" data-selector=".s_alert, .s_blockquote, .s_text_highlight"> - <we-select string="Width"> - <we-button data-select-class="w-25">25%</we-button> - <we-button data-select-class="w-50">50%</we-button> - <we-button data-select-class="w-75">75%</we-button> - <we-button data-select-class="w-100" data-name="so_width_100">100%</we-button> - </we-select> - </div> - - <div id="so_block_align" data-selector=".s_alert, .s_card, .s_blockquote, .s_text_highlight"> - <we-button-group string="Alignment" data-dependencies="!so_width_100"> - <we-button class="fa fa-fw fa-align-left" title="Left" data-select-class="me-auto"/> - <we-button class="fa fa-fw fa-align-center" title="Center" data-select-class="mx-auto"/> - <we-button class="fa fa-fw fa-align-right" title="Right" data-select-class="ms-auto"/> - </we-button-group> - </div> - <!-- Carousel | .s_carousel | .s_quotes_carousel --> <!-- Dedicated colorpicker so that there is not 3 level of o_colored_level. Use inline-style color for the same reason. --> @@ -557,46 +456,7 @@ <t t-set="css_compatible" t-value="True"/> <t t-set="with_gradients" t-value="True"/> </t> - <div data-js="Carousel" - data-selector="section" - data-target="> .carousel"> - <we-row string="Slide"> - <we-button data-add-slide="true" data-no-preview="true" class="o_we_bg_brand_primary">Add Slide</we-button> - </we-row> - <we-select string="Style"> - <we-button data-select-class="s_carousel_default">Default</we-button> - <we-button data-select-class="s_carousel_bordered">Bordered</we-button> - <we-button data-select-class="s_carousel_boxed">Boxed</we-button> - <we-button data-select-class="s_carousel_rounded">Rounded</we-button> - </we-select> - <we-select string="Transition"> - <we-button data-select-class="slide">Slide</we-button> - <we-button data-select-class="carousel-fade slide">Fade</we-button> - <we-divider/> - <we-button data-select-class="">None</we-button> - </we-select> - <we-input string="Speed" - data-select-data-attribute="0s" data-attribute-name="bsInterval" - data-unit="s" data-save-unit="ms" data-step="0.1"/> - </div> - - <div data-js="CarouselItem" - data-selector=".s_carousel .carousel-item, .s_quotes_carousel .carousel-item"> - <we-button class="fa fa-fw fa-angle-left" data-switch-to-slide="left" data-no-preview="true" title="Move Backward"/> - <we-button class="fa fa-fw fa-angle-right me-2" data-switch-to-slide="right" data-no-preview="true" title="Move Forward"/> - <we-button class="fa fa-fw fa-plus o_we_bg_success" data-add-slide-item="true" data-no-preview="true" title="Add Slide"/> - <we-button class="fa fa-fw fa-minus o_we_bg_danger" data-remove-slide="true" data-no-preview="true" title="Remove Slide"/> - </div> - <div data-js="GalleryElement" - data-selector=".s_image_gallery img, .s_carousel .carousel-item"> - <we-row string="Re-order" data-no-preview="true"> - <we-button class="fa fa-fw fa-angle-double-left" title="Move to first" data-position="first"/> - <we-button class="fa fa-fw fa-angle-left" title="Move to previous" data-position="prev"/> - <we-button class="fa fa-fw fa-angle-right" title="Move to next" data-position="next"/> - <we-button class="fa fa-fw fa-angle-double-right" title="Move to last" data-position="last"/> - </we-row> - </div> <!-- Accordion --> <div data-js="collapse" data-selector='.accordion > .card' @@ -634,95 +494,25 @@ </div> <!-- Grid and columns --> - <div data-js="layout_column" - data-selector="section, section.s_carousel_wrapper .carousel-item" - data-target="> *:has(> .row), > .s_allow_columns" - data-exclude=".s_masonry_block, .s_features_grid, .s_media_list, .s_table_of_content, .s_process_steps, .s_image_gallery"> - <we-row> - <we-button-group string="Layout" data-no-preview="true"> - <we-button data-select-layout="grid" data-name="grid_mode">Grid</we-button> - <we-button data-select-layout="normal" data-name="normal_mode">Cols</we-button> - </we-button-group> - <t t-call="website.column_count_option"> - <t t-set="with_grid" t-value="True"/> - </t> - </we-row> - <t t-call="website.grid_layout_options"> - <t t-set="indent" t-value="True"/> - </t> - </div> - - <!-- Vertical Alignment --> - <div data-js="vAlignment" id="row_valign_snippet_option" data-selector=".s_text_image, .s_image_text, .s_three_columns, .s_showcase" data-target=".row"> - <we-button-group class="o_we_sublevel_1" string="Vert. Alignment" title="Vertical Alignment" data-dependencies="normal_mode"> - <we-button title="Align Top" - data-select-class="align-items-start" - data-img="/website/static/src/img/snippets_options/align_top.svg"/> - <we-button title="Align Middle" - data-select-class="align-items-center" - data-img="/website/static/src/img/snippets_options/align_middle.svg"/> - <we-button title="Align Bottom" - data-select-class="align-items-end" - data-img="/website/static/src/img/snippets_options/align_bottom.svg"/> - <we-button title="Stretch to Equal Height" - data-select-class="align-items-stretch" - data-img="/website/static/src/img/snippets_options/align_stretch.svg"/> - </we-button-group> - </div> <!-- Move snippets around --> + <!-- TODO: @owl-options To be removed - must remain inheritable for the time being --> <div data-js="SnippetMove" data-selector="section, .accordion > .card, .s_showcase .row:not(.s_col_no_resize) > div, .s_hr" data-no-scroll=".accordion > .card"> + <!-- <we-button class="fa fa-fw fa-angle-up" data-move-snippet="prev" data-no-preview="true" data-name="move_up_opt"/> <we-button class="fa fa-fw fa-angle-down" data-move-snippet="next" data-no-preview="true" data-name="move_down_opt"/> + --> </div> <div data-js="SnippetMove" data-selector=".row:not(.s_col_no_resize) > div, .nav-item" data-exclude=".s_showcase .row > div" data-name="move_horizontally_opt"> + <!-- <we-button class="fa fa-fw fa-angle-left" data-move-snippet="prev" data-no-preview="true" data-name="move_left_opt"/> <we-button class="fa fa-fw fa-angle-right" data-move-snippet="next" data-no-preview="true" data-name="move_right_opt"/> + --> </div> - <!-- Background --> - <t t-set="only_bg_color_selector" t-value="'section .row > div, .s_text_highlight, .s_mega_menu_thumbnails_footer, .s_hr'"/> - <t t-set="only_bg_color_exclude" t-value="'.s_col_no_bgcolor, .s_col_no_bgcolor.row > div, .s_masonry_block .row > div, .s_color_blocks_2 .row > div, .s_image_gallery .row > div, .s_text_cover .row > .o_not_editable, [data-snippet] :not(.oe_structure) > .s_hr'"/> - - <t t-set="base_only_bg_image_selector" t-value="'.s_tabs .oe_structure > *, footer .oe_structure > *'"/> - <t t-set="only_bg_image_selector" t-value="base_only_bg_image_selector"/> - <t t-set="only_bg_image_exclude" t-value="''"/> - - <t t-set="both_bg_color_image_selector" t-value="'section, .carousel-item, .s_masonry_block .row > div, .s_color_blocks_2 .row > div, .parallax, .s_text_cover .row > .o_not_editable'"/> - <t t-set="both_bg_color_image_exclude" t-value="base_only_bg_image_selector + ', .s_carousel_wrapper, .s_image_gallery .carousel-item, .s_google_map, .s_map, [data-snippet] :not(.oe_structure) > [data-snippet], .s_masonry_block .s_col_no_resize'"/> - - <t t-call="website.snippet_options_background_options"> - <t t-set="selector" t-value="both_bg_color_image_selector"/> - <t t-set="exclude" t-value="both_bg_color_image_exclude"/> - <t t-set="with_colors" t-value="True"/> - <t t-set="with_images" t-value="True"/> - <t t-set="with_videos" t-value="True"/> - <t t-set="with_shapes" t-value="True"/> - <t t-set="with_color_combinations" t-value="True"/> - <t t-set="with_gradients" t-value="True"/> - </t> - - <t t-call="website.snippet_options_background_options"> - <t t-set="selector" t-value="only_bg_color_selector"/> - <t t-set="exclude" t-value="only_bg_color_exclude"/> - <t t-set="with_colors" t-value="True"/> - <t t-set="with_images" t-value="False"/> - <t t-set="with_color_combinations" t-value="True"/> - <t t-set="with_gradients" t-value="True"/> - </t> - - <t t-call="website.snippet_options_background_options"> - <t t-set="selector" t-value="only_bg_image_selector"/> - <t t-set="exclude" t-value="only_bg_image_exclude"/> - <t t-set="with_colors" t-value="False"/> - <t t-set="with_images" t-value="True"/> - <t t-set="with_videos" t-value="True"/> - <t t-set="with_shapes" t-value="True"/> - </t> - <!-- Grid mode columns --> <div data-js="GridColumns" data-selector=".row:not(.s_col_no_resize) > div"> @@ -754,24 +544,6 @@ data-drop-near=".row.o_grid_mode > div" data-exclude=".s_col_no_resize.row > div, .s_col_no_resize"/> - <t t-set="so_snippet_addition_selector" t-translation="off">section, .parallax, .s_hr</t> - <div id="so_snippet_addition" - t-att-data-selector="so_snippet_addition_selector" - data-drop-in=":not(p).oe_structure:not(.oe_structure_solo), :not(.o_mega_menu):not(p)[data-oe-type=html], :not(p).oe_structure.oe_structure_solo:not(:has(> section:not(.s_snippet_group), > div:not(.o_hook_drop_zone)))"/> - <!-- /!\ drop-in here is partly duplicated for s_popup (see dedicated options) --> - <!-- TODO should be improved --> - - <t t-set="so_content_addition_selector" t-translation="off"> - blockquote, .s_card:not(.s_timeline_card), .s_alert, .o_facebook_page, .s_share, .s_social_media, .s_rating, - .s_hr, .s_google_map, .s_map, .s_countdown, .s_chart, .s_text_highlight, .s_progress_bar, .s_badge, - .s_embed_code, .s_donation, .s_add_to_cart, .s_online_appointment, .o_snippet_drop_in_only, .s_image - </t> - - <div id="so_content_addition" - t-att-data-selector="so_content_addition_selector" - t-attf-data-drop-near="p, h1, h2, h3, ul, ol, div:not(.o_grid_item_image) > img, .btn, #{so_content_addition_selector}" - data-drop-in="nav"/> - <div data-js="SnippetSave" data-selector="[data-snippet], a.btn" t-attf-data-exclude=".o_no_save, #{so_submit_button_selector}"> @@ -781,11 +553,6 @@ data-no-preview="true"/> </div> - <div data-js="menu_data" - data-selector=".top_menu li > a, [data-content_menu_id] li > a" - data-exclude=".dropdown-toggle, li.o_header_menu_button a, [data-toggle], .o_offcanvas_logo" - data-no-check="true"/> - <div data-js="WebsiteLevelColor" data-selector="#wrapwrap > header" data-no-check="true" @@ -909,275 +676,6 @@ </we-row> </div> - <!-- Header - Borders & Shadows --> - <div data-js="HeaderBox" - id="option_header_shadow" - data-selector="#wrapwrap > header:not(:has(.o_header_force_no_radius))" - data-target="nav" - data-no-check="true" - groups="website.group_website_designer"> - <t t-call="website.snippet_options_header_box"/> - </div> - - <div data-js="HeaderBox" - id="option_header_shadow" - data-selector="#wrapwrap > header:has(.o_header_force_no_radius)" - data-target="nav" - data-no-check="true" - groups="website.group_website_designer"> - <t t-call="website.snippet_options_header_box"> - <t t-set="_no_border_radius" t-value="True"/> - </t> - </div> - - <div data-selector="#wrapwrap > header" - data-no-check="true" - groups="website.group_website_designer"> - - <we-select string="Scroll Effect" data-dependencies="!header_sidebar_opt" class="o_scroll_effects_selector" data-variable="header-scroll-effect"> - <t t-set="header_effect_standard_label">Standard</t> - <t t-set="header_effect_scroll_label">Scroll</t> - <t t-set="header_effect_fixed_label">Fixed</t> - <t t-set="header_effect_disappears_label">Disappears</t> - <t t-set="header_effect_fadeout_label">Fade Out</t> - <we-button id="option_header_visibility_standard" - t-att-data-select-label="header_effect_standard_label" - class="o_we_img_animate" - data-customize-website-variable="'standard'" - data-name="header_visibility_standard_opt" - data-select-class="o_header_standard" - data-customize-website-views="website.header_visibility_standard" - data-img="/website/static/src/img/snippets_options/header_effect_standard.png"> - <span t-esc='header_effect_standard_label'/> - </we-button> - <we-button id="option_header_effect_scroll" - t-att-data-select-label="header_effect_scroll_label" - class="o_we_img_animate" - data-customize-website-variable="'scroll'" - data-name="header_effect_scroll_opt" - data-select-class="" - data-customize-website-views="" - data-img="/website/static/src/img/snippets_options/header_effect_scroll.png"> - <span t-esc='header_effect_scroll_label'/> - </we-button> - <we-button id="option_header_effect_fixed" - t-att-data-select-label="header_effect_fixed_label" - class="o_we_img_animate" - data-customize-website-variable="'fixed'" - data-name="header_effect_fixed_opt" - data-select-class="o_header_fixed" - data-customize-website-views="website.header_visibility_fixed" - data-img="/website/static/src/img/snippets_options/header_effect_fixed.png"> - <span t-esc='header_effect_fixed_label'/> - </we-button> - <we-button id="option_header_effect_disappears" - t-att-data-select-label="header_effect_disappears_label" - class="o_we_img_animate" - data-customize-website-variable="'disappears'" - data-name="header_effect_disappears_opt" - data-select-class="o_header_disappears" - data-customize-website-views="website.header_visibility_disappears" - data-img="/website/static/src/img/snippets_options/header_effect_disappears.png"> - <span t-esc="header_effect_disappears_label" /> - </we-button> - <we-button id="option_header_effect_fade_out" - t-att-data-select-label="header_effect_fadeout_label" - class="o_we_img_animate" - data-customize-website-variable="'fade-out'" - data-name="header_effect_fade_out_opt" - data-select-class="o_header_fade_out" - data-customize-website-views="website.header_visibility_fade_out" - data-img="/website/static/src/img/snippets_options/header_effect_fade_out.png"> - <span t-esc='header_effect_fadeout_label'/> - </we-button> - </we-select> - </div> - - <div data-js="TopMenuVisibility" - data-selector="[data-main-object^='website.page('] #wrapwrap > header" - data-no-check="true"> - <we-select string="Header Position" data-no-preview="true" - data-dependencies="!header_sidebar_opt"> - <we-button data-name="over_content_header_visibility_opt" - data-visibility="transparent">Over The Content</we-button> - <we-button data-name="regular_header_visibility_opt" - data-visibility="regular">Regular</we-button> - <we-button data-visibility="hidden">Hidden</we-button> - </we-select> - </div> - - <div data-js="topMenuColor" - data-selector="[data-main-object^='website.page('] #wrapwrap > header" - data-no-check="true"> - <we-colorpicker string="Background" - class="o_we_sublevel_1" - id="option_header_transparent_color" - data-select-style="true" - data-prevent-important="true" - data-css-property="background-color" - data-color-prefix="bg-" - data-excluded="theme, common" - data-page-option-name="header_color"/> - <we-colorpicker string="Text Color" - class="o_we_sublevel_1" - id="option_header_transparent_text_color" - data-select-style="true" - data-css-property="color" - data-color-prefix="text-" - data-page-option-name="header_text_color"/> - </div> - - <div data-js="HeaderElements" - data-selector="#wrapwrap > header" - data-no-check="true" - groups="website.group_website_designer"> - <we-row string="Elements" class="o_we_full_row align-items-start"> - <we-button title="Show/hide text element" class="d-flex justify-content-center flex-grow-1" - data-img="/website/static/src/img/snippets_options/header_extra_element_text.svg" - data-customize-website-views="website.header_text_element" - data-reset-view-arch="true" - data-reload="/"/> - <we-button title="Show/hide language selector" class="fa fa-flag d-flex justify-content-center flex-grow-1" - data-name="header_language_selector_opt" - data-customize-website-views="website.header_language_selector" - data-reset-view-arch="true" - data-reload="/"/> - <we-button title="Show/hide search bar" class="fa fa-search d-flex justify-content-center flex-grow-1" - data-customize-website-views="website.header_search_box" - data-reset-view-arch="true" - data-reload="/"/> - <we-button title="Show/hide sign in button" class="fa fa-sign-in d-flex justify-content-center flex-grow-1" - data-customize-website-views="portal.user_sign_in" - data-reload="/" - data-no-preview="true"/> - </we-row> - <we-row string=" " class="o_we_full_row align-items-start mt-1"> - <we-button title="Show/hide social links" class="flex-grow-1 d-flex justify-content-center" - data-img="/website/static/src/img/snippets_options/header_extra_element_social.svg" - data-customize-website-views="website.header_social_links" - data-reset-view-arch="true" - data-reload="/"/> - <we-button title="Show/hide button" class="flex-grow-1 d-flex justify-content-center" - data-img="/website/static/src/img/snippets_options/header_extra_element_cta.svg" - data-customize-website-views="website.header_call_to_action" - data-reset-view-arch="true" - data-reload="/"/> - <we-button title="Show/hide logo" class="flex-grow-1 d-flex justify-content-center" - data-img="/website/static/src/img/snippets_options/header_extra_element_logo.svg" - data-customize-website-views="|website.option_header_brand_name|website.option_header_brand_logo" - data-reset-view-arch="true" - data-reload="/"/> - </we-row> - </div> - - <!-- Header > Navbar Options --> - <div data-js="HeaderNavbar" - data-selector="#wrapwrap > header nav.navbar" - data-no-check="true" - groups="website.group_website_designer"> - - <we-select string="Mobile Alignment" - data-name="header_mobile_alignment_opt" - data-variable="hamburger-position-mobile" - data-reload="/"> - <we-button data-customize-website-views="" data-customize-website-variable="'left'">Left</we-button> - <we-button data-customize-website-views="website.template_header_mobile_align_center, website.template_header_hamburger_mobile_align_center" - data-customize-website-variable="'center'">Center</we-button> - <we-button data-customize-website-views="website.template_header_mobile_align_right, website.template_header_hamburger_mobile_align_right" - data-customize-website-variable="'right'">Right</we-button> - </we-select> - - <we-fontfamilypicker string="Font" data-variable="navbar-font"/> - - <we-row string="Format" class="o_we_header_font_row"> - <we-input data-customize-website-variable="null" data-variable="header-font-size" data-unit="px" data-save-unit="rem"/> - <we-colorpicker data-variable="header-text-color" data-customize-website-variable=""/> - <!-- Generic alignment option controling all the template at once. --> - <!-- Currently needed to be this way as the SCSS variable controls --> - <!-- the mobile alignement which is the same for all templates. --> - <we-select class="o_we_icon_select" data-name="header_alignment_opt" data-variable="hamburger-position" data-reload="/" title="Alignment" data-dependencies="header_default_opt, header_hamburger_opt, header_boxed_opt, header_stretch_opt, header_search_opt, header_sales_one_opt, header_sales_two_opt, header_sales_four_opt, header_sidebar_opt"> - <we-button data-customize-website-views="" - data-customize-website-variable="'left'" - data-icon="fa-align-left"/> - <we-button data-customize-website-views="website.template_header_default_align_center, website.template_header_boxed_align_center, website.template_header_stretch_align_center, website.template_header_search_align_center, website.template_header_sales_one_align_center, website.template_header_sales_two_align_center, website.template_header_sales_four_align_center, website.template_header_sidebar_align_center" - data-customize-website-variable="'center'" - data-icon="fa-align-center" - data-dependencies="!header_hamburger_opt"/> - <we-button data-customize-website-views="website.template_header_default_align_right, website.template_header_hamburger_align_right, website.template_header_boxed_align_right, website.template_header_stretch_align_right, website.template_header_search_align_right, website.template_header_sales_one_align_right, website.template_header_sales_two_align_right, website.template_header_sales_four_align_right, website.template_header_sidebar_align_right" - data-customize-website-variable="'right'" - data-icon="fa-align-right"/> - </we-select> - </we-row> - - <we-select string="Links Style" data-variable="header-links-style" data-reload="/" data-dependencies="!header_stretch_opt"> - <we-button data-name="option_header_navbar_links_default" - data-customize-website-views="" - data-customize-website-variable="'default'">Default</we-button> - <we-button data-name="option_header_navbar_links_fill" - data-customize-website-views="website.header_navbar_pills_style" - data-customize-website-variable="'fill'">Fill</we-button> - <we-button data-name="option_header_navbar_links_outline" - data-customize-website-views="" - data-customize-website-variable="'outline'">Outline</we-button> - <we-button data-name="option_header_navbar_links_pills" - data-customize-website-views="website.header_navbar_pills_style" - data-customize-website-variable="'pills'">Pills</we-button> - <we-button data-name="option_header_navbar_block" - data-customize-website-views="website.header_navbar_pills_style" - data-customize-website-variable="'block'">Block</we-button> - <we-button data-name="option_header_navbar_border_bottom" - data-customize-website-views="" - data-customize-website-variable="'border-bottom'">Border Bottom</we-button> - </we-select> - - <we-select id="option_header_dropdown" string="Sub Menus" data-dependencies="!header_hamburger_opt" data-no-preview="true"> - <we-button data-select-class="o_hoverable_dropdown" - data-customize-website-views="website.header_hoverable_dropdown">On Hover</we-button> - <we-button data-select-class="" data-name="header_dropdown_on_click_opt">On Click</we-button> - </we-select> - </div> - - <div data-selector="#wrapwrap > header nav.navbar .o_header_language_selector" - data-no-check="true" - groups="website.group_website_designer"> - <we-select string="Style" data-reload="/"> - <we-button data-customize-website-views="website.header_language_selector">Dropdown</we-button> - <we-button data-customize-website-views="website.header_language_selector, website.header_language_selector_inline">Inline</we-button> - </we-select> - <we-select string="Label" class="o_we_sublevel_1" data-reload="/" data-no-preview="true"> - <we-button data-customize-website-views="">Text</we-button> - <we-button data-customize-website-views="website.header_language_selector_flag, website.header_language_selector_no_text">Flag</we-button> - <we-button data-customize-website-views="website.header_language_selector_flag">Flag and Text</we-button> - <we-button data-customize-website-views="website.header_language_selector_code, website.header_language_selector_no_text">Code</we-button> - <we-button data-customize-website-views="website.header_language_selector_flag, website.header_language_selector_code, website.header_language_selector_no_text">Flag and Code</we-button> - </we-select> - </div> - - <div data-selector="#wrapwrap > header nav.navbar .navbar-brand" - data-no-check="true" - groups="website.group_website_designer"> - <we-select string="Logo" data-reload="/"> - <we-button data-customize-website-views="" data-name="option_header_brand_none">None</we-button> - <we-button data-customize-website-views="website.option_header_brand_name">Text</we-button> - <we-button data-customize-website-views="website.option_header_brand_logo">Image</we-button> - </we-select> - <we-input string="Height" - class="o_we_sublevel_1" - data-dependencies="!option_header_brand_none" - data-customize-website-variable="null" - data-variable="logo-height" - data-unit="px" - data-save-unit="rem"/> - <we-input string="Height (Scrolled)" - class="o_we_sublevel_1" - data-name="option_logo_height_scrolled" - data-customize-website-variable="null" - data-variable="fixed-logo-height" - data-unit="px" - data-save-unit="rem" - data-dependencies="!header_effect_scroll_opt"/> - </div> - <!-- Footer - Colors & Layouts --> <div data-js="WebsiteLevelColor" data-selector="#wrapwrap > footer" @@ -1257,34 +755,6 @@ <t t-call="website.snippet_options_shadow_widgets"/> </div> - <!-- Scroll to Top --> - <div data-selector="#wrapwrap > footer" - data-no-check="true" - groups="website.group_website_designer"> - <we-row string="Scroll Top Button"> - <we-checkbox data-name="footer_scrolltop_opt" - data-customize-website-views="website.option_footer_scrolltop" - data-customize-website-variable="false|true" - data-variable="footer-scrolltop" - data-reload="/"/> - <we-select data-dependencies="footer_scrolltop_opt" data-apply-to="#o_footer_scrolltop_wrapper"> - <we-button string="Left" data-select-class="justify-content-start"/> - <we-button string="Center" data-select-class="justify-content-center"/> - <we-button string="Right" data-select-class="justify-content-end"/> - </we-select> - </we-row> - </div> - - <div data-js="HideFooter" - data-selector="[data-main-object^='website.page('] #wrapwrap > footer" - data-no-check="true" - groups="website.group_website_designer"> - <we-checkbox string="Page Visibility" - data-name="hide_footer_page_opt" - data-visibility="hidden|shown" - data-no-preview="true"/> - </div> - <!-- Copyright --> <div data-js="WebsiteLevelColor" data-selector=".o_footer_copyright" @@ -1312,54 +782,6 @@ </we-select> </div> - <!-- Anchor Name --> - <div data-js="anchor" - data-selector=":not(p).oe_structure > *, :not(p)[data-oe-type=html] > *" - data-exclude=".modal *, .oe_structure .oe_structure *, [data-oe-type=html] .oe_structure *, .s_popup"> - <we-button class="fa fa-fw fa-link o_we_link" - title="Create a link to target this section" - data-no-preview="true"/> - </div> - - <div data-js="anchor" - data-selector=".s_popup" - data-target=".modal"> - </div> - - <!-- Mega Menu settings --> - <div data-js="MegaMenuLayout" data-selector=".o_mega_menu"> - <we-select string="Template" data-name="mega_menu_template_opt"> - <t t-set="_label">Multi Menus</t> - <we-button t-att-data-select-label="_label" data-select-template="website.s_mega_menu_multi_menus" data-img="/website/static/src/img/snippets_thumbs/s_mega_menu_multi_menus.svg" t-out="_label"/> - <t t-set="_label">Image Menu</t> - <we-button t-att-data-select-label="_label" data-select-template="website.s_mega_menu_menu_image_menu" data-img="/website/static/src/img/snippets_thumbs/s_mega_menu_menu_image_menu.svg" t-out="_label"/> - <t t-set="_label">Odoo Menu</t> - <we-button t-att-data-select-label="_label" data-select-template="website.s_mega_menu_odoo_menu" data-img="/website/static/src/img/snippets_thumbs/s_mega_menu_odoo_menu.svg" t-out="_label"/> - <t t-set="_label">Little Icons</t> - <we-button t-att-data-select-label="_label" data-select-template="website.s_mega_menu_little_icons" data-img="/website/static/src/img/snippets_thumbs/s_mega_menu_little_icons.svg" t-out="_label"/> - <t t-set="_label">Big Icons Subtitles</t> - <we-button t-att-data-select-label="_label" data-select-template="website.s_mega_menu_big_icons_subtitles" data-img="/website/static/src/img/snippets_thumbs/s_mega_menu_big_icons_subtitles.svg" t-out="_label"/> - <t t-set="_label">Images Subtitles</t> - <we-button t-att-data-select-label="_label" data-select-template="website.s_mega_menu_images_subtitles" data-img="/website/static/src/img/snippets_thumbs/s_mega_menu_images_subtitles.svg" t-out="_label"/> - <t t-set="_label">Logos</t> - <we-button t-att-data-select-label="_label" data-select-template="website.s_mega_menu_menus_logos" data-img="/website/static/src/img/snippets_thumbs/s_mega_menu_menus_logos.svg" t-out="_label"/> - <t t-set="_label">Thumbnails</t> - <we-button t-att-data-select-label="_label" data-select-template="website.s_mega_menu_thumbnails" data-img="/website/static/src/img/snippets_thumbs/s_mega_menu_thumbnails.svg" t-out="_label"/> - <t t-set="_label">Cards</t> - <we-button t-att-data-select-label="_label" data-select-template="website.s_mega_menu_cards" data-img="/website/static/src/img/snippets_thumbs/s_mega_menu_cards.svg" t-out="_label"/> - </we-select> - <we-select string="Size"> - <we-button data-select-class="">Full-Width</we-button> - <we-button data-select-class="o_mega_menu_container_size">Narrow</we-button> - </we-select> - </div> - - <div data-js="MegaMenuNoDelete" data-selector=".o_mega_menu > section"/> - - <div data-selector=".o_mega_menu .nav > .nav-link" - data-drop-in=".o_mega_menu nav" - data-drop-near=".o_mega_menu .nav-link"/> - <div data-js="CoverProperties" data-selector=".o_record_cover_container" data-no-check="true"> <we-row string="Background" class="o_we_full_row"> <t t-call="web_editor.snippet_options_background_color_widget"> @@ -1390,181 +812,6 @@ </we-select> </div> - <!-- Stretch section --> - <div data-js="ContainerWidth" data-selector="section, .s_carousel .carousel-item, s_quotes_carousel .carousel-item" - data-exclude="[data-snippet] :not(.oe_structure) > [data-snippet]" data-target="> .container, > .container-fluid, > .o_container_small"> - <we-button-group string="Content Width"> - <we-button data-select-class="o_container_small" - data-img="/website/static/src/img/snippets_options/content_width_small.svg" - title="Small"/> - <we-button data-select-class="container" - data-img="/website/static/src/img/snippets_options/content_width_normal.svg" - title="Regular"/> - <we-button data-select-class="container-fluid" - data-img="/website/static/src/img/snippets_options/content_width_full.svg" - title="Full"/> - </we-button-group> - </div> - - <!-- Scroll to next section button (only for full height) --> - <div data-js="ScrollButton" data-selector="section" data-exclude="[data-snippet] :not(.oe_structure) > [data-snippet], .s_instagram_page"> - <!-- Min height of section --> - <we-button-group string="Height" data-show-scroll-button=""> - <we-button data-name="minheight_auto_opt" data-select-class="" title="Fit content">Auto</we-button> - <we-button data-select-class="o_half_screen_height" title="Half screen">50%</we-button> - <we-button data-select-class="o_full_screen_height" title="Full screen" data-name="full_height_opt" data-show-scroll-button="true">100%</we-button> - </we-button-group> - - <we-input string="Height" class="o_we_sublevel_1" - data-name="fixed_height_opt" - data-dependencies="minheight_auto_opt" - data-select-style="" - data-unit="px" - data-css-property="height" - data-force-style=""/> - <!-- ↑ ↑ ↑ - For this setting, we need to always force the style (= if the block - is naturally 800px tall and the user enters 800px for this setting, - we set 800px as inline style anyway). Indeed, this snippet's style - is based on the height that is forced but once the related public - widgets are started, the inner carousel items receive a min-height - which makes it so the snippet "natural" height is equal to the - initially forced height... so if the style is not forced, it would - ultimately be removed by mistake thinking it is not necessary. - Note: this is forced as not important as we still need the height to - be reset to 'auto' in mobile (generic css rules). - --> - - <we-checkbox string="Scroll Down Button" - data-toggle-button="true" - data-no-preview="true" - data-dependencies="full_height_opt" - data-name="scroll_button_opt"/> - <we-row string="Colors" class="o_we_sublevel_1"> - <we-colorpicker - data-select-style="true" - data-css-property="background-color" - data-color-prefix="bg-" - data-apply-to=".o_scroll_button" - data-dependencies="scroll_button_opt"/> - <we-colorpicker data-select-style="true" - data-css-property="color" - data-color-prefix="text-" - data-apply-to=".o_scroll_button" - data-dependencies="scroll_button_opt"/> - </we-row> - <we-select string="Spacing" - class="o_we_sublevel_1" - data-apply-to=".o_scroll_button" - data-dependencies="scroll_button_opt"> - <we-button data-select-class="">None</we-button> - <we-button data-select-class="mb-1">Extra-Small</we-button> - <we-button data-select-class="mb-2">Small</we-button> - <we-button data-select-class="mb-3">Medium</we-button> - <we-button data-select-class="mb-4">Large</we-button> - <we-button data-select-class="mb-5">Extra-Large</we-button> - </we-select> - </div> - - <t t-set="_device_visibility_widgets"> - <we-button class="o_we_device" data-toggle-device-visibility="no_desktop" title="Show/Hide on Desktop" - data-no-preview="true" - data-img="/website/static/src/img/snippets_options/desktop_invisible.svg" - /> - <we-button class="o_we_device" data-toggle-device-visibility="no_mobile" title="Show/Hide on Mobile" - data-no-preview="true" - data-img="/website/static/src/img/snippets_options/mobile_invisible.svg" - /> - </t> - <div data-js="ConditionalVisibility" data-selector="section, .s_hr"> - <t t-set="current_website" t-value="request.env['website'].get_current_website()"/> - <we-collapse> - <we-row string="Visibility"> - <t t-out="_device_visibility_widgets"/> - <we-select data-attribute-name="visibility" data-no-preview="true"> - <we-button data-select-data-attribute="">No condition</we-button> - <we-button data-select-data-attribute="conditional" data-select-class="o_snippet_invisible" data-name="visibility_conditional">Conditionally</we-button> - </we-select> - </we-row> - - <t t-if="request.geoip.country_code"> - <t t-call="website.snippet_options_conditional_visibility"> - <t t-set="option_name">Country</t> - <t t-set="attribute_rule" t-valuef="visibilityValueCountryRule"/> - <t t-set="save_attribute" t-valuef="visibilityValueCountry"/> - <t t-set="attribute_name" t-valuef="data-country"/> - <t t-set="model" t-valuef="res.country"/> - <t t-set="call_with" t-valuef="code"/> - <t t-set="data_fields" t-valuef="["code"]"/> - </t> - </t> - <t t-if="len(current_website.language_ids) > 1"> - <t t-call="website.snippet_options_conditional_visibility"> - <t t-set="option_name">Languages</t> - <t t-set="attribute_rule" t-valuef="visibilityValueLangRule"/> - <t t-set="save_attribute" t-valuef="visibilityValueLang"/> - <t t-set="attribute_name" t-valuef="lang"/> - <t t-set="model" t-valuef="res.lang"/> - <t t-set="call_with" t-valuef="code"/> - <t t-set="domain" t-translation="off">[["id", "in", <t t-out="current_website.language_ids.ids"/>]]</t> - <t t-set="data_fields" t-valuef="["code"]"/> - </t> - </t> - <t t-call="website.snippet_options_conditional_visibility"> - <t t-set="option_name">UTM Campaign</t> - <t t-set="attribute_rule" t-valuef="visibilityValueUtmCampaignRule"/> - <t t-set="save_attribute" t-valuef="visibilityValueUtmCampaign"/> - <t t-set="attribute_name" t-valuef="data-utm-campaign"/> - <t t-set="model" t-valuef="utm.campaign"/> - <t t-set="call_with" t-valuef="display_name"/> - </t> - <t t-call="website.snippet_options_conditional_visibility"> - <t t-set="option_name">UTM Medium</t> - <t t-set="attribute_rule" t-valuef="visibilityValueUtmMediumRule"/> - <t t-set="save_attribute" t-valuef="visibilityValueUtmMedium"/> - <t t-set="attribute_name" t-valuef="data-utm-medium"/> - <t t-set="model" t-valuef="utm.medium"/> - <t t-set="call_with" t-valuef="display_name"/> - </t> - <t t-call="website.snippet_options_conditional_visibility"> - <t t-set="option_name">UTM Source</t> - <t t-set="attribute_rule" t-valuef="visibilityValueUtmSourceRule"/> - <t t-set="save_attribute" t-valuef="visibilityValueUtmSource"/> - <t t-set="attribute_name" t-valuef="data-utm-source"/> - <t t-set="model" t-valuef="utm.source"/> - <t t-set="call_with" t-valuef="display_name"/> - </t> - <we-select string="Users" - class="o_we_sublevel_1" - data-dependencies="visibility_conditional" - data-attribute-name="data-logged" - data-save-attribute="visibilityValueLogged" - data-no-preview="true" - data-attribute-default-value=""> - <we-button data-select-value="true">Visible for Logged In</we-button> - <we-button data-select-value="false">Visible for Logged Out</we-button> - <we-button data-select-value="">Visible for Everyone</we-button> - </we-select> - </we-collapse> - </div> - - <!-- Mobile/Desktop display options --> - <div data-js="DeviceVisibility" data-selector="section .row > div" - data-exclude=".s_col_no_resize.row > div, .s_masonry_block .s_col_no_resize"> - <we-row string="Visibility"> - <t t-out="_device_visibility_widgets"/> - </we-row> - </div> - - <!-- Cookies Bar --> - <div data-selector="#website_cookies_bar" data-js="CookiesBar" data-target=".modal"> - <we-select string="Layout" class="o_we_inline"> - <we-button data-select-class="o_cookies_discrete" data-select-layout="discrete" data-trigger="position_bottom,s_popup_size_full">Discrete</we-button> - <we-button data-select-class="o_cookies_classic" data-select-layout="classic" data-trigger="position_bottom,s_popup_size_full">Classic</we-button> - <we-button data-name="layout_popup_opt" data-select-class="o_cookies_popup" data-select-layout="popup" data-trigger="position_middle,s_popup_size_md">Popup</we-button> - </we-select> - </div> - <!-- Image position in grid --> <div data-js="GridImage" data-selector="img"> <we-select string="Position"> @@ -2038,13 +1285,6 @@ <we-range string="Saturation" class="o_we_sublevel_1" data-customize-gray="" data-param="gray-extra-saturation" data-step="0.1"/> </we-collapse> </div> - <!-- Info page ('/website/info') --> - <div data-selector="main:has(.o_website_info)" data-page-options="true" groups="website.group_website_designer" data-no-check="true" string="Info Page"> - <we-checkbox string="Odoo Information" - data-customize-website-views="website.show_website_info" - data-no-preview="true" - data-reload="/"/> - </div> <div data-js="SwitchableViews" data-selector="#wrapwrap > main" data-no-check="true" groups="website.group_website_designer"> <!-- Options will be populated by JS --> </div> diff --git a/addons/website_blog/__manifest__.py b/addons/website_blog/__manifest__.py index e37de97c775d5..03b339c7d23d2 100644 --- a/addons/website_blog/__manifest__.py +++ b/addons/website_blog/__manifest__.py @@ -34,6 +34,8 @@ 'website.assets_wysiwyg': [ 'website_blog/static/src/js/options.js', 'website_blog/static/src/snippets/s_blog_posts/options.js', + 'website_blog/static/src/snippets/s_blog_posts/options.xml', + 'website_blog/static/src/snippets/s_searchbar/options.xml', ], 'website.assets_editor': [ 'website_blog/static/src/js/tours/website_blog.js', diff --git a/addons/website_blog/static/src/js/options.js b/addons/website_blog/static/src/js/options.js index eeef93f02ec0f..cb1416fa7b372 100644 --- a/addons/website_blog/static/src/js/options.js +++ b/addons/website_blog/static/src/js/options.js @@ -1,7 +1,8 @@ /** @odoo-module **/ import { _t } from "@web/core/l10n/translation"; -import options from "@web_editor/js/editor/snippets.options"; +import { registry } from "@web/core/registry"; +import options from "@web_editor/js/editor/snippets.options.legacy"; import "@website/js/editor/snippets.options"; import { uniqueId } from "@web/core/utils/functions"; @@ -186,3 +187,8 @@ options.registry.BlogPostTagSelection = options.Class.extend({ uiFragment.querySelector('we-many2many').dataset.recordId = this.blogPostID; }, }); + +// Hides ContainerWidth option for content in blog posts +const ContainerWidthOption = registry.category("snippet_options").get("container_width"); +ContainerWidthOption.exclude = ContainerWidthOption.exclude + ", #o_wblog_post_content *"; +registry.category("snippet_options").add("container_width", ContainerWidthOption, { force: true }); diff --git a/addons/website_blog/static/src/snippets/s_blog_posts/options.js b/addons/website_blog/static/src/snippets/s_blog_posts/options.js index 169e9aacc523d..ecbdda3d8368f 100644 --- a/addons/website_blog/static/src/snippets/s_blog_posts/options.js +++ b/addons/website_blog/static/src/snippets/s_blog_posts/options.js @@ -1,20 +1,22 @@ /** @odoo-module **/ -import options from "@web_editor/js/editor/snippets.options"; -import dynamicSnippetOptions from "@website/snippets/s_dynamic_snippet/options"; +import { registry } from "@web/core/registry"; +import { DynamicSnippetOptions } from "@website/snippets/s_dynamic_snippet/options"; +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; -import wUtils from "@website/js/utils"; -const dynamicSnippetBlogPostsOptions = dynamicSnippetOptions.extend({ +export class DynamicSnippetBlogPostsOptions extends DynamicSnippetOptions { /** - * * @override */ - init: function () { - this._super.apply(this, arguments); + async willStart() { + this.blogs = await this._fetchBlogs(); this.modelNameFilter = 'blog.post'; - this.blogs = {}; - }, + await super.willStart(...arguments); + this.renderContext.blogs = this.blogs; + } //-------------------------------------------------------------------------- // Private @@ -25,56 +27,56 @@ const dynamicSnippetBlogPostsOptions = dynamicSnippetOptions.extend({ * @override * @private */ - _computeWidgetVisibility: function (widgetName, params) { + _computeWidgetVisibility(widgetName, params) { if (widgetName === 'hover_effect_opt') { return this.$target.get(0).dataset.templateKey === 'website_blog.dynamic_filter_template_blog_post_big_picture'; } - return this._super.apply(this, arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * Fetches blogs. * @private * @returns {Promise} */ - _fetchBlogs: function () { - return this.orm.searchRead("blog.blog", wUtils.websiteDomain(this), ["id", "name"]); - }, + _fetchBlogs() { + const websiteId = this.env.services.website.currentWebsite.id; + const websiteDomain = ["|", ["website_id", "=", false], ["website_id", "=", websiteId]]; + return this.env.services.orm.searchRead("blog.blog", websiteDomain, ["id", "name"]); + } /** - * * @override * @private */ - _renderCustomXML: async function (uiFragment) { - await this._super.apply(this, arguments); - await this._renderBlogSelector(uiFragment); - }, - /** - * Renders the blog option selector content into the provided uiFragment. - * @private - * @param {HTMLElement} uiFragment - */ - _renderBlogSelector: async function (uiFragment) { - if (!Object.keys(this.blogs).length) { - const blogsList = await this._fetchBlogs(); - this.blogs = {}; - for (let index in blogsList) { - this.blogs[blogsList[index].id] = blogsList[index]; - } - } - const blogSelectorEl = uiFragment.querySelector('[data-name="blog_opt"]'); - return this._renderSelectUserValueWidgetButtons(blogSelectorEl, this.blogs); - }, + async _getRenderContext() { + const renderContext = super._getRenderContext(); + renderContext.blogs = this.blogs; + return renderContext; + } /** * Sets default options values. * @override * @private */ - _setOptionsDefaultValues: function () { + _setOptionsDefaultValues() { this._setOptionValue('filterByBlogId', -1); - this._super.apply(this, arguments); - }, + super._setOptionsDefaultValues(...arguments); + } +} + +registerWebsiteOption("DynamicSnippetBlogPostsOptions", { + Class: DynamicSnippetBlogPostsOptions, + template: "website_blog.s_blog_posts_option", + selector: ".s_dynamic_snippet_blog_posts, .s_blog_posts", }); -options.registry.dynamic_snippet_blog_posts = dynamicSnippetBlogPostsOptions; +const anchorOption = registry.category("snippet_options").get("Anchor"); +anchorOption.exclude += ",.o_wblog_post_content_field > :not(div, section)"; +registry.category("snippet_options").add("Anchor", anchorOption, { + force: true, +}); -export default dynamicSnippetBlogPostsOptions; +const containerWidthOption = registry.category("snippet_options").get("container_width"); +containerWidthOption.exclude += ",#o_wblog_post_content *"; +registry.category("snippet_options").add("container_width", containerWidthOption, { + force: true, +}); diff --git a/addons/website_blog/static/src/snippets/s_blog_posts/options.xml b/addons/website_blog/static/src/snippets/s_blog_posts/options.xml new file mode 100644 index 0000000000000..3b8642beeff3a --- /dev/null +++ b/addons/website_blog/static/src/snippets/s_blog_posts/options.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="website_blog.s_blog_posts_option" t-inherit="website.s_dynamic_snippet_option" t-inherit-mode="primary"> + <xpath expr="//WeRow[.//WeSelect[@name="'filter_opt'"]]" position="after"> + <WeRow> + <t t-set-slot="title">Blog</t> + <WeSelect noPreview="'true'" name="'blog_opt'" attributeName="'filterByBlogId'"> + <WeButton selectDataAttribute="'-1'">All blogs</WeButton> + <WeButton t-foreach="renderContext.blogs" t-as="blog" t-key="blog.id" + selectDataAttribute="blog.id.toString()" + > + <t t-out="blog.name"/> + </WeButton> + </WeSelect> + </WeRow> + </xpath> + <xpath expr="//WeRow[.//WeSelect[@name="'template_opt'"]]" position="after"> + <WeRow> + <t t-set-slot="title">Hover Effect</t> + <WeSelect class="'o_we_sublevel_1 o_we_inline'" + noWidgetRefresh="'true'" name="'hover_effect_opt'"> + <WeButton selectClass="''">None</WeButton> + <WeButton selectClass="'s_blog_posts_effect_marley'">Marley</WeButton> + <WeButton selectclass="'s_blog_posts_effect_dexter'">Dexter</WeButton> + <WeButton selectClass="'s_blog_posts_effect_chico'">Silly-Chico</WeButton> + </WeSelect> + </WeRow> + </xpath> +</t> + +</templates> diff --git a/addons/website_blog/static/src/snippets/s_searchbar/options.xml b/addons/website_blog/static/src/snippets/s_searchbar/options.xml new file mode 100644 index 0000000000000..713ebfc27a348 --- /dev/null +++ b/addons/website_blog/static/src/snippets/s_searchbar/options.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="website_blog.s_searchbar_options" t-inherit="website.s_searchbar_options" t-inherit-mode="extension"> + <xpath expr="//WeSelect[@name="'scope_opt'"]" position="inside"> + <WeButton setSearchType="'blogs'" selectDataAttribute="'blogs'" name="'search_blogs_opt'" formAction="'/blog'">Blogs</WeButton> + </xpath> + <xpath expr="//WeSelect[@name="'order_opt'"]" position="inside"> + <WeButton setOrderBy="'published_date asc'" selectDataAttribute="'published_date asc'" dependencies="'search_blogs_opt'" name="'order_published_date_asc_opt'">Date (old to new)</WeButton> + <WeButton setOrderBy="'published_date desc'" selectDataAttribute="'published_date desc'" dependencies="'search_blogs_opt'" name="'order_published_date_desc_opt'">Date (new to old)</WeButton> + </xpath> + <xpath expr="//div[@dependencies="'limit_opt'"]" position="inside"> + <WeCheckbox title.translate="Description" + dependencies="'search_blogs_opt'" + selectDataAttribute="'true'" + attributeName="'displayDescription'" + applyTo="'.search-query'"/> + <WeCheckbox title.translate="Publication Date" + dependencies="'search_blogs_opt'" + selectDataAttribute="'true'" + attributeName="'displayDetail'" + applyTo="'.search-query'"/> + </xpath> + </t> + +</templates> diff --git a/addons/website_blog/views/snippets/snippets.xml b/addons/website_blog/views/snippets/snippets.xml index 3aface6818755..59f60186ba5f8 100644 --- a/addons/website_blog/views/snippets/snippets.xml +++ b/addons/website_blog/views/snippets/snippets.xml @@ -164,29 +164,6 @@ data-reload="/"/> </div> </xpath> - <xpath expr="//*[@data-js='anchor']" position="attributes"> - <attribute name="data-exclude" add=".o_wblog_post_content_field > :not(div, section)" separator=","/> - </xpath> - - <!-- Hides ContainerWidth option for content in blog posts --> - <xpath expr="//div[@data-js='ContainerWidth']" position="attributes"> - <attribute name="data-exclude" add="#o_wblog_post_content *" separator=","/> - </xpath> -</template> -<template id="blog_searchbar_input_snippet_options" inherit_id="website.searchbar_input_snippet_options" name="blog search bar snippet options"> - <xpath expr="//div[@data-js='SearchBar']/we-select[@data-name='scope_opt']" position="inside"> - <we-button data-set-search-type="blogs" data-select-data-attribute="blogs" data-name="search_blogs_opt" data-form-action="/blog">Blogs</we-button> - </xpath> - <xpath expr="//div[@data-js='SearchBar']/we-select[@data-name='order_opt']" position="inside"> - <we-button data-set-order-by="published_date asc" data-select-data-attribute="published_date asc" data-dependencies="search_blogs_opt" data-name="order_published_date_asc_opt">Date (old to new)</we-button> - <we-button data-set-order-by="published_date desc" data-select-data-attribute="published_date desc" data-dependencies="search_blogs_opt" data-name="order_published_date_desc_opt">Date (new to old)</we-button> - </xpath> - <xpath expr="//div[@data-js='SearchBar']/div[@data-dependencies='limit_opt']" position="inside"> - <we-checkbox string="Description" data-dependencies="search_blogs_opt" data-select-data-attribute="true" data-attribute-name="displayDescription" - data-apply-to=".search-query"/> - <we-checkbox string="Publication Date" data-dependencies="search_blogs_opt" data-select-data-attribute="true" data-attribute-name="displayDetail" - data-apply-to=".search-query"/> - </xpath> </template> </odoo> diff --git a/addons/website_event/__manifest__.py b/addons/website_event/__manifest__.py index b2b4063448ba7..b04c0c63899db 100644 --- a/addons/website_event/__manifest__.py +++ b/addons/website_event/__manifest__.py @@ -67,6 +67,9 @@ ], 'website.assets_wysiwyg': [ '/website_event/static/src/snippets/s_events/options.js', + '/website_event/static/src/snippets/s_events/options.xml', + '/website_event/static/src/snippets/s_searchbar/options.xml', + '/website_event/static/src/snippets/s_speaker_bio/options.js', 'website_event/static/src/snippets/options.js', ], 'website.assets_editor': [ diff --git a/addons/website_event/static/src/snippets/options.js b/addons/website_event/static/src/snippets/options.js index 383f813b12eac..c1bf724b29097 100644 --- a/addons/website_event/static/src/snippets/options.js +++ b/addons/website_event/static/src/snippets/options.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import options from '@web_editor/js/editor/snippets.options'; +import options from '@web_editor/js/editor/snippets.options.legacy'; options.registry.WebsiteEvent = options.Class.extend({ init() { diff --git a/addons/website_event/static/src/snippets/s_events/options.js b/addons/website_event/static/src/snippets/s_events/options.js index 123953ad96a3f..2220ba207ad2a 100644 --- a/addons/website_event/static/src/snippets/s_events/options.js +++ b/addons/website_event/static/src/snippets/s_events/options.js @@ -1,22 +1,28 @@ /** @odoo-module **/ -import options from '@web_editor/js/editor/snippets.options'; -import dynamicSnippetOptions from '@website/snippets/s_dynamic_snippet/options'; +import { DynamicSnippetOptions } from "@website/snippets/s_dynamic_snippet/options"; +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; -const dynamicSnippetEventOptions = dynamicSnippetOptions.extend({ +export class DynamicSnippetEventOptions extends DynamicSnippetOptions { /** * @override */ - init() { - this._super.apply(this, arguments); + constructor() { + super(...arguments); this.modelNameFilter = 'event.event'; - }, + } _setOptionsDefaultValues() { this._setOptionValue('numberOfRecords', 4); - this._super.apply(this, arguments); - }, + super._setOptionsDefaultValues(...arguments); + } -}); +} -options.registry.event_upcoming_snippet = dynamicSnippetEventOptions; +registerWebsiteOption("DynamicSnippetEventOptions", { + Class: DynamicSnippetEventOptions, + template: "website_event.s_dynamic_snippet_option", + selector: "[data-snippet='s_events']", +}); diff --git a/addons/website_event/static/src/snippets/s_events/options.xml b/addons/website_event/static/src/snippets/s_events/options.xml new file mode 100644 index 0000000000000..8efec646eae6e --- /dev/null +++ b/addons/website_event/static/src/snippets/s_events/options.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="website_event.s_dynamic_snippet_option" t-inherit="website.s_dynamic_snippet_option" t-inherit-mode="primary"> + <xpath expr="//WeRow[.//WeSelect[@name="'filter_opt'"]]" position="after"> + <WeRow> + <t t-set-slot="title">Event Tags</t> + <WeMany2many + name="'event_tag_opt'" + model="'event.tag'" + fakem2m="'true'" + domain="'[["category_id.website_published", "=", true], ["color", "not in", ["0", false]]]'" + limit="'10'" + attributeName="'filterByTagIds'" + fields="'["category_id"]'" + selectDataAttribute="''" + noPreview="'true'" + /> + </WeRow> + </xpath> +</t> + +</templates> diff --git a/addons/website_event/static/src/snippets/s_searchbar/options.xml b/addons/website_event/static/src/snippets/s_searchbar/options.xml new file mode 100644 index 0000000000000..fb7aed065afcd --- /dev/null +++ b/addons/website_event/static/src/snippets/s_searchbar/options.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="website_event.s_searchbar_options" t-inherit="website.s_searchbar_options" t-inherit-mode="extension"> + <xpath expr="//WeSelect[@name="'scope_opt'"]" position="inside"> + <WeButton setSearchType="'events'" selectDataAttribute="'events'" name="'search_events_opt'" formAction="'/events'">Events</WeButton> + </xpath> + <xpath expr="//WeSelect[@name="'order_opt'"]" position="inside"> + <WeButton setOrderBy="'date_begin asc'" selectDataAttribute="'date_begin asc'" dependencies="'search_events_opt'" name="'order_date_begin_asc_opt'">Date (old to new)</WeButton> + <WeButton setOrderBy="'date_end desc'" selectDataAttribute="'date_end desc'" dependencies="'search_events_opt'" name="'order_date_end_desc_opt'">Date (new to old)</WeButton> + </xpath> + <xpath expr="//div[@dependencies="'limit_opt'"]" position="inside"> + <WeCheckbox title.translate="Description" + dependencies="'search_events_opt'" + selectDataAttribute="'true'" + attributeName="'displayDescription'" + applyTo="'.search-query'"/> + <WeCheckbox title.translate="Event Date" + dependencies="'search_events_opt'" + selectDataAttribute="'true'" + attributeName="'displayDetail'" + applyTo="'.search-query'"/> + </xpath> + </t> + +</templates> diff --git a/addons/website_event/static/src/snippets/s_speaker_bio/options.js b/addons/website_event/static/src/snippets/s_speaker_bio/options.js new file mode 100644 index 0000000000000..9f15c53386c6a --- /dev/null +++ b/addons/website_event/static/src/snippets/s_speaker_bio/options.js @@ -0,0 +1,3 @@ +import { registerContentAdditionSelector } from "@web_editor/js/editor/snippets.registry"; + +registerContentAdditionSelector(".s_speaker_bio"); diff --git a/addons/website_event/views/event_snippets.xml b/addons/website_event/views/event_snippets.xml index 5cf27c5c59802..d3fdf2af8a147 100644 --- a/addons/website_event/views/event_snippets.xml +++ b/addons/website_event/views/event_snippets.xml @@ -1,33 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> -<!-- Snippets and options --> -<template id="snippets" inherit_id="website.snippets"> - <xpath expr="//t[@id='event_speaker_bio_hook']" position="replace"> - <t t-snippet="website_event.s_speaker_bio" string="Speaker Bio" t-thumbnail="/website_event/static/src/img/snippets_thumbs/s_speaker_bio.svg"/> - </xpath> -</template> - -<template id="snippet_options" inherit_id="website.snippet_options"> - <xpath expr="//*[@t-set='so_content_addition_selector']" position="inside">, .s_speaker_bio</xpath> -</template> - -<template id="event_searchbar_input_snippet_options" inherit_id="website.searchbar_input_snippet_options" name="event search bar snippet options"> - <xpath expr="//div[@data-js='SearchBar']/we-select[@data-name='scope_opt']" position="inside"> - <we-button data-set-search-type="events" data-select-data-attribute="events" data-name="search_events_opt" data-form-action="/events">Events</we-button> - </xpath> - <xpath expr="//div[@data-js='SearchBar']/we-select[@data-name='order_opt']" position="inside"> - <we-button data-set-order-by="date_begin asc" data-select-data-attribute="date_begin asc" data-dependencies="search_events_opt" data-name="order_date_begin_asc_opt">Date (old to new)</we-button> - <we-button data-set-order-by="date_end desc" data-select-data-attribute="date_end desc" data-dependencies="search_events_opt" data-name="order_date_end_desc_opt">Date (new to old)</we-button> - </xpath> - <xpath expr="//div[@data-js='SearchBar']/div[@data-dependencies='limit_opt']" position="inside"> - <we-checkbox string="Description" data-dependencies="search_events_opt" data-select-data-attribute="true" data-attribute-name="displayDescription" - data-apply-to=".search-query"/> - <we-checkbox string="Event Date" data-dependencies="search_events_opt" data-select-data-attribute="true" data-attribute-name="displayDetail" - data-apply-to=".search-query"/> - </xpath> -</template> - <!-- Snippet - Speaker Bio --> <template id="s_speaker_bio" name="Speaker Bio"> <div class="s_speaker_bio" itemscope="itemscope" itemtype="http://schema.org/Person" itemprop="performer"> diff --git a/addons/website_event/views/snippets/snippets.xml b/addons/website_event/views/snippets/snippets.xml index e41227299cd8a..128f804a594f8 100644 --- a/addons/website_event/views/snippets/snippets.xml +++ b/addons/website_event/views/snippets/snippets.xml @@ -13,6 +13,9 @@ <xpath expr="//t[@id='event_upcoming_snippet_hook']" position="replace"> <t t-snippet="website_event.s_events" string="Events" group="events"/> </xpath> + <xpath expr="//t[@id='event_speaker_bio_hook']" position="replace"> + <t t-snippet="website_event.s_speaker_bio" string="Speaker Bio" t-thumbnail="/website_event/static/src/img/snippets_thumbs/s_speaker_bio.svg"/> + </xpath> </template> <template id="snippet_options" inherit_id="website.snippet_options" name="Event snippet options"> diff --git a/addons/website_event_meet/static/src/js/snippets/options.js b/addons/website_event_meet/static/src/js/snippets/options.js index 149431671a08b..008d34f57dff5 100644 --- a/addons/website_event_meet/static/src/js/snippets/options.js +++ b/addons/website_event_meet/static/src/js/snippets/options.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import options from '@web_editor/js/editor/snippets.options'; +import options from '@web_editor/js/editor/snippets.options.legacy'; options.registry.WebsiteEvent.include({ diff --git a/addons/website_forum/__manifest__.py b/addons/website_forum/__manifest__.py index 0cf203744daaa..1ef6032467dde 100644 --- a/addons/website_forum/__manifest__.py +++ b/addons/website_forum/__manifest__.py @@ -63,6 +63,9 @@ 'website.assets_editor': [ 'website_forum/static/src/js/systray_items/*.js', ], + 'website.assets_wysiwyg': [ + 'website_forum/static/src/snippets/s_searchbar/options.xml', + ], 'web.assets_tests': [ 'website_forum/static/tests/**/*', ], diff --git a/addons/website_forum/static/src/snippets/s_searchbar/options.xml b/addons/website_forum/static/src/snippets/s_searchbar/options.xml new file mode 100644 index 0000000000000..500c3a26983bb --- /dev/null +++ b/addons/website_forum/static/src/snippets/s_searchbar/options.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="website_forum.s_searchbar_options" t-inherit="website.s_searchbar_options" t-inherit-mode="extension"> + <xpath expr="//WeSelect[@name="'scope_opt'"]" position="inside"> + <!-- Using /website/search/forums as result because the current forum search results page cannot be used across several forums --> + <WeButton setSearchType="'forums'" selectDataAttribute="'forums'" name="'search_forums_opt'" formAction="'/website/search/forums'">Forums</WeButton> + </xpath> + <xpath expr="//WeSelect[@name="'order_opt'"]" position="inside"> + <WeButton setOrderBy="'write_date asc'" selectDataAttribute="'write_date asc'" dependencies="'search_forums_opt'" name="'date_asc_opt'">Date (low to high)</WeButton> + <WeButton setOrderBy="'write_date desc'" selectDataAttribute="'write_date desc'" dependencies="'search_forums_opt'" name="'date_desc_opt'">Date (high to low)</WeButton> + </xpath> + <xpath expr="//div[@dependencies="'limit_opt'"]" position="inside"> + <WeCheckbox title.translate="Description" + dependencies="'search_forums_opt'" + selectDataAttribute="'true'" + attributeName="'displayDescription'" + applyTo="'.search-query'"/> + <WeCheckbox title.translate="Date" + dependencies="'search_forums_opt'" + selectDataAttribute="'true'" + attributeName="'displayDetail'" + applyTo="'.search-query'"/> + </xpath> + </t> + +</templates> diff --git a/addons/website_forum/views/snippets/snippets.xml b/addons/website_forum/views/snippets/snippets.xml index 3494bb46b3eef..42f2a00445c06 100644 --- a/addons/website_forum/views/snippets/snippets.xml +++ b/addons/website_forum/views/snippets/snippets.xml @@ -1,23 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> -<template id="forum_searchbar_input_snippet_options" inherit_id="website.searchbar_input_snippet_options" name="forum search bar snippet options"> - <xpath expr="//div[@data-js='SearchBar']/we-select[@data-name='scope_opt']" position="inside"> - <!-- Using /website/search/forums as result because the current forum search results page cannot be used across several forums --> - <we-button data-set-search-type="forums" data-select-data-attribute="forums" data-name="search_forums_opt" data-form-action="/website/search/forums">Forums</we-button> - </xpath> - <xpath expr="//div[@data-js='SearchBar']/we-select[@data-name='order_opt']" position="inside"> - <we-button data-set-order-by="write_date asc" data-select-data-attribute="write_date asc" data-dependencies="search_forums_opt" data-name="date_asc_opt">Date (low to high)</we-button> - <we-button data-set-order-by="write_date desc" data-select-data-attribute="write_date desc" data-dependencies="search_forums_opt" data-name="date_desc_opt">Date (high to low)</we-button> - </xpath> - <xpath expr="//div[@data-js='SearchBar']/div[@data-dependencies='limit_opt']" position="inside"> - <we-checkbox string="Description" data-dependencies="search_forums_opt" data-select-data-attribute="true" data-attribute-name="displayDescription" - data-apply-to=".search-query"/> - <we-checkbox string="Date" data-dependencies="search_forums_opt" data-select-data-attribute="true" data-attribute-name="displayDetail" - data-apply-to=".search-query"/> - </xpath> -</template> - <template id="snippet_options" inherit_id="website.snippet_options" name="Forum Snippet Options"> <xpath expr="." position="inside"> <div data-selector="main:has(#o_wforum_forums_index_list)" data-page-options="true" groups="website.group_website_designer" data-no-check="true" string="Forum Page"> diff --git a/addons/website_hr_recruitment/__manifest__.py b/addons/website_hr_recruitment/__manifest__.py index 6b5a7acce60bc..d5eee9ef101e2 100644 --- a/addons/website_hr_recruitment/__manifest__.py +++ b/addons/website_hr_recruitment/__manifest__.py @@ -36,6 +36,7 @@ ], 'website.assets_wysiwyg': [ 'website_hr_recruitment/static/src/js/website_hr_recruitment_editor.js', + 'website_hr_recruitment/static/src/snippets/s_searchbar/options.xml', ], 'website.assets_editor': [ 'website_hr_recruitment/static/src/js/systray_items/new_content.js', diff --git a/addons/website_hr_recruitment/static/src/snippets/s_searchbar/options.xml b/addons/website_hr_recruitment/static/src/snippets/s_searchbar/options.xml new file mode 100644 index 0000000000000..6ed19736b5bca --- /dev/null +++ b/addons/website_hr_recruitment/static/src/snippets/s_searchbar/options.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="website_hr_recruitment.s_searchbar_options" t-inherit="website.s_searchbar_options" t-inherit-mode="extension"> + <xpath expr="//WeSelect[@name="'scope_opt'"]" position="inside"> + <WeButton setSearchType="'jobs'" selectDataAttribute="'jobs'" name="'search_jobs_opt'" formAction="'/jobs'">Jobs</WeButton> + </xpath> + <xpath expr="//div[@dependencies="'limit_opt'"]" position="inside"> + <WeCheckbox title.translate="Description" + dependencies="'search_jobs_opt'" + selectDataAttribute="'true'" + attributeName="'displayDescription'" + applyTo="'.search-query'"/> + </xpath> + </t> + +</templates> diff --git a/addons/website_hr_recruitment/views/snippets.xml b/addons/website_hr_recruitment/views/snippets.xml index f0f043fe30a8c..400173ee9209d 100644 --- a/addons/website_hr_recruitment/views/snippets.xml +++ b/addons/website_hr_recruitment/views/snippets.xml @@ -1,17 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> - -<template id="jobs_searchbar_input_snippet_options" inherit_id="website.searchbar_input_snippet_options" name="jobs search bar snippet options"> - <xpath expr="//div[@data-js='SearchBar']/we-select[@data-name='scope_opt']" position="inside"> - <we-button data-set-search-type="jobs" data-select-data-attribute="jobs" data-name="search_jobs_opt" data-form-action="/jobs">Jobs</we-button> - </xpath> - <xpath expr="//div[@data-js='SearchBar']/div[@data-dependencies='limit_opt']" position="inside"> - <we-checkbox string="Description" data-dependencies="search_jobs_opt" data-select-data-attribute="true" data-attribute-name="displayDescription" - data-apply-to=".search-query"/> - </xpath> -</template> - <template id="snippet_options" inherit_id="website.snippet_options" name="Hr Recruitment Snippet Options"> <xpath expr="." position="inside"> <div data-selector="main:has(.o_website_hr_recruitment_jobs_list)" data-page-options="true" groups="website.group_website_designer" data-no-check="true" string="Jobs Page"> diff --git a/addons/website_mail_group/__manifest__.py b/addons/website_mail_group/__manifest__.py index 2fe5c9a939c4f..524683d1df5cf 100644 --- a/addons/website_mail_group/__manifest__.py +++ b/addons/website_mail_group/__manifest__.py @@ -16,6 +16,7 @@ 'assets': { 'website.assets_wysiwyg': [ 'website_mail_group/static/src/snippets/s_group/options.js', + 'website_mail_group/static/src/snippets/s_group/options.xml', ], 'web.assets_frontend': [ 'website_mail_group/static/src/snippets/s_group/000.js', diff --git a/addons/website_mail_group/static/src/snippets/s_group/options.js b/addons/website_mail_group/static/src/snippets/s_group/options.js index abc826ae3ac6c..dc8afd29ad809 100644 --- a/addons/website_mail_group/static/src/snippets/s_group/options.js +++ b/addons/website_mail_group/static/src/snippets/s_group/options.js @@ -1,22 +1,22 @@ /** @odoo-module **/ import { _t } from "@web/core/l10n/translation"; -import options from "@web_editor/js/editor/snippets.options"; +import { SnippetOption } from "@web_editor/js/editor/snippets.options"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; import wUtils from "@website/js/utils"; -options.registry.Group = options.Class.extend({ - init() { - this._super(...arguments); - this.orm = this.bindService("orm"); - }, - +class Group extends SnippetOption { + constructor() { + super(...arguments); + this.orm = this.env.services.orm; + } /** * @override */ - async start() { - await this._super(...arguments); - this.mailGroups = await this._getMailGroups(); - }, + async willStart() { + await super.willStart(...arguments); + this.renderContext.mailGroups = await this._getMailGroups(); + } /** * If we have already created groups => select the first one * else => modal prompt (create a new group) @@ -24,22 +24,21 @@ options.registry.Group = options.Class.extend({ * @override */ onBuilt() { - if (this.mailGroups.length) { - this.$target[0].dataset.id = this.mailGroups[0][0]; + if (this.renderContext.mailGroups.length) { + this.$target[0].dataset.id = this.renderContext.mailGroups[0][0]; } else { - const widget = this._requestUserValueWidgets('create_mail_group_opt')[0]; - widget.$el.click(); + this.createGroup(); } - }, + } - cleanForSave: function () { + cleanUI() { // TODO: this should probably be done by the public widget, not the // option code, not important enough to try and fix in stable though. const emailInput = this.$target.find('.o_mg_subscribe_email'); emailInput.val(''); emailInput.removeAttr('readonly'); this.$target.find('.o_mg_subscribe_btn').text(_t('Subscribe')); - }, + } //-------------------------------------------------------------------------- // Options @@ -50,7 +49,7 @@ options.registry.Group = options.Class.extend({ * * @see this.selectClass for parameters */ - createGroup: async function (previewMode, widgetValue, params) { + async createGroup(previewMode, widgetValue, params) { const result = await wUtils.prompt({ id: "editor_new_mail_group_subscribe", window_title: _t("New Mail Group"), @@ -65,31 +64,24 @@ options.registry.Group = options.Class.extend({ const groupId = await this.orm.create("mail.group", [{ name: name }]); this.$target.attr("data-id", groupId); - return this._rerenderXML(); - }, + this.renderContext.mailGroups = await this._getMailGroups(); + } //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- - /** - * @override - */ - async _renderCustomXML(uiFragment) { - const groups = await this._getMailGroups(); - const menuEl = uiFragment.querySelector('.select_discussion_list'); - for (const group of groups) { - const el = document.createElement('we-button'); - el.dataset.selectDataAttribute = group[0]; - el.textContent = group[1]; - menuEl.appendChild(el); - } - }, /** * @private * @return {Promise} */ _getMailGroups() { return this.orm.call("mail.group", "name_search", [""]); - }, + } +} +registerWebsiteOption("Group", { + Class: Group, + template: "website_mail_group.s_group_options", + selector: ".s_group", + dropNear: "p, h1, h2, h3, blockquote, .card", }); diff --git a/addons/website_mail_group/static/src/snippets/s_group/options.xml b/addons/website_mail_group/static/src/snippets/s_group/options.xml new file mode 100644 index 0000000000000..4d75cb93e8f88 --- /dev/null +++ b/addons/website_mail_group/static/src/snippets/s_group/options.xml @@ -0,0 +1,15 @@ +<templates> +<t t-name="website_mail_group.s_group_options"> + <WeRow> + <WeSelect class="'select_discussion_list'" attributeName="'id'" noPreview="'true'"> + <t t-foreach="renderContext.mailGroups" t-as="group" t-key="group[0]"> + <WeButton selectDataAttribute="group[0].toString()"> + <t t-out="group[1]"/> + </WeButton> + </t> + </WeSelect> + <WeButton class="'fa fa-fw fa-plus'" tooltip.translate="Create a public discussion group in your backend" + createGroup="''" noPreview="'true'" name="'create_mail_group_opt'"/> + </WeRow> +</t> +</templates> diff --git a/addons/website_mail_group/views/snippets/s_group.xml b/addons/website_mail_group/views/snippets/s_group.xml index a93c010a9946b..1e7213e860008 100644 --- a/addons/website_mail_group/views/snippets/s_group.xml +++ b/addons/website_mail_group/views/snippets/s_group.xml @@ -9,21 +9,6 @@ </div> </div> </template> - <template id="s_group_options" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <div data-js='Group' - data-selector=".s_group" - data-drop-near="p, h1, h2, h3, blockquote, .card"> - <we-row> - <we-select class="select_discussion_list" data-attribute-name="id" data-no-preview="true"> - <!-- 'we-button' added programmatically with DB data --> - </we-select> - <we-button class="fa fa-fw fa-plus" title="Create a public discussion group in your backend" - data-create-group="" data-no-preview="true" data-name="create_mail_group_opt"/> - </we-row> - </div> - </xpath> - </template> <record id="website_mail_group.s_group_000_js" model="ir.asset"> <field name="name">Group 000 JS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website_mass_mailing/__manifest__.py b/addons/website_mass_mailing/__manifest__.py index 6bde470dad496..a8eb277c4f907 100644 --- a/addons/website_mass_mailing/__manifest__.py +++ b/addons/website_mass_mailing/__manifest__.py @@ -24,10 +24,12 @@ 'website_mass_mailing/static/src/xml/*.xml', ], 'website.assets_wysiwyg': [ - 'website_mass_mailing/static/src/js/website_mass_mailing.editor.js', 'website_mass_mailing/static/src/js/mass_mailing_form_editor.js', 'website_mass_mailing/static/src/scss/website_mass_mailing_edit_mode.scss', - 'website_mass_mailing/static/src/snippets/s_popup/options.js', + 'website_mass_mailing/static/src/snippets/s_newsletter_block/options.js', + 'website_mass_mailing/static/src/snippets/s_newsletter_block/options.xml', + 'website_mass_mailing/static/src/snippets/s_newsletter_subscribe_form/options.js', + 'website_mass_mailing/static/src/snippets/s_newsletter_subscribe_popup/options.js', ], 'web.assets_tests': [ 'website_mass_mailing/static/tests/**/*', diff --git a/addons/website_mass_mailing/static/src/js/website_mass_mailing.js b/addons/website_mass_mailing/static/src/js/website_mass_mailing.js index 1a9afd3015d6b..4414256d9b82b 100644 --- a/addons/website_mass_mailing/static/src/js/website_mass_mailing.js +++ b/addons/website_mass_mailing/static/src/js/website_mass_mailing.js @@ -6,7 +6,7 @@ import {ReCaptcha} from "@google_recaptcha/js/recaptcha"; import { rpc } from "@web/core/network/rpc"; publicWidget.registry.subscribe = publicWidget.Widget.extend({ - selector: ".js_subscribe", + selector: ".js_subscribe", // TODO: @owl-options change to .s_newsletter_subscribe_form ? disabledInEditableMode: false, read_events: { 'click .js_subscribe_btn': '_onSubscribeClick', @@ -99,7 +99,7 @@ publicWidget.registry.subscribe = publicWidget.Widget.extend({ }, _getListId: function () { - return this.$el.closest('[data-snippet=s_newsletter_block').data('list-id') || this.$el.data('list-id'); + return this.$el.closest('[data-snippet=s_newsletter_block]').data('list-id') || this.$el.data('list-id'); }, //-------------------------------------------------------------------------- diff --git a/addons/website_mass_mailing/static/src/js/website_mass_mailing.editor.js b/addons/website_mass_mailing/static/src/snippets/s_newsletter_block/options.js similarity index 59% rename from addons/website_mass_mailing/static/src/js/website_mass_mailing.editor.js rename to addons/website_mass_mailing/static/src/snippets/s_newsletter_block/options.js index ab120bcae5647..b53f3193df348 100644 --- a/addons/website_mass_mailing/static/src/js/website_mass_mailing.editor.js +++ b/addons/website_mass_mailing/static/src/snippets/s_newsletter_block/options.js @@ -1,28 +1,50 @@ -/** @odoo-module **/ - import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; import { _t } from "@web/core/l10n/translation"; import { renderToElement } from "@web/core/utils/render"; -import options from "@web_editor/js/editor/snippets.options"; +import { session } from "@web/session"; +import { + SelectTemplate, + SnippetOption, +} from "@web_editor/js/editor/snippets.options"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; + + +export class NewsletterBlock extends SelectTemplate { + constructor() { + super(...arguments); + this.containerSelector = "> .container, > .container-fluid, > .o_container_small"; + this.selectTemplateWidgetName = "newsletter_template_opt"; + } +} -options.registry.mailing_list_subscribe = options.Class.extend({ - init() { - this._super(...arguments); - this.orm = this.bindService("orm"); - }, +export class NewsletterMailingList extends SnippetOption { + constructor() { + super(...arguments); + this.dialog = this.env.services.dialog; + this.orm = this.env.services.orm; + this.website = this.env.services.website; + } + + /** + * @override + */ + async willStart() { + await super.willStart(); + this.renderContext.recaptcha_public_key = session.recaptcha_public_key; + } /** * @override */ - onBuilt() { - this._super(...arguments); - if (this.mailingLists.length) { - this.$target.attr("data-list-id", this.mailingLists[0][0]); + async onBuilt() { + await super.onBuilt(...arguments); + if (this.renderContext.mailingLists.length) { + this.$target.attr("data-list-id", this.renderContext.mailingLists[0][0].toString()); } else { - this.call("dialog", "add", ConfirmationDialog, { + this.dialog.add(ConfirmationDialog, { body: _t("No mailing list found, do you want to create a new one? This will save all your changes, are you sure you want to proceed?"), confirm: () => { - this.trigger_up("request_save", { + this.env.requestSave({ reload: false, onSuccess: () => { window.location.href = @@ -31,24 +53,40 @@ options.registry.mailing_list_subscribe = options.Class.extend({ }); }, cancel: () => { - this.trigger_up("remove_snippet", { + this.env.removeSnippet({ $snippet: this.$target, }); }, }); } - }, + } /** * @override */ - cleanForSave() { + async cleanForSave() { const previewClasses = ['o_disable_preview', 'o_enable_preview']; const toCleanElsSelector = ".js_subscribe_btn, .js_subscribed_btn"; const toCleanEls = this.$target[0].querySelectorAll(toCleanElsSelector); toCleanEls.forEach(element => { element.classList.remove(...previewClasses); }); - }, + } + + /** + * @override + */ + async _getRenderContext() { + const mailingLists = await this.orm.call( + "mailing.list", + "name_search", + ["", [["is_public", "=", true]]], + { context: this.options.recordInfo.context } + ); + return { + ...super._getRenderContext(), + mailingLists, + }; + } //-------------------------------------------------------------------------- // Options @@ -66,55 +104,12 @@ options.registry.mailing_list_subscribe = options.Class.extend({ thanksMessageEl.classList.toggle("o_enable_preview", widgetValue); toSubscribeEl.classList.toggle("o_enable_preview", !widgetValue); toSubscribeEl.classList.toggle("o_disable_preview", widgetValue); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- + } - /** - * @override - */ - _computeWidgetState(methodName, params) { - if (methodName !== 'toggleThanksMessage') { - return this._super(...arguments); - } - const toSubscribeElSelector = ".js_subscribe_btn.o_disable_preview"; - return this.$target[0].querySelector(toSubscribeElSelector) ? "true" : ""; - }, - /** - * @override - */ - async _renderCustomXML(uiFragment) { - this.mailingLists = await this.orm.call( - "mailing.list", - "name_search", - ["", [["is_public", "=", true]]], - { context: this.options.recordInfo.context } - ); - if (this.mailingLists.length) { - const selectEl = uiFragment.querySelector('we-select[data-attribute-name="listId"]'); - for (const mailingList of this.mailingLists) { - const button = document.createElement('we-button'); - button.dataset.selectDataAttribute = mailingList[0]; - button.textContent = mailingList[1]; - selectEl.appendChild(button); - } - } - const checkboxEl = document.createElement('we-checkbox'); - checkboxEl.setAttribute('string', _t("Display Thanks Button")); - checkboxEl.dataset.toggleThanksMessage = 'true'; - checkboxEl.dataset.noPreview = 'true'; - checkboxEl.dataset.dependencies = "!form_opt"; - uiFragment.appendChild(checkboxEl); - }, -}); - -options.registry.recaptchaSubscribe = options.Class.extend({ /** * Toggle the recaptcha legal terms */ - toggleRecaptchaLegal: function (previewMode, value, params) { + toggleRecaptchaLegal(previewMode, value, params) { const recaptchaLegalEl = this.$target[0].querySelector('.o_recaptcha_legal_terms'); if (recaptchaLegalEl) { recaptchaLegalEl.remove(); @@ -123,7 +118,7 @@ options.registry.recaptchaSubscribe = options.Class.extend({ template.content.append(renderToElement("google_recaptcha.recaptcha_legal_terms")); this.$target[0].appendChild(template.content.firstElementChild); } - }, + } //---------------------------------------------------------------------- // Private @@ -132,11 +127,25 @@ options.registry.recaptchaSubscribe = options.Class.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { switch (methodName) { + case 'toggleThanksMessage': + return this.$target[0].querySelector(".js_subscribe_btn.o_disable_preview") ? "true" : ""; case 'toggleRecaptchaLegal': return !this.$target[0].querySelector('.o_recaptcha_legal_terms') || ''; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } +} + +registerWebsiteOption("NewsletterBlockTemplate", { + Class: NewsletterBlock, + template: "website_mass_mailing.s_newsletter_block_template_options", + selector: ".s_newsletter_block", +}); + +registerWebsiteOption("NewsletterBlockMailingList", { + Class: NewsletterMailingList, + template: "website_mass_mailing.newsletter_mailing_list_options", + selector: ".s_newsletter_block", }); diff --git a/addons/website_mass_mailing/static/src/snippets/s_newsletter_block/options.xml b/addons/website_mass_mailing/static/src/snippets/s_newsletter_block/options.xml new file mode 100644 index 0000000000000..2e32bdf77c433 --- /dev/null +++ b/addons/website_mass_mailing/static/src/snippets/s_newsletter_block/options.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="website_mass_mailing.s_newsletter_block_template_options"> + <xpath expr="//div[1]" position="before"> + <WeSelect title.translate="Template" + name="'newsletter_template_opt'" + attributeName="'newsletterTemplate'" + attributeDefaultValue="'email'"> + <WeButton tooltip.translate="Email Subscription" + selectTemplate="'website_mass_mailing.s_newsletter_block_default_template'" + selectDataAttribute="'email'" + name="'email_opt'"> + Email Subscription + </WeButton> + <WeButton tooltip.translate="Form Subscription" + selectTemplate="'website_mass_mailing.s_newsletter_block_form_template'" + selectDataAttribute="'form'" + name="'form_opt'"> + Form Subscription + </WeButton> + </WeSelect> + </xpath> + </t> + + <t t-name="website_mass_mailing.newsletter_mailing_list_options"> + <xpath expr="//div[1]" position="before"> + <WeSelect title.translate="Newsletter" attributeName="'listId'" dependencies="'!form_opt'"> + <t t-if="renderContext.mailingLists"> + <t t-foreach="renderContext.mailingLists" t-as="mailingList" t-key="mailingList[0]"> + <WeButton selectDataAttribute="mailingList[0].toString()" t-esc="mailingList[1]"/> + </t> + </t> + </WeSelect> + <WeRow title.translate="Display Thanks Button"> + <WeCheckbox toggleThanksMessage="'true'" noPreview="'true'" dependencies="'!form_opt'"/> + </WeRow> + <WeRow title.translate="Show reCaptcha Policy"> + <WeCheckbox t-if="renderContext.recaptcha_public_key" toggleRecaptchaLegal="''" noPreview="'true'"/> + </WeRow> + </xpath> + </t> + +</templates> diff --git a/addons/website_mass_mailing/static/src/snippets/s_newsletter_subscribe_form/options.js b/addons/website_mass_mailing/static/src/snippets/s_newsletter_subscribe_form/options.js new file mode 100644 index 0000000000000..cd7b7db35904e --- /dev/null +++ b/addons/website_mass_mailing/static/src/snippets/s_newsletter_subscribe_form/options.js @@ -0,0 +1,14 @@ +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; +import { NewsletterMailingList } from "../s_newsletter_block/options"; + +registerWebsiteOption("NewsletterSubscribeForm", { + selector: ".js_subscribe", // TODO: @owl-options change to .s_newsletter_subscribe_form ? + dropNear: "p, h1, h2, h3, blockquote, .card", +}); + +registerWebsiteOption("NewsletterSubscribeFormMailingList", { + Class: NewsletterMailingList, + template: "website_mass_mailing.newsletter_mailing_list_options", + selector: ".s_newsletter_subscribe_form", + exclude: ".s_newsletter_list .s_newsletter_subscribe_form, .o_newsletter_popup .s_newsletter_subscribe_form", +}); diff --git a/addons/website_mass_mailing/static/src/snippets/s_newsletter_subscribe_popup/options.js b/addons/website_mass_mailing/static/src/snippets/s_newsletter_subscribe_popup/options.js new file mode 100644 index 0000000000000..fd51cfd24ca5a --- /dev/null +++ b/addons/website_mass_mailing/static/src/snippets/s_newsletter_subscribe_popup/options.js @@ -0,0 +1,11 @@ +import { registerSnippetAdditionSelector } from "@web_editor/js/editor/snippets.registry"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; +import { NewsletterMailingList } from "../s_newsletter_block/options"; + +registerWebsiteOption("NewsletterSubscribePopup", { + Class: NewsletterMailingList, + template: "website_mass_mailing.newsletter_mailing_list_options", + selector: ".o_newsletter_popup", + target: ".s_newsletter_list", +}); +registerSnippetAdditionSelector(".o_newsletter_popup"); diff --git a/addons/website_mass_mailing/static/src/snippets/s_popup/options.js b/addons/website_mass_mailing/static/src/snippets/s_popup/options.js deleted file mode 100644 index 9938e9a0c2b8d..0000000000000 --- a/addons/website_mass_mailing/static/src/snippets/s_popup/options.js +++ /dev/null @@ -1,14 +0,0 @@ -/** @odoo-module **/ - -import options from '@web_editor/js/editor/snippets.options'; - -options.registry.NewsletterLayout = options.registry.SelectTemplate.extend({ - /** - * @constructor - */ - init() { - this._super(...arguments); - this.containerSelector = '> .container, > .container-fluid, > .o_container_small'; - this.selectTemplateWidgetName = 'newsletter_template_opt'; - }, -}); diff --git a/addons/website_mass_mailing/views/snippets_templates.xml b/addons/website_mass_mailing/views/snippets_templates.xml index a960f8db599a3..00b7f45b149f3 100644 --- a/addons/website_mass_mailing/views/snippets_templates.xml +++ b/addons/website_mass_mailing/views/snippets_templates.xml @@ -190,50 +190,6 @@ database, without the s_newsletter_list class. See fixNewsletterListClass. </div> </template> -<template id="newsletter_subscribe_options" name="Newsletter Subscribe Options" inherit_id="website.snippet_options"> - <xpath expr="//*[@t-set='so_snippet_addition_selector']" position="inside">, .o_newsletter_popup</xpath> - <xpath expr="//div[1]" position="before"> - <div data-js="NewsletterLayout" data-selector=".s_newsletter_block"> - <we-select string="Template" - data-name="newsletter_template_opt" - data-attribute-name="newsletterTemplate" - data-attribute-default-value="email"> - <we-button title="Email Subscription" string="Email Subscription" - data-select-template="website_mass_mailing.s_newsletter_block_default_template" - data-select-data-attribute="email" data-name="email_opt"/> - <we-button title="Form Subscription" string="Form Subscription" - data-select-template="website_mass_mailing.s_newsletter_block_form_template" - data-select-data-attribute="form" data-name="form_opt"/> - </we-select> - </div> - <t t-call="website_mass_mailing.newsletter_subscribe_options_common"> - <t t-set="_selector">.o_newsletter_popup</t> - <t t-set="_target">.s_newsletter_list</t> - </t> - <t t-call="website_mass_mailing.newsletter_subscribe_options_common"> - <t t-set="_selector">.s_newsletter_list</t> - <t t-set="_exclude">.s_newsletter_block .s_newsletter_list, .o_newsletter_popup .s_newsletter_list</t> - </t> - <div data-selector=".js_subscribe" data-drop-near="p, h1, h2, h3, blockquote, .card"/> - </xpath> -</template> - -<template id="newsletter_subscribe_options_common"> - <div data-js="mailing_list_subscribe" - t-att-data-selector="_selector" - t-att-data-exclude="_exclude" - t-att-data-target="_target"> - <we-select string="Newsletter" data-attribute-name="listId" data-dependencies="!form_opt"></we-select> - </div> - <div data-js="recaptchaSubscribe" - t-att-data-selector="_selector" - t-att-data-exclude="_exclude" - t-att-data-target="_target"> - <t t-set="recaptcha_public_key" t-value="request.env['ir.config_parameter'].sudo().get_param('recaptcha_public_key')"/> - <we-checkbox t-if="recaptcha_public_key" string="Show reCaptcha Policy" data-toggle-recaptcha-legal="" data-no-preview="true"/> - </div> -</template> - <!-- Extend default mass_mailing snippets with website feature --> <template id="s_mail_block_footer_social" inherit_id="mass_mailing.s_mail_block_footer_social"> diff --git a/addons/website_payment/static/src/snippets/s_donation/options.js b/addons/website_payment/static/src/snippets/s_donation/options.js index a6a92c35582c7..3a184b6752621 100644 --- a/addons/website_payment/static/src/snippets/s_donation/options.js +++ b/addons/website_payment/static/src/snippets/s_donation/options.js @@ -1,24 +1,39 @@ /** @odoo-module **/ import { renderToElement } from "@web/core/utils/render"; -import options from '@web_editor/js/editor/snippets.options'; import { _t } from "@web/core/l10n/translation"; +import { SnippetOption } from "@web_editor/js/editor/snippets.options"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; -options.registry.Donation = options.Class.extend({ +export class Donation extends SnippetOption { /** * @override */ - start() { + constructor() { + super(...arguments); this.defaultDescription = _t("Add a description here"); - return this._super(...arguments); - }, + this.descriptions = []; + if (this.$target[0].dataset.descriptions) { + const descriptionEls = this.$target[0].querySelectorAll('#s_donation_description_inputs > input'); + for (const descriptionEl of descriptionEls) { + this.descriptions.push(descriptionEl.value); + } + } + } + /** + * @override + */ + async willStart() { + await super.willStart(...arguments); + this.renderContext.showOptionDescriptions = this.$target[0].dataset.descriptions; + } /** * @override */ onBuilt() { this._rebuildPrefilledOptions(); - return this._super(...arguments); - }, + return super.onBuilt(...arguments); + } /** * @override */ @@ -26,7 +41,7 @@ options.registry.Donation = options.Class.extend({ if (!this.$target[0].dataset.descriptions) { this._updateDescriptions(); } - }, + } //-------------------------------------------------------------------------- // Public @@ -36,9 +51,9 @@ options.registry.Donation = options.Class.extend({ * @override */ async updateUI() { - await this._super(...arguments); - this._buildDescriptionsList(); - }, + await super.updateUI(...arguments); + this._updateDescriptions(); + } //-------------------------------------------------------------------------- // Options @@ -57,7 +72,7 @@ options.registry.Donation = options.Class.extend({ this.$target[0].dataset.customAmount = "slider"; } this._rebuildPrefilledOptions(); - }, + } /** * Add/remove prefilled buttons. * @@ -65,12 +80,11 @@ options.registry.Donation = options.Class.extend({ */ togglePrefilledOptions(previewMode, widgetValue, params) { this.$target[0].dataset.prefilledOptions = widgetValue; - this.$el.find('.o_we_prefilled_options_list').toggleClass('d-none', !widgetValue); if (!widgetValue && this.$target[0].dataset.displayOptions) { this.$target[0].dataset.customAmount = "slider"; } this._rebuildPrefilledOptions(); - }, + } /** * Add/remove description of prefilled buttons. * @@ -78,8 +92,9 @@ options.registry.Donation = options.Class.extend({ */ toggleOptionDescription(previewMode, widgetValue, params) { this.$target[0].dataset.descriptions = widgetValue; + this.renderContext.showOptionDescriptions = widgetValue; this.renderListItems(false, this._buildPrefilledOptionsList()); - }, + } /** * Select an amount input * @@ -88,7 +103,7 @@ options.registry.Donation = options.Class.extend({ selectAmountInput(previewMode, widgetValue, params) { this.$target[0].dataset.customAmount = widgetValue; this._rebuildPrefilledOptions(); - }, + } /** * Apply the we-list on the target and rebuild the input(s) * @@ -97,13 +112,17 @@ options.registry.Donation = options.Class.extend({ renderListItems(previewMode, value, params) { const valueList = JSON.parse(value); const donationAmounts = []; + this.descriptions = []; delete this.$target[0].dataset.donationAmounts; valueList.forEach((value) => { donationAmounts.push(value.display_name); + if (value.secondInputText) { + this.descriptions.push(value.secondInputText); + } }); this.$target[0].dataset.donationAmounts = JSON.stringify(donationAmounts); this._rebuildPrefilledOptions(); - }, + } /** * Redraws the target whenever the list changes * @@ -112,7 +131,7 @@ options.registry.Donation = options.Class.extend({ listChanged(previewMode, value, params) { this._updateDescriptions(); this._rebuildPrefilledOptions(); - }, + } /** * @see this.selectClass for parameters */ @@ -125,7 +144,7 @@ options.registry.Donation = options.Class.extend({ } else if ($amountInput.length) { $amountInput[0].min = widgetValue; } - }, + } /** * @see this.selectClass for parameters */ @@ -138,7 +157,7 @@ options.registry.Donation = options.Class.extend({ } else if ($amountInput.length) { $amountInput[0].max = widgetValue; } - }, + } /** * @see this.selectClass for parameters */ @@ -148,7 +167,7 @@ options.registry.Donation = options.Class.extend({ if ($rangeSlider.length) { $rangeSlider[0].step = widgetValue; } - }, + } //-------------------------------------------------------------------------- // Private @@ -184,8 +203,8 @@ options.registry.Donation = options.Class.extend({ return this.$target[0].dataset.sliderStep; } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ @@ -193,22 +212,8 @@ options.registry.Donation = options.Class.extend({ if (widgetName === 'free_amount_opt') { return !(this.$target[0].dataset.displayOptions && !this.$target[0].dataset.prefilledOptions); } - return this._super(...arguments); - }, - /** - * @override - */ - _renderCustomXML(uiFragment) { - const list = document.createElement('we-list'); - list.dataset.dependencies = "pre_filled_opt"; - list.dataset.addItemTitle = _t("Add new pre-filled option"); - list.dataset.renderListItems = ''; - list.dataset.unsortable = 'true'; - list.dataset.inputType = 'number'; - list.dataset.defaultValue = 50; - list.dataset.listChanged = ''; - $(uiFragment).find('we-checkbox[data-name="pre_filled_opt"]').after(list); - }, + return super._computeWidgetVisibility(...arguments); + } /** * Build the prefilled options list in the editor panel * @@ -216,38 +221,23 @@ options.registry.Donation = options.Class.extend({ */ _buildPrefilledOptionsList() { const amounts = JSON.parse(this.$target[0].dataset.donationAmounts); - let valueList = amounts.map(amount => { + let valueList = amounts.map((amount, i) => { + let doubleInput = {}; + if (this.$target[0].dataset.descriptions) { + doubleInput = { + firstInputClass: "w-25", + secondInputClass: "w-auto", + secondInputText: this.descriptions[i] || this.defaultDescription, + } + } return { id: amount, display_name: amount, + ...doubleInput, }; }); return JSON.stringify(valueList); - }, - /** - * Add descriptions in the prefilled options list of the - * editor panel. - * - * @private - */ - _buildDescriptionsList() { - if (this.$target[0].dataset.descriptions) { - const $descriptions = this.$target.find('#s_donation_description_inputs > input'); - const $tableEl = this.$el.find('we-list table'); - $tableEl.find("tr").toArray().forEach((trEl, i) => { - const $inputAmount = $(trEl).find('td').first(); - $inputAmount.addClass('w-25'); - const tdEl = document.createElement('td'); - const inputEl = document.createElement('input'); - inputEl.type = 'text'; - inputEl.value = $descriptions[i] ? $descriptions[i].value : this.defaultDescription; - tdEl.classList.add('w-auto'); - tdEl.appendChild(inputEl); - $(tdEl).insertAfter($inputAmount); - }); - this._updateDescriptions(); - } - }, + } /** * Update descriptions in the input hidden. * @@ -256,16 +246,15 @@ options.registry.Donation = options.Class.extend({ _updateDescriptions() { const descriptionInputs = this.$target.find('#s_donation_description_inputs'); descriptionInputs.empty(); - const descriptions = this.$el.find('we-list input[type=text]'); - descriptions.toArray().forEach((description) => { + this.descriptions.forEach((description) => { const inputEl = document.createElement('input'); inputEl.type = 'hidden'; inputEl.classList.add('o_translatable_input_hidden', 'd-block', 'mb-1', 'w-100'); inputEl.name = 'donation_descriptions'; - inputEl.value = description.value; + inputEl.value = description; descriptionInputs[0].appendChild(inputEl); }); - }, + } /** * Rebuild options in the DOM. * @@ -311,9 +300,10 @@ options.registry.Donation = options.Class.extend({ })); this.$target.find('#s_donation_description_inputs').after($prefilledButtons); } - }, + } +} +registerWebsiteOption("Donation", { + Class: Donation, + template: "website_payment.s_donation_options", + selector: ".s_donation", }); - -export default { - Donation: options.registry.Donation, -}; diff --git a/addons/website_payment/static/src/snippets/s_donation/options.xml b/addons/website_payment/static/src/snippets/s_donation/options.xml index 95c52e91e1645..18603dd71d1fe 100644 --- a/addons/website_payment/static/src/snippets/s_donation/options.xml +++ b/addons/website_payment/static/src/snippets/s_donation/options.xml @@ -39,4 +39,38 @@ <output class="s_range_bubble" contenteditable="false">25</output> </div> </t> + + <t t-name="website_payment.s_donation_options"> + <WeInput title.translate="Recipient Email" class="'o_we_large'" selectDataAttribute="''" + attributeName="'donationEmail'"/> + <WeCheckbox title.translate="Display Options" name="'display_options_opt'" + displayOptions="'true'" noPreview="'true'"/> + <WeCheckbox title.translate="Pre-filled Options" name="'pre_filled_opt'" noPreview="'true'" + togglePrefilledOptions="'true'" dependencies="'!no_input_opt'"/> + <t t-set="addItemTitle">Add new pre-filled option</t> + <WeList dependencies="'pre_filled_opt'" addItemTitle="addItemTitle" renderListItems="''" + unsortable="'true'" inputType="'number'" defaultValue="'50'" listChanged="''" + doubleInput="renderContext.showOptionDescriptions ? 'true' : ''"/> + <WeCheckbox title.translate="Descriptions" class="'o_we_sublevel_1'" noPreview="'true'" + toggleOptionDescription="'true'" dependencies="'pre_filled_opt'"/> + <WeSelect title.translate="Custom Amount" noPreview="'true'"> + <WeButton name="'free_amount_opt'" selectAmountInput="'freeAmount'">Input</WeButton> + <WeButton name="'slider_opt'" selectAmountInput="'slider'" + dependencies="'display_options_opt'"> + Slider + </WeButton> + <WeButton name="'no_input_opt'" selectAmountInput="''" + dependencies="'pre_filled_opt'"> + None + </WeButton> + </WeSelect> + <WeInput title.translate="Minimum" class="'o_we_sublevel_1'" step="'1'" setMinimumAmount="''" + dependencies="'!no_input_opt'"/> + <WeInput title.translate="Maximum" class="'o_we_sublevel_1'" step="'1'" setMaximumAmount="''" + dependencies="'slider_opt'"/> + <WeInput title.translate="Step" class="'o_we_sublevel_1'" step="'1'" setSliderStep="''" + dependencies="'slider_opt'"/> + <WeInput title.translate="Default Amount" step="'1'" attributeDefaultValue="'25'" + selectDataAttribute="''" attributeName="'defaultAmount'"/> + </t> </templates> diff --git a/addons/website_payment/views/snippets/s_donation.xml b/addons/website_payment/views/snippets/s_donation.xml index 3f0e7c8c6fc7c..3fded5520f59e 100644 --- a/addons/website_payment/views/snippets/s_donation.xml +++ b/addons/website_payment/views/snippets/s_donation.xml @@ -77,45 +77,6 @@ </section> </template> -<template id="s_donation_options" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <div data-js="Donation" data-selector=".s_donation"> - <we-input class="o_we_large" string="Recipient Email" data-select-data-attribute="" - data-attribute-name="donationEmail"/> - <we-checkbox string="Display Options" - data-name="display_options_opt" - data-display-options="true" - data-no-preview="true"> - </we-checkbox> - <we-checkbox string="Pre-filled Options" - data-name="pre_filled_opt" - data-toggle-prefilled-options="true" - data-dependencies="!no_input_opt" - data-no-preview="true"> - </we-checkbox> - <we-checkbox string="Descriptions" - class="o_we_sublevel_1" - data-toggle-option-description="true" - data-dependencies="pre_filled_opt" - data-no-preview="true"> - </we-checkbox> - <we-select string="Custom Amount" data-no-preview="true"> - <we-button data-name="free_amount_opt" data-select-amount-input="freeAmount">Input</we-button> - <we-button data-name="slider_opt" data-select-amount-input="slider" data-dependencies="display_options_opt">Slider</we-button> - <we-button data-name="no_input_opt" data-select-amount-input="" data-dependencies="pre_filled_opt">None</we-button> - </we-select> - <we-input string="Minimum" class="o_we_sublevel_1" - data-step="1" data-set-minimum-amount="" data-dependencies="!no_input_opt"/> - <we-input string="Maximum" class="o_we_sublevel_1" - data-step="1" data-set-maximum-amount="" data-dependencies="slider_opt"/> - <we-input string="Step" class="o_we_sublevel_1" - data-step="1" data-set-slider-step="" data-dependencies="slider_opt"/> - <we-input string="Default Amount" data-step="1" data-attribute-default-value="25" - data-select-data-attribute="" data-attribute-name="defaultAmount"/> - </div> - </xpath> -</template> - <record id="website_payment.s_donation_000_js" model="ir.asset"> <field name="name">Donation 000 JS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website_sale/__manifest__.py b/addons/website_sale/__manifest__.py index 655eca86a0524..44a4898408944 100644 --- a/addons/website_sale/__manifest__.py +++ b/addons/website_sale/__manifest__.py @@ -91,7 +91,6 @@ 'website_sale/static/src/js/website_sale_price_range_option.js', 'website_sale/static/src/js/website_sale_product_configurator.js', 'website_sale/static/src/js/website_sale_utils.js', - 'website_sale/static/src/xml/website_sale_utils.xml', 'website_sale/static/src/js/website_sale_recently_viewed.js', 'website_sale/static/src/js/website_sale_tracking.js', 'website/static/lib/multirange/multirange_custom.js', @@ -135,14 +134,23 @@ ], 'website.assets_wysiwyg': [ 'website_sale/static/src/scss/website_sale.editor.scss', + 'website_sale/static/src/js/editor/snippets.options.js', + 'website_sale/static/src/js/editor/snippets.options.xml', + 'website_sale/static/src/js/editor/pages/all_products/options.xml', + 'website_sale/static/src/js/editor/pages/checkout/options.js', + 'website_sale/static/src/js/editor/pages/checkout/options.xml', + 'website_sale/static/src/js/editor/pages/product/options.xml', 'website_sale/static/src/snippets/s_dynamic_snippet_products/options.js', + 'website_sale/static/src/snippets/s_dynamic_snippet_products/options.xml', 'website_sale/static/src/snippets/s_add_to_cart/options.js', + 'website_sale/static/src/snippets/s_add_to_cart/options.xml', + 'website_sale/static/src/snippets/s_searchbar/options.js', + 'website_sale/static/src/snippets/s_searchbar/options.xml', 'website_sale/static/src/js/website_sale.editor.js', 'website_sale/static/src/js/website_sale_form_editor.js', ], 'website.assets_editor': [ 'website_sale/static/src/js/systray_items/*.js', - 'website_sale/static/src/xml/website_sale_utils.xml', ], 'website.backend_assets_all_wysiwyg': [ 'website_sale/static/src/js/components/wysiwyg_adapter/wysiwyg_adapter.js', diff --git a/addons/website_sale/models/website.py b/addons/website_sale/models/website.py index bbc31b3d1558e..310e8de6af588 100644 --- a/addons/website_sale/models/website.py +++ b/addons/website_sale/models/website.py @@ -198,6 +198,11 @@ def _get_product_sort_mapping(): ('list_price desc', _("Price - High to Low")), ] + @staticmethod + @api.model + def get_product_sort_mapping(): + return Website._get_product_sort_mapping() + #=== BUSINESS METHODS ===# # This method is cached, must not return records! See also #8795 @@ -665,5 +670,3 @@ def _get_checkout_steps(self, current_step=None): def has_ecommerce_access(self): """ Return whether the current user is allowed to access eCommerce-related content. """ return not (self.env.user._is_public() and self.ecommerce_access == 'logged_in') - - diff --git a/addons/website_sale/static/src/js/components/wysiwyg_adapter/wysiwyg_adapter.js b/addons/website_sale/static/src/js/components/wysiwyg_adapter/wysiwyg_adapter.js index bac34065108fc..7d230b84938ca 100644 --- a/addons/website_sale/static/src/js/components/wysiwyg_adapter/wysiwyg_adapter.js +++ b/addons/website_sale/static/src/js/components/wysiwyg_adapter/wysiwyg_adapter.js @@ -26,6 +26,53 @@ patch(WysiwygAdapterComponent.prototype, { this.deletedRibbonClasses = ''; this.ribbonPositionClasses = {'left': 'o_ribbon_left', 'right': 'o_ribbon_right'}; }, + /** + * Returns a copy of this.ribbons. + */ + getRibbons() { + return Object.assign({}, this.ribbons); + }, + /** + * Returns all ribbon classes, current and deleted, so they can be removed. + */ + getRibbonClasses() { + return Object.values(this.ribbons).reduce((classes, ribbon) => { + return classes + ` ${this.ribbonPositionClasses[ribbon.position]}`; + }, '') + this.deletedRibbonClasses; + }, + /** + * Deletes a ribbon. + * + * @param {number} id - The id of the ribbon to delete. + */ + deleteRibbon(id) { + this.deletedRibbonClasses += ` ${ + this.ribbonPositionClasses[this.ribbons[id].position] + }`; + delete this.ribbons[id]; + }, + /** + * Sets a ribbon. + * + * @param {Object} ribbon + * @property {number} ribbon.id + */ + setRibbon(ribbon) { + const previousRibbon = this.ribbons[ribbon.id]; + if (previousRibbon) { + this.deletedRibbonClasses += ` ${this.ribbonPositionClasses[previousRibbon.position]}`; + } + this.ribbons[ribbon.id] = ribbon; + }, + /** + * Sets which ribbon is used by a product template. + * + * @param {number} templateId - The product template's id. + * @param {number|false} ribbonId - The ribbon's id. + */ + setProductRibbon(templateId, ribbonId) { + this.productTemplatesRibbons.push({templateId, ribbonId}); + }, /** * @override */ @@ -131,75 +178,6 @@ patch(WysiwygAdapterComponent.prototype, { // Handlers //-------------------------------------------------------------------------- - /** - * Returns a copy of this.ribbons through a callback. - * - * @private - */ - _onGetRibbons(ev) { - ev.data.callback(Object.assign({}, this.ribbons)); - }, - /** - * Returns all ribbon classes, current and deleted, so they can be removed. - * - * @private - */ - _onGetRibbonClasses(ev) { - const classes = Object.values(this.ribbons).reduce((classes, ribbon) => { - return classes + ` ${this.ribbonPositionClasses[ribbon.position]}`; - }, '') + this.deletedRibbonClasses; - ev.data.callback(classes); - }, - /** - * Deletes a ribbon. - * - * @private - */ - _onDeleteRibbon(ev) { - this.deletedRibbonClasses += ` ${ - this.ribbonPositionClasses[this.ribbons[ev.data.id].position] - }`; - delete this.ribbons[ev.data.id]; - }, - /** - * Sets a ribbon; - * - * @private - */ - _onSetRibbon(ev) { - const {ribbon} = ev.data; - const previousRibbon = this.ribbons[ribbon.id]; - if (previousRibbon) { - this.deletedRibbonClasses += ` ${this.ribbonPositionClasses[previousRibbon.position]}`; - } - this.ribbons[ribbon.id] = ribbon; - }, - /** - * Sets which ribbon is used by a product template. - * - * @private - */ - _onSetProductRibbon(ev) { - const {templateId, ribbonId} = ev.data; - this.productTemplatesRibbons.push({templateId, ribbonId}); - }, - /** - * @override - */ - _trigger_up(ev) { - const methods = { - get_ribbons: this._onGetRibbons.bind(this), - get_ribbon_classes: this._onGetRibbonClasses.bind(this), - delete_ribbon: this._onDeleteRibbon.bind(this), - set_ribbon: this._onSetRibbon.bind(this), - set_product_ribbon: this._onSetProductRibbon.bind(this), - } - if (methods[ev.name]) { - return methods[ev.name](ev); - } else { - return super._trigger_up(...arguments); - } - }, // TODO this whole patch actually seems unnecessary. The bug it solved seems // to stay solved if this is removed. To investigate. /** diff --git a/addons/website_sale/static/src/js/editor/pages/all_products/options.xml b/addons/website_sale/static/src/js/editor/pages/all_products/options.xml new file mode 100644 index 0000000000000..4f269646b8c7b --- /dev/null +++ b/addons/website_sale/static/src/js/editor/pages/all_products/options.xml @@ -0,0 +1,223 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <!-- All products page --> + <t t-name="website_sale.WebsiteSaleGridLayout"> + <WeSelect title.translate="Layout" noPreview="'true'" reload="'/'"> + <WeButton customizeWebsiteViews="''" name="'grid_view_opt'">Grid</WeButton> + <WeButton customizeWebsiteViews="'website_sale.products_list_view'">List</WeButton> + </WeSelect> + <WeRow title.translate="Size" class="'o_we_sublevel_1'"> + <WeInput setPpg="''" step="'1'" noPreview="'true'" reload="'/'"/> + <span class="'mx-2 o_wsale_ppr_by'" t-att-class="{'d-none': renderContext.isListLayoutEnabled}">by</span> + <WeSelect class="'o_wsale_ppr_submenu' + (renderContext.isListLayoutEnabled ? ' d-none' : '')" + dependencies="'grid_view_opt'" + noPreview="'true'" + reload="'/'"> + <WeButton setPpr="'2'">2</WeButton> + <WeButton setPpr="'3'">3</WeButton> + <WeButton setPpr="'4'">4</WeButton> + </WeSelect> + </WeRow> + <WeSelect title.translate="Style" class="'o_we_sublevel_1'"> + <WeButton selectClass="`''`" + customizeWebsiteViews="''"> + Default + </WeButton> + <WeButton selectClass="`'o_wsale_design_cards'`" + customizeWebsiteViews="'website_sale.products_design_card'"> + Cards + </WeButton> + <WeButton selectClass="`'o_wsale_design_thumbs'`" + customizeWebsiteViews="'website_sale.products_design_thumbs'"> + Thumbnails + </WeButton> + <WeButton selectClass="`'o_wsale_design_grid'`" + customizeWebsiteViews="'website_sale.products_design_grid'"> + Grid + </WeButton> + </WeSelect> + <WeSelect title.translate="Images Size" class="'o_we_sublevel_1'"> + <WeButton selectClass="`'o_wsale_context_thumb_4_3'`" + customizeWebsiteViews="'website_sale.products_thumb_4_3'"> + Landscape (4/3) + </WeButton> + <WeButton selectClass="`''`" + customizeWebsiteViews="''"> + Default (1/1) + </WeButton> + <WeButton selectClass="`'o_wsale_context_thumb_4_5'`" + customizeWebsiteViews="'website_sale.products_thumb_4_5'"> + Portrait (4/5) + </WeButton> + <WeButton selectClass="`'o_wsale_context_thumb_2_3'`" + customizeWebsiteViews="'website_sale.products_thumb_2_3'"> + Vertical (2/3) + </WeButton> + </WeSelect> + <WeButtonGroup title.translate="Fill" class="'o_we_sublevel_2'" variable="'thumb_size'"> + <WeButton selectClass="`''`" + img="'/website/static/src/img/snippets_options/content_width_normal.svg'" + customizeWebsiteViews="''"> + </WeButton> + <WeButton selectClass="`'o_wsale_context_thumb_cover'`" + name="'thumb_cover'" + variable="'thumb_cover'" + img="'/website/static/src/img/snippets_options/content_width_full.svg'" + customizeWebsiteViews="'website_sale.products_thumb_cover'"> + </WeButton> + </WeButtonGroup> + <WeCheckbox title.translate="Search Bar" + customizeWebsiteViews="'website_sale.search'" + noPreview="'true'" + reload="'/'"/> + <WeCheckbox title.translate="Prod. Desc." + customizeWebsiteViews="'website_sale.products_description'" + noPreview="'true'" + reload="'/'"/> + <WeRow title.translate="Categories" id="o_wsale_grid_left_panel" variable="'filmstrip'"> + <WeButton customizeWebsiteViews="'website_sale.products_categories'" + name="'categories_opt'" + noPreview="'true'" + reload="'/'"> + Left + </WeButton> + <WeButton customizeWebsiteViews="'website_sale.products_categories_top'" + name="'categories_opt_top'" + noPreview="'true'" + reload="'/'"> + Top + </WeButton> + </WeRow> + <WeCheckbox title.translate="Collapse Category Recursive" + id="collapse_category_recursive" + class="'o_we_sublevel_1'" + customizeWebsiteViews="'website_sale.option_collapse_products_categories'" + dependencies="'categories_opt'" + noPreview="'true'" + reload="'/'"/> + <WeRow title.translate="Attributes" class="'o_we_full_row'"> + <WeButton customizeWebsiteViews="'website_sale.products_attributes'" + name="'attributes_opt'" + noPreview="'true'" + reload="'/'"> + Left + </WeButton> + <WeButton customizeWebsiteViews="'website_sale.products_attributes_top'" + name="'attributes_opt_top'" + noPreview="'true'" + reload="'/'"> + Top + </WeButton> + </WeRow> + <WeCheckbox title.translate="Price Filter" + class="'o_we_sublevel_1'" + customizeWebsiteViews="'website_sale.filter_products_price'" + dependencies="'attributes_opt, attributes_opt_top'" + noPreview="'true'" + reload="'/'"/> + <WeCheckbox title.translate="Product Tags Filter" + class="'o_we_sublevel_1'" + customizeWebsiteViews="'website_sale.filter_products_tags'" + dependencies="'attributes_opt, attributes_opt_top'" + noPreview="'true'" + reload="'/'"/> + <WeRow title.translate="Top Bar" class="'o_we_full_row'"> + <WeButton customizeWebsiteViews="'website_sale.sort'" + noPreview="'true'" + reload="'/'"> + Sort by + </WeButton> + <WeButton customizeWebsiteViews="'website_sale.add_grid_or_list_option'" + noPreview="'true'" + reload="'/'"> + Layout + </WeButton> + </WeRow> + <WeSelect title.translate="Default Sort" class="'o_wsale_sort_submenu'" noPreview="'true'" reload="'/'"> + <t t-foreach="renderContext.productSorts" t-as="query_and_label" t-key="query_and_label[0]"> + <WeButton setDefaultSort="query_and_label[0]"><t t-esc="query_and_label[1]"/></WeButton> + </t> + </WeSelect> + <WeRow title.translate="Buttons" class="'o_we_full_row'"> + <WeButton tooltip.translate="Add to Cart" + class="'fa fa-fw fa-shopping-cart o_we_add_to_cart_btn'" + customizeWebsiteViews="'website_sale.products_add_to_cart'" + noPreview="'true'" + reload="'/'"/> + </WeRow> + </t> + <!-- Product --> + <t t-name="website_sale.WebsiteSaleProductsItem"> + <div class="o_wsale_soptions_menu_sizes" t-att-class="{'d-none': renderContext.isListLayoutEnabled}"> + <WeRow title.translate="Size"> + <div class="oe_size_table"> + <t t-foreach="[...Array(renderContext.ppr).keys()]" t-as="y" t-key="y"> + <div class="oe_size_row"> + <t t-foreach="[...Array(renderContext.ppr).keys()]" t-as="x" t-key="x"> + <WeButton setProductSize="`${x + 1},${y + 1}`" + class="'oe_size_cell' + (x + 1 lte renderContext.selectedSize.x and y + 1 lte renderContext.selectedSize.y ? ' selected' : '')"/> + </t> + </div> + </t> + </div> + </WeRow> + </div> + + <WeRow title.translate="Re-order"> + <WeButton tooltip.translate="Push to top" changeSequence="'top'" noPreview="'true'" class="'fa fa-fw fa-angle-double-left'"/> + <WeButton tooltip.translate="Push up" changeSequence="'up'" noPreview="'true'" class="'fa fa-fw fa-angle-left'"/> + <WeButton tooltip.translate="Push down" changeSequence="'down'" noPreview="'true'" class="'fa fa-fw fa-angle-right'"/> + <WeButton tooltip.translate="Push to bottom" changeSequence="'bottom'" noPreview="'true'" class="'fa fa-fw fa-angle-double-right'"/> + </WeRow> + + <WeRow> + <WeSelect title.translate="Ribbon" class="'o_wsale_ribbon_select'"> + <WeButton setRibbon="''" name="'no_ribbon_opt'">None</WeButton> + <t t-foreach="renderContext.ribbons" t-as="id" t-key="id"> + <t t-set="ribbon" t-value="renderContext.ribbons[id]"/> + <WeButton setRibbon="ribbon.id?.toString() || ''"> + <t t-out="ribbon.name"/> + <i t-attf-class="fa fa-arrow-#{ribbon.position === 'left' ? 'left' : 'right'} ms-1"/> + <span + t-attf-class="o_wsale_color_preview ms-1" + t-attf-style="background-color: #{ribbon.bg_color}" + /> + <span + t-attf-class="o_wsale_color_preview ms-1" + t-attf-style="background-color: #{ribbon.text_color || renderContext.defaultTextColor} !important;" + /> + </WeButton> + </t> + </WeSelect> + <WeButton editRibbon="''" tooltip.translate="Edit" class="'fa fa-edit'" noPreview="'true'" dependencies="'!no_ribbon_opt'"/> + <WeButton createRibbon="''" name="'create_ribbon_opt'" tooltip.translate="Create" class="'fa fa-plus text-success'" noPreview="'true'"/> + </WeRow> + <div name="'ribbon_customize_opt'" t-att-class="{'d-none': !renderContext.ribbonEditMode}"> + <WeInput title.translate="Name" + class="'o_we_sublevel_1 o_we_large'" + setRibbonName="'Ribbon name'" + applyTo="'.o_ribbon'"/> + <WeColorpicker title.translate="Background" + class="'o_we_sublevel_1'" + selectStyle="''" + cssProperty="'background-color'" + colorPrefix="'text-bg-'" + applyTo="'.o_ribbon'"/> + <WeColorpicker title.translate="Text" + class="'o_we_sublevel_1'" + selectStyle="''" + cssProperty="'color'" + applyTo="'.o_ribbon'"/> + <WeSelect title.translate="Position" class="'o_we_sublevel_1'"> + <WeButton setRibbonPosition="'left'">Left</WeButton> + <WeButton setRibbonPosition="'right'">Right</WeButton> + </WeSelect> + <WeRow title="' '"> + <WeButton class="'o_we_bg_danger'" deleteRibbon="''" noPreview="'true'"> + Delete Ribbon + </WeButton> + </WeRow> + </div> + </t> +</templates> diff --git a/addons/website_sale/static/src/js/editor/pages/checkout/options.js b/addons/website_sale/static/src/js/editor/pages/checkout/options.js new file mode 100644 index 0000000000000..1446598b77832 --- /dev/null +++ b/addons/website_sale/static/src/js/editor/pages/checkout/options.js @@ -0,0 +1,11 @@ +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; + +registerWebsiteOption("WebsiteSaleCheckoutPage", { + template: "website_sale.checkout_page", + selector: "main:has(.oe_website_sale .o_wizard)", + noCheck: true, + data: { + groups: ["website.group_website_designer"], + pageOptions: true, + }, +}); diff --git a/addons/website_sale/static/src/js/editor/pages/checkout/options.xml b/addons/website_sale/static/src/js/editor/pages/checkout/options.xml new file mode 100644 index 0000000000000..e864ce5ff0a2e --- /dev/null +++ b/addons/website_sale/static/src/js/editor/pages/checkout/options.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <!-- Checkout page --> + <t t-name="website_sale.checkout_page"> + <WeCheckbox title.translate="Extra Step" + customizeWebsiteViews="'website_sale.extra_info'" + noPreview="'true'" + reload="'/'"/> + <WeCheckbox title.translate="Suggested Accessories" + customizeWebsiteViews="'website_sale.suggested_products_list'" + noPreview="'true'" + reload="'/'"/> + <WeCheckbox title.translate="Promo Code" + customizeWebsiteViews="'website_sale.reduction_code'" + noPreview="'true'" + reload="'/'"/> + <WeCheckbox title.translate="Accept Terms & Conditions" + customizeWebsiteViews="'website_sale.accept_terms_and_conditions'" + noPreview="'true'" + reload="'/'"/> + <WeCheckbox title.translate="Show B2B Fields" + customizeWebsiteViews="'website_sale.address_b2b'" + noPreview="'true'" + reload="'/'"/> + </t> + +</templates> diff --git a/addons/website_sale/static/src/js/editor/pages/product/options.xml b/addons/website_sale/static/src/js/editor/pages/product/options.xml new file mode 100644 index 0000000000000..69b77625bc105 --- /dev/null +++ b/addons/website_sale/static/src/js/editor/pages/product/options.xml @@ -0,0 +1,174 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <!-- Product page --> + <t t-name="website_sale.WebsiteSaleProductPage"> + <WeRow title.translate="Customers" class="'o_we_full_row'"> + <WeButton customizeWebsiteViews="'website_sale.product_comment'" + noPreview="'true'" + reload="'/'"> + Rating + </WeButton> + <WeButton name="'attributes_opt'" + customizeWebsiteViews="'website_sale.product_share_buttons'" + noPreview="'true'" + reload="'/'"> + Share + </WeButton> + </WeRow> + + <WeCheckbox title.translate="Select Quantity" + customizeWebsiteViews="'website_sale.product_quantity'" + noPreview="'true'" + reload="'/'"/> + + <WeCheckbox title.translate="Tax Indication" + customizeWebsiteViews="'website_sale.tax_indication'" + noPreview="'true'" + reload="'/'"/> + + <WeSelect title.translate="Variants" + name="'variants_opt'" + groups="'product.group_product_variant'" + noPreview="'true'" + reload="'/'"> + <WeButton name="'variants_options_opt'" + customizeWebsiteViews="''"> + Options + </WeButton> + <WeButton name="'variants_products_list_opt'" + customizeWebsiteViews="'website_sale.product_variants'"> + Products List + </WeButton> + </WeSelect> + + <WeCheckbox title.translate="Product Tags" + customizeWebsiteViews="'website_sale.product_tags'" + noPreview="'true'" + reload="'/'"/> + + <WeRow title.translate="Cart" class="'o_we_full_row'" name="'o_wsale_buy_now_opt'"> + <WeButton tooltip.translate="Buy Now" + class="'o_we_buy_now_btn'" + customizeWebsiteViews="'website_sale.product_buy_now'" + noPreview="'true'" + reload="'/'"> + <i class="fa fa-fw fa-bolt"/> + Buy Now + </WeButton> + </WeRow> + + <!-- Image config --> + <WeButtonGroup title.translate="Images Width" noPreview="'true'" reload="'/'"> + <WeButton setImageWidth="'none'" + img="'/website_sale/static/src/img/snippet_options/image-width-none.svg'" + tooltip.translate="None"/> + <WeButton setImageWidth="'50_pc'" + img="'/website_sale/static/src/img/snippet_options/image-width-50.svg'" + tooltip.translate="50 percent"/> + <WeButton setImageWidth="'66_pc'" + img="'/website_sale/static/src/img/snippet_options/image-width-66.svg'" + tooltip.translate="66 percent"/> + <WeButton setImageWidth="'100_pc'" + img="'/website_sale/static/src/img/snippet_options/image-width-100.svg'" + tooltip.translate="100 percent"/> + </WeButtonGroup> + + <WeSelect title.translate="Layout" name="'o_wsale_image_layout'" noPreview="'true'" reload="'/'"> + <WeButton setImageLayout="'carousel'">Carousel</WeButton> + <WeButton setImageLayout="'grid'">Grid</WeButton> + </WeSelect> + + <WeSelect title.translate="Image Zoom" + class="'o_we_sublevel_1'" + name="'o_wsale_zoom_mode'" + noPreview="'true'" + reload="'/'"> + <WeButton name="'o_wsale_zoom_hover'" + customizeWebsiteViews="'website_sale.product_picture_magnify_hover'"> + Magnifier on hover + </WeButton> + <WeButton name="'o_wsale_zoom_click'" + customizeWebsiteViews="'website_sale.product_picture_magnify_click'"> + Pop-up on Click + </WeButton> + <WeButton name="'o_wsale_zoom_both'" + customizeWebsiteViews="'website_sale.product_picture_magnify_both'"> + Both + </WeButton> + <WeButton name="'o_wsale_zoom_none'" + customizeWebsiteViews="''"> + None + </WeButton> + </WeSelect> + + <!-- Carousel config --> + <WeButtonGroup title.translate="Thumbnails" + class="'o_we_sublevel_1'" + name="'o_wsale_thumbnail_pos'" + noPreview="'true'" + reload="'/'"> + <WeButton class="'fa fa-fw fa-long-arrow-left'" + tooltip.translate="Left" + customizeWebsiteViews="'website_sale.carousel_product_indicators_left'"/> + <WeButton class="'fa fa-fw fa-long-arrow-down'" + tooltip.translate="Bottom" + customizeWebsiteViews="'website_sale.carousel_product_indicators_bottom'"/> + </WeButtonGroup> + + <!-- Grid config --> + <WeRange title.translate="Image Spacing" + class="'o_we_sublevel_1'" + name="'o_wsale_grid_spacing'" + noPreview="'true'" + reload="'/'" + max="'3'" + step="'1'" + setSpacing="''"/> + + <WeSelect title.translate="Columns" + class="'o_we_sublevel_1'" + name="'o_wsale_grid_columns'" + noPreview="'true'" + reload="'/'"> + <WeButton setColumns="'1'">1</WeButton> + <WeButton setColumns="'2'">2</WeButton> + <WeButton setColumns="'3'">3</WeButton> + </WeSelect> + + <WeRow title.translate="Main Image"> + <WeButton class="'o_we_bg_success'" + name="'o_wsale_replace_main_image'" + replaceMainImage="'true'" + noPreview="'true'"> + Replace + </WeButton> + </WeRow> + + <WeRow title.translate="Extra Images"> + <WeButton class="'o_we_bg_success'" + name="'o_wsale_add_extra_images'" + addImages="'true'" + noPreview="'true'"> + Add + </WeButton> + <WeButton class="'o_we_bg_danger'" + name="'o_wsale_clear_extra_images'" + clearImages="'true'" + noPreview="'true'"> + Remove all + </WeButton> + </WeRow> + </t> + + <!-- Product attributes --> + <t t-name="website_sale.WebsiteSaleProductAttribute"> + <WeSelect title.translate="Display Type" noPreview="'true'"> + <WeButton setDisplayType="'radio'">Radio</WeButton> + <WeButton setDisplayType="'pills'">Pills</WeButton> + <WeButton setDisplayType="'select'">Select</WeButton> + <WeButton setDisplayType="'color'">Color</WeButton> + </WeSelect> + </t> + +</templates> diff --git a/addons/website_sale/static/src/js/editor/snippets.options.js b/addons/website_sale/static/src/js/editor/snippets.options.js new file mode 100644 index 0000000000000..63497f704178e --- /dev/null +++ b/addons/website_sale/static/src/js/editor/snippets.options.js @@ -0,0 +1,10 @@ +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; + +registerWebsiteOption("HeaderShoppingCart", { + template: "website_sale.HeaderShoppingCart", + selector: "#wrapwrap > header", + noCheck: true, + data: { + groups: ["website.group_website_designer"], + }, +}); diff --git a/addons/website_sale/static/src/js/editor/snippets.options.xml b/addons/website_sale/static/src/js/editor/snippets.options.xml new file mode 100644 index 0000000000000..12494d04d624e --- /dev/null +++ b/addons/website_sale/static/src/js/editor/snippets.options.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <!-- Header shopping cart button --> + <t t-name="website_sale.HeaderShoppingCart"> + <WeRow title.translate="Show Empty" class="'o_we_full_row'"> + <div class="'d-flex gap-1 mb-1 w-100'"> + <WeButton tooltip.translate="Show/hide shopping cart" + class="'o_btn_show_empty_cart fa fa-shopping-cart d-flex justify-content-center flex-grow-1'" + customizeWebsiteViews="'website_sale.header_hide_empty_cart_link|'" + noPreview="'true'" + reload="'/'"/> + </div> + </WeRow> + </t> + +</templates> diff --git a/addons/website_sale/static/src/js/website_sale.editor.js b/addons/website_sale/static/src/js/website_sale.editor.js index 2c316c6b6ef80..60b2b290d9eaa 100644 --- a/addons/website_sale/static/src/js/website_sale.editor.js +++ b/addons/website_sale/static/src/js/website_sale.editor.js @@ -1,30 +1,38 @@ -/** @odoo-module **/ - -import options from "@web_editor/js/editor/snippets.options"; +import { SnippetOption } from "@web_editor/js/editor/snippets.options"; +import options from "@web_editor/js/editor/snippets.options.legacy"; import { MediaDialog } from "@web_editor/components/media_dialog/media_dialog"; import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; import { _t } from "@web/core/l10n/translation"; import "@website/js/editor/snippets.options"; import { rpc } from "@web/core/network/rpc"; -import { renderToElement } from "@web/core/utils/render"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; + +export class WebsiteSaleGridLayout extends SnippetOption { + constructor() { + super(...arguments); + this.orm = this.env.services.orm; + } -options.registry.WebsiteSaleGridLayout = options.Class.extend({ /** * @override */ - start: function () { - this.ppg = parseInt(this.$target.closest('[data-ppg]').data('ppg')); - this.ppr = parseInt(this.$target.closest('[data-ppr]').data('ppr')); - this.default_sort = this.$target.closest('[data-default-sort]').data('default-sort'); - return this._super.apply(this, arguments); - }, + async _getRenderContext() { + return { + ...(await super._getRenderContext()), + productSorts: await this.orm.call("website", "get_product_sort_mapping"), + isListLayoutEnabled: this.$target.closest('#products_grid').hasClass('o_wsale_layout_list'), + }; + } + /** * @override */ - onFocus: function () { - var listLayoutEnabled = this.$target.closest('#products_grid').hasClass('o_wsale_layout_list'); - this.$el.filter('.o_wsale_ppr_submenu').toggleClass('d-none', listLayoutEnabled); - }, + async willStart() { + this.ppg = parseInt(this.$target.closest('[data-ppg]').data('ppg')); + this.ppr = parseInt(this.$target.closest('[data-ppr]').data('ppr')); + this.default_sort = this.$target.closest('[data-default-sort]').data('default-sort'); + return super.willStart(...arguments); + } //-------------------------------------------------------------------------- // Options @@ -33,7 +41,7 @@ options.registry.WebsiteSaleGridLayout = options.Class.extend({ /** * @see this.selectClass for params */ - setPpg: function (previewMode, widgetValue, params) { + setPpg(previewMode, widgetValue, params) { const PPG_LIMIT = 10000; const ppg = parseInt(widgetValue); if (!ppg || ppg < 1) { @@ -41,34 +49,21 @@ options.registry.WebsiteSaleGridLayout = options.Class.extend({ } this.ppg = Math.min(ppg, PPG_LIMIT); return rpc('/shop/config/website', { 'shop_ppg': this.ppg }); - }, + } /** * @see this.selectClass for params */ - setPpr: function (previewMode, widgetValue, params) { + setPpr(previewMode, widgetValue, params) { this.ppr = parseInt(widgetValue); return rpc('/shop/config/website', { 'shop_ppr': this.ppr }); - }, + } /** * @see this.selectClass for params */ - setDefaultSort: function (previewMode, widgetValue, params) { + setDefaultSort(previewMode, widgetValue, params) { this.default_sort = widgetValue; return rpc('/shop/config/website', { 'shop_default_sort': this.default_sort }); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - async updateUIVisibility() { - await this._super(...arguments); - const pprSelector = this.el.querySelector('.o_wsale_ppr_submenu.d-none'); - this.el.querySelector('.o_wsale_ppr_by').classList.toggle('d-none', pprSelector); - }, + } //-------------------------------------------------------------------------- // Private @@ -77,7 +72,7 @@ options.registry.WebsiteSaleGridLayout = options.Class.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { switch (methodName) { case 'setPpg': { return this.ppg; @@ -89,41 +84,68 @@ options.registry.WebsiteSaleGridLayout = options.Class.extend({ return this.default_sort; } } - return this._super(...arguments); + return super._computeWidgetState(...arguments); + } +} +registerWebsiteOption("WebsiteSaleGridLayout", { + Class: WebsiteSaleGridLayout, + template: "website_sale.WebsiteSaleGridLayout", + selector: "main:has(.o_wsale_products_page)", + target: "#products_grid .o_wsale_products_grid_table_wrapper > table", + noCheck: true, + data: { + string: _t("Products Page"), + groups: ["website.group_website_designer"], + pageOptions: true, }, }); -options.registry.WebsiteSaleProductsItem = options.Class.extend({ - events: Object.assign({}, options.Class.prototype.events || {}, { - 'mouseenter .o_wsale_soptions_menu_sizes table': '_onTableMouseEnter', - 'mouseleave .o_wsale_soptions_menu_sizes table': '_onTableMouseLeave', - 'mouseover .o_wsale_soptions_menu_sizes td': '_onTableItemMouseEnter', - 'click .o_wsale_soptions_menu_sizes td': '_onTableItemClick', - }), +export class WebsiteSaleProductsItem extends SnippetOption { + constructor({ options }) { + super(...arguments); + this.wysiwyg = options.wysiwyg; + this._selectedSize = { x: 1, y: 1 }; + } /** * @override */ - willStart: async function () { - const _super = this._super.bind(this); + async _getRenderContext() { + const classes = this.$ribbon[0].className; + this.$ribbon[0].className = ''; + const defaultTextColor = window.getComputedStyle(this.$ribbon[0]).color; + this.$ribbon[0].className = classes; + this.ribbons = this.wysiwyg.getRibbons(); + return { + ...(await super._getRenderContext(...arguments)), + defaultTextColor, + isListLayoutEnabled: this.$target.closest('#products_grid').hasClass('o_wsale_layout_list'), + ppr: this.ppr, + ribbonEditMode: this.ribbonEditMode, + ribbons: this.ribbons, + selectedSize: this._selectedSize, + }; + } + + /** + * @override + */ + async willStart() { this.ppr = this.$target.closest('[data-ppr]').data('ppr'); this.productTemplateID = parseInt(this.$target.find('[data-oe-model="product.template"]').data('oe-id')); this.ribbonPositionClasses = {'left': 'o_ribbon_left', 'right': 'o_ribbon_right'}; - this.ribbons = await new Promise(resolve => this.trigger_up('get_ribbons', {callback: resolve})); + this.ribbons = this.wysiwyg.getRibbons(); this.$ribbon = this.$target.find('.o_ribbon'); - return _super(...arguments); - }, + return super.willStart(...arguments); + } /** * @override */ - onFocus: function () { - var listLayoutEnabled = this.$target.closest('#products_grid').hasClass('o_wsale_layout_list'); - this.$el.find('.o_wsale_soptions_menu_sizes') - .toggleClass('d-none', listLayoutEnabled); + async onFocus() { // Ribbons may have been edited or deleted in another products' option, need to make sure they're up to date this.rerender = true; this.ribbonEditMode = false; - }, + } //-------------------------------------------------------------------------- // Options @@ -133,7 +155,7 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ * @override */ async selectStyle(previewMode, widgetValue, params) { - const proms = [this._super(...arguments)]; + const proms = [super.selectStyle(...arguments)]; if (params.cssProperty === 'background-color' && params.colorNames.includes(widgetValue)) { // Reset text-color when choosing a background-color class, so it uses the automatic text-color of the class. proms.push(this.selectStyle(previewMode, '', {cssProperty: 'color'})); @@ -142,7 +164,7 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ if (!previewMode) { await this._saveRibbon(); } - }, + } /** * @see this.selectClass for params */ @@ -154,15 +176,17 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ } if (!previewMode) { this.ribbonEditMode = false; + this.rerender = true; } await this._setRibbon(widgetValue); - }, + } /** * @see this.selectClass for params */ editRibbon(previewMode, widgetValue, params) { this.ribbonEditMode = !this.ribbonEditMode; - }, + this.rerender = true; + } /** * @see this.selectClass for params */ @@ -171,8 +195,9 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ this.$ribbon.text(_t('Ribbon Name')); this.$ribbon.addClass('bg-primary o_ribbon_left'); this.ribbonEditMode = true; + this.rerender = true; await this._saveRibbon(true); - }, + } /** * @see this.selectClass for params */ @@ -188,12 +213,12 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ return; } const {ribbonId} = this.$target[0].dataset; - this.trigger_up('delete_ribbon', {id: ribbonId}); - this.ribbons = await new Promise(resolve => this.trigger_up('get_ribbons', {callback: resolve})); - this.rerender = true; + this.wysiwyg.deleteRibbon(ribbonId); + this.ribbons = this.wysiwyg.getRibbons(); await this._setRibbon(ribbonId); this.ribbonEditMode = false; - }, + this.rerender = true; + } /** * @see this.selectClass for params */ @@ -202,7 +227,7 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ if (!previewMode) { await this._saveRibbon(); } - }, + } /** * @see this.selectClass for params */ @@ -211,16 +236,34 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ /o_ribbon_(left|right)/, this.ribbonPositionClasses[widgetValue] ); await this._saveRibbon(); - }, + } /** * @see this.selectClass for params */ - changeSequence: function (previewMode, widgetValue, params) { + changeSequence(previewMode, widgetValue, params) { return rpc('/shop/config/product', { product_id: this.productTemplateID, sequence: widgetValue, }).then(() => this._reloadEditable()); - }, + } + + /** + * @see this.selectClass for params + */ + async setProductSize(previewMode, widgetValue, params) { + const [x, y] = widgetValue ? widgetValue.split(",").map(v => parseInt(v)) : [1, 1]; + + if (previewMode) { + this.renderContext.selectedSize = previewMode === "reset" ? this._selectedSize : { x, y }; + } else { + await rpc('/shop/config/product', { + product_id: this.productTemplateID, + x: x, + y: y, + }); + this._reloadEditable(); + } + } //-------------------------------------------------------------------------- // Public @@ -229,55 +272,25 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ /** * @override */ - updateUI: async function () { - await this._super.apply(this, arguments); - - var sizeX = parseInt(this.$target.attr('colspan') || 1); - var sizeY = parseInt(this.$target.attr('rowspan') || 1); - - var $size = this.$el.find('.o_wsale_soptions_menu_sizes'); - $size.find('tr:nth-child(-n + ' + sizeY + ') td:nth-child(-n + ' + sizeX + ')') - .addClass('selected'); + async updateUI({ assetsChanged }) { + this._selectedSize = { + x: parseInt(this.$target.attr('colspan') || 1), + y: parseInt(this.$target.attr('rowspan') || 1), + }; - // Adapt size array preview to fit ppr - $size.find('tr td:nth-child(n + ' + parseInt(this.ppr + 1) + ')').hide(); - if (this.rerender) { + if (this.rerender || assetsChanged) { this.rerender = false; - return this._rerenderXML(); + const newContext = await this._getRenderContext(); + Object.assign(this.renderContext, newContext); } - }, - /** - * @override - */ - updateUIVisibility: async function () { - // TODO: update this once updateUIVisibility can be used to compute visibility - // of arbitrary DOM elements and not just widgets. - await this._super(...arguments); - this.$el.find('[data-name="ribbon_customize_opt"]').toggleClass('d-none', !this.ribbonEditMode); - }, + + return super.updateUI(...arguments); + } //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- - /** - * @override - */ - async _renderCustomXML(uiFragment) { - const $select = $(uiFragment.querySelector('.o_wsale_ribbon_select')); - this.ribbons = await new Promise(resolve => this.trigger_up('get_ribbons', {callback: resolve})); - const classes = this.$ribbon[0].className; - this.$ribbon[0].className = ''; - const defaultTextColor = window.getComputedStyle(this.$ribbon[0]).color; - this.$ribbon[0].className = classes; - Object.values(this.ribbons).forEach(ribbon => { - $select.append(renderToElement('website_sale.ribbonSelectItem', { - ribbon, - isLeft: ribbon.position === 'left', - textColor: ribbon.text_color || defaultTextColor, - })); - }); - }, /** * @override */ @@ -295,8 +308,8 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ return 'right'; } } - return this._super(methodName, params); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ @@ -304,8 +317,8 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ if (widgetName === 'create_ribbon_opt') { return !this.ribbonEditMode; } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * Saves the ribbons. * @@ -321,11 +334,11 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ 'position': (this.$ribbon.attr('class').includes('o_ribbon_left')) ? 'left' : 'right', }; ribbon.id = isNewRibbon ? Date.now() : parseInt(this.$target.closest('.oe_product')[0].dataset.ribbonId); - this.trigger_up('set_ribbon', {ribbon: ribbon}); - this.ribbons = await new Promise(resolve => this.trigger_up('get_ribbons', {callback: resolve})); + this.wysiwyg.setRibbon(ribbon); + this.ribbons = this.wysiwyg.getRibbons(); this.rerender = true; await this._setRibbon(ribbon.id); - }, + } /** * Sets the ribbon. * @@ -334,10 +347,7 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ */ async _setRibbon(ribbonId) { this.$target[0].dataset.ribbonId = ribbonId; - this.trigger_up('set_product_ribbon', { - templateId: this.productTemplateID, - ribbonId: ribbonId || false, - }); + this.wysiwyg.setProductRibbon(this.productTemplateID, ribbonId || false); const ribbon = ( this.ribbons[ribbonId] || {name: '', bg_color: '', text_color: '', position: 'left'} @@ -348,8 +358,7 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ const $editableDocument = $(this.$target[0].ownerDocument.body); const $ribbons = $editableDocument.find(`[data-ribbon-id="${ribbonId}"] .o_ribbon`); $ribbons.empty().append(ribbon.name); - let htmlClasses; - this.trigger_up('get_ribbon_classes', {callback: classes => htmlClasses = classes}); + const htmlClasses = this.wysiwyg.getRibbonClasses(); $ribbons.removeClass(htmlClasses); $ribbons.addClass(this.ribbonPositionClasses[ribbon.position]); @@ -363,64 +372,24 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ // The ribbon does not have a savable parent, so we need to trigger the // saving process manually by flagging the ribbon as dirty. this.$ribbon.addClass('o_dirty'); - }, + } //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- - /** - * @private - */ - _onTableMouseEnter: function (ev) { - $(ev.currentTarget).addClass('oe_hover'); - }, - /** - * @private - */ - _onTableMouseLeave: function (ev) { - $(ev.currentTarget).removeClass('oe_hover'); - }, - /** - * @private - */ - _onTableItemMouseEnter: function (ev) { - var $td = $(ev.currentTarget); - var $table = $td.closest("table"); - var x = $td.index() + 1; - var y = $td.parent().index() + 1; - - var tr = []; - for (var yi = 0; yi < y; yi++) { - tr.push("tr:eq(" + yi + ")"); - } - var $selectTr = $table.find(tr.join(",")); - var td = []; - for (var xi = 0; xi < x; xi++) { - td.push("td:eq(" + xi + ")"); - } - var $selectTd = $selectTr.find(td.join(",")); - - $table.find("td").removeClass("select"); - $selectTd.addClass("select"); - }, - /** - * @private - */ - _onTableItemClick: function (ev) { - var $td = $(ev.currentTarget); - var x = $td.index() + 1; - var y = $td.parent().index() + 1 - // TODO this should be awaited somehow - rpc('/shop/config/product', { - product_id: this.productTemplateID, - x: x, - y: y, - }).then(() => this._reloadEditable()); - }, _reloadEditable() { - return this.trigger_up('request_save', {reload: true, optionSelector: `.oe_product:has(span[data-oe-id=${this.productTemplateID}])`}); + this.env.requestSave({ + reload: true, + optionSelector: `.oe_product:has(span[data-oe-id=${this.productTemplateID}])`, + }); } +} +registerWebsiteOption("WebsiteSaleProductsItem", { + Class: WebsiteSaleProductsItem, + template: "website_sale.WebsiteSaleProductsItem", + selector: "#products_grid .oe_product", + noCheck: true, }); // Small override of the MediaDialog to retrieve the attachment ids instead of img elements @@ -438,12 +407,13 @@ class AttachmentMediaDialog extends MediaDialog { } } -options.registry.WebsiteSaleProductPage = options.Class.extend({ - init() { - this._super(...arguments); - this.orm = this.bindService("orm"); - this.notification = this.bindService("notification"); - }, +export class WebsiteSaleProductPage extends SnippetOption { + constructor() { + super(...arguments); + this.dialog = this.env.services.dialog; + this.notification = this.env.services.notification; + this.orm = this.env.services.orm; + } /** * @override @@ -462,14 +432,14 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ this.productDetailMain = this.$target[0].querySelector('#product_detail_main'); this.productPageCarousel = this.$target[0].querySelector("#o-carousel-product"); this.productPageGrid = this.$target[0].querySelector("#o-grid-product"); - return this._super(...arguments); - }, + return super.willStart(...arguments); + } _getZoomOptionData() { return this._userValueWidgets.find(widget => { return widget.options && widget.options.dataAttributes && widget.options.dataAttributes.name === "o_wsale_zoom_mode"; }); - }, + } /** * @override @@ -481,7 +451,7 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ await this._customizeWebsiteData(defaultZoomOption, { possibleValues: zoomOption._methodsParams.optionsPossibleValues["customizeWebsiteViews"] }, true); } return rpc('/shop/config/website', { product_page_image_width: widgetValue }); - }, + } /** * @override @@ -497,15 +467,15 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ await this._customizeWebsiteData(defaultZoomOption, { possibleValues: zoomOption._methodsParams.optionsPossibleValues["customizeWebsiteViews"] }, true); } return rpc('/shop/config/website', { product_page_image_layout: widgetValue }); - }, + } /** * Emulate click on the main image of the carousel. */ - replaceMainImage: function () { + replaceMainImage() { const image = this.productDetailMain.querySelector(`[data-oe-model="${this.mode}"][data-oe-field=image_1920] img`); image.dispatchEvent(new Event('dblclick', {bubbles: true})); - }, + } _getSelectedVariantValues($container) { const combination = $container.find('input.js_product_change:checked').data('combination'); @@ -527,12 +497,12 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ }); return values; - }, + } /** * Prompts the user for images, then saves the new images. */ - addImages: function () { + addImages() { if(this.mode === 'product.template'){ this.notification.add( 'Pictures will be added to the main image. Use "Instant" attributes to set pictures on each variants', @@ -540,7 +510,7 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ ); } let extraImageEls; - this.call("dialog", "add", AttachmentMediaDialog, { + this.dialog.add(AttachmentMediaDialog, { multiImages: true, onlyImages: true, // Kinda hack-ish but the regular save does not get the information we need @@ -563,11 +533,14 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ product_template_id: this.productTemplateID, combination_ids: this._getSelectedVariantValues(this.$target.find('.js_add_cart_variants')), }).then(() => { - this.trigger_up('request_save', {reload: true, optionSelector: this.data.selector}); + this.env.requestSave({ + reload: true, + optionSelector: this.data.selector, + }); }); } }); - }, + } async _convertAttachmentToWebp(attachment, imageEl) { // This method is widely adapted from onFileUploaded in ImageField. @@ -614,21 +587,24 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ mimetype: "image/jpeg", }]]); } - }, + } /** * Removes all extra-images from the product. */ - clearImages: function () { + clearImages() { return rpc(`/shop/product/clear-images`, { model: this.mode, product_product_id: this.productProductID, product_template_id: this.productTemplateID, combination_ids: this._getSelectedVariantValues(this.$target.find('.js_add_cart_variants')), }).then(() => { - this.trigger_up('request_save', {reload: true, optionSelector: this.data.selector}); + this.env.requestSave({ + reload: true, + optionSelector: this.data.selector, + }); }); - }, + } /** * @override @@ -645,14 +621,14 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ return rpc('/shop/config/website', { 'product_page_image_spacing': spacing, }); - }, + } setColumns(previewMode, widgetValue, params) { this.productPageGrid.dataset.grid_columns = widgetValue; return rpc('/shop/config/website', { 'product_page_grid_columns': widgetValue, }); - }, + } /** * @override @@ -674,8 +650,8 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ case 'setColumns': return this.productPageGrid && this.productPageGrid.dataset.grid_columns || 1; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } async _computeWidgetVisibility(widgetName, params) { const hasImages = this.productDetailMain.dataset.image_width != 'none'; @@ -698,28 +674,41 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ case 'o_wsale_zoom_both': return hasImages && !isFullImage; } - return this._super(widgetName, params); + return super._computeWidgetVisibility(widgetName, params); } +} +registerWebsiteOption("WebsiteSaleProductPage", { + Class: WebsiteSaleProductPage, + template: "website_sale.WebsiteSaleProductPage", + selector: "main:has(.o_wsale_product_page)", + noCheck: true, + data: { + groups: ["website.group_website_designer"], + pageOptions: true, + }, }); -options.registry.WebsiteSaleProductAttribute = options.Class.extend({ +export class WebsiteSaleProductAttribute extends SnippetOption { /** * @override */ - willStart: async function () { + async willStart() { this.attributeID = this.$target.closest('[data-attribute_id]').data('attribute_id'); - return this._super(...arguments); - }, + return super.willStart(...arguments); + } /** * @see this.selectClass for params */ - setDisplayType: function (previewMode, widgetValue, params) { + setDisplayType(previewMode, widgetValue, params) { return rpc('/shop/config/attribute', { attribute_id: this.attributeID, display_type: widgetValue, - }).then(() => this.trigger_up('request_save', {reload: true, optionSelector: this.data.selector})); - }, + }).then(() => this.env.requestSave({ + reload: true, + optionSelector: this.data.selector, + })); + } /** * @override @@ -729,8 +718,14 @@ options.registry.WebsiteSaleProductAttribute = options.Class.extend({ case 'setDisplayType': return this.$target.closest('[data-attribute_display_type]').data('attribute_display_type'); } - return this._super(methodName, params); - }, + return super._computeWidgetState(methodName, params); + } +} +registerWebsiteOption("WebsiteSaleProductAttribute", { + Class: WebsiteSaleProductAttribute, + template: "website_sale.WebsiteSaleProductAttribute", + selector: "#product_detail .o_wsale_product_attribute", + noCheck: true, }); // Disable save for alternative products snippet diff --git a/addons/website_sale/static/src/scss/website_sale.editor.scss b/addons/website_sale/static/src/scss/website_sale.editor.scss index 2e58445b07844..ae7f78a4b8dac 100644 --- a/addons/website_sale/static/src/scss/website_sale.editor.scss +++ b/addons/website_sale/static/src/scss/website_sale.editor.scss @@ -1,30 +1,29 @@ -.o_wsale_soptions_menu_sizes { +#oe_snippets > .o_we_customize_panel .o_wsale_soptions_menu_sizes { we-title { align-self: flex-start; } - table { + .oe_size_table { + display: table; margin: auto; + border-collapse: collapse; - td { - margin: 0; - padding: 0; - width: 20px; - height: 20px; - border: 1px #dddddd solid; - cursor: pointer; + .oe_size_row { + display: table-row; - &.selected { - background-color: #B1D4F1; - } - } - - &.oe_hover td { - &.selected { + .oe_size_cell { + display: table-cell; + margin: 0; + padding: 0; + width: 20px; + height: 20px; + border: 1px #dddddd solid; background-color: transparent; - } - &.select { - background-color: #B1D4F1; + border-radius: 0; + + &.selected { + background-color: #B1D4F1; + } } } } diff --git a/addons/website_sale/static/src/snippets/s_add_to_cart/options.js b/addons/website_sale/static/src/snippets/s_add_to_cart/options.js index 91453131e5dfd..ed85aea113100 100644 --- a/addons/website_sale/static/src/snippets/s_add_to_cart/options.js +++ b/addons/website_sale/static/src/snippets/s_add_to_cart/options.js @@ -1,29 +1,23 @@ /** @odoo-module **/ -import options from '@web_editor/js/editor/snippets.options'; import { _t } from "@web/core/l10n/translation"; +import { SnippetOption } from "@web_editor/js/editor/snippets.options"; +import { registerWebsiteOption } from "@website/js/editor/snippets.registry"; + +export class AddToCart extends SnippetOption { + constructor() { + super(...arguments); + this.orm = this.env.services.orm; + } + /** + * @override + */ + async willStart() { + await super.willStart(...arguments); + this._updateVariantDomain(); + } -options.registry.AddToCart = options.Class.extend({ - events: Object.assign({}, options.Class.prototype.events || {}, { - 'click .reset-variant-picker': '_onClickResetVariantPicker', - 'click .reset-product-picker': '_onClickResetProductPicker', - }), - - init() { - this._super(...arguments); - this.orm = this.bindService("orm"); - }, - - async updateUI() { - if (this.rerender) { - this.rerender = false; - await this._rerenderXML(); - return; - } - return this._super.apply(this, arguments); - }, - - _setButtonDisabled: function (isDisabled) { + _setButtonDisabled(isDisabled) { const buttonEl = this._buttonEl(); if (isDisabled) { @@ -31,7 +25,11 @@ options.registry.AddToCart = options.Class.extend({ } else { buttonEl.classList.remove('disabled'); } - }, + } + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- async setProductTemplate(previewMode, widgetValue, params) { this.$target[0].dataset.productTemplate = widgetValue; @@ -40,37 +38,40 @@ options.registry.AddToCart = options.Class.extend({ this._setButtonDisabled(false); await this._fetchVariants(widgetValue); - this.rerender = true; this._updateButton(); - }, + } setProductVariant(previewMode, widgetValue, params) { this.$target[0].dataset.productVariant = widgetValue; this._updateButton(); - }, + } setAction(previewMode, widgetValue, params) { this.$target[0].dataset.action = widgetValue; this._updateButton(); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - _onClickResetVariantPicker() { + } + /** + * @see this.selectClass for parameters + */ + resetProductPicker(previewMode, widgetValue, params) { + this._resetProductChoice(); + this._resetVariantChoice(); + this._resetAction(); + this._updateButton(); + } + /** + * @see this.selectClass for parameters + */ + resetVariantPicker(previewMode, widgetValue, params) { this._resetVariantChoice(); this._resetAction(); this._updateButton(); - }, + } - _onClickResetProductPicker() { - this._resetProductChoice(); - this._resetVariantChoice(); - this._resetAction(); - this._updateButton(); - }, + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- /** * Fetches the variants ids from the server @@ -80,29 +81,29 @@ options.registry.AddToCart = options.Class.extend({ "product.product", [["product_tmpl_id", "=", parseInt(productTemplateId)]], ["id"] ); this.$target[0].dataset.variants = response.map(variant => variant.id); - }, + } _resetProductChoice() { this.$target[0].dataset.productTemplate = ''; this._buttonEl().classList.add('disabled'); - }, - + } _resetVariantChoice() { this.$target[0].dataset.productVariant = ''; - }, + this._updateVariantDomain(); + } - _resetAction: function () { + _resetAction() { this.$target[0].dataset.action = "add_to_cart"; - }, + } /** * Returns an array of variant ids from the dom */ _variantIds() { return this.$target[0].dataset.variants.split(',').map(stringId => parseInt(stringId)); - }, + } _buttonEl() { const buttonEl = this.$target[0].querySelector('.s_add_to_cart_btn'); @@ -111,14 +112,14 @@ options.registry.AddToCart = options.Class.extend({ return this._buildButtonEl(); } return buttonEl; - }, + } _buildButtonEl() { const buttonEl = document.createElement('button'); buttonEl.classList.add("s_add_to_cart_btn", "btn", "btn-secondary", "mb-2"); this.$target[0].append(buttonEl); return buttonEl; - }, + } /** * Updates the button's html @@ -134,7 +135,7 @@ options.registry.AddToCart = options.Class.extend({ variantIds.length > 1 ? this.$target[0].dataset.productVariant : variantIds[0]; buttonEl.dataset.action = this.$target[0].dataset.action; this._updateButtonContent(); - }, + } _updateButtonContent() { let iconEl = document.createElement('i'); @@ -147,18 +148,19 @@ options.registry.AddToCart = options.Class.extend({ iconEl.classList = buttonContentElement.classList; this._buttonEl().replaceChildren(iconEl, buttonContentElement.text); - }, - + } /** - * Called when the template is chosen and that we want to update the m2o variant widget with the right variants. + * @private */ - async _renderCustomXML(uiFragment) { + _updateVariantDomain() { if (this.$target[0].dataset.productTemplate) { - // That means that a template was selected and we want to update the content of the variant picker based on the template id - const productVariantPickerEl = uiFragment.querySelector('we-many2one[data-name="product_variant_picker_opt"]'); - productVariantPickerEl.dataset.domain = `[["product_tmpl_id", "=", ${this.$target[0].dataset.productTemplate}]]`; + // That means that a template was selected and we want to update the + // content of the variant picker based on the template id. + this.renderContext.variantDomain = `[["product_tmpl_id", "=", ${this.$target[0].dataset.productTemplate}]]`; + } else { + this.renderContext.variantDomain = "[]"; } - }, + } /** * @override @@ -175,8 +177,8 @@ options.registry.AddToCart = options.Class.extend({ return this.$target[0].dataset.action; } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override @@ -203,9 +205,11 @@ options.registry.AddToCart = options.Class.extend({ return false; } } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } +} +registerWebsiteOption("AddToCart", { + Class: AddToCart, + template: "website_sale.s_add_to_cart_options", + selector: ".s_add_to_cart", }); -export default { - AddToCart: options.registry.AddToCart, -}; diff --git a/addons/website_sale/static/src/snippets/s_add_to_cart/options.xml b/addons/website_sale/static/src/snippets/s_add_to_cart/options.xml new file mode 100644 index 0000000000000..6ac236fa7fca3 --- /dev/null +++ b/addons/website_sale/static/src/snippets/s_add_to_cart/options.xml @@ -0,0 +1,31 @@ +<templates id="template" xml:space="preserve"> +<t t-name="website_sale.s_add_to_cart_options"> + <WeRow title.translate="Product"> + <WeMany2one model="'product.template'" + setProductTemplate="''" + name="'product_template_picker_opt'" + noPreview="'true'" + domain='`[["is_published", "=", true], ["sale_ok", "=", true]]`'/> + <WeButton name="'product_template_reset_opt'" + class="'reset-product-picker align-self-end fa fa-fw fa-times'" + resetProductPicker="''"/> + </WeRow> + <WeRow title.translate="Variant"> + <t t-set="defaultMessage">Visitor's Choice</t> + <WeMany2one class="'o_we_sublevel_1'" + model="'product.product'" + setProductVariant="''" + name="'product_variant_picker_opt'" + noPreview="'true'" + defaultMessage="defaultMessage" + domain="renderContext.variantDomain"/> + <WeButton name="'product_variant_reset_opt'" + class="'reset-variant-picker align-self-end fa fa-fw fa-times'" + resetVariantPicker="''"/> + </WeRow> + <WeSelect title.translate="Action" name="'action_picker_opt'" noPreview="'true'"> + <WeButton setAction="'add_to_cart'">Add to Cart</WeButton> + <WeButton setAction="'buy_now'">Buy Now</WeButton> + </WeSelect> +</t> +</templates> diff --git a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js index ce83136dadfd3..e97e711d63414 100644 --- a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js +++ b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js @@ -1,35 +1,32 @@ /** @odoo-module **/ -import options from "@web_editor/js/editor/snippets.options"; -import s_dynamic_snippet_carousel_options from "@website/snippets/s_dynamic_snippet_carousel/options"; - -import wUtils from "@website/js/utils"; +import { DynamicSnippetCarouselOptions } from "@website/snippets/s_dynamic_snippet_carousel/options"; +import { + registerWebsiteOption, +} from "@website/js/editor/snippets.registry"; const alternativeSnippetRemovedOptions = [ 'filter_opt', 'product_category_opt', 'product_tag_opt', 'product_names_opt', ] -const dynamicSnippetProductsOptions = s_dynamic_snippet_carousel_options.extend({ +export class DynamicSnippetProductsOptions extends DynamicSnippetCarouselOptions { /** - * * @override */ - init: function () { - this._super.apply(this, arguments); + async willStart() { + this.productCategories = await this._fetchProductCategories(); this.modelNameFilter = 'product.product'; - // Directly calling $() will not work in this case since we are querying something - // in an iframe const productTemplateId = this.$target.closest("#wrapwrap").find("input.product_template_id"); this.hasProductTemplateId = productTemplateId.val(); if (!this.hasProductTemplateId) { this.contextualFilterDomain.push(['product_cross_selling', '=', false]); } - this.productCategories = {}; this.isAlternativeProductSnippet = this.$target.hasClass('o_wsale_alternative_products'); + await super.willStart(); + this.renderContext.productCategories = this.productCategories; + } - this.orm = this.bindService("orm"); - }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -42,49 +39,41 @@ const dynamicSnippetProductsOptions = s_dynamic_snippet_carousel_options.extend( if (this.isAlternativeProductSnippet && alternativeSnippetRemovedOptions.includes(widgetName)) { return false; } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * Fetches product categories. * @private * @returns {Promise} */ - _fetchProductCategories: function () { - return this.orm.searchRead("product.public.category", wUtils.websiteDomain(this), ["id", "name"]); - }, - /** - * - * @override - * @private - */ - _renderCustomXML: async function (uiFragment) { - await this._super.apply(this, arguments); - await this._renderProductCategorySelector(uiFragment); - }, + _fetchProductCategories() { + const websiteId = this.env.services.website.currentWebsite.id; + const websiteDomain = ["|", ["website_id", "=", false], ["website_id", "=", websiteId]]; + return this.env.services.orm.searchRead("product.public.category", websiteDomain, ["id", "name"]); + } /** * Renders the product categories option selector content into the provided uiFragment. * @private * @param {HTMLElement} uiFragment */ - _renderProductCategorySelector: async function (uiFragment) { - const productCategories = await this._fetchProductCategories(); - for (let index in productCategories) { - this.productCategories[productCategories[index].id] = productCategories[index]; - } - const productCategoriesSelectorEl = uiFragment.querySelector('[data-name="product_category_opt"]'); - return this._renderSelectUserValueWidgetButtons(productCategoriesSelectorEl, this.productCategories); - }, + async _getRenderContext() { + const renderContext = super._getRenderContext(); + renderContext.productCategories = this.productCategories; + return renderContext; + } /** * @override * @private */ - _setOptionsDefaultValues: function () { + _setOptionsDefaultValues() { this._setOptionValue('productCategoryId', 'all'); this._setOptionValue('showVariants', true); - this._super.apply(this, arguments); - }, -}); + super._setOptionsDefaultValues(...arguments); + } +} -options.registry.dynamic_snippet_products = dynamicSnippetProductsOptions; - -export default dynamicSnippetProductsOptions; +registerWebsiteOption("DynamicSnippetProductsOptions", { + Class: DynamicSnippetProductsOptions, + template: "website_sale.s_dynamic_snippet_products_template_option", + selector: ".s_dynamic_snippet_products", +}); diff --git a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.xml b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.xml new file mode 100644 index 0000000000000..55dd6e6fe4302 --- /dev/null +++ b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="website_sale.s_dynamic_snippet_products_template_option" t-inherit="website.s_dynamic_snippet_carousel_option" t-inherit-mode="primary"> + <xpath expr="//WeRow[.//WeSelect[@name="'filter_opt'"]]" position="after"> + <WeRow> + <t t-set-slot="title">Category</t> + <WeSelect name="'product_category_opt'" attributeName="'productCategoryId'" noPreview="'true'"> + <WeButton selectDataAttribute="'all'">All Products</WeButton> + <WeButton selectDataAttribute="'current'">Current Category or All</WeButton> + <WeButton t-foreach="renderContext.productCategories" t-as="category" t-key="category.id" + selectDataAttribute="category.id.toString()" + > + <t t-out="category.name"/> + </WeButton> + </WeSelect> + </WeRow> + <WeRow> + <t t-set-slot="title">Tags</t> + <WeMany2many + name="'product_tag_opt'" + attributeName="'productTagIds'" + noPreview="'true'" + model="'product.tag'" + allowDelete="'true'" + fakem2m="'true'" + selectDataAttribute="''"/> + </WeRow> + <WeRow> + <t t-set-slot="title">Show variants</t> + <WeCheckbox + selectDataAttribute="'true'" + attributeName="'showVariants'" + noPreview="'true'"/> + </WeRow> + <WeRow> + <t t-set-slot="title">Product Names</t> + <t t-set="title">Comma-separated list of parts of product names, barcodes or internal reference</t> + <t t-set="placeholder">e.g. lamp,bin</t> + <WeInput class="'o_we_large'" name="'product_names_opt'" + attributeName="'productNames'" noPreview="'true'" selectDataAttribute="''" + placeholder="placeholder" title="title"/> + </WeRow> + </xpath> +</t> + +</templates> diff --git a/addons/website_sale/static/src/snippets/s_searchbar/options.js b/addons/website_sale/static/src/snippets/s_searchbar/options.js new file mode 100644 index 0000000000000..a54d1cf9f9c00 --- /dev/null +++ b/addons/website_sale/static/src/snippets/s_searchbar/options.js @@ -0,0 +1,22 @@ +import { registry } from "@web/core/registry"; +import { patch } from "@web/core/utils/patch"; + +const websiteSearchBar = registry.category("snippet_options").get("SearchBar"); +patch(websiteSearchBar.Class.prototype, { + /** + * @override + */ + _constructor() { + super._constructor(...arguments); + this.orm = this.env.services.orm; + }, + /** + * @override + */ + async _getRenderContext() { + return { + ...(await super._getRenderContext()), + productSorts: await this.orm.call("website", "get_product_sort_mapping"), + }; + }, +}); diff --git a/addons/website_sale/static/src/snippets/s_searchbar/options.xml b/addons/website_sale/static/src/snippets/s_searchbar/options.xml new file mode 100644 index 0000000000000..ca407228f622f --- /dev/null +++ b/addons/website_sale/static/src/snippets/s_searchbar/options.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="website_sale.s_searchbar_options" t-inherit="website.s_searchbar_options" t-inherit-mode="extension"> + <xpath expr="//WeSelect[@name="'scope_opt'"]" position="inside"> + <WeButton setSearchType="'products'" selectDataAttribute="'products'" name="'search_products_opt'" formAction="'/shop'">Products</WeButton> + </xpath> + <xpath expr="//WeSelect[@name="'order_opt'"]" position="inside"> + <t t-foreach="renderContext.productSorts" t-as="query_and_label" t-key="query_and_label[0]"> + <!-- name asc is already part of the general sorting methods of this snippet. --> + <WeButton t-if="query_and_label[0] != 'name asc'" + setOrderBy="query_and_label[0]" + selectDataAttribute="query_and_label[0]" + dependencies="'search_products_opt'"> + <t t-out="query_and_label[1]"/> + </WeButton> + </t> + </xpath> + <xpath expr="//div[@dependencies="'limit_opt'"]" position="inside"> + <WeCheckbox title.translate="Description" + dependencies="'search_products_opt'" + selectDataAttribute="'true'" + attributeName="'displayDescription'" + applyTo="'.search-query'"/> + <WeCheckbox title.translate="Category" + dependencies="'search_products_opt'" + selectDataAttribute="'true'" + attributeName="'displayExtraLink'" + applyTo="'.search-query'"/> + <WeCheckbox title.translate="Price" + dependencies="'search_products_opt'" + selectDataAttribute="'true'" + attributeName="'displayDetail'" + applyTo="'.search-query'"/> + <WeCheckbox title.translate="Image" + dependencies="'search_products_opt'" + selectDataAttribute="'true'" + attributeName="'displayImage'" + applyTo="'.search-query'"/> + </xpath> + </t> + +</templates> diff --git a/addons/website_sale/static/src/xml/website_sale_utils.xml b/addons/website_sale/static/src/xml/website_sale_utils.xml deleted file mode 100644 index 4e386568af777..0000000000000 --- a/addons/website_sale/static/src/xml/website_sale_utils.xml +++ /dev/null @@ -1,18 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<templates id="template" xml:space="preserve"> - -<!-- Products Search Bar autocomplete item --> -<we-button t-name="website_sale.ribbonSelectItem" t-att-data-set-ribbon="ribbon.id"> - <t t-out="ribbon.name"/> - <span t-attf-class="fa fa-arrow-#{isLeft ? 'left' : 'right'} ms-1"></span> - <span - t-attf-class="o_wsale_color_preview ms-1" - t-attf-style="background-color: #{ribbon.bg_color}" - /> - <span - t-attf-class="o_wsale_color_preview ms-1" - t-attf-style="background-color: #{textColor} !important;" - /> -</we-button> - -</templates> diff --git a/addons/website_sale/views/snippets/s_add_to_cart.xml b/addons/website_sale/views/snippets/s_add_to_cart.xml index bd827c2118e75..37764e3a779a0 100644 --- a/addons/website_sale/views/snippets/s_add_to_cart.xml +++ b/addons/website_sale/views/snippets/s_add_to_cart.xml @@ -7,41 +7,6 @@ </button> </div> </template> - <template id="s_add_to_cart_options" inherit_id="website.snippet_options"> - <xpath expr="." position="inside"> - <div data-js="AddToCart" - data-selector=".s_add_to_cart"> - <we-row> - <we-many2one string="Product" - data-model="product.template" - data-set-product-template="" - data-name="product_template_picker_opt" - data-no-preview="true" - data-domain='[["is_published", "=", true], ["sale_ok", "=", true]]' - /> - <we-button data-name="product_template_reset_opt" - class="reset-product-picker align-self-end fa fa-fw fa-times"> - </we-button> - </we-row> - <we-row> - <we-many2one string="Variant" class="o_we_sublevel_1" - data-model="product.product" - data-set-product-variant="" - data-name="product_variant_picker_opt" - data-no-preview="true" - data-default-message="Visitor's Choice" - /> - <we-button data-name="product_variant_reset_opt" - class="reset-variant-picker align-self-end fa fa-fw fa-times"> - </we-button> - </we-row> - <we-select data-name="action_picker_opt" string="Action" data-no-preview="true"> - <we-button data-set-action="add_to_cart">Add to Cart</we-button> - <we-button data-set-action="buy_now">Buy Now</we-button> - </we-select> - </div> - </xpath> - </template> <record id="website_sale.s_add_to_cart_000_js" model="ir.asset"> <field name="name">Add to Cart 000 JS</field> <field name="bundle">web.assets_frontend</field> diff --git a/addons/website_sale/views/snippets/snippets.xml b/addons/website_sale/views/snippets/snippets.xml index cb888f65df281..04fd0a6be9468 100644 --- a/addons/website_sale/views/snippets/snippets.xml +++ b/addons/website_sale/views/snippets/snippets.xml @@ -14,323 +14,7 @@ </xpath> </template> -<template id="snippet_options" inherit_id="website.snippet_options" name="e-commerce snippet options"> - <xpath expr="." position="inside"> - <!-- All products page --> - <div data-js="WebsiteSaleGridLayout" data-page-options="true" groups="website.group_website_designer" data-selector="main:has(.o_wsale_products_page)" data-no-check="true" - string="Products Page" data-target="#products_grid .o_wsale_products_grid_table_wrapper > table"> - <we-select string="Layout" data-no-preview="true" data-reload="/"> - <we-button data-customize-website-views="" data-name="grid_view_opt">Grid</we-button> - <we-button data-customize-website-views="website_sale.products_list_view">List</we-button> - </we-select> - <we-row string="Size" class="o_we_sublevel_1"> - <we-input data-set-ppg="" data-step="1" data-no-preview="true" data-reload="/"/> - <span class="mx-2 o_wsale_ppr_by">by</span> - <we-select class="o_wsale_ppr_submenu" data-dependencies="grid_view_opt" data-no-preview="true" data-reload="/"> - <we-button data-set-ppr="2">2</we-button> - <we-button data-set-ppr="3">3</we-button> - <we-button data-set-ppr="4">4</we-button> - </we-select> - </we-row> - <we-select string="Style" class="o_we_sublevel_1"> - <we-button data-select-class="" - data-customize-website-views=""> - Default - </we-button> - <we-button data-select-class="o_wsale_design_cards" - data-customize-website-views="website_sale.products_design_card"> - Cards - </we-button> - <we-button data-select-class="o_wsale_design_thumbs" - data-customize-website-views="website_sale.products_design_thumbs"> - Thumbnails - </we-button> - <we-button data-select-class="o_wsale_design_grid" - data-customize-website-views="website_sale.products_design_grid"> - Grid - </we-button> - </we-select> - <we-select string="Images Size" class="o_we_sublevel_1"> - <we-button data-select-class="o_wsale_context_thumb_4_3" - data-customize-website-views="website_sale.products_thumb_4_3"> - Landscape (4/3) - </we-button> - <we-button data-select-class="" - data-customize-website-views=""> - Default (1/1) - </we-button> - <we-button data-select-class="o_wsale_context_thumb_4_5" - data-customize-website-views="website_sale.products_thumb_4_5"> - Portrait (4/5) - </we-button> - <we-button data-select-class="o_wsale_context_thumb_2_3" - data-customize-website-views="website_sale.products_thumb_2_3"> - Vertical (2/3) - </we-button> - </we-select> - <we-button-group string="Fill" class="o_we_sublevel_2" data-variable="thumb_size"> - <we-button data-select-class="" - data-img="/website/static/src/img/snippets_options/content_width_normal.svg" - data-customize-website-views=""> - </we-button> - <we-button data-select-class="o_wsale_context_thumb_cover" - data-name="thumb_cover" - data-variable="thumb_cover" - data-img="/website/static/src/img/snippets_options/content_width_full.svg" - data-customize-website-views="website_sale.products_thumb_cover"> - </we-button> - </we-button-group> - <we-checkbox string="Search Bar" - data-customize-website-views="website_sale.search" - data-no-preview="true" - data-reload="/"/> - <we-checkbox string="Prod. Desc." - data-customize-website-views="website_sale.products_description" - data-no-preview="true" - data-reload="/"/> - <we-row id="o_wsale_grid_left_panel" string="Categories" data-variable="filmstrip"> - <we-button string="Left" - data-customize-website-views="website_sale.products_categories" - data-name="categories_opt" - data-no-preview="true" - data-reload="/"/> - <we-button string="Top" - data-customize-website-views="website_sale.products_categories_top" - data-name="categories_opt_top" - data-no-preview="true" - data-reload="/"/> - </we-row> - <we-checkbox id="collapse_category_recursive" string="Collapse Category Recursive" - class="o_we_sublevel_1" - data-customize-website-views="website_sale.option_collapse_products_categories" - data-dependencies="categories_opt" - data-no-preview="true" - data-reload="/"/> - <we-row string="Attributes" class="o_we_full_row"> - <we-button string="Left" - data-customize-website-views="website_sale.products_attributes" - data-name="attributes_opt" - data-no-preview="true" - data-reload="/"/> - <we-button string="Top" - data-customize-website-views="website_sale.products_attributes_top" - data-name="attributes_opt_top" - data-no-preview="true" - data-reload="/"/> - </we-row> - <we-checkbox string="Price Filter" - class="o_we_sublevel_1" - data-customize-website-views="website_sale.filter_products_price" - data-dependencies="attributes_opt, attributes_opt_top" - data-no-preview="true" - data-reload="/"/> - <we-checkbox string="Product Tags Filter" - class="o_we_sublevel_1" - data-customize-website-views="website_sale.filter_products_tags" - data-dependencies="attributes_opt, attributes_opt_top" - data-no-preview="true" - data-reload="/" - /> - <we-row string="Top Bar" class="o_we_full_row"> - <we-button string="Sort by" - data-customize-website-views="website_sale.sort" - data-no-preview="true" - data-reload="/"/> - <we-button string="Layout" - data-customize-website-views="website_sale.add_grid_or_list_option" - data-no-preview="true" - data-reload="/"/> - </we-row> - <we-select string="Default Sort" class="o_wsale_sort_submenu" data-no-preview="true" data-reload="/"> - <t t-foreach="request.env['website']._get_product_sort_mapping()" t-as="query_and_label"> - <we-button t-att-data-set-default-sort="query_and_label[0]"><t t-esc="query_and_label[1]"/></we-button> - </t> - </we-select> - <we-row string="Buttons" class="o_we_full_row"> - <we-button title="Add to Cart" class="fa fa-fw fa-shopping-cart o_we_add_to_cart_btn" - data-customize-website-views="website_sale.products_add_to_cart" - data-no-preview="true" - data-reload="/"/> - </we-row> - </div> - <!-- Product --> - <div data-js="WebsiteSaleProductsItem" - data-selector="#products_grid .oe_product" - data-no-check="true"> - <div class="o_wsale_soptions_menu_sizes"> - <we-row string="Size"> - <table> - <tr> - <td/><td/><td/><td/> - </tr> - <tr> - <td/><td/><td/><td/> - </tr> - <tr> - <td/><td/><td/><td/> - </tr> - <tr> - <td/><td/><td/><td/> - </tr> - </table> - </we-row> - </div> - <we-row string="Re-order" data-no-preview="true"> - <we-button title="Push to top" data-change-sequence="top" class="fa fa-fw fa-angle-double-left"/> - <we-button title="Push up" data-change-sequence="up" class="fa fa-fw fa-angle-left"/> - <we-button title="Push down" data-change-sequence="down" class="fa fa-fw fa-angle-right"/> - <we-button title="Push to bottom" data-change-sequence="bottom" class="fa fa-fw fa-angle-double-right"/> - </we-row> - - <we-row> - <we-select string="Ribbon" class="o_wsale_ribbon_select"> - <we-button data-set-ribbon="" data-name="no_ribbon_opt">None</we-button> - <!-- Ribbons are filled in JS --> - </we-select> - <we-button data-edit-ribbon="" title="Edit" class="fa fa-edit" data-no-preview="true" data-dependencies="!no_ribbon_opt"/> - <we-button data-create-ribbon="" data-name="create_ribbon_opt" title="Create" class="fa fa-plus text-success" data-no-preview="true"/> - </we-row> - <div class="d-none" data-name="ribbon_customize_opt"> - <we-input string="Name" class="o_we_sublevel_1 o_we_large" - data-set-ribbon-name="Ribbon name" data-apply-to=".o_ribbon"/> - <we-colorpicker string="Background" class="o_we_sublevel_1" - title="" data-select-style="" data-css-property="background-color" data-color-prefix="text-bg-" data-apply-to=".o_ribbon"/> - <we-colorpicker string="Text" class="o_we_sublevel_1" - title="" data-select-style="" data-css-property="color" data-apply-to=".o_ribbon"/> - <we-select string="Position" class="o_we_sublevel_1"> - <we-button data-set-ribbon-position="left">Left</we-button> - <we-button data-set-ribbon-position="right">Right</we-button> - </we-select> - <we-row string=" "> - <we-button class="o_we_bg_danger" data-delete-ribbon="" data-no-preview="true"> - Delete Ribbon - </we-button> - </we-row> - </div> - </div> - <div data-selector="#wrapwrap > header" - data-no-check="true" - groups="website.group_website_designer"> - <we-row string="Show Empty" class="o_we_full_row"> - <div class="d-flex gap-1 mb-1 w-100"> - <we-button title="Show/hide shopping cart" class="o_btn_show_empty_cart fa fa-shopping-cart d-flex justify-content-center flex-grow-1" - data-customize-website-views="website_sale.header_hide_empty_cart_link|" - data-no-preview="true" - data-reload="/"/> - </div> - </we-row> - </div> - <!-- Product image --> - <div data-js="WebsiteSaleProductAttribute" data-selector="#product_detail .o_wsale_product_attribute" data-no-check="true"> - <we-select string="Display Type" data-no-preview="true"> - <we-button data-set-display-type="radio">Radio</we-button> - <we-button data-set-display-type="pills">Pills</we-button> - <we-button data-set-display-type="select">Select</we-button> - <we-button data-set-display-type="color">Color</we-button> - </we-select> - </div> - <!-- Product page --> - <div data-js="WebsiteSaleProductPage" data-selector="main:has(.o_wsale_product_page)" data-page-options="true" groups="website.group_website_designer" data-no-check="true" string="Product Page"> - <we-row string="Customers" class="o_we_full_row"> - <we-button string="Rating" - data-customize-website-views="website_sale.product_comment" - data-no-preview="true" - data-reload="/"/> - <we-button string="Share" - data-name="attributes_opt" - data-customize-website-views="website_sale.product_share_buttons" - data-no-preview="true" - data-reload="/"/> - </we-row> - <we-checkbox string="Select Quantity" - data-customize-website-views="website_sale.product_quantity" - data-no-preview="true" - data-reload="/"/> - <we-checkbox string="Tax Indication" - data-customize-website-views="website_sale.tax_indication" - data-no-preview="true" - data-reload="/"/> - <we-select data-name="variants_opt" groups="product.group_product_variant" string="Variants" data-no-preview="true" data-reload="/"> - <we-button data-name="variants_options_opt" data-customize-website-views="">Options</we-button> - <we-button data-name="variants_products_list_opt" data-customize-website-views="website_sale.product_variants">Products List</we-button> - </we-select> - <we-checkbox string="Product Tags" - data-customize-website-views="website_sale.product_tags" - data-no-preview="true" - data-reload="/" - /> - <we-row string="Cart" class="o_we_full_row" data-name="o_wsale_buy_now_opt"> - <we-button title="Buy Now" class="o_we_buy_now_btn" - data-customize-website-views="website_sale.product_buy_now" - data-no-preview="true" - data-reload="/"> - <i class="fa fa-fw fa-bolt"/> - Buy Now - </we-button> - </we-row> - <!-- Image config --> - <we-button-group string="Images Width" data-no-preview="true" data-reload="/"> - <we-button data-set-image-width="none" data-img="/website_sale/static/src/img/snippet_options/image-width-none.svg" title="None"/> - <we-button data-set-image-width="50_pc" data-img="/website_sale/static/src/img/snippet_options/image-width-50.svg" title="50 percent"/> - <we-button data-set-image-width="66_pc" data-img="/website_sale/static/src/img/snippet_options/image-width-66.svg" title="66 percent"/> - <we-button data-set-image-width="100_pc" data-img="/website_sale/static/src/img/snippet_options/image-width-100.svg" title="100 percent"/> - </we-button-group> - <we-select string="Layout" data-name="o_wsale_image_layout" data-no-preview="true" data-reload="/"> - <we-button data-set-image-layout="carousel">Carousel</we-button> - <we-button data-set-image-layout="grid">Grid</we-button> - </we-select> - <we-select string="Image Zoom" class="o_we_sublevel_1" data-name="o_wsale_zoom_mode" data-no-preview="true" data-reload="/"> - <we-button data-name="o_wsale_zoom_hover" data-customize-website-views="website_sale.product_picture_magnify_hover">Magnifier on hover</we-button> - <we-button data-name="o_wsale_zoom_click" data-customize-website-views="website_sale.product_picture_magnify_click">Pop-up on Click</we-button> - <we-button data-name="o_wsale_zoom_both" data-customize-website-views="website_sale.product_picture_magnify_both">Both</we-button> - <we-button data-name="o_wsale_zoom_none" data-customize-website-views="">None</we-button> - </we-select> - <!-- Carousel config --> - <we-button-group string="Thumbnails" class="o_we_sublevel_1" data-name="o_wsale_thumbnail_pos" data-no-preview="true" data-reload="/"> - <we-button class="fa fa-fw fa-long-arrow-left" title="Left" data-customize-website-views="website_sale.carousel_product_indicators_left"/> - <we-button class="fa fa-fw fa-long-arrow-down" title="Bottom" data-customize-website-views="website_sale.carousel_product_indicators_bottom"/> - </we-button-group> - <!-- Grid config --> - <we-range string="Image Spacing" class="o_we_sublevel_1" data-name="o_wsale_grid_spacing" data-no-preview="true" data-reload="/" data-max="3" data-step="1" data-set-spacing=""/> - <we-select string="Columns" class="o_we_sublevel_1" data-name="o_wsale_grid_columns" data-no-preview="true" data-reload="/"> - <we-button data-set-columns="1">1</we-button> - <we-button data-set-columns="2">2</we-button> - <we-button data-set-columns="3">3</we-button> - </we-select> - <we-row string="Main Image"> - <we-button class="o_we_bg_success" data-name="o_wsale_replace_main_image" data-replace-main-image="true" data-no-preview="true">Replace</we-button> - </we-row> - <we-row string="Extra Images"> - <we-button class="o_we_bg_success" data-name="o_wsale_add_extra_images" data-add-images="true" data-no-preview="true">Add</we-button> - <we-button class="o_we_bg_danger" data-name="o_wsale_clear_extra_images" data-clear-images="true" data-no-preview="true">Remove all</we-button> - </we-row> - </div> - <!-- Checkout page --> - <div data-selector="main:has(.oe_website_sale .o_wizard)" data-page-options="true" groups="website.group_website_designer" data-no-check="true" string="Checkout Pages"> - <we-checkbox string="Extra Step" - data-customize-website-views="website_sale.extra_info" - data-no-preview="true" - data-reload="/"/> - <we-checkbox string="Suggested Accessories" - data-customize-website-views="website_sale.suggested_products_list" - data-no-preview="true" - data-reload="/"/> - <we-checkbox string="Promo Code" - data-customize-website-views="website_sale.reduction_code" - data-no-preview="true" - data-reload="/"/> - <we-checkbox string="Accept Terms & Conditions" - data-customize-website-views="website_sale.accept_terms_and_conditions" - data-no-preview="true" - data-reload="/"/> - <we-checkbox string="Show B2B Fields" - data-customize-website-views="website_sale.address_b2b" - data-no-preview="true" - data-reload="/"/> - </div> - </xpath> -</template> <template id="snippets_options_web_editor" inherit_id="web_editor.snippet_options" name="e-commerce base snippet options"> <xpath expr="//div[@data-js='ReplaceMedia']" position="inside"> @@ -346,26 +30,4 @@ </xpath> </template> -<template id="product_searchbar_input_snippet_options" inherit_id="website.searchbar_input_snippet_options" name="product search bar snippet options"> - <xpath expr="//div[@data-js='SearchBar']/we-select[@data-name='scope_opt']" position="inside"> - <we-button data-set-search-type="products" data-select-data-attribute="products" data-name="search_products_opt" data-form-action="/shop">Products</we-button> - </xpath> - <xpath expr="//div[@data-js='SearchBar']/we-select[@data-name='order_opt']" position="inside"> - <t t-foreach="request.env['website']._get_product_sort_mapping()" t-as="query_and_label"> - <!-- name asc is already part of the general sorting methods of this snippet. --> - <we-button t-if="query_and_label[0] != 'name asc'" t-att-data-set-order-by="query_and_label[0]" t-att-data-select-data-attribute="query_and_label[0]" data-dependencies="search_products_opt"><t t-out="query_and_label[1]"/></we-button> - </t> - </xpath> - <xpath expr="//div[@data-js='SearchBar']/div[@data-dependencies='limit_opt']" position="inside"> - <we-checkbox string="Description" data-dependencies="search_products_opt" data-select-data-attribute="true" data-attribute-name="displayDescription" - data-apply-to=".search-query"/> - <we-checkbox string="Category" data-dependencies="search_products_opt" data-select-data-attribute="true" data-attribute-name="displayExtraLink" - data-apply-to=".search-query"/> - <we-checkbox string="Price" data-dependencies="search_products_opt" data-select-data-attribute="true" data-attribute-name="displayDetail" - data-apply-to=".search-query"/> - <we-checkbox string="Image" data-dependencies="search_products_opt" data-select-data-attribute="true" data-attribute-name="displayImage" - data-apply-to=".search-query"/> - </xpath> -</template> - </odoo> diff --git a/addons/website_slides/__manifest__.py b/addons/website_slides/__manifest__.py index e0ec8320941a9..af2098eeb2187 100644 --- a/addons/website_slides/__manifest__.py +++ b/addons/website_slides/__manifest__.py @@ -118,6 +118,9 @@ 'web.assets_tests': [ 'website_slides/static/tests/tours/*.js', ], + 'website.assets_wysiwyg': [ + 'website_slides/static/src/snippets/s_searchbar/options.xml', + ], 'website_slides.slide_embed_assets': [ # TODO this bundle now includes 'assets_common' files directly, but # most of these files are useless in this context, clean this up. diff --git a/addons/website_slides/static/src/snippets/s_searchbar/options.xml b/addons/website_slides/static/src/snippets/s_searchbar/options.xml new file mode 100644 index 0000000000000..2eb76d6f2383f --- /dev/null +++ b/addons/website_slides/static/src/snippets/s_searchbar/options.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + + <t t-name="website_slides.s_searchbar_options" t-inherit="website.s_searchbar_options" t-inherit-mode="extension"> + <xpath expr="//WeSelect[@name="'scope_opt'"]" position="inside"> + <WeButton setSearchType="'slides'" selectDataAttribute="'slides'" name="'search_slides_opt'" formAction="'/slides/all'">Courses</WeButton> + </xpath> + <xpath expr="//WeSelect[@name="'order_opt'"]" position="inside"> + <WeButton setOrderBy="'slide_last_update asc'" selectDataAttribute="'slide_last_update asc'" dependencies="'search_slides_opt'" name="'order_slide_last_update_asc_opt'">Date (old to new)</WeButton> + <WeButton setOrderBy="'slide_last_update desc'" selectDataAttribute="'slide_last_update desc'" dependencies="'search_slides_opt'" name="'order_slide_last_update_desc_opt'">Date (new to old)</WeButton> + </xpath> + <xpath expr="//div[@dependencies="'limit_opt'"]" position="inside"> + <WeCheckbox title.translate="Description" + dependencies="'search_slides_opt'" + selectDataAttribute="'true'" + attributeName="'displayDescription'" + applyTo="'.search-query'"/> + <WeCheckbox title.translate="Publication Date" + dependencies="'search_slides_opt'" + selectDataAttribute="'true'" + attributeName="'displayDetail'" + applyTo="'.search-query'"/> + </xpath> + </t> + +</templates> diff --git a/addons/website_slides/views/slide_snippets.xml b/addons/website_slides/views/slide_snippets.xml index a050bfa1213d3..3e804b9f7ab78 100644 --- a/addons/website_slides/views/slide_snippets.xml +++ b/addons/website_slides/views/slide_snippets.xml @@ -2,22 +2,6 @@ <odoo> <!-- Snippets and options --> -<template id="slide_searchbar_input_snippet_options" inherit_id="website.searchbar_input_snippet_options" name="slide search bar snippet options"> - <xpath expr="//div[@data-js='SearchBar']/we-select[@data-name='scope_opt']" position="inside"> - <we-button data-set-search-type="slides" data-select-data-attribute="slides" data-name="search_slides_opt" data-form-action="/slides/all">Courses</we-button> - </xpath> - <xpath expr="//div[@data-js='SearchBar']/we-select[@data-name='order_opt']" position="inside"> - <we-button data-set-order-by="slide_last_update asc" data-select-data-attribute="slide_last_update asc" data-dependencies="search_slides_opt" data-name="order_slide_last_update_asc_opt">Date (old to new)</we-button> - <we-button data-set-order-by="slide_last_update desc" data-select-data-attribute="slide_last_update desc" data-dependencies="search_slides_opt" data-name="order_slide_last_update_desc_opt">Date (new to old)</we-button> - </xpath> - <xpath expr="//div[@data-js='SearchBar']/div[@data-dependencies='limit_opt']" position="inside"> - <we-checkbox string="Description" data-dependencies="search_slides_opt" data-select-data-attribute="true" data-attribute-name="displayDescription" - data-apply-to=".search-query"/> - <we-checkbox string="Publication Date" data-dependencies="search_slides_opt" data-select-data-attribute="true" data-attribute-name="displayDetail" - data-apply-to=".search-query"/> - </xpath> -</template> - <template id="snippet_options" inherit_id="website.snippet_options" name="Slides Snippet Options"> <xpath expr="." position="inside"> <div data-selector="main:has(.o_wslides_home_main)" data-page-options="true" groups="website.group_website_designer" data-no-check="true" string="Courses Page">