[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:
co-authored by
Michaël Mattiello
parent
6e4862d2ba
commit
ebf646b44f
@@ -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 && !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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user