[FIX] web,web_editor,mail : Fix ModelFieldSelector and DynamicPlaceholder
Add missing feature of the ModelFieldSelector popover : * keyboard navigation * additional debug value * add default value page ( for dynamic placeholder) * add validate callback Migrate dynamic placeholder to be compatible with new OWL field. task-2978722 X-original-commit: 929108e29168058fad5da85e0ff9c66d2c5b2525 Part-of: odoo/odoo#102603
This commit is contained in:
@@ -54,7 +54,7 @@
|
||||
<page string="Content" name="content">
|
||||
<field name="can_write" invisible="1"/>
|
||||
<field name="body_html" widget="html" class="oe-bordered-editor"
|
||||
options="{'style-inline': true, 'codeview': true }"
|
||||
options="{'style-inline': true, 'codeview': true, 'dynamic_placeholder': true}"
|
||||
attrs="{'readonly': [('can_write', '=', False), ('id', '!=', False)]}"/>
|
||||
<field name="attachment_ids" widget="many2many_binary"/>
|
||||
</page>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { HtmlField } from "@web_editor/js/backend/html_field";
|
||||
import { getWysiwygClass } from 'web_editor.loader';
|
||||
import { device } from 'web.config';
|
||||
import { MassMailingMobilePreviewDialog } from "./mass_mailing_mobile_preview";
|
||||
import { getRangePosition } from '@web_editor/js/editor/odoo-editor/src/utils/utils';
|
||||
|
||||
const {
|
||||
useEffect,
|
||||
@@ -79,6 +80,29 @@ export class MassMailingHtmlField extends HtmlField {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} popover
|
||||
* @param {Object} position
|
||||
* @override
|
||||
*/
|
||||
positionDynamicPlaceholder(popover, position) {
|
||||
const editable = this.wysiwyg.$iframe ? this.wysiwyg.$iframe[0] : this.wysiwyg.$editable[0];
|
||||
const relativeParentPosition = editable.getBoundingClientRect();
|
||||
|
||||
let topPosition = relativeParentPosition.top;
|
||||
let leftPosition = relativeParentPosition.left;
|
||||
|
||||
const rangePosition = getRangePosition(popover, this.wysiwyg.options.document);
|
||||
topPosition += rangePosition.top;
|
||||
// Offset the popover to ensure the arrow is pointing at
|
||||
// the precise range location.
|
||||
leftPosition += rangePosition.left - 14;
|
||||
|
||||
// Apply the position back to the element.
|
||||
popover.style.top = topPosition + 'px';
|
||||
popover.style.left = leftPosition + 'px';
|
||||
}
|
||||
|
||||
async commitChanges() {
|
||||
if (this.props.readonly || !this.isRendered) {
|
||||
return super.commitChanges();
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { useModelField } from "./model_field_hook";
|
||||
import { useUniquePopover } from "./unique_popover_hook";
|
||||
import { ModelFieldSelectorPopover } from "./model_field_selector_popover";
|
||||
|
||||
const { Component, onWillStart, onWillUpdateProps } = owl;
|
||||
|
||||
function useUniquePopover() {
|
||||
const popover = usePopover();
|
||||
let remove = null;
|
||||
return Object.assign(Object.create(popover), {
|
||||
add(target, component, props, options) {
|
||||
if (remove) {
|
||||
remove();
|
||||
}
|
||||
remove = popover.add(target, component, props, options);
|
||||
return () => {
|
||||
remove();
|
||||
remove = null;
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export class ModelFieldSelector extends Component {
|
||||
setup() {
|
||||
this.popover = useUniquePopover();
|
||||
|
||||
@@ -12,12 +12,17 @@ export class ModelFieldSelectorPopover extends Component {
|
||||
setup() {
|
||||
this.chain = Array.from(this.props.chain);
|
||||
this.modelField = useModelField();
|
||||
this.unfilteredFields = {};
|
||||
this.fields = {};
|
||||
this.fieldKeys = [];
|
||||
this.currentActiveFieldId = 0;
|
||||
this.searchValue = "";
|
||||
this.defaultValue = "";
|
||||
this.isDefaultValueVisible = false;
|
||||
this.fullFieldName = this.fieldNameChain.join(".");
|
||||
if (!this.env.isSmall) {
|
||||
useAutofocus();
|
||||
useAutofocus({ refName: 'autofocusDefaultValue', selectAll: true });
|
||||
}
|
||||
|
||||
onWillStart(async () => {
|
||||
@@ -25,6 +30,10 @@ export class ModelFieldSelectorPopover extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
get currentActiveField() {
|
||||
return this.fieldKeys[this.currentActiveFieldId];
|
||||
}
|
||||
|
||||
get currentNode() {
|
||||
return this.chain[this.chain.length - 1];
|
||||
}
|
||||
@@ -38,7 +47,15 @@ export class ModelFieldSelectorPopover extends Component {
|
||||
}
|
||||
|
||||
async loadFields() {
|
||||
this.fields = await this.modelField.loadModelFields(this.currentNode.resModel);
|
||||
this.unfilteredFields = await this.modelField.loadModelFields(this.currentNode.resModel);
|
||||
this.fields = {...this.unfilteredFields};
|
||||
this.fieldKeys = this.sortedKeys(this.fields);
|
||||
for (let key of this.fieldKeys) {
|
||||
const field = this.fields[key];
|
||||
if (!field.searchable || !this.props.filter(field)) {
|
||||
delete this.fields[key];
|
||||
}
|
||||
}
|
||||
this.fieldKeys = this.sortedKeys(this.fields);
|
||||
}
|
||||
sortedKeys(obj) {
|
||||
@@ -53,6 +70,47 @@ export class ModelFieldSelectorPopover extends Component {
|
||||
this.render();
|
||||
}
|
||||
|
||||
async onInputKeydown(ev) {
|
||||
switch (ev.key) {
|
||||
case "ArrowUp":
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (this.currentActiveFieldId> 0) {
|
||||
this.currentActiveFieldId--;
|
||||
await this.render();
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (this.currentActiveFieldId < this.fieldKeys.length-1) {
|
||||
this.currentActiveFieldId++;
|
||||
await this.render();
|
||||
}
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.onPreviousBtnClick();
|
||||
break;
|
||||
case "Escape":
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.close();
|
||||
break;
|
||||
case "Enter":
|
||||
case "ArrowRight":
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (this.isDefaultValueVisible) {
|
||||
this.selectDefaultValue(true);
|
||||
} else {
|
||||
const field = { ...this.fields[this.currentActiveField], name: this.currentActiveField }
|
||||
this.onFieldSelected(field);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
onSearch(ev) {
|
||||
this.searchValue = ev.target.value;
|
||||
let fieldKeys = this.sortedKeys(this.fields);
|
||||
@@ -62,6 +120,10 @@ export class ModelFieldSelectorPopover extends Component {
|
||||
this.fieldKeys = fieldKeys;
|
||||
this.render();
|
||||
}
|
||||
onDefaultValue(ev) {
|
||||
this.defaultValue = ev.target.value;
|
||||
this.render();
|
||||
}
|
||||
onPreviousBtnClick() {
|
||||
this.searchValue = "";
|
||||
if (this.currentNode.field === null) {
|
||||
@@ -72,16 +134,31 @@ export class ModelFieldSelectorPopover extends Component {
|
||||
}
|
||||
onFieldSelected(field) {
|
||||
this.searchValue = "";
|
||||
this.currentActiveFieldId = 0;
|
||||
this.currentNode.field = field;
|
||||
if (field.relation && this.props.followRelations) {
|
||||
this.chain.push({
|
||||
resModel: field.relation,
|
||||
field: null,
|
||||
});
|
||||
this.update();
|
||||
} else if(this.props.needDefaultValue) {
|
||||
this.isDefaultValueVisible = true;
|
||||
this.render();
|
||||
this.update();
|
||||
} else {
|
||||
this.update();
|
||||
this.props.close();
|
||||
this.props.validate(this.fieldNameChain, this.defaultValue);
|
||||
}
|
||||
}
|
||||
selectDefaultValue (acceptDefaultValue) {
|
||||
if (!acceptDefaultValue) {
|
||||
this.defaultValue = "";
|
||||
}
|
||||
this.props.close();
|
||||
this.update();
|
||||
this.props.validate(this.fieldNameChain, this.defaultValue);
|
||||
}
|
||||
async onFieldNameChange(ev) {
|
||||
this.fullFieldName = ev.target.value.replace(/\s+/g, "");
|
||||
@@ -98,19 +175,24 @@ export class ModelFieldSelectorPopover extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(ModelFieldSelectorPopover, {
|
||||
template: "web.ModelFieldSelectorPopover",
|
||||
props: {
|
||||
chain: Array,
|
||||
update: Function,
|
||||
showSearchInput: Boolean,
|
||||
isDebugMode: Boolean,
|
||||
loadChain: Function,
|
||||
filter: Function,
|
||||
close: Function,
|
||||
followRelations: { type: Boolean, optional: true },
|
||||
},
|
||||
defaultProps: {
|
||||
followRelations: true,
|
||||
},
|
||||
});
|
||||
ModelFieldSelectorPopover.defaultProps = {
|
||||
validate: () => {},
|
||||
needDefaultValue: false,
|
||||
isDebugMode: false,
|
||||
followRelations: true,
|
||||
};
|
||||
|
||||
ModelFieldSelectorPopover.props = {
|
||||
chain: Array,
|
||||
update: Function,
|
||||
showSearchInput: Boolean,
|
||||
isDebugMode: { type: Boolean, optional: true},
|
||||
loadChain: Function,
|
||||
filter: Function,
|
||||
close: Function,
|
||||
followRelations: { type: Boolean, optional: true },
|
||||
needDefaultValue: { type: Boolean, optional: true},
|
||||
validate: { type: Function, optional: true},
|
||||
};
|
||||
|
||||
ModelFieldSelectorPopover.template = "web.ModelFieldSelectorPopover";
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.ModelFieldSelectorPopover" owl="1">
|
||||
<div class="o_field_selector_popover" tabindex="0">
|
||||
<div class="o_field_selector_popover"
|
||||
t-on-keydown="onInputKeydown" tabindex="0">
|
||||
<div class="o_field_selector_popover_header text-center">
|
||||
<t t-if="chain.length > 1">
|
||||
<i
|
||||
@@ -13,7 +14,12 @@
|
||||
t-on-click="onPreviousBtnClick"
|
||||
/>
|
||||
</t>
|
||||
<div class="o_field_selector_title" t-esc="currentFieldName" />
|
||||
<t t-if="isDefaultValueVisible">
|
||||
<div class="o_field_selector_title">Default value</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="o_field_selector_title" t-out="currentFieldName" />
|
||||
</t>
|
||||
<i
|
||||
class="fa fa-times o_field_selector_popover_option o_field_selector_close"
|
||||
title="Close"
|
||||
@@ -21,20 +27,37 @@
|
||||
aria-label="Close"
|
||||
t-on-click="props.close"
|
||||
/>
|
||||
|
||||
<t t-if="props.showSearchInput">
|
||||
<t t-if="props.showSearchInput && !isDefaultValueVisible">
|
||||
<div class="o_field_selector_search mt-2">
|
||||
<input type="text" placeholder='Search...' class="o_input" t-att-value="searchValue" t-on-input="onSearch" t-ref="autofocus" />
|
||||
<input type="text"
|
||||
placeholder='Search...'
|
||||
class="o_input"
|
||||
t-att-value="searchValue"
|
||||
t-on-input="onSearch"
|
||||
t-ref="autofocus" />
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="isDefaultValueVisible">
|
||||
<div class="o_field_selector_default_value_input mt-2">
|
||||
<input type="text"
|
||||
placeholder='Type a default text or press ENTER'
|
||||
class="o_input"
|
||||
t-att-value="defaultValue"
|
||||
t-on-input="onDefaultValue"
|
||||
t-ref="autofocusDefaultValue"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div class="o_field_selector_popover_body">
|
||||
<ul class="o_field_selector_page">
|
||||
<ul class="o_field_selector_page" t-if="!isDefaultValueVisible">
|
||||
<t t-foreach="fieldKeys" t-as="fieldKey" t-key="fieldKey">
|
||||
<t t-set="field" t-value="fields[fieldKey]" />
|
||||
<t t-if="field.searchable and props.filter(field)">
|
||||
<li class="o_field_selector_item" t-on-click="() => this.onFieldSelected({ ...field, name: fieldKey })">
|
||||
<t t-esc="field.string" />
|
||||
<li
|
||||
t-on-click="() => this.onFieldSelected({ ...field, name: fieldKey })"
|
||||
t-attf-class="o_field_selector_item #{fieldKey === currentActiveField and ' active' or ''}">
|
||||
<t t-out="field.string" />
|
||||
<div t-if="props.isDebugMode" class="text-muted o_field_selector_item_title"><t t-out="fieldKey"/> (<t t-out="field.type"/>)</div>
|
||||
<t t-if="field.relation and props.followRelations">
|
||||
<i
|
||||
class="fa fa-chevron-right o_field_selector_relation_icon"
|
||||
@@ -47,6 +70,16 @@
|
||||
</t>
|
||||
</t>
|
||||
</ul>
|
||||
<ul class="o_field_selector_page" t-if="isDefaultValueVisible">
|
||||
<li class="o_field_selector_item" t-on-click="() => this.selectDefaultValue(defaultValue !== '')">
|
||||
<t t-if="defaultValue === ''">
|
||||
<div class="text-muted o_field_selector_item_title">Default text is used when no values are set</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="text-muted o_field_selector_item_title">As a default text when no value are set</div>
|
||||
</t>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<t t-if="props.isDebugMode">
|
||||
<div class="o_field_selector_popover_footer">
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
|
||||
export function useUniquePopover() {
|
||||
const popover = usePopover();
|
||||
let remove = null;
|
||||
return Object.assign(Object.create(popover), {
|
||||
add(target, component, props, options) {
|
||||
if (remove) {
|
||||
remove();
|
||||
}
|
||||
remove = popover.add(target, component, props, options);
|
||||
return () => {
|
||||
remove();
|
||||
remove = null;
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const { Component } = owl;
|
||||
export class Popover extends Component {
|
||||
setup() {
|
||||
usePosition(this.props.target, {
|
||||
onPositioned: this.onPositioned,
|
||||
onPositioned: this.props.onPositioned || this.onPositioned,
|
||||
position: this.props.position,
|
||||
});
|
||||
}
|
||||
@@ -72,6 +72,10 @@ Popover.props = {
|
||||
validate: (p) => ["top", "bottom", "left", "right"].includes(p),
|
||||
optional: true,
|
||||
},
|
||||
onPositioned: {
|
||||
type: Function,
|
||||
optional: true,
|
||||
},
|
||||
target: HTMLElement,
|
||||
slots: {
|
||||
type: Object,
|
||||
|
||||
@@ -25,6 +25,7 @@ class PopoverController extends Component {
|
||||
target: this.target,
|
||||
position: this.props.position,
|
||||
popoverClass: this.props.popoverClass,
|
||||
onPositioned: this.props.onPositioned,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export const popoverService = {
|
||||
* @param {function(): void} [options.onClose]
|
||||
* @param {string} [options.popoverClass]
|
||||
* @param {string} [options.position]
|
||||
* @param {function} [options.onPositioned]
|
||||
* @returns {function(): void}
|
||||
*/
|
||||
function add(target, Component, props, options = {}) {
|
||||
@@ -39,6 +40,7 @@ export const popoverService = {
|
||||
close: closeFn,
|
||||
onClose: options.onClose,
|
||||
position: options.position,
|
||||
onPositioned: options.onPositioned,
|
||||
popoverClass: options.popoverClass,
|
||||
closeOnClickAway: options.closeOnClickAway,
|
||||
preventClose: options.preventClose,
|
||||
|
||||
@@ -7,12 +7,57 @@ 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";
|
||||
|
||||
const { Component } = owl;
|
||||
const { Component, onMounted, onWillUnmount, useRef } = owl;
|
||||
|
||||
export class CharField extends Component {
|
||||
setup() {
|
||||
if (this.props.dynamicPlaceholder) {
|
||||
this.dynamicPlaceholder = useDynamicPlaceholder();
|
||||
}
|
||||
|
||||
this.input = useRef("input");
|
||||
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.update(this.input.el.value.replace(triggerKeyReplaceRegex, '') + dynamicPlaceholder);
|
||||
}
|
||||
}
|
||||
onDynamicPlaceholderClose() {
|
||||
this.input.el.focus();
|
||||
}
|
||||
|
||||
get formattedValue() {
|
||||
@@ -31,11 +76,13 @@ CharField.template = "web.CharField";
|
||||
CharField.components = {
|
||||
TranslationButton,
|
||||
};
|
||||
CharField.defaultProps = { dynamicPlaceholder: false };
|
||||
CharField.props = {
|
||||
...standardFieldProps,
|
||||
autocomplete: { type: String, optional: true },
|
||||
isPassword: { type: Boolean, optional: true },
|
||||
placeholder: { type: String, optional: true },
|
||||
dynamicPlaceholder: { type: Boolean, optional: true},
|
||||
shouldTrim: { type: Boolean, optional: true },
|
||||
maxLength: { type: Number, optional: true },
|
||||
isTranslatable: { type: Boolean, optional: true },
|
||||
@@ -49,6 +96,7 @@ CharField.extractProps = ({ attrs, field }) => {
|
||||
shouldTrim: field.trim && !archParseBoolean(attrs.password), // passwords shouldn't be trimmed
|
||||
maxLength: field.size,
|
||||
isTranslatable: field.translate,
|
||||
dynamicPlaceholder: attrs.options.dynamic_placeholder,
|
||||
autocomplete: attrs.autocomplete,
|
||||
isPassword: archParseBoolean(attrs.password),
|
||||
placeholder: attrs.placeholder,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/** @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,
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,15 @@ import { _lt } from "@web/core/l10n/translation";
|
||||
import { useInputField } from "../input_field_hook";
|
||||
import { standardFieldProps } from "../standard_field_props";
|
||||
import { TranslationButton } from "../translation_button";
|
||||
import { useDynamicPlaceholder } from "../dynamicplaceholder_hook";
|
||||
|
||||
const { Component, useEffect, useRef } = owl;
|
||||
const { Component, useEffect, onMounted, onWillUnmount, useRef } = owl;
|
||||
|
||||
export class TextField extends Component {
|
||||
setup() {
|
||||
if (this.props.dynamicPlaceholder) {
|
||||
this.dynamicPlaceholder = useDynamicPlaceholder();
|
||||
}
|
||||
this.textareaRef = useRef("textarea");
|
||||
useInputField({ getValue: () => this.props.value || "", refName: "textarea" });
|
||||
|
||||
@@ -18,6 +22,45 @@ 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.update(this.textareaRef.el.value.replace(triggerKeyReplaceRegex, '') + dynamicPlaceholder);
|
||||
}
|
||||
}
|
||||
onDynamicPlaceholderClose() {
|
||||
this.textareaRef.el.focus();
|
||||
}
|
||||
|
||||
get minimumHeight() {
|
||||
@@ -62,10 +105,12 @@ TextField.template = "web.TextField";
|
||||
TextField.components = {
|
||||
TranslationButton,
|
||||
};
|
||||
TextField.defaultProps = {dynamicPlaceholder: false};
|
||||
TextField.props = {
|
||||
...standardFieldProps,
|
||||
isTranslatable: { type: Boolean, optional: true },
|
||||
placeholder: { type: String, optional: true },
|
||||
dynamicPlaceholder: { type: Boolean, optional: true},
|
||||
};
|
||||
|
||||
TextField.displayName = _lt("Multiline Text");
|
||||
@@ -75,6 +120,7 @@ TextField.extractProps = ({ attrs, field }) => {
|
||||
return {
|
||||
isTranslatable: field.translate,
|
||||
placeholder: attrs.placeholder,
|
||||
dynamicPlaceholder: attrs.options.dynamic_placeholder,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -94,14 +94,7 @@ QUnit.module("Components", (hooks) => {
|
||||
`;
|
||||
|
||||
// Create the domain selector and its mock environment
|
||||
await mountComponent(Parent, {
|
||||
props: {
|
||||
resModel: "partner",
|
||||
value: "[]",
|
||||
readonly: false,
|
||||
isDebugMode: true,
|
||||
},
|
||||
});
|
||||
await mountComponent(Parent);
|
||||
|
||||
// As we gave an empty domain, there should be a visible button to add
|
||||
// the first domain part
|
||||
@@ -125,10 +118,11 @@ QUnit.module("Components", (hooks) => {
|
||||
);
|
||||
|
||||
// The field selector popover should contain the list of "partner"
|
||||
// fields. "Bar" should be among them.
|
||||
// fields. "Bar" should be among them. "Bar" result li will display the
|
||||
// name of the field and some debug info.
|
||||
assert.strictEqual(
|
||||
document.body.querySelector(".o_field_selector_popover li").textContent,
|
||||
"Bar",
|
||||
"Barbar (boolean)",
|
||||
"field selector popover should contain the 'Bar' field"
|
||||
);
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ QUnit.module("Components", (hooks) => {
|
||||
readonly="false"
|
||||
resModel="'partner'"
|
||||
fieldName="fieldName"
|
||||
isDebugMode="true"
|
||||
isDebugMode="false"
|
||||
update="(value) => this.onUpdate(value)"
|
||||
/>
|
||||
`;
|
||||
@@ -222,7 +222,7 @@ QUnit.module("Components", (hooks) => {
|
||||
readonly: false,
|
||||
fieldName: "product_id",
|
||||
resModel: "partner",
|
||||
isDebugMode: true,
|
||||
isDebugMode: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,15 +8,19 @@ 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 { QWeb } from 'web.core';
|
||||
import ajax from 'web.ajax';
|
||||
import {
|
||||
useBus,
|
||||
useService,
|
||||
} from "@web/core/utils/hooks";
|
||||
import { getAdjacentPreviousSiblings, getAdjacentNextSiblings } from '@web_editor/js/editor/odoo-editor/src/utils/utils';
|
||||
import {
|
||||
getAdjacentPreviousSiblings,
|
||||
getAdjacentNextSiblings,
|
||||
getRangePosition
|
||||
} from '@web_editor/js/editor/odoo-editor/src/utils/utils';
|
||||
import { toInline } from 'web_editor.convertInline';
|
||||
|
||||
const {
|
||||
markup,
|
||||
Component,
|
||||
@@ -66,6 +70,9 @@ export class HtmlField extends Component {
|
||||
this.iframeRef = useRef("iframe");
|
||||
this.codeViewButtonRef = useRef("codeViewButton");
|
||||
|
||||
if (this.props.dynamicPlaceholder) {
|
||||
this.dynamicPlaceholder = useDynamicPlaceholder();
|
||||
}
|
||||
this.rpc = useService("rpc");
|
||||
|
||||
this.onIframeUpdated = this.env.onIframeUpdated || (() => {});
|
||||
@@ -148,6 +155,7 @@ export class HtmlField extends Component {
|
||||
onAttachmentChange: this._onAttachmentChange.bind(this),
|
||||
onWysiwygBlur: this._onWysiwygBlur.bind(this),
|
||||
...this.props.wysiwygOptions,
|
||||
...dynamicPlaceholderOptions,
|
||||
recordInfo: {
|
||||
res_model: this.props.record.resModel,
|
||||
res_id: this.props.record.resId,
|
||||
@@ -235,6 +243,33 @@ export class HtmlField extends Component {
|
||||
|
||||
}
|
||||
}
|
||||
onDynamicPlaceholderValidate(chain, defaultValue) {
|
||||
if (chain) {
|
||||
let dynamicPlaceholder = "object." + chain.join('.');
|
||||
dynamicPlaceholder += defaultValue && defaultValue !== '' ? ` or '''${defaultValue}'''` : '';
|
||||
const t = document.createElement('T');
|
||||
t.setAttribute('t-out', dynamicPlaceholder);
|
||||
this.wysiwyg.odooEditor.execCommand('insert', t);
|
||||
}
|
||||
}
|
||||
onDynamicPlaceholderClose() {
|
||||
this.wysiwyg.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} popover
|
||||
* @param {Object} position
|
||||
*/
|
||||
positionDynamicPlaceholder(popover, position) {
|
||||
let topPosition = this.wysiwygRangePosition.top;
|
||||
// Offset the popover to ensure the arrow is pointing at
|
||||
// the precise range location.
|
||||
let leftPosition = this.wysiwygRangePosition.left - 14;
|
||||
|
||||
// Apply the position back to the element.
|
||||
popover.style.top = topPosition + 'px';
|
||||
popover.style.left = leftPosition + 'px';
|
||||
}
|
||||
async commitChanges({ urgent } = {}) {
|
||||
if (this._isDirty() || urgent) {
|
||||
if (this.wysiwyg) {
|
||||
@@ -386,6 +421,9 @@ export class HtmlField extends Component {
|
||||
const $editable = this.wysiwyg.getEditable();
|
||||
const html = this.wysiwyg.getValue();
|
||||
const $odooEditor = $editable.closest('.odoo-editor-editable');
|
||||
// Save correct nodes references.
|
||||
const originalContents = document.createDocumentFragment();
|
||||
originalContents.append(...$editable[0].childNodes);
|
||||
// Remove temporarily the class so that css editing will not be converted.
|
||||
$odooEditor.removeClass('odoo-editor-editable');
|
||||
$editable.html(html);
|
||||
@@ -393,7 +431,7 @@ export class HtmlField extends Component {
|
||||
await toInline($editable, this.cssRules, this.wysiwyg.$iframe);
|
||||
$odooEditor.addClass('odoo-editor-editable');
|
||||
|
||||
this.wysiwyg.setValue($editable.html());
|
||||
$editable[0].replaceChildren(...originalContents.childNodes);
|
||||
}
|
||||
async _getWysiwygClass() {
|
||||
return getWysiwygClass();
|
||||
@@ -467,6 +505,7 @@ HtmlField.components = {
|
||||
TranslationButton,
|
||||
HtmlFieldWysiwygAdapterComponent,
|
||||
};
|
||||
HtmlField.defaultProps = {dynamicPlaceholder: false};
|
||||
HtmlField.props = {
|
||||
...standardFieldProps,
|
||||
isTranslatable: { type: Boolean, optional: true },
|
||||
@@ -474,6 +513,7 @@ HtmlField.props = {
|
||||
fieldName: { type: String, optional: true },
|
||||
codeview: { type: Boolean, optional: true },
|
||||
isCollaborative: { type: Boolean, optional: true },
|
||||
dynamicPlaceholder: { type: Boolean, optional: true, default: false },
|
||||
cssReadonlyAssetId: { type: String, optional: true },
|
||||
cssEditAssetId: { type: String, optional: true },
|
||||
isInlineStyle: { type: Boolean, optional: true },
|
||||
@@ -493,6 +533,7 @@ HtmlField.extractProps = ({ attrs, field }) => {
|
||||
|
||||
isCollaborative: attrs.options.collaborative,
|
||||
cssReadonlyAssetId: attrs.options.cssReadonly,
|
||||
dynamicPlaceholder: attrs.options.dynamic_placeholder,
|
||||
cssEditAssetId: attrs.options.cssEdit,
|
||||
isInlineStyle: attrs.options['style-inline'],
|
||||
wrapper: attrs.options.wrapper,
|
||||
|
||||
@@ -977,7 +977,7 @@ const Wysiwyg = Widget.extend({
|
||||
* Set cursor to the editor latest position before blur or to the last editable node, ready to type.
|
||||
*/
|
||||
focus: function () {
|
||||
if (this.odooEditor && !this.odooEditor.historyResetLatestComputedSelection()) {
|
||||
if (this.odooEditor && !this.odooEditor.historyResetLatestComputedSelection(true)) {
|
||||
// If the editor don't have an history step to focus to,
|
||||
// We place the cursor after the end of the editor exiting content.
|
||||
const range = document.createRange();
|
||||
|
||||
Reference in New Issue
Block a user