[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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user