From eae20c47eb0c1ee83a64b4a2c9ecab4a2a157b1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Geelen=20=28sge=29?= Date: Fri, 6 Jan 2023 13:27:43 +0000 Subject: [PATCH] [REF] web,web_editor,mail : dynamicplaceholder hook Refactor the Dynamic Placeholder hook to have better separation of concern from the Field that need it. task-3081325 Part-of: odoo/odoo#109326 --- addons/mail/views/mail_template_views.xml | 3 +- .../views/mailing_mailing_views.xml | 5 +- .../src/views/fields/char/char_field.js | 58 ++------- .../views/fields/dynamic_placeholder_hook.js | 116 ++++++++++++++++++ .../views/fields/dynamicplaceholder_hook.js | 61 --------- .../src/views/fields/text/text_field.js | 58 ++------- .../static/src/js/backend/html_field.js | 37 +++--- 7 files changed, 164 insertions(+), 174 deletions(-) create mode 100644 addons/web/static/src/views/fields/dynamic_placeholder_hook.js delete mode 100644 addons/web/static/src/views/fields/dynamicplaceholder_hook.js diff --git a/addons/mail/views/mail_template_views.xml b/addons/mail/views/mail_template_views.xml index 0be8580fb0d..357160be16e 100644 --- a/addons/mail/views/mail_template_views.xml +++ b/addons/mail/views/mail_template_views.xml @@ -45,7 +45,8 @@ required="1" placeholder='e.g. "Welcome email"'/> - + diff --git a/addons/mass_mailing/views/mailing_mailing_views.xml b/addons/mass_mailing/views/mailing_mailing_views.xml index 942542bef0b..5318d742c71 100644 --- a/addons/mass_mailing/views/mailing_mailing_views.xml +++ b/addons/mass_mailing/views/mailing_mailing_views.xml @@ -174,7 +174,7 @@
@@ -245,6 +245,7 @@ 'cssEdit': 'mass_mailing.iframe_css_assets_edit', 'inline-field': 'body_html', 'dynamic_placeholder': true, + 'dynamic_placeholder_model_reference_field': 'mailing_model_real', 'cssReadonly': 'mass_mailing.iframe_css_assets_edit' }" attrs="{'readonly': [('state', 'in', ('sending', 'done'))]}"/>
@@ -308,7 +309,7 @@ diff --git a/addons/web/static/src/views/fields/char/char_field.js b/addons/web/static/src/views/fields/char/char_field.js index a7c77a3fd71..3fd63a3fd43 100644 --- a/addons/web/static/src/views/fields/char/char_field.js +++ b/addons/web/static/src/views/fields/char/char_field.js @@ -7,9 +7,9 @@ import { formatChar } from "../formatters"; import { useInputField } from "../input_field_hook"; import { standardFieldProps } from "../standard_field_props"; import { TranslationButton } from "../translation_button"; -import { useDynamicPlaceholder } from "../dynamicplaceholder_hook"; +import { useDynamicPlaceholder } from "../dynamic_placeholder_hook"; -import { Component, onMounted, onWillUnmount, useRef } from "@odoo/owl"; +import { Component, useExternalListener, useRef, useEffect } from "@odoo/owl"; export class CharField extends Component { static template = "web.CharField"; @@ -22,55 +22,20 @@ export class CharField extends Component { isPassword: { type: Boolean, optional: true }, placeholder: { type: String, optional: true }, dynamicPlaceholder: { type: Boolean, optional: true }, + dynamicPlaceholderModelReferenceField: { type: String, optional: true }, }; static defaultProps = { dynamicPlaceholder: false }; setup() { - if (this.props.dynamicPlaceholder) { - this.dynamicPlaceholder = useDynamicPlaceholder(); - } - this.input = useRef("input"); + if (this.props.dynamicPlaceholder) { + const dynamicPlaceholder = useDynamicPlaceholder(this.input); + useExternalListener(document, "keydown", dynamicPlaceholder.onKeydown); + useEffect(() => + dynamicPlaceholder.updateModel(this.props.dynamicPlaceholderModelReferenceField) + ); + } useInputField({ getValue: () => this.props.value || "", parse: (v) => this.parse(v) }); - onMounted(this.onMounted); - onWillUnmount(this.onWillUnmount); - } - async onKeydownListener(ev) { - if (ev.key === this.dynamicPlaceholder.TRIGGER_KEY && ev.target === this.input.el) { - const baseModel = this.props.record.data.mailing_model_real; - if (baseModel) { - await this.dynamicPlaceholder.open(this.input.el, baseModel, { - validateCallback: this.onDynamicPlaceholderValidate.bind(this), - closeCallback: this.onDynamicPlaceholderClose.bind(this), - }); - } - } - } - onMounted() { - if (this.props.dynamicPlaceholder) { - this.keydownListenerCallback = this.onKeydownListener.bind(this); - document.addEventListener("keydown", this.keydownListenerCallback); - } - } - onWillUnmount() { - if (this.props.dynamicPlaceholder) { - document.removeEventListener("keydown", this.keydownListenerCallback); - } - } - onDynamicPlaceholderValidate(chain, defaultValue) { - if (chain) { - const triggerKeyReplaceRegex = new RegExp(`${this.dynamicPlaceholder.TRIGGER_KEY}$`); - let dynamicPlaceholder = "{{object." + chain.join("."); - dynamicPlaceholder += - defaultValue && defaultValue !== "" ? ` or '''${defaultValue}'''}}` : "}}"; - this.props.record.update({ - [this.props.name]: - this.input.el.value.replace(triggerKeyReplaceRegex, "") + dynamicPlaceholder, - }); - } - } - onDynamicPlaceholderClose() { - this.input.el.focus(); } get shouldTrim() { @@ -100,7 +65,8 @@ export const charField = { supportedTypes: ["char"], extractProps: ({ attrs, options }) => ({ isPassword: archParseBoolean(attrs.password), - dynamicPlaceholder: options.dynamic_placeholder, + dynamicPlaceholder: options?.dynamic_placeholder || false, + dynamicPlaceholderModelReferenceField: options?.dynamic_placeholder_model_reference_field || "", autocomplete: attrs.autocomplete, placeholder: attrs.placeholder, }), diff --git a/addons/web/static/src/views/fields/dynamic_placeholder_hook.js b/addons/web/static/src/views/fields/dynamic_placeholder_hook.js new file mode 100644 index 00000000000..fe0b9abb59f --- /dev/null +++ b/addons/web/static/src/views/fields/dynamic_placeholder_hook.js @@ -0,0 +1,116 @@ +/** @odoo-module **/ + +import { useUniquePopover } from "@web/core/model_field_selector/unique_popover_hook"; +import { useModelField } from "@web/core/model_field_selector/model_field_hook"; +import { useService } from "@web/core/utils/hooks"; +import { ModelFieldSelectorPopover } from "@web/core/model_field_selector/model_field_selector_popover"; +import { useComponent } from "@odoo/owl"; + +export function useDynamicPlaceholder(elementRef) { + const TRIGGER_KEY = "#"; + const ownerField = useComponent(); + const triggerKeyReplaceRegex = new RegExp(`${TRIGGER_KEY}$`); + const popover = useUniquePopover(); + const modelField = useModelField(); + const notification = useService("notification"); + + let model = null; + let dynamicPlaceholderChain = []; + + const onDynamicPlaceholderValidate = function (chain, defaultValue) { + const element = elementRef?.el; + if (!element) { + return; + } + let rangeIndex = parseInt(element.getAttribute("data-oe-dynamic-placeholder-range-index")); + // When the user cancel/close the popover, the chain is empty. + if (chain) { + let dynamicPlaceholder = "{{object." + chain.join("."); + dynamicPlaceholder += + defaultValue && defaultValue !== "" ? ` or '''${defaultValue}'''}}` : "}}"; + + const baseValue = element.value; + const splitedValue = [baseValue.slice(0, rangeIndex), baseValue.slice(rangeIndex)]; + const newValue = + splitedValue[0].replace(triggerKeyReplaceRegex, "") + + dynamicPlaceholder + + splitedValue[1]; + const changes = { [ownerField.props.name]: newValue }; + ownerField.props.record.update(changes); + element.value = newValue; + + // -1 to take the removal of the trigger key char into account + rangeIndex += dynamicPlaceholder.length - 1; + element.setSelectionRange(rangeIndex, rangeIndex); + element.removeAttribute("data-oe-dynamic-placeholder-range-index"); + } + }; + const onDynamicPlaceholderClose = function () { + elementRef?.el.focus(); + }; + + /** + * Open a Model Field Selector which can select fields to create a dynamic + * placeholder string in the Input with or without a default text value. + * + * @public + * @param {Object} options + * @param {function} options.validateCallback + * @param {function} options.closeCallback + * @param {function} [options.positionCallback] + */ + async function open(options) { + if (!model) { + return notification.add( + ownerField.env._t( + "You need to select a model before opening the dynamic placeholder selector." + ), + { type: "danger" } + ); + } + + dynamicPlaceholderChain = await modelField.loadChain(model, ""); + popover.add( + elementRef?.el, + ModelFieldSelectorPopover, + { + chain: dynamicPlaceholderChain, + update: (chain) => { dynamicPlaceholderChain = chain; }, + validate: options.validateCallback, + showSearchInput: true, + isDebugMode: true, + needDefaultValue: true, + loadChain: modelField.loadChain, + filter: (model) => !["one2many", "boolean", "many2many"].includes(model.type), + }, + { + closeOnClickAway: true, + onClose: options.closeCallback, + onPositioned: options.positionCallback, + } + ); + } + async function onKeydown(ev) { + const element = elementRef?.el; + if (ev.target === element && ev.key === TRIGGER_KEY) { + const currentRangeIndex = element.selectionStart; + // +1 to take the trigger key char into account + element.setAttribute("data-oe-dynamic-placeholder-range-index", currentRangeIndex + 1); + await open({ + validateCallback: onDynamicPlaceholderValidate, + closeCallback: onDynamicPlaceholderClose, + }); + } + } + function updateModel(model_name_location) { + const recordData = ownerField.props.record.data; + model = recordData[model_name_location] || recordData.model; + } + + return { + updateModel: updateModel, + onKeydown: onKeydown, + setElementRef: (er) => (elementRef = er), + open: open, + }; +} diff --git a/addons/web/static/src/views/fields/dynamicplaceholder_hook.js b/addons/web/static/src/views/fields/dynamicplaceholder_hook.js deleted file mode 100644 index 3546906af6b..00000000000 --- a/addons/web/static/src/views/fields/dynamicplaceholder_hook.js +++ /dev/null @@ -1,61 +0,0 @@ -/** @odoo-module **/ - -import { useUniquePopover } from "@web/core/model_field_selector/unique_popover_hook"; -import { useModelField } from "@web/core/model_field_selector/model_field_hook"; -import { ModelFieldSelectorPopover } from "@web/core/model_field_selector/model_field_selector_popover"; - -export function useDynamicPlaceholder() { - const popover = useUniquePopover(); - const modelField = useModelField(); - - let dynamicPlaceholderChain = []; - - function update(chain) { - dynamicPlaceholderChain = chain; - } - - return { - TRIGGER_KEY: '#', - /** - * Open a Model Field Selector which can select fields to create a dynamic - * placeholder string in the Input with or without a default text value. - * - * @public - * @param {HTMLElement} element - * @param {String} baseModel - * @param {Object} options - * @param {function} options.validateCallback - * @param {function} options.closeCallback - * @param {function} [options.positionCallback] - */ - async open( - element, - baseModel, - options = {} - ) { - dynamicPlaceholderChain = await modelField.loadChain(baseModel, ""); - - popover.add( - element, - ModelFieldSelectorPopover, - { - chain: dynamicPlaceholderChain, - update: update, - validate: options.validateCallback, - showSearchInput: true, - isDebugMode: true, - needDefaultValue: true, - loadChain: modelField.loadChain, - filter: (model) => !["one2many", "boolean", "many2many"].includes(model.type), - }, - { - closeOnClickAway: true, - onClose: options.closeCallback, - onPositioned: options.positionCallback, - } - ); - } - }; -} - - diff --git a/addons/web/static/src/views/fields/text/text_field.js b/addons/web/static/src/views/fields/text/text_field.js index 3290c464d86..21cbf5da34f 100644 --- a/addons/web/static/src/views/fields/text/text_field.js +++ b/addons/web/static/src/views/fields/text/text_field.js @@ -3,13 +3,13 @@ import { _lt } from "@web/core/l10n/translation"; import { registry } from "@web/core/registry"; import { useSpellCheck } from "@web/core/utils/hooks"; -import { useDynamicPlaceholder } from "../dynamicplaceholder_hook"; +import { useDynamicPlaceholder } from "../dynamic_placeholder_hook"; import { useInputField } from "../input_field_hook"; import { parseInteger } from "../parsers"; import { standardFieldProps } from "../standard_field_props"; import { TranslationButton } from "../translation_button"; -import { Component, onMounted, onWillUnmount, useEffect, useRef } from "@odoo/owl"; +import { Component, useExternalListener, useEffect, useRef } from "@odoo/owl"; export class TextField extends Component { static template = "web.TextField"; @@ -20,6 +20,7 @@ export class TextField extends Component { ...standardFieldProps, placeholder: { type: String, optional: true }, dynamicPlaceholder: { type: Boolean, optional: true }, + dynamicPlaceholderModelReferenceField: { type: String, optional: true }, rowCount: { type: Number, optional: true }, }; static defaultProps = { @@ -28,11 +29,15 @@ export class TextField extends Component { }; setup() { - if (this.props.dynamicPlaceholder) { - this.dynamicPlaceholder = useDynamicPlaceholder(); - } this.divRef = useRef("div"); this.textareaRef = useRef("textarea"); + if (this.props.dynamicPlaceholder) { + const dynamicPlaceholder = useDynamicPlaceholder(this.textareaRef); + useExternalListener(document, "keydown", dynamicPlaceholder.onKeydown); + useEffect(() => + dynamicPlaceholder.updateModel(this.props.dynamicPlaceholderModelReferenceField) + ); + } useInputField({ getValue: () => this.props.value || "", refName: "textarea" }); useSpellCheck({ refName: "textarea" }); @@ -41,46 +46,6 @@ export class TextField extends Component { this.resize(); } }); - onMounted(this.onMounted); - onWillUnmount(this.onWillUnmount); - } - async onKeydownListener(ev) { - if (ev.key === this.dynamicPlaceholder.TRIGGER_KEY && ev.target === this.textareaRef.el) { - const baseModel = this.props.record.data.mailing_model_real; - if (baseModel) { - await this.dynamicPlaceholder.open(this.textareaRef.el, baseModel, { - validateCallback: this.onDynamicPlaceholderValidate.bind(this), - closeCallback: this.onDynamicPlaceholderClose.bind(this), - }); - } - } - } - onMounted() { - if (this.props.dynamicPlaceholder) { - this.keydownListenerCallback = this.onKeydownListener.bind(this); - document.addEventListener("keydown", this.keydownListenerCallback); - } - } - onWillUnmount() { - if (this.props.dynamicPlaceholder) { - document.removeEventListener("keydown", this.keydownListenerCallback); - } - } - onDynamicPlaceholderValidate(chain, defaultValue) { - if (chain) { - const triggerKeyReplaceRegex = new RegExp(`${this.dynamicPlaceholder.TRIGGER_KEY}$`); - let dynamicPlaceholder = "{{object." + chain.join("."); - dynamicPlaceholder += - defaultValue && defaultValue !== "" ? ` or '''${defaultValue}'''}}` : "}}"; - this.props.record.update({ - [this.props.name]: - this.textareaRef.el.value.replace(triggerKeyReplaceRegex, "") + - dynamicPlaceholder, - }); - } - } - onDynamicPlaceholderClose() { - this.textareaRef.el.focus(); } get isTranslatable() { @@ -131,7 +96,8 @@ export const textField = { supportedTypes: ["html", "text"], extractProps: ({ attrs, options }) => ({ placeholder: attrs.placeholder, - dynamicPlaceholder: options.dynamic_placeholder, + dynamicPlaceholder: options?.dynamic_placeholder || false, + dynamicPlaceholderModelReferenceField: options?.dynamic_placeholder_model_reference_field || "", rowCount: attrs.rows && parseInteger(attrs.rows), }), }; diff --git a/addons/web_editor/static/src/js/backend/html_field.js b/addons/web_editor/static/src/js/backend/html_field.js index 57aabdac90f..5df4741eb97 100644 --- a/addons/web_editor/static/src/js/backend/html_field.js +++ b/addons/web_editor/static/src/js/backend/html_field.js @@ -8,7 +8,7 @@ import { standardFieldProps } from "@web/views/fields/standard_field_props"; import { getWysiwygClass } from 'web_editor.loader'; import { QWebPlugin } from '@web_editor/js/backend/QWebPlugin'; import { TranslationButton } from "@web/views/fields/translation_button"; -import { useDynamicPlaceholder } from "@web/views/fields/dynamicplaceholder_hook"; +import { useDynamicPlaceholder } from "@web/views/fields/dynamic_placeholder_hook"; import { QWeb } from 'web.core'; import ajax from 'web.ajax'; import { @@ -157,6 +157,9 @@ export class HtmlField extends Component { } })(); }); + onMounted(() => { + this.dynamicPlaceholder?.setElementRef(this.wysiwyg); + }); onWillUnmount(() => { if (this._qwebPlugin) { this._qwebPlugin.destroy(); @@ -191,22 +194,18 @@ export class HtmlField extends Component { fontawesome: 'fa-magic', callback: () => { this.wysiwygRangePosition = getRangePosition(document.createElement('x'), this.wysiwyg.options.document || document); - const baseModel = this.props.record.data.mailing_model_real || this.props.record.data.model; - if (baseModel) { - // The method openDynamicPlaceholder need to be triggered - // after the focus from powerBox prevalidate. - setTimeout(async () => { - await this.dynamicPlaceholder.open( - this.wysiwyg.$editable[0], - baseModel, - { - validateCallback: this.onDynamicPlaceholderValidate.bind(this), - closeCallback: this.onDynamicPlaceholderClose.bind(this), - positionCallback: this.positionDynamicPlaceholder.bind(this), - } - ); - }); - } + this.dynamicPlaceholder.updateModel(this.props.dynamicPlaceholderModelReferenceField); + // The method openDynamicPlaceholder need to be triggered + // after the focus from powerBox prevalidate. + setTimeout(async () => { + await this.dynamicPlaceholder.open( + { + validateCallback: this.onDynamicPlaceholderValidate.bind(this), + closeCallback: this.onDynamicPlaceholderClose.bind(this), + positionCallback: this.positionDynamicPlaceholder.bind(this), + } + ); + }); }, } ], @@ -621,6 +620,7 @@ HtmlField.props = { codeview: { type: Boolean, optional: true }, isCollaborative: { type: Boolean, optional: true }, dynamicPlaceholder: { type: Boolean, optional: true, default: false }, + dynamicPlaceholderModelReferenceField: { type: String, optional: true }, cssReadonlyAssetId: { type: String, optional: true }, cssEditAssetId: { type: String, optional: true }, isInlineStyle: { type: Boolean, optional: true }, @@ -671,7 +671,8 @@ export const htmlField = { isCollaborative: options.collaborative, cssReadonlyAssetId: options.cssReadonly, - dynamicPlaceholder: options.dynamic_placeholder, + dynamicPlaceholder: options?.dynamic_placeholder || false, + dynamicPlaceholderModelReferenceField: options?.dynamic_placeholder_model_reference_field || "", cssEditAssetId: options.cssEdit, isInlineStyle: options['style-inline'], wrapper: options.wrapper,