From 8b91e9f00fc2facc478128562c8509f4374f9cce Mon Sep 17 00:00:00 2001 From: abd-msyukyu-odoo Date: Fri, 20 Jun 2025 15:03:34 +0200 Subject: [PATCH 01/43] [IMP] html_builder, website: remove website dependency from snippet_service In order to be able to use the `snippet_service` for `mass_mailing`, it can not depends on `website`. This commit removes that dependency and adjust impacted files. It is now possible to use different `snippetName` depending on the desired snippets bundle. task-4247642 --- addons/html_builder/static/src/builder.js | 6 ++-- addons/html_builder/static/src/builder.xml | 2 +- .../src/core/disable_snippets_plugin.js | 2 +- .../static/src/core/drop_zone_plugin.js | 2 +- .../static/src/core/version_control_plugin.js | 4 +-- .../static/src/sidebar/block_tab.js | 6 ++-- .../static/src/snippets/snippet_service.js | 31 ++++++++++++------- addons/html_builder/static/tests/helpers.js | 4 ++- .../plugins/snippets_powerbox_plugin.js | 5 +-- .../website_preview/website_builder_action.js | 25 +++++++++++++-- 10 files changed, 57 insertions(+), 30 deletions(-) diff --git a/addons/html_builder/static/src/builder.js b/addons/html_builder/static/src/builder.js index c3e07fc785184..82a85bbe0aaf8 100644 --- a/addons/html_builder/static/src/builder.js +++ b/addons/html_builder/static/src/builder.js @@ -34,7 +34,7 @@ export class Builder extends Component { reloadEditor: { type: Function, optional: true }, onEditorLoad: { type: Function, optional: true }, installSnippetModule: { type: Function, optional: true }, - snippetsName: { type: String }, + snippetModel: { type: Object }, toggleMobile: { type: Function }, overlayRef: { type: Function }, iframeLoaded: { type: Object }, @@ -66,6 +66,7 @@ export class Builder extends Component { this.dialog = useService("dialog"); this.ui = useService("ui"); this.notification = useService("notification"); + this.snippetModel = useState(this.props.snippetModel); const editorBus = new EventBus(); @@ -128,6 +129,7 @@ export class Builder extends Component { cleanForSaveHandlers, wrapWithSaveSnippetHandlers ), + snippetModel: this.snippetModel, getShared: () => this.editor.shared, updateInvisibleElementsPanel: () => this.updateInvisibleEls(), allowCustomStyle: true, @@ -138,8 +140,6 @@ export class Builder extends Component { ); this.props.onEditorLoad(this.editor); - this.snippetModel = useState(useService("html_builder.snippets")); - onWillStart(async () => { await this.snippetModel.load(); // Ensure that the iframe is loaded and the editor is created before diff --git a/addons/html_builder/static/src/builder.xml b/addons/html_builder/static/src/builder.xml index ca03c0a83418d..52af0c64d1baa 100644 --- a/addons/html_builder/static/src/builder.xml +++ b/addons/html_builder/static/src/builder.xml @@ -34,7 +34,7 @@
- + diff --git a/addons/html_builder/static/src/core/disable_snippets_plugin.js b/addons/html_builder/static/src/core/disable_snippets_plugin.js index c3c0793cbbfa4..cd77d7770d2db 100644 --- a/addons/html_builder/static/src/core/disable_snippets_plugin.js +++ b/addons/html_builder/static/src/core/disable_snippets_plugin.js @@ -14,7 +14,7 @@ export class DisableSnippetsPlugin extends Plugin { }; setup() { - this.snippetModel = this.services["html_builder.snippets"]; + this.snippetModel = this.config.snippetModel; this._disableSnippets = this.disableUndroppableSnippets.bind(this); // TODO only for website ? diff --git a/addons/html_builder/static/src/core/drop_zone_plugin.js b/addons/html_builder/static/src/core/drop_zone_plugin.js index 8b15b6f96164e..65822a26390b6 100644 --- a/addons/html_builder/static/src/core/drop_zone_plugin.js +++ b/addons/html_builder/static/src/core/drop_zone_plugin.js @@ -15,7 +15,7 @@ export class DropZonePlugin extends Plugin { ]; setup() { - this.snippetModel = this.services["html_builder.snippets"]; + this.snippetModel = this.config.snippetModel; this.dropzoneSelectors = this.getResource("dropzone_selector"); this.iframe = this.document.defaultView.frameElement; } diff --git a/addons/html_builder/static/src/core/version_control_plugin.js b/addons/html_builder/static/src/core/version_control_plugin.js index 1d84d07244496..91bb7603f5770 100644 --- a/addons/html_builder/static/src/core/version_control_plugin.js +++ b/addons/html_builder/static/src/core/version_control_plugin.js @@ -14,7 +14,7 @@ export class VersionControlPlugin extends Plugin { return this.accessPerOutdatedEl.get(el); } const snippetKey = el.dataset.snippet; - const snippet = this.services["html_builder.snippets"].getOriginalSnippet(snippetKey); + const snippet = this.config.snippetModel.getOriginalSnippet(snippetKey); let isUpToDate = true; if (snippet) { const { @@ -34,7 +34,7 @@ export class VersionControlPlugin extends Plugin { } replaceWithNewVersion(el) { const snippetKey = el.dataset.snippet; - const snippet = this.services["html_builder.snippets"].getOriginalSnippet(snippetKey); + const snippet = this.config.snippetModel.getOriginalSnippet(snippetKey); const cloneEl = snippet.content.cloneNode(true); el.replaceWith(cloneEl); this.dependencies["builderOptions"].updateContainers(cloneEl); diff --git a/addons/html_builder/static/src/sidebar/block_tab.js b/addons/html_builder/static/src/sidebar/block_tab.js index ba8532bc861f1..b1ee0b40b066c 100644 --- a/addons/html_builder/static/src/sidebar/block_tab.js +++ b/addons/html_builder/static/src/sidebar/block_tab.js @@ -13,13 +13,15 @@ import { CustomInnerSnippet } from "./custom_inner_snippet"; export class BlockTab extends Component { static template = "html_builder.BlockTab"; static components = { Snippet, CustomInnerSnippet }; - static props = {}; + static props = { + snippetModel: { type: Object }, + }; setup() { this.dialog = useService("dialog"); this.orm = useService("orm"); this.popover = useService("popover"); - this.snippetModel = useState(useService("html_builder.snippets")); + this.snippetModel = useState(this.props.snippetModel); this.blockTabRef = useRef("block-tab"); // Needed to avoid race condition in tours. this.state = useState({ ongoingInsertion: false }); diff --git a/addons/html_builder/static/src/snippets/snippet_service.js b/addons/html_builder/static/src/snippets/snippet_service.js index a902714fde999..acc404cc37e05 100644 --- a/addons/html_builder/static/src/snippets/snippet_service.js +++ b/addons/html_builder/static/src/snippets/snippet_service.js @@ -5,17 +5,31 @@ import { Reactive } from "@web/core/utils/reactive"; import { escape } from "@web/core/utils/strings"; import { AddSnippetDialog } from "./add_snippet_dialog"; import { registry } from "@web/core/registry"; -import { user } from "@web/core/user"; import { markup } from "@odoo/owl"; +export class SnippetModelFactory { + constructor(services) { + this.services = services; + } + + makeSnippetModel(snippetsName, { context = {} } = {}) { + return new SnippetModel(this.services, { + snippetsName, + context, + }); + } +} + export class SnippetModel extends Reactive { constructor(services, { snippetsName, context }) { super(); this.orm = services.orm; this.dialog = services.dialog; this.snippetsName = snippetsName; + this.uiService = services.ui; this.context = context; this.loadProm = null; + this.beforeReload = null; this.snippetsByCategory = { snippet_groups: [], @@ -393,18 +407,11 @@ export class SnippetModel extends Reactive { } registry.category("services").add("html_builder.snippets", { - dependencies: ["orm", "dialog", "website"], + dependencies: ["orm", "dialog"], - start(env, { orm, dialog, website }) { - const services = { orm, dialog, website }; - const context = { - lang: website.currentWebsite?.metadata.lang, - user_lang: user.context.lang, - }; + start(env, { orm, dialog }) { + const services = { orm, dialog }; - return new SnippetModel(services, { - snippetsName: "website.snippets", - context, - }); + return new SnippetModelFactory(services); }, }); diff --git a/addons/html_builder/static/tests/helpers.js b/addons/html_builder/static/tests/helpers.js index 66b5a3b8bab86..3b294875341fd 100644 --- a/addons/html_builder/static/tests/helpers.js +++ b/addons/html_builder/static/tests/helpers.js @@ -20,6 +20,7 @@ import { import { isBrowserFirefox } from "@web/core/browser/feature_detection"; import { registry } from "@web/core/registry"; import { uniqueId } from "@web/core/utils/functions"; +import { useService } from "@web/core/utils/hooks"; export function patchWithCleanupImg() { const defaultImg = @@ -96,6 +97,7 @@ class BuilderContainer extends Component { useSubEnv({ builderRef: useRef("container"), }); + this.snippetModel = useService("html_builder.snippets").makeSnippetModel(""); } onLoad() { @@ -105,7 +107,7 @@ class BuilderContainer extends Component { getBuilderProps() { return { closeEditor: () => {}, - snippetsName: "", + snippetModel: this.snippetModel, toggleMobile: () => { this.state.isMobile = !this.state.isMobile; }, diff --git a/addons/website/static/src/builder/plugins/snippets_powerbox_plugin.js b/addons/website/static/src/builder/plugins/snippets_powerbox_plugin.js index 66c2183e1c88e..123251539fb71 100644 --- a/addons/website/static/src/builder/plugins/snippets_powerbox_plugin.js +++ b/addons/website/static/src/builder/plugins/snippets_powerbox_plugin.js @@ -139,10 +139,7 @@ class SnippetsPowerboxPlugin extends Plugin { }; insertSnippet(name) { - const snippet = this.services["html_builder.snippets"].getSnippetByName( - "snippet_content", - name - ); + const snippet = this.config.snippetModel.getSnippetByName("snippet_content", name); const content = snippet.content.cloneNode(true); this.dependencies.dom.insert(content); this.dependencies.history.addStep(); diff --git a/addons/website/static/src/client_actions/website_preview/website_builder_action.js b/addons/website/static/src/client_actions/website_preview/website_builder_action.js index 08629bdd30923..8e84f4c328e64 100644 --- a/addons/website/static/src/client_actions/website_preview/website_builder_action.js +++ b/addons/website/static/src/client_actions/website_preview/website_builder_action.js @@ -33,6 +33,7 @@ import { renderToElement } from "@web/core/utils/render"; import { isBrowserChrome, isBrowserMicrosoftEdge } from "@web/core/browser/feature_detection"; import { router } from "@web/core/browser/router"; import { getScrollingElement } from "@web/core/utils/scrolling"; +import { user } from "@web/core/user"; const websiteSystrayRegistry = registry.category("website_systray"); @@ -135,7 +136,7 @@ export class WebsiteBuilderClientAction extends Component { if (!this.ui.isSmall) { // preload builder and snippets so clicking on "edit" is faster loadBundle("website.website_builder_assets").then(() => { - this.env.services["html_builder.snippets"].load(); + this.snippetModel.load(); }); } }); @@ -182,12 +183,30 @@ export class WebsiteBuilderClientAction extends Component { get testMode() { return false; } - + + /** + * Require `html_builder.assets` to be loaded. + */ + get snippetModel() { + if (!this._snippetModel) { + this._snippetModel = this.env.services["html_builder.snippets"].makeSnippetModel( + "website.snippets", + { + context: { + lang: this.websiteService.currentWebsite?.metadata.lang, + user_lang: user.context.lang, + }, + } + ); + } + return this._snippetModel; + } + get websiteBuilderProps() { const builderProps = { closeEditor: this.reloadIframeAndCloseEditor.bind(this), reloadEditor: this.reloadEditor.bind(this), - snippetsName: "website.snippets", + snippetModel: this.snippetModel, toggleMobile: this.toggleMobile.bind(this), installSnippetModule: this.installSnippetModule.bind(this), overlayRef: this.overlayRef, From f3ec2b15313460fd2093607816cfa45cf8b8d40b Mon Sep 17 00:00:00 2001 From: abd-msyukyu-odoo Date: Thu, 12 Jun 2025 15:26:37 +0200 Subject: [PATCH 02/43] [REM] html_editor: remove dead code for toolbar --- addons/html_editor/static/src/editor.js | 1 - .../html_editor/static/src/fields/html_field.js | 2 +- .../static/src/main/toolbar/toolbar_plugin.js | 8 +++----- addons/html_editor/static/src/wysiwyg.js | 16 +--------------- addons/html_editor/static/src/wysiwyg.xml | 3 --- 5 files changed, 5 insertions(+), 25 deletions(-) diff --git a/addons/html_editor/static/src/editor.js b/addons/html_editor/static/src/editor.js index 9680134fdb229..797fe3733ad07 100644 --- a/addons/html_editor/static/src/editor.js +++ b/addons/html_editor/static/src/editor.js @@ -25,7 +25,6 @@ import { fixInvalidHTML, initElementForEdition } from "./utils/sanitize"; * @property { boolean } [allowInlineAtRoot] * @property { string } [baseContainer] * @property { PluginConstructor[] } [Plugins] - * @property { boolean } [disableFloatingToolbar] * @property { string[] } [classList] * @property { Object } [localOverlayContainers] * @property { Object } [embeddedComponentInfo] diff --git a/addons/html_editor/static/src/fields/html_field.js b/addons/html_editor/static/src/fields/html_field.js index 1081327448f90..6f6c5a04188d6 100644 --- a/addons/html_editor/static/src/fields/html_field.js +++ b/addons/html_editor/static/src/fields/html_field.js @@ -258,8 +258,8 @@ export class HtmlField extends Component { } if (this.props.embeddedComponents) { - // TODO @engagement: fill this array with default/base components config.resources.embedded_components = [...MAIN_EMBEDDINGS]; + config.embeddedComponentInfo = { app: this.__owl__.app, env: this.env }; } const { sanitize_tags, sanitize } = this.props.record.fields[this.props.name]; diff --git a/addons/html_editor/static/src/main/toolbar/toolbar_plugin.js b/addons/html_editor/static/src/main/toolbar/toolbar_plugin.js index 8c13e6d800505..14b0caa1ec454 100644 --- a/addons/html_editor/static/src/main/toolbar/toolbar_plugin.js +++ b/addons/html_editor/static/src/main/toolbar/toolbar_plugin.js @@ -306,11 +306,9 @@ export class ToolbarPlugin extends Plugin { updateToolbar(selectionData = this.dependencies.selection.getSelectionData()) { this.updateNamespace(); - if (!this.config.disableFloatingToolbar) { - this.updateToolbarVisibility(selectionData); - if (!this.overlay.isOpen) { - return; - } + this.updateToolbarVisibility(selectionData); + if (!this.overlay.isOpen) { + return; } this.updateButtonsStates(selectionData.editableSelection); } diff --git a/addons/html_editor/static/src/wysiwyg.js b/addons/html_editor/static/src/wysiwyg.js index 73db0669beb59..5ab88209b3c3c 100644 --- a/addons/html_editor/static/src/wysiwyg.js +++ b/addons/html_editor/static/src/wysiwyg.js @@ -1,4 +1,4 @@ -import { Component, onMounted, onWillDestroy, useRef, useState, useSubEnv } from "@odoo/owl"; +import { Component, onMounted, onWillDestroy, useRef, useSubEnv } from "@odoo/owl"; import { Editor } from "./editor"; import { Toolbar } from "./main/toolbar/toolbar"; import { useChildRef, useSpellCheck } from "@web/core/utils/hooks"; @@ -30,7 +30,6 @@ export class Wysiwyg extends Component { class: { type: String, optional: true }, contentClass: { type: String, optional: true }, // on editable element style: { type: String, optional: true }, - toolbar: { type: Boolean, optional: true }, iframe: { type: Boolean, optional: true }, copyCss: { type: Boolean, optional: true }, onLoad: { type: Function, optional: true }, @@ -44,9 +43,6 @@ export class Wysiwyg extends Component { }; setup() { - this.state = useState({ - showToolbar: false, - }); this.overlayRef = useChildRef(); useSubEnv({ localOverlayContainerKey: uniqueId("wysiwyg"), @@ -61,9 +57,6 @@ export class Wysiwyg extends Component { }); onMounted(() => { - // now that component is mounted, editor is attached to el, and - // plugins are started, so we can allow the toolbar to be displayed - this.state.showToolbar = true; /** @type { any } **/ const el = contentRef.el; @@ -107,17 +100,10 @@ export class Wysiwyg extends Component { getEditorConfig() { return { ...this.props.config, - // TODO ABD TODO @phoenix: check if there is too much info in the wysiwyg env. - // i.e.: env has X because of parent component, - // embedded component descendant sometimes uses X from env which is set conditionally: - // -> it will override the one one from the parent => OK. - // -> it will not => the embedded component still has X in env because of its ancestors => Issue. - embeddedComponentInfo: { app: this.__owl__.app, env: this.env }, localOverlayContainers: { key: this.env.localOverlayContainerKey, ref: this.overlayRef, }, - disableFloatingToolbar: this.props.toolbar, }; } } diff --git a/addons/html_editor/static/src/wysiwyg.xml b/addons/html_editor/static/src/wysiwyg.xml index 9a346e30da18e..fa31266c2d678 100644 --- a/addons/html_editor/static/src/wysiwyg.xml +++ b/addons/html_editor/static/src/wysiwyg.xml @@ -1,9 +1,6 @@
- - -
From f89aadfcded47af6b0e6bce816f91da0aca64edf Mon Sep 17 00:00:00 2001 From: abd-msyukyu-odoo Date: Wed, 12 Mar 2025 14:56:48 +0100 Subject: [PATCH 03/43] mass_mailing_builder --- addons/mass_mailing_egg/__manifest__.py | 52 +++ .../html_field/mass_mailing_html_field.js | 67 ++++ .../html_field/mass_mailing_html_field.xml | 13 + .../static/src/html_builder/builder.js | 272 ++++++++++++++ .../static/src/html_builder/builder.xml | 59 +++ .../src/html_builder/tabs/design_tab.js | 16 + .../src/html_builder/tabs/design_tab.xml | 21 ++ .../static/src/iframe/mass_mailing_iframe.js | 113 ++++++ .../static/src/iframe/mass_mailing_iframe.xml | 21 ++ .../themes/theme_selector/theme_selector.js | 10 + .../themes/theme_selector/theme_selector.xml | 23 ++ .../static/src/themes/theme_service.js | 35 ++ .../views/mailing_mailing_views.xml | 345 ++++++++++++++++++ 13 files changed, 1047 insertions(+) create mode 100644 addons/mass_mailing_egg/__manifest__.py create mode 100644 addons/mass_mailing_egg/static/src/fields/html_field/mass_mailing_html_field.js create mode 100644 addons/mass_mailing_egg/static/src/fields/html_field/mass_mailing_html_field.xml create mode 100644 addons/mass_mailing_egg/static/src/html_builder/builder.js create mode 100644 addons/mass_mailing_egg/static/src/html_builder/builder.xml create mode 100644 addons/mass_mailing_egg/static/src/html_builder/tabs/design_tab.js create mode 100644 addons/mass_mailing_egg/static/src/html_builder/tabs/design_tab.xml create mode 100644 addons/mass_mailing_egg/static/src/iframe/mass_mailing_iframe.js create mode 100644 addons/mass_mailing_egg/static/src/iframe/mass_mailing_iframe.xml create mode 100644 addons/mass_mailing_egg/static/src/themes/theme_selector/theme_selector.js create mode 100644 addons/mass_mailing_egg/static/src/themes/theme_selector/theme_selector.xml create mode 100644 addons/mass_mailing_egg/static/src/themes/theme_service.js create mode 100644 addons/mass_mailing_egg/views/mailing_mailing_views.xml diff --git a/addons/mass_mailing_egg/__manifest__.py b/addons/mass_mailing_egg/__manifest__.py new file mode 100644 index 0000000000000..3f21105f4b705 --- /dev/null +++ b/addons/mass_mailing_egg/__manifest__.py @@ -0,0 +1,52 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Email Marketing Egg', + 'summary': 'Design, send and track emails', + 'version': '1.0', + 'sequence': 61, + 'website': 'https://www.odoo.com/app/email-marketing', + 'category': 'Hidden', + 'auto_install': True, + 'depends': [ + 'mass_mailing', + 'html_builder', + 'html_editor', + ], + 'data': [ + 'views/mailing_mailing_views.xml' + ], + 'assets': { + 'mass_mailing_egg.assets_builder': [ # equivalent html_builder.assets in website_builder_action.xml + # lazy builder assets NOT applied in iframe + ('include', 'html_builder.assets'), + 'mass_mailing_egg/static/src/html_builder/**/*', + ], + # TODO EGGMAIL: evaluate if necessary to have interactions for mass_mailing + # 'mass_mailing_egg.assets_iframe_core': [ # web.assets_frontend lite, minimal env to spawn interactions + # # minimal JS assets required to view the mail content + # ], + 'mass_mailing_egg.assets_iframe_style': [ # equivalent website.inside_builder_style in website_builder_action.js + # minimal style assets required to view the mail content + # convert_inline ONLY uses this and inline styles. + ], + # 'mass_mailing_egg.assets_iframe_edit': [ # equivalent html_builder.assets_edit_frontend in website_builder_action.js + # # JS and style assets required to edit the mail content + # ], + 'mass_mailing_egg.assets_iframe_dark': [ # separated complement of assets_iframe_style for dark mode + # style assets for dark mode. Not used by convert_inline. + # TODO EGGMAIL: investigate how this can behave properly with convert_inline (i.e. user chooses pretty colors for + # dark mode, but these colors are not pretty when the dark mode is not there anymore after sending the mail). + ], + 'web.assets_backend': [ + 'mass_mailing_egg/static/src/interaction_service/**/*', + 'mass_mailing_egg/static/src/fields/**/*', + 'mass_mailing_egg/static/src/theme_selector/**/*', + ], + 'web.assets_unit_tests': [ + # 'mass_mailing_egg/static/tests/**/*', + ], + }, + 'author': 'Odoo S.A.', + 'license': 'LGPL-3', +} diff --git a/addons/mass_mailing_egg/static/src/fields/html_field/mass_mailing_html_field.js b/addons/mass_mailing_egg/static/src/fields/html_field/mass_mailing_html_field.js new file mode 100644 index 0000000000000..0214050ce324f --- /dev/null +++ b/addons/mass_mailing_egg/static/src/fields/html_field/mass_mailing_html_field.js @@ -0,0 +1,67 @@ +import { + HtmlMailField, + htmlMailField, +} from "@mail/views/web/fields/html_mail_field/html_mail_field"; +import { registry } from "@web/core/registry"; +import { LocalOverlayContainer } from "@html_editor/local_overlay_container"; +import { MassMailingIframe } from "@mass_mailing_egg/iframe/mass_mailing_iframe"; +import { ThemeSelector } from "@mass_mailing_egg/themes/theme_selector/theme_selector"; + +export class MassMailingHtmlField extends HtmlMailField { + static template = "mass_mailing_egg.HtmlField"; + static components = { + ...HtmlMailField.components, + LocalOverlayContainer, + MassMailingIframe, + ThemeSelector, + }; + + setup() { + super.setup(); + this.state.showThemeSelector = this.props.record.isNew; + Object.assign(this.state, { + showThemeSelector: this.props.record.isNew, + themeOptions: undefined, + }); + } + + /** + * @override + */ + getConfig() { + // TODO EGGMAIL do we want the codeview? + // TODO EGGMAIL do we want dynamic placeholders? + return super.getConfig(); + } + + /** + * @override + */ + getReadonlyConfig() { + // TODO EGGMAIL ? + return super.getReadonlyConfig(); + } + + getBuilderConfig() { + return { + // TODO EGGMAIL: allow the builder to show the theme selection again + // Applying a new Theme from the builder should CREATE AN EDITOR STEP + // that can be UNDONE. + showThemeSelector: () => (this.state.showThemeSelector = true), + }; + } + + getThemeSelectorConfig() { + return { + setThemeOptions: (themeOptions) => (this.state.themeOptions = themeOptions), + }; + } +} + +export const massMailingHtmlField = { + ...htmlMailField, + component: MassMailingHtmlField, + // TODO EGGMAIL decide which options we want in extractProps? +}; + +registry.category("fields").add("mass_mailing_egg_html", massMailingHtmlField); diff --git a/addons/mass_mailing_egg/static/src/fields/html_field/mass_mailing_html_field.xml b/addons/mass_mailing_egg/static/src/fields/html_field/mass_mailing_html_field.xml new file mode 100644 index 0000000000000..bcbc0d01de93c --- /dev/null +++ b/addons/mass_mailing_egg/static/src/fields/html_field/mass_mailing_html_field.xml @@ -0,0 +1,13 @@ + + +
+ + + + + + + +
+
+
diff --git a/addons/mass_mailing_egg/static/src/html_builder/builder.js b/addons/mass_mailing_egg/static/src/html_builder/builder.js new file mode 100644 index 0000000000000..7ce59a1adff4f --- /dev/null +++ b/addons/mass_mailing_egg/static/src/html_builder/builder.js @@ -0,0 +1,272 @@ +import { CORE_PLUGINS } from "@html_builder/core/core_plugins"; +import { BlockTab } from "@html_builder/sidebar/block_tab"; +import { CustomizeTab } from "@html_builder/sidebar/customize_tab"; +import { EDITOR_COLOR_CSS_VARIABLES, getCSSVariableValue } from "@html_builder/utils/utils_css"; +import { Editor } from "@html_editor/editor"; +import { MAIN_PLUGINS } from "@html_editor/plugin_sets"; +import { withSequence } from "@html_editor/utils/resource"; +import { DesignTab } from "@mass_mailing_egg/tabs/design_tab"; +import { + Component, + EventBus, + onMounted, + onWillDestroy, + onWillStart, + useState, + useSubEnv, +} from "@odoo/owl"; +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import { useHotkey } from "@web/core/hotkeys/hotkey_hook"; +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { useRef, useService } from "@web/core/utils/hooks"; +import { addLoadingEffect } from "@web/core/utils/ui"; +import { useSetupAction } from "@web/search/action_hook"; + +const DISABLED_PLUGINS = new Set([ + "PowerButtonsPlugin", + "DoubleClickImagePreviewPlugin", + "SeparatorPlugin", + "StarPlugin", + "BannerPlugin", +]); + +/** + * Mirror of `html_builder/.../builder.js adapted for mass_mailing + * TODO EGGMAIL: re-read the original file and update this one to be sure + * related parts match. Ideally we should have one generic builder for both + * use cases. + */ +export class Builder extends Component { + static template = "mass_mailing_egg.Builder"; + static components = { + BlockTab, + CustomizeTab, + DesignTab, + }; + static props = { + config: { type: Object }, + discard: { type: Function }, + iframeLoaded: { type: Object }, + onChange: { type: Function }, + overlayRef: { type: Function }, + reloadEditor: { type: Function }, + save: { type: Function }, + Plugins: { type: Array, optional: true }, + }; + static defaultProps = { + Plugins: [], + }; + + setup() { + this.dialog = useService("dialog"); + this.builderSidebarRef = useRef("builderSidebar"); + this.noSelectionTab = "blocks"; + this.state = useState({ + canUndo: false, + canRedo: false, + activeTab: "blocks", + currentOptionsContainers: undefined, + }); + + // TODO EGGMAIL: evaluate which plugins we need (not embedded components, ...) + const mainPlugins = MAIN_PLUGINS; + const corePlugins = CORE_PLUGINS; + const Plugins = this.filterPlugins([...mainPlugins, ...corePlugins, ...this.props.Plugins]); + const snippetModel = useState(useService("html_builder.snippets")); + const editorBus = new EventBus(); + const editor = new Editor( + { + Plugins, + // TODO EGGMAIL: evaluate what this implies + allowCustomStyle: true, + allowTargetBlank: true, + getShared: () => editor.shared, + localOverlayContainers: { + key: this.env.localOverlayContainerKey, + ref: this.props.overlayRef, + }, + onChange: ({ isPreviewing }) => { + if (!isPreviewing) { + this.state.canRedo = editor.shared.history.canRedo(); + this.state.canUndo = editor.shared.history.canUndo(); + this.props.onChange(); + editorBus.trigger("UPDATE_EDITING_ELEMENT"); + editorBus.trigger("DOM_UPDATED"); + } + }, + reloadEditor: (param = {}) => { + this.props.reloadEditor({ + initialTab: this.state.activeTab, + ...param, + }); + }, + resources: { + can_display_toolbar: (namespace) => !["image", "icon"].includes(namespace), + change_current_options_containers_listeners: (currentOptionsContainers) => { + this.state.currentOptionsContainers = currentOptionsContainers; + if (!currentOptionsContainers.length) { + // If there is no option, fallback on the current + // fallback tab. + this.setTab(this.noSelectionTab); + return; + } + this.setTab("customize"); + }, + on_mobile_preview_clicked: withSequence(20, () => { + editorBus.trigger("DOM_UPDATED"); + }), + trigger_dom_updated: () => { + editorBus.trigger("DOM_UPDATED"); + }, + unsplittable_node_predicates: (/** @type {Node} */ node) => + node.querySelector?.("[data-oe-translation-source-sha]"), + }, + replaceSnippet: async (snippet) => await snippetModel.replaceSnippet(snippet), + saveSnippet: (snippetEl, cleanForSaveHandlers) => + snippetModel.saveSnippet(snippetEl, cleanForSaveHandlers), + ...this.props.config, + }, + this.env.services + ); + + useSubEnv({ editor, editorBus }); + onWillStart(async () => { + await snippetModel.load(); + + // Ensure that the iframe is loaded and the editor is created before + // instantiating the sub components that potentially need the + // editor. + const iframeEl = await this.props.iframeLoaded; + // TODO EGGMAIL check where the #wrapwrap thingy is set + this.editor.attachTo(iframeEl.contentDocument.body.querySelector("#wrapwrap")); + }); + onMounted(() => { + editor.document.body.classLiist.add("editor_enable"); + this.setCSSVariables(); + }); + onWillDestroy(() => editor.destroy()); + + useHotkey("control+z", () => this.undo()); + useHotkey("control+y", () => this.redo()); + useHotkey("control+shift+z", () => this.redo()); + // TODO EGGMAIL evaluate this in context of a standard form view + useSetupAction({ + beforeUnload: (ev) => this.onBeforeUnload(ev), + beforeLeave: () => this.onBeforeLeave(), + }); + } + + discard() { + if (this.state.canUndo) { + this.dialog.add(ConfirmationDialog, { + body: _t( + "If you discard the current edits, all unsaved changes will be lost. You can cancel to return to edit mode." + ), + confirm: () => this.props.discard(), + cancel: () => {}, + }); + } else { + this.props.discard(); + } + } + + filterPlugins(plugins) { + return plugins.filter((p) => !DISABLED_PLUGINS.has(p.name)); + } + + async onBeforeLeave() { + if (this.state.canUndo) { + let continueProcess = true; + await new Promise((resolve) => { + this.dialog.add(ConfirmationDialog, { + body: _t("If you proceed, your changes will be lost"), + confirmLabel: _t("Continue"), + confirm: () => resolve(), + cancel: () => { + continueProcess = false; + resolve(); + }, + }); + }); + return continueProcess; + } + return true; + } + + onBeforeUnload(ev) { + if (!this.isSaving && this.state.canUndo) { + ev.preventDefault(); + ev.returnValue = "Unsaved changes"; + } + } + + onFullscreenClick() { + // TODO EGGMAIL + } + + onMobilePreviewClick() { + // TODO EGGMAIL + this.editor.resources["on_mobile_preview_clicked"].forEach((handler) => handler()); + } + + /** + * Called when clicking on a tab. Sets the active tab to the given tab. + * + * @param {String} tab the tab to set + */ + onTabClick(tab) { + this.setTab(tab); + // Deactivate the options when clicking on the "BLOCKS" or "DESIGN" tabs. + if (tab === "design" || tab === "blocks") { + this.editor.shared["builder-options"].deactivateContainers(); + } + } + + redo() { + this.env.editor.shared.history.redo(); + } + + async save() { + this.isSaving = true; + // TODO EGGMAIL: handle the urgent save and the fail of the save operation + const snippetMenuEl = this.builderSidebarRef.el; + // Add a loading effect on the save button and disable the other actions + addLoadingEffect(snippetMenuEl.querySelector("[data-action='save']")); + const actionButtonEls = snippetMenuEl.querySelectorAll("[data-action]"); + for (const actionButtonEl of actionButtonEls) { + actionButtonEl.disabled = true; + } + // TODO EGGMAIL review the whole save process in concordance with a + // standard form view (should concord with formstatusindicator and record + // saving) + await this.env.editor.shared.savePlugin.save(); + await this.props.save(); + this.isSaving = false; + } + + setCSSVariables() { + const el = this.builderSidebarRef.el; + for (const style of EDITOR_COLOR_CSS_VARIABLES) { + let value = getCSSVariableValue(style); + if (value.startsWith("'") && value.endsWith("'")) { + // Gradient values are recovered within a string. + value = value.substring(1, value.length - 1); + } + // TODO EGGMAIL: check what we need to do about this + el.style.setProperty(`--we-cp-${style}`, value); + } + } + + setTab(tab) { + this.state.activeTab = tab; + // Set the fallback tab on the "Design" tab if it was selected. + this.noSelectionTab = tab === "design" ? "design" : "blocks"; + } + + undo() { + this.env.editor.shared.history.undo(); + } +} + +registry.category("lazy_components").add("mass_mailing_egg.Builder", Builder); diff --git a/addons/mass_mailing_egg/static/src/html_builder/builder.xml b/addons/mass_mailing_egg/static/src/html_builder/builder.xml new file mode 100644 index 0000000000000..89daa1e34f567 --- /dev/null +++ b/addons/mass_mailing_egg/static/src/html_builder/builder.xml @@ -0,0 +1,59 @@ + + + + +
+
+
+
+
+ + + + +
+
+
+ + + +
+
+ + + + + + + + + + +
+
+
+ +
diff --git a/addons/mass_mailing_egg/static/src/html_builder/tabs/design_tab.js b/addons/mass_mailing_egg/static/src/html_builder/tabs/design_tab.js new file mode 100644 index 0000000000000..f7c271858c201 --- /dev/null +++ b/addons/mass_mailing_egg/static/src/html_builder/tabs/design_tab.js @@ -0,0 +1,16 @@ +import { Component, useState } from "@odoo/owl"; +import { OptionsContainer } from "./option_container"; +import { useOptionsSubEnv } from "@html_builder/utils/utils"; + +export class DesignTab extends Component { + static template = "mass_mailing_egg.DesignTab"; + static components = { OptionsContainer }; + + setup() { + useOptionsSubEnv(() => [this.env.editor.document.body]); + this.state = useState({ + fontsData: {}, + }); + this.optionsContainers = this.env.editor.resources["design_options"]; + } +} diff --git a/addons/mass_mailing_egg/static/src/html_builder/tabs/design_tab.xml b/addons/mass_mailing_egg/static/src/html_builder/tabs/design_tab.xml new file mode 100644 index 0000000000000..f7f06427ea28b --- /dev/null +++ b/addons/mass_mailing_egg/static/src/html_builder/tabs/design_tab.xml @@ -0,0 +1,21 @@ + + + +
+
+ + + + +
+
+
+ +
diff --git a/addons/mass_mailing_egg/static/src/iframe/mass_mailing_iframe.js b/addons/mass_mailing_egg/static/src/iframe/mass_mailing_iframe.js new file mode 100644 index 0000000000000..34002694e1e6c --- /dev/null +++ b/addons/mass_mailing_egg/static/src/iframe/mass_mailing_iframe.js @@ -0,0 +1,113 @@ +import { parseHTML } from "@html_editor/utils/html"; +import { Component, onWillUpdateProps, useRef } from "@odoo/owl"; +import { LazyComponent, loadBundle } from "@web/core/assets"; +import { Deferred } from "@web/core/utils/concurrency"; +import { uniqueId } from "@web/core/utils/functions"; +import { useService } from "@web/core/utils/hooks"; +import { renderToString } from "@web/core/utils/render"; + +export class MassMailingIframe extends Component { + static template = "mass_mailing_egg.MassMailingIframe"; + static components = { + LazyComponent, + }; + static props = { + overlayRef: { type: Object }, + config: { type: Object }, + discard: { type: Function }, + onChange: { type: Function }, + reloadEditor: { type: Function }, + save: { type: Function }, + templateHTML: { type: String }, + // TODO EGGMAIL: remove templateStyleAssets if templates never need custom style assets + templateStyleAssets: { type: Array, optional: true }, + }; + static defaultProps = { + templateContext: {}, + templateStyleAssets: [], + }; + + setup() { + this.hotkeyService = useService("hotkey"); + this.interactionService = useService("mass_mailing_egg.interactions"); + this.iframeRef = useRef("iframeRef"); + this.setupIframe(); + onWillUpdateProps((nextProps) => { + if (this.props.templateHTML !== nextProps.templateHTML) { + this.setupIframe(); + } + }); + } + + setupIframe() { + this.iframeLoaded = new Deferred(); + this.iframeKey = uniqueId("mass_mailing_iframe_"); + } + + async onIframeLoad() { + const iframeDoc = this.iframeRef.el.contentDocument; + iframeDoc.head.append(this.renderHeadContent()); + iframeDoc.body.append(this.renderBodyContent()); + await this.loadAssetsEditBundle(); + // Set `ready` symbol for tours + this.iframeRef.el.setAttribute("is-ready", "true"); + this.iframeRef.el.contentWindow.addEventListener("beforeUnload", () => { + this.iframeRef.el.removeAttribute("is-ready"); + }); + this.iframeLoaded.resolve(this.iframeRef.el); + } + + async loadAssetsEditBundle() { + await Promise.all([ + loadBundle("mass_mailing_egg.assets_iframe_style", { + targetDoc: this.iframeRef.el.contentDocument, + }), + // TODO EGGMAIL: handle dark mode assets + // TODO EGGMAIL: to remove following 2 assets if interactions are not needed + loadBundle("mass_mailing_egg.assets_iframe_core", { + targetDoc: this.iframeRef.el.contentDocument, + }), + loadBundle("mass_mailing_egg.assets_iframe_edit", { + targetDoc: this.iframeRef.el.contentDocument, + }), + // TODO EGGMAIL: remove if templates never need custom style assets + ...this.props.templateStyleAssets.map((asset) => + loadBundle(asset, { + targetDoc: this.iframeRef.el.contentDocument, + }) + ), + ]); + } + + /** + * Render a template in the realm of the iframe document, to avoid OWL + * component validation errors (an Element created from the parent document + * of an iframe is not an instance of the Element class from the iframe + * document). + * + * @param {String} template + * @param {Object} context + * @returns {DocumentFragment} + */ + renderToIframeRealmFragment(template, context) { + return parseHTML(renderToString(this.iframeRef.el, template, context)); + } + + renderHeadContent() { + return this.renderToIframeRealmFragment("mass_mailing_egg.IframeHead"); + } + + renderBodyContent() { + const fragment = this.renderToIframeRealmFragment("mass_mailing_egg.IframeBody"); + const editable = fragment.querySelector(".note-editable"); + editable.append(parseHTML(this.iframeRef.el.contentDocument, this.props.templateHTML)); + return fragment; + } + + getBuilderProps() { + return { + ...this.props, + iframeLoaded: this.iframeLoaded, + }; + } +} diff --git a/addons/mass_mailing_egg/static/src/iframe/mass_mailing_iframe.xml b/addons/mass_mailing_egg/static/src/iframe/mass_mailing_iframe.xml new file mode 100644 index 0000000000000..420eb29e4c03a --- /dev/null +++ b/addons/mass_mailing_egg/static/src/iframe/mass_mailing_iframe.xml @@ -0,0 +1,21 @@ + + +
+