[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
This commit is contained in:
Sébastien Geelen (sge)
2023-02-24 16:09:45 +01:00
parent 3e5d16967f
commit eae20c47eb
7 changed files with 164 additions and 174 deletions
+2 -1
View File
@@ -45,7 +45,8 @@
required="1" placeholder='e.g. "Welcome email"'/></h1>
<group>
<field name="model_id" placeholder="e.g. Contact" required="1" options="{'no_create': True}"/>
<field name="subject" placeholder='e.g. "Welcome to MyCompany" or "Nice to meet you, {{ object.name }}"'/>
<field name="subject" options="{'dynamic_placeholder': true}"
placeholder='e.g. "Welcome to MyCompany" or "Nice to meet you, {{ object.name }}"'/>
<field name="model" invisible="1"/>
<field name="description"/>
</group>
@@ -174,7 +174,7 @@
<label for="subject">Subject</label>
<div class="o_mass_mailing_subject d-flex flex-row align-items-baseline">
<field class="text-break" name="subject" string="Subject"
options="{'dynamic_placeholder': true}"
options="{'dynamic_placeholder': true, 'dynamic_placeholder_model_reference_field': 'mailing_model_real'}"
attrs="{'readonly': [('state', 'in', ('sending', 'done'))]}"
widget="char_emojis" placeholder="e.g. New Sale on all T-shirts"/>
<field name="favorite" invisible="1"/>
@@ -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'))]}"/>
</div>
@@ -308,7 +309,7 @@
<group>
<group string="Email Content" name="email_content" attrs="{'invisible': [('mailing_type', '!=', 'mail')]}">
<field class="o_text_overflow" name="preview" string="Preview Text"
options="{'dynamic_placeholder': true}"
options="{'dynamic_placeholder': true, 'dynamic_placeholder_model_reference_field': 'mailing_model_real'}"
attrs="{'readonly': [('state', 'in', ('sending', 'done'))]}"
widget="char_emojis" placeholder="e.g. Check it out before it's too late!"/>
<field name="email_from" attrs="{'readonly': [('state', 'in', ('sending', 'done'))]}"/>
@@ -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,
}),
@@ -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,
};
}
@@ -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,
}
);
}
};
}
@@ -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),
}),
};
@@ -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,