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,