[IMP] web: support of properties in domain selector

Before this commit, users could not query properties
with the domain selector. Now, all the properties are
listed when selecting a properties field.

task id: 3283212

closes odoo/odoo#115270

Signed-off-by: Mathieu Duckerts-Antoine <dam@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:
Michael (mcm)
2023-04-27 04:31:00 +02:00
co-authored by Mathieu Duckerts-Antoine
parent 187ad14840
commit a79765ed33
9 changed files with 421 additions and 26 deletions
@@ -164,7 +164,7 @@ export class DomainSelector extends Component {
fieldDef = { type: "integer" };
}
node.field = { ...fieldDef, name: field };
node.operator = getOperatorsInfo(fieldDef.type)[0];
node.operator = getOperatorsInfo(fieldDef)[0];
node.value = getDefaultFieldValue(fieldDef);
this.notifyChanges();
}
@@ -205,11 +205,11 @@ export class DomainSelector extends Component {
}
getEditorInfo(node) {
return getEditorInfo(node.field.type, node.operator.key);
return getEditorInfo(node.field, node.operator.key);
}
getOperatorsInfo(node) {
const operators = getOperatorsInfo(node.field.type);
const operators = getOperatorsInfo(node.field);
if (!operators.some((op) => op.key === node.operator.key)) {
operators.push(node.operator);
}
@@ -236,7 +236,32 @@ const SELECTION = {
in: SELECTION_EDITOR_IN,
not_in: SELECTION_EDITOR_IN,
},
defaultValue: (field) => field.selection[0][0],
defaultValue: (field) => field.selection[0][0] ?? false,
};
// ----------------------------------------------------------------------------
const PROPERTIES = {
operators: ["set", "not_set"],
defaultValue: () => false,
};
const PROPERTIES_SELECTION = {
operators: ["equal", "not_equal", "set", "not_set"],
editors: {
default: makeEditor(Select, ({ value, update, field }) => ({
value,
update,
options: field.selection || [],
})),
},
defaultValue: (field) => field.selection?.[0]?.[0] ?? false,
};
const PROPERTIES_RELATIONAL = {
operators: ["equal", "not_equal", "set", "not_set"],
editors: { default: makeEditor(Input) },
defaultValue: (field) => (field.type === "many2one" ? 1 : []),
};
// ============================================================================
@@ -253,24 +278,40 @@ export const FIELD_DESCRIPTIONS = {
many2one: RELATIONAL,
monetary: NUMBER,
one2many: RELATIONAL,
properties: PROPERTIES,
properties_definition: PROPERTIES,
selection: SELECTION,
text: TEXT,
};
export function getFieldInfo(fieldType) {
return FIELD_DESCRIPTIONS[fieldType] || DEFAULT;
const PROPERTIES_DESCRIPTIONS = {
selection: PROPERTIES_SELECTION,
many2many: PROPERTIES_RELATIONAL,
many2one: PROPERTIES_RELATIONAL,
one2many: PROPERTIES_RELATIONAL,
tags: PROPERTIES_RELATIONAL,
};
export function getFieldInfo(fieldDef) {
if (fieldDef.is_property && PROPERTIES_DESCRIPTIONS[fieldDef.type]) {
return PROPERTIES_DESCRIPTIONS[fieldDef.type];
} else if (FIELD_DESCRIPTIONS[fieldDef.type]) {
return FIELD_DESCRIPTIONS[fieldDef.type];
} else {
return DEFAULT;
}
}
export function getEditorInfo(fieldType, operator) {
const fieldInfo = getFieldInfo(fieldType);
export function getEditorInfo(fieldDef, operator) {
const fieldInfo = getFieldInfo(fieldDef);
return fieldInfo.editors[operator] || fieldInfo.editors.default;
}
export function getOperatorsInfo(fieldType) {
return selectOperators(getFieldInfo(fieldType).operators);
export function getOperatorsInfo(fieldDef) {
return selectOperators(getFieldInfo(fieldDef).operators);
}
export function getDefaultFieldValue(field) {
const desc = getFieldInfo(field.type);
return desc.defaultValue(field);
export function getDefaultFieldValue(fieldDef) {
const desc = getFieldInfo(fieldDef);
return desc.defaultValue(fieldDef);
}
+69 -8
View File
@@ -1,6 +1,7 @@
/** @odoo-module **/
import { Cache } from "@web/core/utils/cache";
import { Domain } from "@web/core/domain";
import { registry } from "@web/core/registry";
/**
@@ -40,16 +41,70 @@ export const fieldService = {
}
/**
* @param {string} resModel valid model name
* @param {Object} fieldDefs
* @param {string} name
* @param {import("@web/core/domain").DomainListRepr} [domain=[]]
* @returns {Promise<Object>}
*/
async function _loadPropertyDefinitions(fieldDefs, name, domain = []) {
const {
definition_record: definitionRecord,
definition_record_field: definitionRecordField,
} = fieldDefs[name];
const definitionRecordModel = fieldDefs[definitionRecord].relation;
domain = Domain.and([[[definitionRecordField, "!=", false]], domain]).toList();
const records = await orm.searchRead(definitionRecordModel, domain, [
"display_name",
definitionRecordField,
]);
const definitions = {};
for (const record of records) {
for (const definition of record[definitionRecordField]) {
definitions[definition.name] = {
is_property: true,
// for now, all properties are searchable but their definitions don't contain that info
searchable: true,
// differentiate definitions with same name but on different parent
record_name: record.display_name,
...definition,
};
}
}
return definitions;
}
/**
* @param {string} resModel
* @param {string} fieldName
* @param {import("@web/core/domain").DomainListRepr} [domain]
* @returns {Promise<object[]>}
*/
async function loadPropertyDefinitions(resModel, fieldName, domain) {
const fieldDefs = await loadFields(resModel);
return _loadPropertyDefinitions(fieldDefs, fieldName, domain);
}
/**
* @param {string|null} resModel valid model name or null (case virtual)
* @param {Object|null} fieldDefs
* @param {string[]} names
*/
async function _loadPath(resModel, fieldDefs, names) {
if (!fieldDefs) {
return { isInvalid: "path", names, modelsInfo: [] };
}
const [name, ...remainingNames] = names;
const modelsInfo = [{ resModel, fieldDefs }];
if (resModel === "*" && remainingNames.length) {
return { isInvalid: "path", names, modelsInfo };
}
const fieldDef = fieldDefs[name];
if (name !== "*" && !fieldDef) {
if ((name !== "*" && !fieldDef) || (name === "*" && remainingNames.length)) {
return { isInvalid: "path", names, modelsInfo };
}
@@ -57,16 +112,22 @@ export const fieldService = {
return { names, modelsInfo };
}
if (name === "*") {
return { isInvalid: "path", names, modelsInfo };
}
let subResult;
if (fieldDef.relation) {
const subResult = await _loadPath(
subResult = await _loadPath(
fieldDef.relation,
await loadFields(fieldDef.relation),
remainingNames
);
} else if (fieldDef.type === "properties") {
subResult = await _loadPath(
"*",
await _loadPropertyDefinitions(fieldDefs, name),
remainingNames
);
}
if (subResult) {
const result = {
names,
modelsInfo: [...modelsInfo, ...subResult.modelsInfo],
@@ -95,7 +156,7 @@ export const fieldService = {
return _loadPath(resModel, fieldDefs, path.split("."));
}
return { loadFields, loadPath };
return { loadFields, loadPath, loadPropertyDefinitions };
},
};
@@ -89,6 +89,13 @@ export class ModelFieldSelector extends Component {
}
const { isInvalid, modelsInfo, names } = await this.fieldService.loadPath(resModel, path);
const result = { isInvalid: !!isInvalid, displayNames: [] };
if (!isInvalid) {
const lastFieldDef = modelsInfo.at(-1).fieldDefs[names.at(-1)];
if (["properties", "properties_definition"].includes(lastFieldDef.type)) {
// there is no known case where we want to select a 'properties' field directly
result.isInvalid = true;
}
}
for (let index = 0; index < names.length; index++) {
const name = names[index];
const fieldDef = modelsInfo[index]?.fieldDefs[name];
@@ -105,9 +105,7 @@ export class ModelFieldSelectorPopover extends Component {
setup() {
this.fieldService = useService("field");
this.state = useState({ page: null });
this.keepLast = new KeepLast();
this.debouncedSearchFields = debounce(this.searchFields.bind(this), 250);
onWillStart(async () => {
@@ -201,6 +199,9 @@ export class ModelFieldSelectorPopover extends Component {
}
selectField(field) {
if (field.type === "properties") {
return this.followRelation(field);
}
this.keepLast.add(Promise.resolve());
this.state.page.selectedName = field.name;
this.props.update(this.state.page.path);
@@ -245,7 +246,7 @@ export class ModelFieldSelectorPopover extends Component {
const focusedFieldName = this.state.page.focusedFieldName;
if (focusedFieldName) {
const fieldDef = this.state.page.fieldDefs[focusedFieldName];
if (fieldDef.relation) {
if (fieldDef.relation || fieldDef.type === "properties") {
this.followRelation(fieldDef);
}
}
@@ -38,12 +38,13 @@
<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" />
<t t-if="fieldDef.record_name"> (<t t-esc="fieldDef.record_name" />)</t>
<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">
<t t-if="(!fieldDef.is_property and fieldDef.relation and props.followRelations) or fieldDef.type === 'properties'">
<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>
</button>
</t>
</li>
</t>
@@ -799,4 +799,96 @@ QUnit.module("Components", (hooks) => {
);
assert.containsOnce(target, ".o_model_field_selector_warning");
});
QUnit.test("support properties", async (assert) => {
assert.expect(7);
serverData.models.partner.fields.properties = {
string: "Properties",
type: "properties",
definition_record: "product_id",
definition_record_field: "definitions",
searchable: true,
};
serverData.models.product.fields.definitions = {
string: "Definitions",
type: "properties_definition",
};
serverData.models.product.records[0].definitions = [
{ name: "xphone_prop_1", string: "P1", type: "boolean" },
{ name: "xphone_prop_2", string: "P2", type: "selection", selection: [] },
];
serverData.models.product.records[1].definitions = [
{ name: "xpad_prop_1", string: "P3", type: "many2one", relation: "partner" },
];
let expectedDomain = `[("id", "=", 1)]`;
class Parent extends Component {
static template = xml`
<DomainSelector
resModel="'partner'"
value="value"
readonly="false"
isDebugMode="true"
update="(domain) => this.onUpdate(domain)"
/>
`;
static components = { DomainSelector };
setup() {
this.value = expectedDomain;
}
onUpdate(domain) {
assert.strictEqual(domain, expectedDomain);
this.value = domain;
this.render();
}
}
await mountComponent(Parent);
await openModelFieldSelectorPopover(target);
await click(
target,
".o_model_field_selector_popover_item[data-name='properties'] .o_model_field_selector_popover_relation_icon"
);
assert.strictEqual(
target.querySelector(".o_model_field_selector_value").textContent,
"Properties"
);
expectedDomain = `[("properties.xphone_prop_2", "=", False)]`;
await click(
target.querySelector(
".o_model_field_selector_popover_item[data-name='xphone_prop_2'] button"
)
);
assert.strictEqual(
target.querySelector(".o_model_field_selector_value").textContent,
"PropertiesP2"
);
assert.deepEqual(
[...target.querySelectorAll(".o_domain_leaf_operator_select option")].map(
(e) => e.value
),
["equal", "not_equal", "set", "not_set"]
);
await openModelFieldSelectorPopover(target);
expectedDomain = `[("properties.xpad_prop_1", "=", 1)]`;
await click(
target.querySelector(
".o_model_field_selector_popover_item[data-name='xpad_prop_1'] button"
)
);
assert.strictEqual(
target.querySelector(".o_model_field_selector_value").textContent,
"PropertiesP3"
);
assert.deepEqual(
[...target.querySelectorAll(".o_domain_leaf_operator_select option")].map(
(e) => e.value
),
["equal", "not_equal", "set", "not_set"]
);
});
});
@@ -13,6 +13,22 @@ function getModelInfo(resModel) {
};
}
function getDefinitions() {
const records = serverData.models.species.records;
const fieldDefs = {};
for (const record of records) {
for (const definition of record.definitions) {
fieldDefs[definition.name] = {
is_property: true,
searchable: true,
record_name: record.display_name,
...definition,
};
}
}
return { resModel: "*", fieldDefs };
}
let serverData;
QUnit.module("Field Service", {
async beforeEach() {
@@ -26,6 +42,13 @@ QUnit.module("Field Service", {
write_date: { string: "Last Modified on", type: "datetime" },
age: { type: "integer", string: "Age" },
location_id: { type: "many2one", string: "Location", relation: "location" },
species: { type: "many2one", string: "Species", relation: "species" },
property_field: {
string: "Properties",
type: "properties",
definition_record: "species",
definition_record_field: "definitions",
},
},
},
location: {
@@ -37,6 +60,42 @@ QUnit.module("Field Service", {
tortoise_ids: { type: "one2many", string: "Turtles", relation: "tortoise" },
},
},
species: {
fields: {
id: { string: "ID", type: "integer" },
display_name: { string: "Display Name", type: "char" },
name: { string: "Name", type: "char", default: "name" },
write_date: { string: "Last Modified on", type: "datetime" },
definitions: { string: "Definitions", type: "properties_definition" },
},
records: [
{
id: 1,
display_name: "Galápagos tortoise",
definitions: [
{
name: "galapagos_lifespans",
string: "Lifespans",
type: "integer",
},
{
name: "location_ids",
string: "Locations",
type: "many2many",
relation: "location",
},
],
},
{
id: 2,
display_name: "Aldabra giant tortoise",
definitions: [
{ name: "aldabra_lifespans", string: "Lifespans", type: "integer" },
{ name: "color", string: "Color", type: "char" },
],
},
],
},
},
};
serviceRegistry.add("field", fieldService);
@@ -118,6 +177,31 @@ QUnit.test("loadPath", async (assert) => {
],
},
},
{
resModel: "tortoise",
path: "property_field",
expectedResult: {
names: ["property_field"],
modelsInfo: [getModelInfo("tortoise")],
},
},
{
resModel: "tortoise",
path: "property_field.galapagos_lifespans",
expectedResult: {
names: ["property_field", "galapagos_lifespans"],
modelsInfo: [getModelInfo("tortoise"), getDefinitions()],
},
},
{
resModel: "tortoise",
path: "property_field.location_ids.tortoise_ids",
expectedResult: {
isInvalid: "path",
names: ["property_field", "location_ids", "tortoise_ids"],
modelsInfo: [getModelInfo("tortoise"), getDefinitions()],
},
},
];
const env = await makeTestEnv({ serverData });
@@ -63,6 +63,27 @@ function getFocusedFieldName(target) {
return target.querySelector(".o_model_field_selector_popover_item.active").innerText;
}
function addProperties() {
serverData.models.partner.fields.properties = {
string: "Properties",
type: "properties",
definition_record: "product_id",
definition_record_field: "definitions",
searchable: true,
};
serverData.models.product.fields.definitions = {
string: "Definitions",
type: "properties_definition",
};
serverData.models.product.records[0].definitions = [
{ name: "xphone_prop_1", string: "P1", type: "boolean" },
{ name: "xphone_prop_2", string: "P2", type: "char" },
];
serverData.models.product.records[1].definitions = [
{ name: "xpad_prop_1", string: "P1", type: "date" },
];
}
QUnit.module("Components", (hooks) => {
hooks.beforeEach(async () => {
serverData = {
@@ -720,4 +741,91 @@ QUnit.module("Components", (hooks) => {
target.querySelector(".o_model_field_selector_popover_search .o_input")
);
});
QUnit.test("support properties", async (assert) => {
addProperties();
class Parent extends Component {
static components = { ModelFieldSelector };
static template = xml`
<ModelFieldSelector
readonly="false"
resModel="'partner'"
path="path"
isDebugMode="true"
update="(path, fieldInfo) => this.onUpdate(path)"
/>
`;
setup() {
this.path = "foo";
}
onUpdate(path) {
this.path = path;
assert.step(path);
this.render();
}
}
await mountComponent(Parent);
await openModelFieldSelectorPopover(target);
assert.strictEqual(getTitle(target), "");
assert.containsOnce(target, ".o_model_field_selector_popover_item[data-name='properties']");
assert.containsOnce(
target,
".o_model_field_selector_popover_item[data-name='properties'] .o_model_field_selector_popover_relation_icon"
);
assert.deepEqual(getModelFieldSelectorValues(target), ["Foo"]);
assert.containsNone(target, ".o_model_field_selector_warning");
await click(
target,
".o_model_field_selector_popover_item[data-name='properties'] .o_model_field_selector_popover_relation_icon",
"click on the relation icon should open the properties page"
);
assert.deepEqual(getModelFieldSelectorValues(target), ["Properties"]);
assert.containsOnce(target, ".o_model_field_selector_warning");
assert.verifySteps([]);
await clickPrev(target);
assert.strictEqual(getTitle(target), "");
await click(
target,
".o_model_field_selector_popover_item[data-name='properties'] .o_model_field_selector_popover_item_name",
"click on the name should open the properties page"
);
assert.strictEqual(getTitle(target), "Properties");
assert.strictEqual(
target.querySelector(".o_model_field_selector_value").textContent,
"Properties"
);
assert.containsN(target, ".o_model_field_selector_popover_item", 3);
assert.containsOnce(
target,
".o_model_field_selector_popover_item[data-name='xphone_prop_1']"
);
assert.containsOnce(
target,
".o_model_field_selector_popover_item[data-name='xphone_prop_2']"
);
assert.containsOnce(
target,
".o_model_field_selector_popover_item[data-name='xpad_prop_1']"
);
assert.deepEqual(getDisplayedFieldNames(target), [
"P1 (xphone)xphone_prop_1 (boolean)",
"P1 (xpad)xpad_prop_1 (date)",
"P2 (xphone)xphone_prop_2 (char)",
]);
await click(
target,
".o_model_field_selector_popover_item[data-name='xphone_prop_2'] .o_model_field_selector_popover_item_name"
);
assert.verifySteps(["properties.xphone_prop_2"]);
assert.strictEqual(
target.querySelector(".o_model_field_selector_value").textContent,
"PropertiesP2"
);
assert.containsNone(target, ".o_model_field_selector_warning");
});
});