[IMP] *: ModelFieldSelector improvements

We refactor the ModelFieldSelector and ModelFieldSelectorPopover
components.

We also improve a bit ModelFieldSelectorPopover:
    - on first page, the button to go back is no longer available
      (so that it is now more difficult to produce an invalid path)
    - we always start with a page presenting the model where the last
      selected field name belongs to
    - the keyboard navigation is improved
    - click on model field selector opens the popover with the focus in
      the search input (if any)
    - for relational fields in popover: the user can either click on the
      relational field (and select it) or a special button that make him
      follow the relation to the field comodel

We also refactor the hook useDynamicPlaceholder to make it use a new
component DynamicPlaceholderPopover that uses ModelFieldSelectorPopover.

Task ID: 3272798

closes odoo/odoo#117951

Related: odoo/enterprise#39673
Signed-off-by: Michaël Mattiello <mcm@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Michaël Mattiello <mcm@odoo.com>
This commit is contained in:
Mathieu Duckerts-Antoine
2023-04-19 21:58:34 +02:00
co-authored by Michaël Mattiello
parent 6e4862d2ba
commit ebf646b44f
20 changed files with 1094 additions and 769 deletions
@@ -91,22 +91,22 @@ registry.category("web_tour.tours").add("mail/static/tests/tours/dynamic_placeho
},
{
content: "Check if the dynamic placeholder popover is opened",
trigger: "div.o_field_selector_popover",
trigger: "div.o_model_field_selector_popover",
run: function () {},
},
{
content: "Click on the first entry of the dynamic placeholder",
trigger: "div.o_field_selector_popover li:first-child",
trigger: "div.o_model_field_selector_popover li:first-child",
},
{
content: "Enter a default value",
trigger:
'div.o_field_selector_popover .o_field_selector_default_value_input input[type="text"]',
'div.o_model_field_selector_popover .o_model_field_selector_default_value_input input[type="text"]',
run: "text defValue",
},
{
content: "Click on the the dynamic placeholder default value",
trigger: "div.o_field_selector_popover li:first-child",
trigger: "div.o_model_field_selector_popover li:first-child",
},
{
content: "Wait for the popover to disappear",
@@ -13,7 +13,10 @@ import {
} from "@web/core/domain_selector/domain_selector_fields";
import { BranchDomainNode } from "@web/core/domain_selector/domain_selector_nodes";
import { findOperator } from "@web/core/domain_selector/domain_selector_operators";
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
import {
loadFieldInfo,
ModelFieldSelector,
} from "@web/core/model_field_selector/model_field_selector";
import { useService } from "@web/core/utils/hooks";
export class DomainSelector extends Component {
@@ -113,16 +116,8 @@ export class DomainSelector extends Component {
if ("01".includes(field.toString())) {
return { type: "integer" };
}
if (typeof field !== "string" || !field) {
return null;
}
const { isInvalid, names, modelsInfo } = await this.fieldService.loadPath(resModel, field);
if (isInvalid) {
return null;
}
const name = names.at(-1);
const { fieldDefs } = modelsInfo.at(-1);
return fieldDefs[name];
const { fieldDef } = await loadFieldInfo(this.fieldService, resModel, field);
return fieldDef;
}
createNewLeaf() {
@@ -154,13 +149,20 @@ export class DomainSelector extends Component {
this.notifyChanges();
}
resetDomain() {
this.notifyChanges();
}
updateBranchOperator(node, operator) {
node.operator = operator;
this.notifyChanges();
}
async updateField(node, field) {
const fieldDef = await this.loadFieldDef(this.props.resModel, field);
updateField(node, field, { fieldDef }) {
if (!fieldDef) {
field = "";
fieldDef = { type: "integer" };
}
node.field = { ...fieldDef, name: field };
node.operator = getOperatorsInfo(fieldDef.type)[0];
node.value = getDefaultFieldValue(fieldDef);
@@ -98,11 +98,11 @@
border: 1px solid darken($o-brand-lightsecondary, 10%);
padding: 2px 4px;
> .o_field_selector {
> .o_model_field_selector {
float: left;
margin-right: 4px;
> .o_field_selector_value > .o_field_selector_chain_part {
> .o_model_field_selector_value > .o_model_field_selector_chain_part {
border: none;
font-weight: 700;
}
@@ -124,11 +124,11 @@
width: auto;
background-color: white;
&.o_field_selector {
&.o_model_field_selector {
flex: 0 1 40%;
width: 40%;
> .o_field_selector_value > .o_field_selector_chain_part {
> .o_model_field_selector_value > .o_model_field_selector_chain_part {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -3,8 +3,8 @@
<t t-name="web._DomainSelector" owl="1">
<div class="o_domain_node o_domain_tree o_domain_selector" aria-atomic="true" t-att-class="className" t-ref="root">
<t t-set="node" t-value="tree.root" />
<t t-if="tree.isSupported">
<t t-set="node" t-value="tree.root" />
<t t-if="node.children.length === 0">
<span>Match <strong>all records</strong></span>
<t t-if="!props.readonly">
@@ -27,7 +27,7 @@
<t t-else="">
This domain is not supported.
<t t-if="!props.readonly">
<button class="btn btn-sm btn-primary o_domain_add_first_node_button ms-1" t-on-click="() => this.insertRootLeaf(node)">Reset domain</button>
<button class="btn btn-sm btn-primary o_reset_domain_button ms-1" t-on-click="() => this.resetDomain()">Reset domain</button>
</t>
</t>
<t t-if="props.isDebugMode and (!tree.isSupported or !props.readonly)">
@@ -138,7 +138,7 @@
</t>
<t t-name="web._DomainSelector.leaf:readonly" owl="1">
<ModelFieldSelector fieldName="node.field.name" resModel="props.resModel" readonly="true" />
<ModelFieldSelector path="node.field.name" resModel="props.resModel" readonly="true" />
<t t-if="typeof node.value === 'boolean'">
is <t t-if="node.value ? ['is_not', 'not_equal'].includes(node.operator.key) : ['is', 'equal'].includes(node.operator.key)"> not </t> set
</t>
@@ -156,8 +156,7 @@
</t>
<t t-name="web._DomainSelector.leaf:editable" owl="1">
<ModelFieldSelector fieldName="node.field.name" resModel="props.resModel" readonly="false" update="(field) => this.updateField(node, field)" isDebugMode="props.isDebugMode" />
<ModelFieldSelector path="node.field.name" resModel="props.resModel" readonly="false" update="(path, fieldInfo) => this.updateField(node, path, fieldInfo)" isDebugMode="props.isDebugMode" />
<div>
<select class="o_domain_leaf_operator_select o_input text-truncate pe-3" t-on-change="(ev) => this.updateLeafOperator(node, ev.target.value)">
<t t-foreach="getOperatorsInfo(node)" t-as="operator" t-key="operator.key">
@@ -165,7 +164,6 @@
</t>
</select>
</div>
<t t-if="node.operator.valueMode !== 'none'">
<div class="o_ds_value_cell">
<t t-set="editor" t-value="getEditorInfo(node)" />
@@ -1,38 +0,0 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { zipWith } from "@web/core/utils/arrays";
export function useModelField() {
const fieldService = useService("field");
const loadModelFields = (resModel) => {
return fieldService.loadFields(resModel);
};
const loadChain = async (resModel, path) => {
if ("01".includes(path.toString())) {
return [{ resModel, field: null }];
}
if (typeof path !== "string" || !path) {
return [{ resModel, field: null }];
}
const { isInvalid, names, modelsInfo } = await fieldService.loadPath(resModel, path);
if (isInvalid) {
return [{ resModel, field: null }];
}
const chain = zipWith(names, modelsInfo, (name, { resModel, fieldDefs }) => {
return { resModel, field: fieldDefs[name] };
});
const lastField = chain.at(-1)?.field;
if (lastField.relation) {
chain.push({ resModel: lastField.relation, field: null });
}
return chain;
};
return {
loadModelFields,
loadChain,
};
}
@@ -1,99 +1,118 @@
/** @odoo-module **/
import { useModelField } from "./model_field_hook";
import { usePopover } from "@web/core/popover/popover_hook";
import { Component, onWillStart, onWillUpdateProps, useState } from "@odoo/owl";
import { KeepLast } from "@web/core/utils/concurrency";
import { ModelFieldSelectorPopover } from "./model_field_selector_popover";
import { usePopover } from "@web/core/popover/popover_hook";
import { useService } from "@web/core/utils/hooks";
import { Component, onWillStart, onWillUpdateProps, toRaw, useState } from "@odoo/owl";
export class ModelFieldSelector extends Component {
setup() {
this.popover = usePopover(this.constructor.components.Popover, {
popoverClass: "o_popover_field_selector",
onClose: () => {
if (this.state.isDirty) {
this.props.update(this.state.fieldName, toRaw(this.state.chain));
}
},
});
this.modelField = useModelField();
this.state = useState({
chain: [],
fieldName: this.props.fieldName,
isDirty: false,
});
onWillStart(async () => {
this.state.chain = await this.loadChain(this.props.resModel, this.props.fieldName);
});
onWillUpdateProps(async (nextProps) => {
this.state.chain = await this.loadChain(nextProps.resModel, nextProps.fieldName);
this.state.fieldName = nextProps.fieldName;
this.state.isDirty = false;
});
export async function loadFieldInfo(fieldService, resModel, path) {
if (typeof path !== "string" || !path) {
return { resModel, fieldDef: null };
}
get fieldNameChain() {
return this.getFieldNameChain(this.state.fieldName ?? this.props.fieldName);
}
getFieldNameChain(fieldName) {
return fieldName.length ? fieldName.split(".") : [];
}
async loadChain(resModel, fieldName) {
if ("01".includes(fieldName)) {
return [{ resModel, field: { string: fieldName } }];
}
return this.modelField.loadChain(resModel, fieldName);
}
async update(fieldName, isFieldSelected) {
this.state.fieldName = fieldName;
this.state.isDirty = !isFieldSelected;
this.state.chain = await this.loadChain(this.props.resModel, fieldName);
if (isFieldSelected) {
await this.props.update(fieldName, toRaw(this.state.chain));
}
}
onFieldSelectorClick(ev) {
if (this.props.readonly) {
return;
}
this.popover.open(ev.currentTarget, {
chain: this.state.chain,
update: this.update.bind(this),
showSearchInput: this.props.showSearchInput,
isDebugMode: this.props.isDebugMode,
loadChain: this.loadChain.bind(this),
filter: this.props.filter,
followRelations: this.props.followRelations,
});
const { isInvalid, names, modelsInfo } = await fieldService.loadPath(resModel, path);
if (isInvalid) {
return { resModel, fieldDef: null };
}
const name = names.at(-1);
const modelInfo = modelsInfo.at(-1);
return { resModel: modelInfo.resModel, fieldDef: modelInfo.fieldDefs[name] };
}
Object.assign(ModelFieldSelector, {
template: "web._ModelFieldSelector",
components: {
function makeString(value) {
return String(value ?? "-");
}
export class ModelFieldSelector extends Component {
static template = "web._ModelFieldSelector";
static components = {
Popover: ModelFieldSelectorPopover,
},
props: {
fieldName: String,
};
static props = {
resModel: String,
path: { optional: true },
readonly: { type: Boolean, optional: true },
showSearchInput: { type: Boolean, optional: true },
isDebugMode: { type: Boolean, optional: true },
update: { type: Function, optional: true },
filter: { type: Function, optional: true },
followRelations: { type: Boolean, optional: true },
},
defaultProps: {
};
static defaultProps = {
readonly: true,
isDebugMode: false,
showSearchInput: true,
update: () => {},
filter: () => true,
followRelations: true,
},
});
};
setup() {
this.popover = usePopover(this.constructor.components.Popover, {
popoverClass: "o_popover_field_selector",
onClose: async () => {
if (this.newPath) {
const fieldInfo = await loadFieldInfo(
this.fieldService,
this.props.resModel,
this.newPath
);
this.props.update(this.newPath, fieldInfo);
}
},
});
this.keepLast = new KeepLast();
this.fieldService = useService("field");
this.state = useState({
isInvalid: false,
displayNames: [],
});
onWillStart(() => this.updatePath(this.props.resModel, this.props.path));
onWillUpdateProps((nextProps) => this.updatePath(nextProps.resModel, nextProps.path));
}
async updatePath(resModel, path, isConcurrent) {
let prom = this.loadPath(resModel, path);
if (isConcurrent) {
prom = this.keepLast.add(prom);
}
const state = await prom;
Object.assign(this.state, state);
}
async loadPath(resModel, path) {
// the model should be checked maybe
if ([0, 1].includes(path)) {
return { isInvalid: false, displayNames: [makeString(path)] };
}
if (typeof path !== "string" || !path) {
return { isInvalid: true, displayNames: [makeString()] };
}
const { isInvalid, modelsInfo, names } = await this.fieldService.loadPath(resModel, path);
const result = { isInvalid: !!isInvalid, displayNames: [] };
for (let index = 0; index < names.length; index++) {
const name = names[index];
const fieldDef = modelsInfo[index]?.fieldDefs[name];
result.displayNames.push(fieldDef?.string || makeString(name));
}
return result;
}
openPopover(currentTarget) {
if (this.props.readonly) {
return;
}
this.newPath = null;
this.popover.open(currentTarget, {
resModel: this.props.resModel,
path: this.props.path,
update: (path) => {
this.newPath = path;
this.updatePath(this.props.resModel, path, true);
},
showSearchInput: this.props.showSearchInput,
isDebugMode: this.props.isDebugMode,
filter: this.props.filter,
followRelations: this.props.followRelations,
});
}
}
@@ -1,42 +1,22 @@
.o_field_selector:not(.o_legacy_field_selector) {
.o_model_field_selector {
position: relative;
> .o_field_selector_value {
display: flex;
flex-flow: row wrap;
align-items: center;
height: 100%;
&.o_edit_mode {
cursor: pointer;
}
> .o_model_field_selector_value {
min-height: 20px; // needed when there is no value in it and used standalone
&:active, &:focus, &:active:focus {
outline: none;
}
> .o_field_selector_chain_part {
padding: 0px 1px;
> .o_model_field_selector_chain_part {
cursor: inherit;
border: 1px solid darken($o-brand-lightsecondary, 10%);
background: $o-brand-lightsecondary;
margin-bottom: 1px;
}
> i {
align-self: center;
margin: 0 2px;
font-size: 10px;
}
}
> .o_field_selector_controls {
@include o-position-absolute(0, 0, 1px);
display: flex;
align-items: center;
cursor: pointer;
}
&.o_edit_mode {
> .o_field_selector_controls::after {
@include o-caret-down;
}
> .o_field_selector_popover {
@include o-position-absolute($top: 100%, $left: 0);
}
}
}
@@ -2,29 +2,20 @@
<templates xml:space="preserve">
<t t-name="web._ModelFieldSelector" owl="1">
<div
class="o_field_selector"
aria-atomic="true"
t-att-class="props.readonly ? 'o_read_mode' : 'o_edit_mode o_input'"
t-on-click="onFieldSelectorClick"
>
<div class="o_field_selector_value" tabindex="0">
<t t-foreach="state.chain" t-as="node" t-key="node_index">
<t t-if="node.field">
<t t-if="!node_first">
<i class="fa fa-chevron-right" role="img" aria-label="Followed by" title="Followed-by" />
</t>
<span class="o_field_selector_chain_part">
<t t-esc="node.field.string" />
</span>
<div class="o_model_field_selector flex d-flex" aria-atomic="true" t-att-class="props.readonly ? 'o_read_mode' : 'o_edit_mode o_input'" t-on-click="(ev) => this.openPopover(ev.currentTarget)">
<div class="o_model_field_selector_value flex-grow-1 h-100" tabindex="0" t-att-data-tooltip="state.displayNames.join(' > ')" data-tooltip-position="top">
<t t-foreach="state.displayNames" t-as="displayName" t-key="displayName_index">
<t t-if="!displayName_first">
<i class="fa fa-chevron-right m-1" role="img" aria-label="Followed by" title="Followed by" />
</t>
<span class="o_model_field_selector_chain_part p-1 mb-1">
<t t-esc="displayName" />
</span>
</t>
</div>
<t t-if="!fieldNameChain.length">
<div class="o_field_selector_controls" tabindex="0">
<i role="alert" class="fa fa-exclamation-triangle o_field_selector_warning" title="Invalid field chain" aria-label="Invalid field chain"/>
</div>
</t>
<div t-if="!props.readonly and state.isInvalid" class="o_model_field_selector_controls ms-2" tabindex="0">
<i class="fa fa-exclamation-triangle text-warning o_model_field_selector_warning" role="alert" aria-label="Invalid field chain" title="Invalid field chain"/>
</div>
</div>
</t>
@@ -1,202 +1,274 @@
/** @odoo-module **/
import { sortBy } from "../utils/arrays";
import { useModelField } from "./model_field_hook";
import { Component, onWillStart, useEffect, useRef, useState } from "@odoo/owl";
import { debounce } from "@web/core/utils/timing";
import { fuzzyLookup } from "@web/core/utils/search";
import { useAutofocus } from "../utils/hooks";
import { KeepLast } from "@web/core/utils/concurrency";
import { sortBy } from "@web/core/utils/arrays";
import { useService } from "@web/core/utils/hooks";
import { Component, onWillStart } from "@odoo/owl";
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 () => {
await this.loadFields();
});
class Page {
constructor(resModel, fieldDefs, previousPage = null, selectedName = null) {
this.resModel = resModel;
this.fieldDefs = fieldDefs;
this.previousPage = previousPage;
this.selectedName = selectedName;
this.sortedFieldNames = sortBy(Object.keys(fieldDefs), (key) => fieldDefs[key].string);
this.fieldNames = this.sortedFieldNames;
this.query = "";
this.focusedFieldName = null;
this.resetFocusedFieldName();
}
get currentActiveField() {
return this.fieldKeys[this.currentActiveFieldId];
}
get currentNode() {
return this.chain[this.chain.length - 1];
}
get currentFieldName() {
const nodes = this.chain.filter((node) => node.field);
return nodes.length ? nodes[nodes.length - 1].field.string : "";
}
get fieldNameChain() {
return this.chain.filter((node) => node.field).map((node) => node.field.name);
}
async loadFields() {
this.unfilteredFields = await this.modelField.loadModelFields(this.currentNode.resModel);
this.fields = { ...this.unfilteredFields };
this.fieldKeys = this.sortedKeys(this.fields);
for (const key of this.fieldKeys) {
const field = this.fields[key];
if (!field.searchable || !this.props.filter(field)) {
delete this.fields[key];
get path() {
const previousPath = this.previousPage?.path || "";
if (this.selectedName) {
if (previousPath) {
return `${previousPath}.${this.selectedName}`;
} else {
return this.selectedName;
}
}
this.fieldKeys = this.sortedKeys(this.fields);
return previousPath;
}
sortedKeys(obj) {
const keys = Object.keys(obj);
return sortBy(keys, (key) => obj[key].string);
get selectedField() {
return this.fieldDefs[this.selectedName];
}
async update(isSelected) {
const fieldNameChain = this.fieldNameChain.join(".");
this.fullFieldName = fieldNameChain;
await this.loadFields();
await this.props.update(fieldNameChain, isSelected);
if (isSelected) {
this.props.close();
get title() {
const prefix = this.previousPage?.previousPage ? "... > " : "";
const title = this.previousPage?.selectedField.string || "";
return `${prefix}${title}`;
}
focus(direction) {
if (!this.fieldNames.length) {
return;
}
const index = this.fieldNames.indexOf(this.focusedFieldName);
if (direction === "previous") {
if (index === 0) {
this.focusedFieldName = this.fieldNames[this.fieldNames.length - 1];
} else {
this.focusedFieldName = this.fieldNames[index - 1];
}
} else {
this.render();
if (index === this.fieldNames.length - 1) {
this.focusedFieldName = this.fieldNames[0];
} else {
this.focusedFieldName = this.fieldNames[index + 1];
}
}
}
async onInputKeydown(ev) {
resetFocusedFieldName() {
if (this.selectedName && this.fieldNames.includes(this.selectedName)) {
this.focusedFieldName = this.selectedName;
} else {
this.focusedFieldName = this.fieldNames.length ? this.fieldNames[0] : null;
}
}
searchFields(query = "") {
this.query = query;
this.fieldNames = this.sortedFieldNames;
if (query) {
this.fieldNames = fuzzyLookup(
query,
this.fieldNames,
(key) => this.fieldDefs[key].string
);
}
this.resetFocusedFieldName();
}
}
export class ModelFieldSelectorPopover extends Component {
static template = "web.ModelFieldSelectorPopover";
static props = {
close: Function,
filter: { type: Function, optional: true },
followRelations: { type: Boolean, optional: true },
isDebugMode: { type: Boolean, optional: true },
path: String,
resModel: String,
showSearchInput: { type: Boolean, optional: true },
update: Function,
};
static defaultProps = {
filter: (fieldDef) => fieldDef.searchable,
isDebugMode: false,
followRelations: true,
};
setup() {
this.fieldService = useService("field");
this.state = useState({ page: null });
this.keepLast = new KeepLast();
this.debouncedSearchFields = debounce(this.searchFields.bind(this), 250);
onWillStart(async () => {
this.state.page = await this.loadPages(this.props.resModel, this.props.path);
});
const rootRef = useRef("root");
useEffect(() => {
const focusedElement = rootRef.el.querySelector(
".o_model_field_selector_popover_item.active"
);
if (focusedElement) {
// current page can be empty (e.g. after a search)
focusedElement.scrollIntoView({ block: "center" });
}
});
useEffect(
() => {
if (this.props.showSearchInput) {
const searchInput = rootRef.el.querySelector(
".o_model_field_selector_popover_search .o_input"
);
searchInput.focus();
}
},
() => [this.state.page]
);
}
filter(fieldDefs, path) {
const filteredKeys = Object.keys(fieldDefs).filter((k) =>
this.props.filter(fieldDefs[k], path)
);
return Object.fromEntries(filteredKeys.map((k) => [k, fieldDefs[k]]));
}
async followRelation(fieldDef) {
const { modelsInfo } = await this.keepLast.add(
this.fieldService.loadPath(this.state.page.resModel, `${fieldDef.name}.*`)
);
this.state.page.selectedName = fieldDef.name;
const { resModel, fieldDefs } = modelsInfo.at(-1);
this.openPage(
new Page(resModel, this.filter(fieldDefs, this.state.page.path), this.state.page)
);
}
goToPreviousPage() {
this.keepLast.add(Promise.resolve());
this.openPage(this.state.page.previousPage);
}
async loadNewPath(path) {
const newPage = await this.keepLast.add(this.loadPages(this.props.resModel, path));
this.openPage(newPage);
}
async loadPages(resModel, path) {
if (typeof path !== "string" || !path.length) {
const fieldDefs = await this.fieldService.loadFields(resModel);
return new Page(resModel, this.filter(fieldDefs, path));
}
const { isInvalid, modelsInfo, names } = await this.fieldService.loadPath(resModel, path);
switch (isInvalid) {
case "model":
throw new Error(`Invalid model name: ${resModel}`);
case "path": {
const { resModel, fieldDefs } = modelsInfo[0];
return new Page(resModel, this.filter(fieldDefs, path), null, path);
}
default: {
let page = null;
for (let index = 0; index < names.length; index++) {
const name = names[index];
const { resModel, fieldDefs } = modelsInfo[index];
page = new Page(resModel, this.filter(fieldDefs, path), page, name);
}
return page;
}
}
}
openPage(page) {
this.state.page = page;
this.state.page.searchFields();
this.props.update(page.path);
}
searchFields(query) {
this.state.page.searchFields(query);
}
selectField(field) {
this.keepLast.add(Promise.resolve());
this.state.page.selectedName = field.name;
this.props.update(this.state.page.path);
this.props.close();
}
onDebugInputKeydown(ev) {
switch (ev.key) {
case "ArrowUp":
case "Enter": {
ev.preventDefault();
ev.stopPropagation();
if (this.currentActiveFieldId > 0) {
this.currentActiveFieldId--;
await this.render();
this.loadNewPath(ev.currentTarget.value);
break;
}
}
}
// @TODO should rework/improve this and maybe use hotkeys
async onInputKeydown(ev) {
const { page } = this.state;
switch (ev.key) {
case "ArrowUp": {
if (ev.target.selectionStart === 0) {
page.focus("previous");
}
break;
case "ArrowDown":
ev.preventDefault();
ev.stopPropagation();
if (this.currentActiveFieldId < this.fieldKeys.length - 1) {
this.currentActiveFieldId++;
await this.render();
}
case "ArrowDown": {
if (ev.target.selectionStart === page.query.length) {
page.focus("next");
}
break;
case "ArrowLeft":
ev.preventDefault();
ev.stopPropagation();
this.onPreviousBtnClick();
}
case "ArrowLeft": {
if (ev.target.selectionStart === 0 && page.previousPage) {
this.goToPreviousPage();
}
break;
case "Escape":
}
case "ArrowRight": {
if (ev.target.selectionStart === page.query.length) {
const focusedFieldName = this.state.page.focusedFieldName;
if (focusedFieldName) {
const fieldDef = this.state.page.fieldDefs[focusedFieldName];
if (fieldDef.relation) {
this.followRelation(fieldDef);
}
}
}
break;
}
case "Enter": {
const focusedFieldName = this.state.page.focusedFieldName;
if (focusedFieldName) {
const fieldDef = this.state.page.fieldDefs[focusedFieldName];
this.selectField(fieldDef);
} else {
ev.preventDefault();
ev.stopPropagation();
}
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);
if (this.searchValue) {
fieldKeys = fuzzyLookup(this.searchValue, fieldKeys, (key) => this.fields[key].string);
}
this.fieldKeys = fieldKeys;
this.render();
}
onDefaultValue(ev) {
this.defaultValue = ev.target.value;
this.render();
}
onPreviousBtnClick() {
this.searchValue = "";
if (this.currentNode.field === null) {
this.chain.pop();
}
this.currentNode.field = null;
this.update();
}
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.update();
} else {
this.update(true);
this.props.validate(this.fieldNameChain, this.defaultValue);
}
}
selectDefaultValue(acceptDefaultValue) {
if (!acceptDefaultValue) {
this.defaultValue = "";
}
this.update(true);
this.props.validate(this.fieldNameChain, this.defaultValue);
}
async onFieldNameChange(ev) {
this.fullFieldName = ev.target.value.replace(/\s+/g, "");
const { resModel } = this.props.chain[0];
try {
this.chain = await this.props.loadChain(resModel, this.fullFieldName);
this.update();
} catch {
// WOWL TODO: rethrow error when not the expected type
this.chain = [{ resModel, field: null }];
await this.props.update("");
this.render();
}
}
}
}
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,7 @@
border-bottom-color: map-get($theme-colors, 'primary');
}
.o_field_selector_popover:not(.o_legacy_field_selector_popover) {
.o_model_field_selector_popover {
width: 265px;
background: white;
--o-input-background-color: white;
@@ -11,19 +11,19 @@
outline: none;
}
.o_field_selector_popover_header {
.o_model_field_selector_popover_header {
color: white;
background: map-get($theme-colors, 'primary');
font-weight: bold;
padding: 5px 0 5px 0.4em;
.o_field_selector_title {
.o_model_field_selector_popover_title {
width: 100%;
@include o-text-overflow;
padding: 0px 35px;
text-align: center;
}
.o_field_selector_search {
.o_model_field_selector_popover_search {
padding-right: 0.4rem;
> .o_input {
font-size: 13px;
@@ -32,14 +32,14 @@
line-height: normal;
}
}
.o_field_selector_popover_option {
.o_model_field_selector_popover_option {
@include o-position-absolute($top: 0);
padding: 8px;
&.o_field_selector_prev_page {
&.o_model_field_selector_popover_prev_page {
left: 0;
}
&.o_field_selector_close {
&.o_model_field_selector_popover_close {
right: 0;
}
&:hover {
@@ -47,18 +47,16 @@
}
}
}
.o_field_selector_popover_body {
.o_field_selector_page {
.o_model_field_selector_popover_body {
.o_model_field_selector_popover_page {
position: relative;
height: 320px;
overflow: auto;
margin: 0;
padding: 0;
> .o_field_selector_item {
> .o_model_field_selector_popover_item {
list-style: none;
position: relative;
padding: 5px 0 5px 0.4em;
cursor: pointer;
font-family: Arial;
font-size: 13px;
@@ -67,19 +65,16 @@
&.active {
background: #f5f5f5;
}
.o_field_selector_item_title {
.o_model_field_selector_popover_item_title {
font-size: 12px;
}
.o_field_selector_relation_icon {
@include o-position-absolute($top: 0, $right: 0, $bottom: 0);
display: flex;
align-items: center;
padding: 10px;
.o_model_field_selector_popover_item_relation {
border-left: 1px solid #eee;
}
}
}
}
.o_field_selector_popover_footer {
.o_model_field_selector_popover_footer {
background: map-get($theme-colors, 'primary');
padding: 5px 0.4em;
@@ -2,89 +2,56 @@
<templates xml:space="preserve">
<t t-name="web.ModelFieldSelectorPopover" owl="1">
<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
class="fa fa-arrow-left o_field_selector_popover_option o_field_selector_prev_page"
title="Previous"
role="img"
aria-label="Previous"
t-on-click="onPreviousBtnClick"
<div class="o_model_field_selector_popover" tabindex="0" t-ref="root" t-on-keydown="onInputKeydown">
<div class="o_model_field_selector_popover_header text-center">
<t t-if="state.page.previousPage">
<i class="fa fa-arrow-left o_model_field_selector_popover_option o_model_field_selector_popover_prev_page"
title="Previous"
role="img"
aria-label="Previous"
t-on-click="() => this.goToPreviousPage()"
/>
</t>
<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"
role="img"
aria-label="Close"
t-on-click="props.close"
<div class="o_model_field_selector_popover_title">
<t t-esc="state.page.title"/>
</div>
<i class="fa fa-times o_model_field_selector_popover_option o_model_field_selector_popover_close"
title="Close"
role="img"
aria-label="Close"
t-on-click="props.close"
/>
<t t-if="props.showSearchInput &amp;&amp; !isDefaultValueVisible">
<div class="o_field_selector_search mt-2">
<t t-if="props.showSearchInput">
<div class="o_model_field_selector_popover_search mt-2">
<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"/>
t-att-value="state.page.query"
t-on-input="(ev) => this.debouncedSearchFields(ev.target.value)" />
</div>
</t>
</div>
<div class="o_field_selector_popover_body">
<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
t-on-click="() => this.onFieldSelected({ ...field, name: fieldKey })"
t-att-data-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"
role="img"
aria-label="Relation to follow"
title="Relation to follow"
/>
</t>
</li>
</t>
<div class="o_model_field_selector_popover_body">
<ul class="o_model_field_selector_popover_page">
<t t-foreach="state.page.fieldNames" t-as="fieldName" t-key="fieldName">
<t t-set="fieldDef" t-value="state.page.fieldDefs[fieldName]" />
<li class="o_model_field_selector_popover_item d-flex" t-att-class="{ 'active': fieldName === state.page.focusedFieldName }" t-att-data-name="fieldName">
<button class="o_model_field_selector_popover_item_name btn flex-fill text-start" t-on-click="() => this.selectField(fieldDef)">
<t t-esc="fieldDef.string" />
<div t-if="props.isDebugMode" class="text-muted o_model_field_selector_popover_item_title"><t t-esc="fieldName"/> (<t t-esc="fieldDef.type"/>)</div>
</button>
<t t-if="fieldDef.relation and props.followRelations">
<button class="o_model_field_selector_popover_item_relation btn btn-link" t-on-click.stop="() => this.followRelation(fieldDef)">
<i class="fa fa-chevron-right o_model_field_selector_popover_relation_icon" role="img" aria-label="Relation to follow" title="Relation to follow" />
</button>
</t>
</li>
</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">
<input type="text" class="o_input o_field_selector_debug" t-att-value="fullFieldName" t-on-change="onFieldNameChange" />
<div class="o_model_field_selector_popover_footer">
<input type="text" class="o_model_field_selector_debug o_input" t-att-value="state.page.path" t-on-change="(ev) => this.loadNewPath(ev.target.value)" t-on-keydown="onDebugInputKeydown"/>
</div>
</t>
</div>
@@ -1,10 +1,9 @@
/** @odoo-module **/
import { usePopover } from "@web/core/popover/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";
import { DynamicPlaceholderPopover } from "./dynamic_placeholder_popover";
export function useDynamicPlaceholder(elementRef) {
const TRIGGER_KEY = "#";
@@ -12,25 +11,23 @@ export function useDynamicPlaceholder(elementRef) {
const triggerKeyReplaceRegex = new RegExp(`${TRIGGER_KEY}$`);
let closeCallback;
let positionCallback;
const popover = usePopover(ModelFieldSelectorPopover, {
const popover = usePopover(DynamicPlaceholderPopover, {
onclose: () => closeCallback?.(),
onPositioned: () => positionCallback?.(),
});
const modelField = useModelField();
const notification = useService("notification");
let model = null;
let dynamicPlaceholderChain = [];
const onDynamicPlaceholderValidate = function (chain, defaultValue) {
const onDynamicPlaceholderValidate = function (path, 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(".");
// When the user cancel/close the popover, the path is empty.
if (path) {
let dynamicPlaceholder = "{{object." + path;
dynamicPlaceholder +=
defaultValue && defaultValue !== "" ? ` or '''${defaultValue}'''}}` : "}}";
@@ -73,19 +70,11 @@ export function useDynamicPlaceholder(elementRef) {
{ type: "danger" }
);
}
dynamicPlaceholderChain = await modelField.loadChain(model, "");
closeCallback = opts.closeCallback;
positionCallback = opts.positionCallback;
popover.open(elementRef?.el, {
chain: dynamicPlaceholderChain,
update: (chain) => (dynamicPlaceholderChain = chain),
resModel: model,
validate: opts.validateCallback,
showSearchInput: true,
isDebugMode: true,
needDefaultValue: true,
loadChain: modelField.loadChain,
filter: (model) => !["one2many", "boolean", "many2many"].includes(model.type),
});
}
async function onKeydown(ev) {
@@ -0,0 +1,53 @@
/** @odoo-module **/
import { useAutofocus } from "@web/core/utils/hooks";
import { ModelFieldSelectorPopover } from "@web/core/model_field_selector/model_field_selector_popover";
import { Component, useState } from "@odoo/owl";
export class DynamicPlaceholderPopover extends Component {
static template = "web.DynamicPlaceholderPopover";
static components = {
ModelFieldSelectorPopover,
};
static props = ["resModel", "validate", "close"];
setup() {
useAutofocus();
this.state = useState({
path: "",
isPathSelected: false,
defaultValue: "",
});
}
filter(fieldDef) {
return !["one2many", "boolean", "many2many"].includes(fieldDef.type) && fieldDef.searchable;
}
closeFieldSelector() {
this.state.isPathSelected = true;
}
setPath(path) {
this.state.path = path;
}
setDefaultValue(value) {
this.state.defaultValue = value;
}
validate() {
this.props.close();
this.props.validate(this.state.path, this.state.defaultValue);
}
// @TODO should rework this to use hotkeys
async onInputKeydown(ev) {
switch (ev.key) {
case "Enter": {
this.validate();
break;
}
case "Escape": {
this.props.close();
break;
}
}
}
}
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DynamicPlaceholderPopover" owl="1">
<t t-if="state.isPathSelected">
<div class="o_model_field_selector_popover" t-on-keydown="onInputKeydown">
<div class="o_model_field_selector_popover_header text-center">
<div class="o_model_field_selector_popover_title">Default value</div>
<i class="fa fa-times o_model_field_selector_popover_option o_model_field_selector_close"
title="Close"
role="img"
aria-label="Close"
t-on-click="props.close"
/>
<div class="o_model_field_selector_default_value_input mt-2">
<input type="text"
placeholder='Type a default text or press ENTER'
class="o_input"
t-att-value="state.defaultValue"
t-on-input="(ev) => this.setDefaultValue(ev.target.value)"
t-ref="autofocus"/>
</div>
</div>
<div class="o_model_field_selector_popover_body">
<ul class="o_model_field_selector_page">
<li class="o_model_field_selector_item" t-on-click="() => this.validate()">
<t t-if="state.defaultValue === ''">
<div class="text-muted o_model_field_selector_item_title">Default text is used when no values are set</div>
</t>
<t t-else="">
<div class="text-muted o_model_field_selector_item_title">As a default text when no value are set</div>
</t>
</li>
</ul>
</div>
<div class="o_model_field_selector_popover_footer">
<input type="text" class="o_input o_model_field_selector_debug" t-att-value="state.path" t-on-change="(ev) => this.loadNewPath(ev.target.value)" />
</div>
</div>
</t>
<t t-else="">
<ModelFieldSelectorPopover
close.bind="closeFieldSelector"
filter.bind="filter"
followRelations="true"
isDebugMode="!!env.debug"
path="state.path"
resModel="props.resModel"
showSearchInput="true"
update.bind="setPath"
/>
</t>
</t>
</templates>
@@ -143,7 +143,7 @@ export class PropertiesField extends Component {
return false;
}
if (target.closest(".o_field_selector_popover")) {
if (target.closest(".o_model_field_selector_popover")) {
// domain selector
return false;
}
@@ -14,6 +14,7 @@ import { registerCleanup } from "../helpers/cleanup";
import { makeTestEnv } from "../helpers/mock_env";
import { makeFakeLocalizationService } from "../helpers/mock_services";
import { click, editInput, editSelect, getFixture, mount, triggerEvent } from "../helpers/utils";
import { openModelFieldSelectorPopover } from "./model_field_selector_tests";
let serverData;
let target;
@@ -108,13 +109,13 @@ QUnit.module("Components", (hooks) => {
// Clicking on the button should add a visible field selector in the
// widget so that the user can change the field chain
await click(target, ".o_domain_add_first_node_button");
assert.containsOnce(target, ".o_field_selector", "there should be a field selector");
assert.containsOnce(target, ".o_model_field_selector");
// Focusing the field selector input should open a field selector popover
await click(target, ".o_field_selector");
await click(target, ".o_model_field_selector");
assert.containsOnce(
document.body,
".o_field_selector_popover",
".o_model_field_selector_popover",
"field selector popover should be visible"
);
@@ -122,14 +123,20 @@ QUnit.module("Components", (hooks) => {
// 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,
document.body.querySelector(
".o_model_field_selector_popover .o_model_field_selector_popover_item_name"
).textContent,
"Barbar (boolean)",
"field selector popover should contain the 'Bar' field"
);
// Clicking the "Bar" field should change the internal domain and this
// should be displayed in the debug textarea
await click(document.body.querySelector(".o_field_selector_popover li"));
await click(
document.body.querySelector(
".o_model_field_selector_popover .o_model_field_selector_popover_item_name"
)
);
assert.containsOnce(target, "textarea.o_domain_debug_input");
assert.strictEqual(
target.querySelector(".o_domain_debug_input").value,
@@ -429,7 +436,10 @@ QUnit.module("Components", (hooks) => {
"should still have a single domain node"
);
assert.strictEqual(target.querySelector(".o_field_selector_chain_part").innerText, "1");
assert.strictEqual(
target.querySelector(".o_model_field_selector_chain_part").innerText,
"1"
);
assert.strictEqual(
target.querySelector(".o_domain_leaf_operator_select").value,
"equal"
@@ -451,7 +461,10 @@ QUnit.module("Components", (hooks) => {
"should still have a single domain node"
);
assert.strictEqual(target.querySelector(".o_field_selector_chain_part").innerText, "0");
assert.strictEqual(
target.querySelector(".o_model_field_selector_chain_part").innerText,
"0"
);
assert.strictEqual(
target.querySelector(".o_domain_leaf_operator_select").value,
"equal"
@@ -545,7 +558,10 @@ QUnit.module("Components", (hooks) => {
// Create the domain selector and its mock environment
await mountComponent(Parent);
assert.strictEqual(target.querySelector(".o_field_selector_chain_part").innerText, "State");
assert.strictEqual(
target.querySelector(".o_model_field_selector_chain_part").innerText,
"State"
);
assert.strictEqual(
target.querySelector(".o_domain_leaf_operator_select").value,
"not_equal"
@@ -553,7 +569,10 @@ QUnit.module("Components", (hooks) => {
await editSelect(target, ".o_domain_leaf_operator_select", "equal");
assert.strictEqual(target.querySelector(".o_field_selector_chain_part").innerText, "State");
assert.strictEqual(
target.querySelector(".o_model_field_selector_chain_part").innerText,
"State"
);
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "equal"); // option "="
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, `"abc"`);
});
@@ -637,7 +656,7 @@ QUnit.module("Components", (hooks) => {
isDebugMode: false,
},
});
assert.containsNone(target, ".o_domain_add_first_node_button");
assert.containsNone(target, ".o_reset_domain_button");
assert.containsNone(target, ".o_domain_debug_input");
});
@@ -650,7 +669,7 @@ QUnit.module("Components", (hooks) => {
isDebugMode: true,
},
});
assert.containsNone(target, ".o_domain_add_first_node_button");
assert.containsNone(target, ".o_reset_domain_button");
assert.containsOnce(target, ".o_domain_debug_input");
assert.ok(target.querySelector(".o_domain_debug_input").hasAttribute("readonly"));
});
@@ -664,7 +683,7 @@ QUnit.module("Components", (hooks) => {
isDebugMode: false,
},
});
assert.containsOnce(target, ".o_domain_add_first_node_button");
assert.containsOnce(target, ".o_reset_domain_button");
assert.containsNone(target, ".o_domain_debug_input");
});
@@ -677,8 +696,79 @@ QUnit.module("Components", (hooks) => {
isDebugMode: true,
},
});
assert.containsOnce(target, ".o_domain_add_first_node_button");
assert.containsOnce(target, ".o_reset_domain_button");
assert.containsOnce(target, ".o_domain_debug_input");
assert.notOk(target.querySelector(".o_domain_debug_input").hasAttribute("readonly"));
});
QUnit.test("reset domain", async (assert) => {
class Parent extends Component {
setup() {
this.value = `[`;
}
onUpdate(domain) {
assert.step(domain);
this.value = domain;
this.render();
}
}
Parent.components = { DomainSelector };
Parent.template = xml`
<DomainSelector
resModel="'partner'"
value="value"
readonly="false"
update="(domain) => this.onUpdate(domain)"
/>
`;
await mountComponent(Parent);
assert.strictEqual(
target.querySelector(".o_domain_selector").innerText.toLowerCase(),
"this domain is not supported. reset domain"
);
assert.containsOnce(target, ".o_reset_domain_button");
assert.containsNone(target, ".o_domain_add_first_node_button");
await click(target, ".o_reset_domain_button");
assert.strictEqual(
target.querySelector(".o_domain_selector").innerText.toLowerCase(),
"match all records add filter"
);
assert.containsNone(target, ".o_reset_domain_button");
assert.containsOnce(target, ".o_domain_add_first_node_button");
assert.verifySteps(["[]"]);
});
QUnit.test("incorrect path in debug input in model field selector popover", async (assert) => {
class Parent extends Component {
setup() {
this.value = `[("id", "=", 1)]`;
}
onUpdate(domain) {
assert.step(domain);
this.value = domain;
this.render();
}
}
Parent.components = { DomainSelector };
Parent.template = xml`
<DomainSelector
resModel="'partner'"
value="value"
readonly="false"
isDebugMode="true"
update="(domain) => this.onUpdate(domain)"
/>
`;
await mountComponent(Parent);
await openModelFieldSelectorPopover(target);
await editInput(target, ".o_model_field_selector_debug", "a");
await click(target, ".o_model_field_selector_popover_close");
assert.verifySteps([`[("", "=", 1)]`]);
assert.strictEqual(
target.querySelector(".o_model_field_selector_chain_part").innerText,
"-"
);
assert.containsOnce(target, ".o_model_field_selector_warning");
});
});
@@ -1,17 +1,26 @@
/** @odoo-module **/
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
import { browser } from "@web/core/browser/browser";
import {
click,
editInput,
getFixture,
getNodesTextContent,
mount,
nextTick,
patchWithCleanup,
triggerEvent,
} from "../helpers/utils";
import { Component, useState, xml } from "@odoo/owl";
import { fieldService } from "@web/core/field_service";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { makeFakeLocalizationService } from "../helpers/mock_services";
import { makeTestEnv } from "../helpers/mock_env";
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
import { ormService } from "@web/core/orm_service";
import { popoverService } from "@web/core/popover/popover_service";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { makeTestEnv } from "../helpers/mock_env";
import { click, getFixture, triggerEvent, mount, editInput } from "../helpers/utils";
import { makeFakeLocalizationService } from "../helpers/mock_services";
import { Component, xml } from "@odoo/owl";
import { fieldService } from "@web/core/field_service";
let target;
let serverData;
@@ -22,6 +31,38 @@ async function mountComponent(Component, params = {}) {
return mount(Component, target, { env, props: params.props || {} });
}
export async function openModelFieldSelectorPopover(target) {
await click(target, ".o_model_field_selector");
}
function getDisplayedFieldNames(target) {
return getNodesTextContent(
target.querySelectorAll(".o_model_field_selector_popover_item_name")
);
}
function getModelFieldSelectorValues(target) {
return getNodesTextContent(target.querySelectorAll("span.o_model_field_selector_chain_part"));
}
function getTitle(target) {
return target.querySelector(
".o_model_field_selector_popover .o_model_field_selector_popover_title"
).innerText;
}
async function clickPrev(target) {
await click(target, ".o_model_field_selector_popover_prev_page");
}
async function followRelation(target, index = 0) {
await click(target.querySelectorAll(".o_model_field_selector_popover_item_relation")[index]);
}
function getFocusedFieldName(target) {
return target.querySelector(".o_model_field_selector_popover_item.active").innerText;
}
QUnit.module("Components", (hooks) => {
hooks.beforeEach(async () => {
serverData = {
@@ -69,18 +110,17 @@ QUnit.module("Components", (hooks) => {
QUnit.test("creating a field chain from scratch", async (assert) => {
function getValueFromDOM(el) {
return [...el.querySelectorAll(".o_field_selector_chain_part")]
return [...el.querySelectorAll(".o_model_field_selector_chain_part")]
.map((part) => part.textContent.trim())
.join(" -> ");
}
class Parent extends Component {
setup() {
this.fieldName = "";
this.path = "";
}
onUpdate(value) {
assert.step(`update: ${value}`);
this.fieldName = value;
onUpdate(path) {
assert.step(`update: ${path}`);
this.path = path;
this.render();
}
}
@@ -89,309 +129,195 @@ QUnit.module("Components", (hooks) => {
<ModelFieldSelector
readonly="false"
resModel="'partner'"
fieldName="fieldName"
path="path"
isDebugMode="false"
update="(value) => this.onUpdate(value)"
update="(pathInfo) => this.onUpdate(pathInfo)"
/>
`;
// Create the field selector and its mock environment
const fieldSelector = await mountComponent(Parent);
// Focusing the field selector input should open a field selector popover
await click(target, ".o_field_selector");
await openModelFieldSelectorPopover(target);
assert.strictEqual(
target.querySelector("input.o_input[placeholder='Search...']"),
document.activeElement,
"the field selector input should be focused"
);
assert.containsOnce(
target,
".o_field_selector_popover",
"field selector popover should be visible"
document.activeElement
);
assert.containsOnce(target, ".o_model_field_selector_popover");
// The field selector popover should contain the list of "partner"
// fields. "Bar" should be among them.
assert.strictEqual(
target.querySelector(".o_field_selector_popover .o_field_selector_item").textContent,
"Bar",
"field selector popover should contain the 'Bar' field"
target.querySelector(".o_model_field_selector_popover_item_name").textContent,
"Bar"
);
// Clicking the "Bar" field should close the popover and set the field
// chain to "bar" as it is a basic field
await click(target.querySelector(".o_field_selector_popover .o_field_selector_item"));
assert.containsNone(
target,
".o_field_selector_popover",
"field selector popover should be closed now"
);
assert.strictEqual(
getValueFromDOM(target),
"Bar",
"field selector value should be displayed with a 'Bar' tag"
);
assert.strictEqual(
fieldSelector.fieldName,
"bar",
"the selected field should be correctly set"
);
await click(target.querySelector(".o_model_field_selector_popover_item_name"));
assert.containsNone(target, ".o_model_field_selector_popover");
assert.strictEqual(getValueFromDOM(target), "Bar");
assert.strictEqual(fieldSelector.path, "bar");
assert.verifySteps(["update: bar"]);
// Focusing the input again should open the same popover
await click(target, ".o_field_selector");
assert.containsOnce(
target,
".o_field_selector_popover",
"field selector popover should be visible"
);
await openModelFieldSelectorPopover(target);
assert.containsOnce(target, ".o_model_field_selector_popover");
// The field selector popover should contain the list of "partner"
// fields. "Product" should be among them.
assert.containsOnce(
target,
".o_field_selector_popover .o_field_selector_relation_icon",
".o_model_field_selector_popover .o_model_field_selector_popover_relation_icon",
"field selector popover should contain the 'Product' field"
);
// Clicking on the "Product" field should update the popover to show
// the product fields (so only "Product Name" should be there)
await click(
target.querySelector(".o_field_selector_popover .o_field_selector_relation_icon")
);
assert.containsOnce(
target,
".o_field_selector_popover .o_field_selector_item",
"there should be only one field proposition for 'product' model"
target.querySelector(
".o_model_field_selector_popover .o_model_field_selector_popover_relation_icon"
)
);
assert.containsOnce(target, ".o_model_field_selector_popover_item_name");
assert.strictEqual(
target.querySelector(".o_field_selector_popover .o_field_selector_item").textContent,
target.querySelector(".o_model_field_selector_popover_item_name").textContent,
"Product Name",
"the name of the only suggestion should be 'Product Name'"
);
// Clicking on "Product Name" should close the popover and set the chain
// to "product_id.name"
await click(target.querySelector(".o_field_selector_popover .o_field_selector_item"));
assert.containsNone(
target,
".o_field_selector_popover",
"field selector popover should be closed now"
);
assert.strictEqual(
getValueFromDOM(target),
"Product -> Product Name",
"field selector value should be displayed with two tags: 'Product' and 'Product Name'"
);
await click(target.querySelector(".o_model_field_selector_popover_item_name"));
assert.containsNone(target, ".o_model_field_selector_popover");
assert.strictEqual(getValueFromDOM(target), "Product -> Product Name");
assert.verifySteps(["update: product_id.name"]);
// Remove the current selection and recreate it again
await click(target, ".o_field_selector");
await click(target, ".o_field_selector_prev_page");
await click(target, ".o_field_selector_prev_page");
await click(target, ".o_field_selector_close");
assert.verifySteps(["update: "]);
await openModelFieldSelectorPopover(target);
await click(target, ".o_model_field_selector_popover_prev_page");
await click(target, ".o_model_field_selector_popover_close");
assert.verifySteps(["update: product_id"]);
await click(target, ".o_field_selector");
await openModelFieldSelectorPopover(target);
assert.containsOnce(
target,
".o_field_selector_popover .o_field_selector_relation_icon",
"field selector popover should contain the 'Product' field"
".o_model_field_selector_popover .o_model_field_selector_popover_relation_icon"
);
await click(
target.querySelector(".o_field_selector_popover .o_field_selector_relation_icon")
);
await click(target.querySelector(".o_field_selector_popover .o_field_selector_item"));
assert.containsNone(
target,
".o_field_selector_popover",
"field selector popover should be closed now"
);
assert.strictEqual(
getValueFromDOM(target),
"Product -> Product Name",
"field selector value should be displayed with two tags: 'Product' and 'Product Name'"
target.querySelector(
".o_model_field_selector_popover .o_model_field_selector_popover_relation_icon"
)
);
await click(target.querySelector(".o_model_field_selector_popover_item_name"));
assert.containsNone(target, ".o_model_field_selector_popover");
assert.strictEqual(getValueFromDOM(target), "Product -> Product Name");
assert.verifySteps(["update: product_id.name"]);
});
QUnit.test("default field chain should set the page data correctly", async (assert) => {
assert.expect(3);
// Create the field selector and its mock environment
// passing 'product_id' as a prefilled field-chain
await mountComponent(ModelFieldSelector, {
props: {
readonly: false,
fieldName: "product_id",
path: "product_id",
resModel: "partner",
isDebugMode: false,
},
});
// Focusing the field selector input should open a field selector popover
await click(target, ".o_field_selector");
assert.containsOnce(
target,
".o_field_selector_popover",
"field selector popover should be visible"
);
// The field selector popover should contain the list of "product"
// fields. "Product Name" should be among them.
assert.containsOnce(
target,
".o_field_selector_popover .o_field_selector_item",
"there should be only one field proposition for 'product' model"
);
assert.strictEqual(
target.querySelector(".o_field_selector_popover .o_field_selector_item").textContent,
"Product Name",
"the name of the only suggestion should be 'Product Name'"
await openModelFieldSelectorPopover(target);
assert.containsOnce(target, ".o_model_field_selector_popover");
assert.deepEqual(getDisplayedFieldNames(target), ["Bar", "Foo", "Product"]);
assert.hasClass(
target.querySelectorAll(".o_model_field_selector_popover_item:nth-child(3)"),
"active"
);
});
QUnit.test("use the filter option", async (assert) => {
assert.expect(2);
// Create the field selector and its mock environment
await mountComponent(ModelFieldSelector, {
props: {
readonly: false,
fieldName: "",
path: "",
resModel: "partner",
filter: (field) => field.type === "many2one",
filter: (field) => field.type === "many2one" && field.searchable,
},
});
await click(target, ".o_field_selector");
assert.containsOnce(
target,
".o_field_selector_popover .o_field_selector_item",
"there should only be one element"
);
assert.strictEqual(
target.querySelector(".o_field_selector_popover .o_field_selector_page").textContent,
"Product",
"the available field should be the many2one"
);
await openModelFieldSelectorPopover(target);
assert.deepEqual(getDisplayedFieldNames(target), ["Product"]);
});
QUnit.test("default `showSearchInput` option", async (assert) => {
assert.expect(6);
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
// Create the field selector and its mock environment
await mountComponent(ModelFieldSelector, {
props: {
readonly: false,
fieldName: "",
path: "",
resModel: "partner",
},
});
await click(target, ".o_field_selector");
await openModelFieldSelectorPopover(target);
assert.containsOnce(
target,
".o_field_selector_popover .o_field_selector_search",
"there should be a search input"
".o_model_field_selector_popover .o_model_field_selector_popover_search"
);
assert.deepEqual(getDisplayedFieldNames(target), ["Bar", "Foo", "Product"]);
// without search
assert.containsN(
// search 'xx'
await editInput(
target,
".o_field_selector_popover .o_field_selector_item",
3,
"there should be three available fields"
);
assert.strictEqual(
target.querySelector(".o_field_selector_popover .o_field_selector_page").textContent,
"BarFooProduct",
"the available field should be correct"
".o_model_field_selector_popover .o_model_field_selector_popover_search input",
"xx"
);
assert.deepEqual(getDisplayedFieldNames(target), []);
const input = target.querySelector(
".o_field_selector_popover .o_field_selector_search input"
);
input.value = "xx";
await triggerEvent(input, null, "input");
assert.containsNone(
// search 'Pro'
await editInput(
target,
".o_field_selector_popover .o_field_selector_item",
"there shouldn't be any element"
);
input.value = "Pro";
await triggerEvent(input, null, "input");
assert.containsOnce(
target,
".o_field_selector_popover .o_field_selector_item",
"there should only be one element"
);
assert.strictEqual(
target.querySelector(".o_field_selector_popover .o_field_selector_page").textContent,
"Product",
"the available field should be the Product"
".o_model_field_selector_popover .o_model_field_selector_popover_search input",
"Pro"
);
assert.deepEqual(getDisplayedFieldNames(target), ["Product"]);
});
QUnit.test("false `showSearchInput` option", async (assert) => {
assert.expect(1);
// Create the field selector and its mock environment
await mountComponent(ModelFieldSelector, {
props: {
readonly: false,
showSearchInput: false,
fieldName: "",
path: "",
resModel: "partner",
},
});
await click(target, ".o_field_selector");
await openModelFieldSelectorPopover(target);
assert.containsNone(
target,
".o_field_selector_popover .o_field_selector_search",
"there should be no search input"
".o_model_field_selector_popover .o_model_field_selector_popover_search"
);
});
QUnit.test("create a field chain with value 1 i.e. TRUE_LEAF", async (assert) => {
assert.expect(1);
//create the field selector with domain value ["1"]
await mountComponent(ModelFieldSelector, {
props: {
readonly: false,
showSearchInput: false,
fieldName: "1",
path: 1,
resModel: "partner",
},
});
assert.strictEqual(
target.querySelector(".o_field_selector_chain_part").textContent.trim(),
"1",
"field name value should be 1."
target.querySelector(".o_model_field_selector_chain_part").textContent.trim(),
"1"
);
});
QUnit.test("create a field chain with value 0 i.e. FALSE_LEAF", async (assert) => {
assert.expect(1);
//create the field selector with domain value ["0"]
await mountComponent(ModelFieldSelector, {
props: {
readonly: false,
showSearchInput: false,
fieldName: "0",
path: 0,
resModel: "partner",
},
});
assert.strictEqual(
target.querySelector(".o_field_selector_chain_part").textContent.trim(),
target.querySelector(".o_model_field_selector_chain_part").textContent.trim(),
"0",
"field name value should be 0."
);
@@ -406,18 +332,17 @@ QUnit.module("Components", (hooks) => {
};
await mountComponent(ModelFieldSelector, {
mockRPC(route, { method }) {
mockRPC(_, { method }) {
if (method === "fields_get") {
assert.step("fields_get");
}
},
props: {
readonly: false,
fieldName: "partner_id.partner_id.partner_id.foo",
path: "partner_id.partner_id.partner_id.foo",
resModel: "partner",
},
});
assert.verifySteps(["fields_get"]);
});
@@ -431,10 +356,10 @@ QUnit.module("Components", (hooks) => {
class Parent extends Component {
setup() {
this.fieldName = "partner_id.foo";
this.path = "partner_id.foo";
}
onUpdate(value) {
this.fieldName = value;
onUpdate(path) {
this.path = path;
this.render();
}
}
@@ -443,65 +368,83 @@ QUnit.module("Components", (hooks) => {
<ModelFieldSelector
readonly="false"
resModel="'partner'"
fieldName="fieldName"
update="(value) => this.onUpdate(value)"
path="path"
update="(pathInfo) => this.onUpdate(pathInfo)"
/>
`;
await mountComponent(Parent);
assert.deepEqual(getModelFieldSelectorValues(target), ["Partner", "Foo"]);
assert.containsNone(target, ".o_model_field_selector i.o_model_field_selector_warning");
assert.deepEqual(
[...target.querySelectorAll(".o_field_selector_value span")].map((el) => el.innerText),
["Partner", "Foo"]
await openModelFieldSelectorPopover(target);
await click(target, ".o_model_field_selector_popover_prev_page");
assert.deepEqual(getModelFieldSelectorValues(target), ["Partner"]);
assert.containsNone(target, ".o_model_field_selector i.o_model_field_selector_warning");
await click(
target,
".o_model_field_selector_popover_item:nth-child(1) .o_model_field_selector_popover_item_name"
);
assert.containsNone(target, ".o_field_selector i.o_field_selector_warning");
assert.deepEqual(getModelFieldSelectorValues(target), ["Bar"]);
assert.containsNone(target, ".o_model_field_selector_popover");
});
await click(target, ".o_field_selector");
await click(target, ".o_field_selector_prev_page");
assert.deepEqual(
[...target.querySelectorAll(".o_field_selector_value span")].map((el) => el.innerText),
["Partner"]
);
assert.containsNone(target, ".o_field_selector i.o_field_selector_warning");
await click(target, ".o_field_selector_prev_page");
assert.deepEqual(
[...target.querySelectorAll(".o_field_selector_value span")].map((el) => el.innerText),
[""]
);
assert.containsOnce(target, ".o_field_selector i.o_field_selector_warning");
await click(target, ".o_field_selector_popover .o_field_selector_item:nth-child(1)");
assert.deepEqual(
[...target.querySelectorAll(".o_field_selector_value span")].map((el) => el.innerText),
["Bar"]
QUnit.test("select a relational field does not follow relation", async (assert) => {
await mountComponent(ModelFieldSelector, {
props: {
readonly: false,
path: "",
resModel: "partner",
update(path) {
assert.step(path);
},
},
});
await openModelFieldSelectorPopover(target);
assert.containsOnce(
target,
".o_model_field_selector_popover_item:last-child .o_model_field_selector_popover_relation_icon"
);
assert.containsNone(target, ".o_field_selector_popover");
await click(
target,
".o_model_field_selector_popover_item:last-child .o_model_field_selector_popover_item_name"
);
assert.verifySteps(["product_id"]);
assert.containsNone(target, ".o_popover");
await openModelFieldSelectorPopover(target);
assert.deepEqual(getDisplayedFieldNames(target), ["Bar", "Foo", "Product"]);
assert.containsOnce(target, ".o_model_field_selector_popover_relation_icon");
await click(target, ".o_model_field_selector_popover_relation_icon");
assert.deepEqual(getDisplayedFieldNames(target), ["Product Name"]);
assert.containsOnce(target, ".o_popover");
await click(target, ".o_model_field_selector_popover_item_name");
assert.verifySteps(["product_id.name"]);
assert.containsNone(target, ".o_popover");
});
QUnit.test("can follow relations", async (assert) => {
await mountComponent(ModelFieldSelector, {
props: {
readonly: false,
fieldName: "",
path: "",
resModel: "partner",
followRelations: true, // default
update(value) {
assert.strictEqual(value, "product_id");
update(path) {
assert.strictEqual(path, "product_id");
},
},
});
await openModelFieldSelectorPopover(target);
assert.deepEqual(getDisplayedFieldNames(target), ["Bar", "Foo", "Product"]);
assert.containsOnce(target, ".o_model_field_selector_popover_relation_icon");
await click(target, ".o_field_selector");
assert.containsOnce(
target,
".o_field_selector_item:last-child .o_field_selector_relation_icon"
);
await click(target, ".o_field_selector_item:last-child .o_field_selector_relation_icon");
await click(target, ".o_model_field_selector_popover_relation_icon");
assert.deepEqual(getDisplayedFieldNames(target), ["Product Name"]);
assert.containsOnce(target, ".o_popover");
});
@@ -509,19 +452,24 @@ QUnit.module("Components", (hooks) => {
await mountComponent(ModelFieldSelector, {
props: {
readonly: false,
fieldName: "",
path: "",
resModel: "partner",
followRelations: false,
update(value) {
assert.strictEqual(value, "product_id");
update(path) {
assert.strictEqual(path, "product_id");
},
},
});
await openModelFieldSelectorPopover(target);
assert.deepEqual(getDisplayedFieldNames(target), ["Bar", "Foo", "Product"]);
assert.containsNone(target, ".o_model_field_selector_popover_relation_icon");
await click(target, ".o_field_selector");
assert.containsNone(target, ".o_field_selector_relation_icon");
await click(target, ".o_field_selector_item:last-child");
await click(
target,
".o_model_field_selector_popover_item:nth-child(3) .o_model_field_selector_popover_item_name"
);
assert.containsNone(target, ".o_popover");
assert.deepEqual(getModelFieldSelectorValues(target), ["Product"]);
});
QUnit.test("Edit path in popover debug input", async (assert) => {
@@ -534,10 +482,10 @@ QUnit.module("Components", (hooks) => {
class Parent extends Component {
setup() {
this.fieldName = "foo";
this.path = "foo";
}
onUpdate(value) {
this.fieldName = value;
onUpdate(path) {
this.path = path;
this.render();
}
}
@@ -546,30 +494,230 @@ QUnit.module("Components", (hooks) => {
<ModelFieldSelector
readonly="false"
resModel="'partner'"
fieldName="fieldName"
path="path"
isDebugMode="true"
update="(value) => this.onUpdate(value)"
update="(pathInfo) => this.onUpdate(pathInfo)"
/>
`;
await mountComponent(Parent);
assert.deepEqual(getModelFieldSelectorValues(target), ["Foo"]);
assert.deepEqual(
[...target.querySelectorAll(".o_field_selector_value span")].map((el) => el.innerText),
["Foo"]
);
await click(target, ".o_field_selector");
await openModelFieldSelectorPopover(target);
await editInput(
target,
".o_field_selector_popover .o_field_selector_debug",
".o_model_field_selector_popover .o_model_field_selector_debug",
"partner_id.bar"
);
assert.deepEqual(getModelFieldSelectorValues(target), ["Partner", "Bar"]);
});
assert.deepEqual(
[...target.querySelectorAll(".o_field_selector_value span")].map((el) => el.innerText),
["Partner", "Bar"]
QUnit.test("title on first four pages", async (assert) => {
serverData.models.turtle = {
fields: {
mother_id: {
string: "Mother",
type: "many2one",
relation: "turtle",
searchable: true,
},
},
};
await mountComponent(ModelFieldSelector, {
props: {
readonly: false,
path: "mother_id",
resModel: "turtle",
},
});
await openModelFieldSelectorPopover(target);
assert.strictEqual(getTitle(target), "");
await followRelation(target);
assert.strictEqual(getTitle(target), "Mother");
await followRelation(target);
assert.strictEqual(getTitle(target), "... > Mother");
await followRelation(target);
assert.strictEqual(getTitle(target), "... > Mother");
});
QUnit.test("start on complex path and click prev", async (assert) => {
serverData.models.turtle = {
fields: {
mother_id: {
string: "Mother",
type: "many2one",
relation: "turtle",
searchable: true,
},
father_id: {
string: "Father",
type: "many2one",
relation: "turtle",
searchable: true,
},
},
};
await mountComponent(ModelFieldSelector, {
props: {
readonly: false,
path: "mother_id.father_id.mother_id",
resModel: "turtle",
},
});
await openModelFieldSelectorPopover(target);
// viewing third page
// mother is selected on that page
assert.strictEqual(getTitle(target), "... > Father");
assert.strictEqual(getFocusedFieldName(target), "Mother");
assert.deepEqual(getModelFieldSelectorValues(target), ["Mother", "Father", "Mother"]);
// select Father on third page and go to next page
// no selection on fourth page --> first item is focused
await followRelation(target);
assert.strictEqual(getTitle(target), "... > Father");
assert.strictEqual(getFocusedFieldName(target), "Father");
assert.deepEqual(getModelFieldSelectorValues(target), ["Mother", "Father", "Father"]);
// go back to third page. Nothing has changed
await clickPrev(target);
assert.strictEqual(getTitle(target), "... > Father");
assert.strictEqual(getFocusedFieldName(target), "Father");
assert.deepEqual(getModelFieldSelectorValues(target), ["Mother", "Father", "Father"]);
// go back to second page. Nothing has changed.
await clickPrev(target);
assert.strictEqual(getTitle(target), "Mother");
assert.strictEqual(getFocusedFieldName(target), "Father");
assert.deepEqual(getModelFieldSelectorValues(target), ["Mother", "Father"]);
// go back to first page. Nothing has changed.
await clickPrev(target);
assert.strictEqual(getTitle(target), "");
assert.strictEqual(getFocusedFieldName(target), "Mother");
assert.deepEqual(getModelFieldSelectorValues(target), ["Mother"]);
assert.containsNone(target, ".o_model_field_selector_popover_prev_page");
});
QUnit.test("support of invalid paths", async (assert) => {
class Parent extends Component {
setup() {
this.state = useState({ path: `` });
}
}
Parent.components = { ModelFieldSelector };
Parent.template = xml`<ModelFieldSelector resModel="'partner'" readonly="false" path="state.path"/>`;
const parent = await mountComponent(Parent);
assert.deepEqual(getModelFieldSelectorValues(target), ["-"]);
assert.containsOnce(target, ".o_model_field_selector_warning");
parent.state.path = undefined;
await nextTick();
assert.deepEqual(getModelFieldSelectorValues(target), ["-"]);
assert.containsOnce(target, ".o_model_field_selector_warning");
parent.state.path = {};
await nextTick();
assert.deepEqual(getModelFieldSelectorValues(target), ["-"]);
assert.containsOnce(target, ".o_model_field_selector_warning");
parent.state.path = `a`;
await nextTick();
assert.deepEqual(getModelFieldSelectorValues(target), ["a"]);
assert.containsOnce(target, ".o_model_field_selector_warning");
parent.state.path = `foo.a`;
await nextTick();
assert.deepEqual(getModelFieldSelectorValues(target), ["Foo", "a"]);
assert.containsOnce(target, ".o_model_field_selector_warning");
parent.state.path = `a.foo`;
await nextTick();
assert.deepEqual(getModelFieldSelectorValues(target), ["a", "foo"]);
assert.containsOnce(target, ".o_model_field_selector_warning");
});
QUnit.test("debug input", async (assert) => {
assert.expect(10);
let num = 1;
class Parent extends Component {
setup() {
this.state = useState({ path: `` });
}
update(path, fieldInfo) {
if (num === 1) {
assert.strictEqual(path, "a");
assert.deepEqual(fieldInfo, {
fieldDef: null,
resModel: "partner",
});
num++;
} else {
assert.strictEqual(path, "foo");
assert.deepEqual(fieldInfo, {
fieldDef: {
name: "foo",
searchable: true,
string: "Foo",
type: "char",
},
resModel: "partner",
});
}
}
}
Parent.components = { ModelFieldSelector };
Parent.template = xml`<ModelFieldSelector resModel="'partner'" readonly="false" isDebugMode="true" path="state.path" update.bind="update"/>`;
await mountComponent(Parent);
assert.deepEqual(getModelFieldSelectorValues(target), ["-"]);
assert.containsOnce(target, ".o_model_field_selector_warning");
await openModelFieldSelectorPopover(target);
await editInput(target, ".o_model_field_selector_debug", "a");
assert.deepEqual(getModelFieldSelectorValues(target), ["a"]);
assert.containsOnce(target, ".o_model_field_selector_warning");
await click(target, ".o_model_field_selector_popover_close");
await openModelFieldSelectorPopover(target);
const debugInput = target.querySelector(".o_model_field_selector_debug");
debugInput.focus();
debugInput.value = "foo";
await triggerEvent(debugInput, null, "keydown", { key: "Enter" });
assert.deepEqual(getModelFieldSelectorValues(target), ["Foo"]);
assert.containsNone(target, ".o_model_field_selector_warning");
await click(target, ".o_model_field_selector_popover_close");
});
QUnit.test("focus on search input", async (assert) => {
class Parent extends Component {
setup() {
this.state = useState({ path: `foo` });
}
update() {}
}
Parent.components = { ModelFieldSelector };
Parent.template = xml`<ModelFieldSelector resModel="'partner'" readonly="false" path="state.path" update.bind="update"/>`;
await mountComponent(Parent);
await openModelFieldSelectorPopover(target);
assert.strictEqual(
document.activeElement,
target.querySelector(".o_model_field_selector_popover_search .o_input")
);
await followRelation(target);
assert.strictEqual(
document.activeElement,
target.querySelector(".o_model_field_selector_popover_search .o_input")
);
});
});
@@ -235,9 +235,9 @@ QUnit.module("Search", (hooks) => {
await openAdvancedSearchDialog(target);
assert.containsOnce(target, ".modal");
assert.containsOnce(target, ".modal .modal-body .o_domain_selector");
assert.containsOnce(target, ".o_field_selector");
assert.containsOnce(target, ".o_model_field_selector");
assert.strictEqual(
target.querySelector(".o_field_selector").innerText,
target.querySelector(".o_model_field_selector").innerText,
"Floaty McFloatface"
);
assert.containsOnce(target, ".o_domain_leaf_operator_select");
@@ -273,7 +273,7 @@ QUnit.module("Search", (hooks) => {
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
assert.strictEqual(target.querySelector(".o_field_selector").innerText, "User");
assert.strictEqual(target.querySelector(".o_model_field_selector").innerText, "User");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "equal");
assert.strictEqual(target.querySelector(".o_ds_value_cell input").value, "7");
});
@@ -604,7 +604,7 @@ QUnit.module("Search", (hooks) => {
await openAdvancedSearchDialog(target);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_field_selector_chain_part")),
getNodesTextContent(target.querySelectorAll(".o_model_field_selector_chain_part")),
["DateTime", "DateTime", "A date", "A date"]
); // 0 not found!
@@ -197,33 +197,33 @@ QUnit.module("Fields", (hooks) => {
// Clicking on the button should add the [["id", "=", "1"]] domain, so
// there should be a field selector in the DOM
await click(target, ".o_domain_add_first_node_button");
assert.containsOnce(target, ".o_field_selector", "there should be a field selector");
assert.containsOnce(target, ".o_model_field_selector", "there should be a field selector");
// Focusing the field selector input should open the field selector
// popover
await click(target, ".o_field_selector");
await click(target, ".o_model_field_selector");
assert.containsOnce(
document.body,
".o_field_selector_popover",
".o_model_field_selector_popover",
"field selector popover should be visible"
);
assert.containsOnce(
document.body,
".o_field_selector_search input",
".o_model_field_selector_popover_search input",
"field selector popover should contain a search input"
);
// The popover should contain the list of partner_type fields and so
// there should be the "Color index" field
assert.strictEqual(
document.body.querySelector(".o_field_selector_item").textContent,
document.body.querySelector(".o_model_field_selector_popover_item").textContent,
"Color index",
"field selector popover should contain 'Color index' field"
);
// Clicking on this field should close the popover, then changing the
// associated value should reveal one matched record
await click(document.body.querySelector(".o_field_selector_item"));
await click(document.body.querySelector(".o_model_field_selector_popover_item_name"));
const input = target.querySelector(".o_domain_leaf_value_input");
input.value = 2;
@@ -264,8 +264,12 @@ QUnit.module("Fields", (hooks) => {
});
await click(target, ".o_domain_add_first_node_button");
await click(target, ".o_field_selector");
await click(document.body.querySelector(".o_field_selector_item[data-name='image']"));
await click(target, ".o_model_field_selector");
await click(
document.body.querySelector(
".o_model_field_selector_popover_item[data-name='image'] button"
)
);
});
QUnit.test("domain field is correctly reset on every view change", async function (assert) {
@@ -293,15 +297,15 @@ QUnit.module("Fields", (hooks) => {
// selector to change this
assert.containsOnce(
target,
".o_field_domain .o_field_selector",
".o_field_domain .o_model_field_selector",
"there should be a field selector"
);
// Focusing its input should open the field selector popover
await click(target.querySelector(".o_field_selector"));
await click(target.querySelector(".o_model_field_selector"));
assert.containsOnce(
document.body,
".o_field_selector_popover",
".o_model_field_selector_popover",
"field selector popover should be visible"
);
@@ -309,11 +313,11 @@ QUnit.module("Fields", (hooks) => {
// popover should contain the list of "product" fields
assert.containsOnce(
document.body,
".o_field_selector_item",
".o_model_field_selector_popover_item",
"field selector popover should contain only one field"
);
assert.strictEqual(
document.body.querySelector(".o_field_selector_item").textContent,
document.body.querySelector(".o_model_field_selector_popover_item").textContent,
"Product Name",
"field selector popover should contain 'Product Name' field"
);
@@ -322,22 +326,22 @@ QUnit.module("Fields", (hooks) => {
await editInput(target, ".o_field_widget[name='bar'] input", "partner_type");
// Refocusing the field selector input should open the popover again
await click(target.querySelector(".o_field_selector"));
await click(target.querySelector(".o_model_field_selector"));
assert.containsOnce(
document.body,
".o_field_selector_popover",
".o_model_field_selector_popover",
"field selector popover should be visible"
);
// Now the list of fields should be the ones of the "partner_type" model
assert.containsN(
document.body,
".o_field_selector_item",
".o_model_field_selector_popover_item",
2,
"field selector popover should contain two fields"
);
assert.strictEqual(
document.body.querySelector(".o_field_selector_item").textContent,
document.body.querySelector(".o_model_field_selector_popover_item").textContent,
"Color index",
"field selector popover should contain 'Color index' field"
);
@@ -627,9 +627,9 @@ QUnit.module("Fields", (hooks) => {
await click(target, "[name=txt] textarea");
assert.strictEqual(document.activeElement, target.querySelector("[name=txt] textarea"));
assert.containsNone(document.body, ".o_popover .o_field_selector_popover");
assert.containsNone(document.body, ".o_popover .o_model_field_selector_popover");
triggerHotkey("#");
await nextTick();
assert.containsOnce(document.body, ".o_popover .o_field_selector_popover");
assert.containsOnce(document.body, ".o_popover .o_model_field_selector_popover");
});
});