From a79765ed3370c459d44cba6cecbed0aa1e7e917f Mon Sep 17 00:00:00 2001 From: "Michael (mcm)" Date: Thu, 20 Apr 2023 11:27:56 +0000 Subject: [PATCH] [IMP] web: support of properties in domain selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Mathieu Duckerts-Antoine Co-authored-by: Michaël Mattiello --- .../core/domain_selector/domain_selector.js | 6 +- .../domain_selector/domain_selector_fields.js | 61 ++++++++-- addons/web/static/src/core/field_service.js | 77 +++++++++++-- .../model_field_selector.js | 7 ++ .../model_field_selector_popover.js | 7 +- .../model_field_selector_popover.xml | 5 +- .../tests/core/domain_selector_tests.js | 92 +++++++++++++++ .../static/tests/core/field_service_tests.js | 84 ++++++++++++++ .../tests/core/model_field_selector_tests.js | 108 ++++++++++++++++++ 9 files changed, 421 insertions(+), 26 deletions(-) diff --git a/addons/web/static/src/core/domain_selector/domain_selector.js b/addons/web/static/src/core/domain_selector/domain_selector.js index 34d493b8d62..a920dc9f59b 100644 --- a/addons/web/static/src/core/domain_selector/domain_selector.js +++ b/addons/web/static/src/core/domain_selector/domain_selector.js @@ -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); } diff --git a/addons/web/static/src/core/domain_selector/domain_selector_fields.js b/addons/web/static/src/core/domain_selector/domain_selector_fields.js index 5aff8aaaaa5..2fe11c26dbc 100644 --- a/addons/web/static/src/core/domain_selector/domain_selector_fields.js +++ b/addons/web/static/src/core/domain_selector/domain_selector_fields.js @@ -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); } diff --git a/addons/web/static/src/core/field_service.js b/addons/web/static/src/core/field_service.js index d35cd0f999e..4c405efa855 100644 --- a/addons/web/static/src/core/field_service.js +++ b/addons/web/static/src/core/field_service.js @@ -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} + */ + 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} + */ + 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 }; }, }; diff --git a/addons/web/static/src/core/model_field_selector/model_field_selector.js b/addons/web/static/src/core/model_field_selector/model_field_selector.js index 88dcfb23d65..49265b50630 100644 --- a/addons/web/static/src/core/model_field_selector/model_field_selector.js +++ b/addons/web/static/src/core/model_field_selector/model_field_selector.js @@ -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]; diff --git a/addons/web/static/src/core/model_field_selector/model_field_selector_popover.js b/addons/web/static/src/core/model_field_selector/model_field_selector_popover.js index e824d1d9368..83eb837697e 100644 --- a/addons/web/static/src/core/model_field_selector/model_field_selector_popover.js +++ b/addons/web/static/src/core/model_field_selector/model_field_selector_popover.js @@ -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); } } diff --git a/addons/web/static/src/core/model_field_selector/model_field_selector_popover.xml b/addons/web/static/src/core/model_field_selector/model_field_selector_popover.xml index d52327d0e70..fffc9b258c5 100644 --- a/addons/web/static/src/core/model_field_selector/model_field_selector_popover.xml +++ b/addons/web/static/src/core/model_field_selector/model_field_selector_popover.xml @@ -38,12 +38,13 @@
  • - + +
  • diff --git a/addons/web/static/tests/core/domain_selector_tests.js b/addons/web/static/tests/core/domain_selector_tests.js index f3a8ca45fb5..6c708664b43 100644 --- a/addons/web/static/tests/core/domain_selector_tests.js +++ b/addons/web/static/tests/core/domain_selector_tests.js @@ -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` + + `; + 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"] + ); + }); }); diff --git a/addons/web/static/tests/core/field_service_tests.js b/addons/web/static/tests/core/field_service_tests.js index 5e85c52bdad..454bd4cc0bb 100644 --- a/addons/web/static/tests/core/field_service_tests.js +++ b/addons/web/static/tests/core/field_service_tests.js @@ -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 }); diff --git a/addons/web/static/tests/core/model_field_selector_tests.js b/addons/web/static/tests/core/model_field_selector_tests.js index 2a0d1b3cd8c..198e5b28017 100644 --- a/addons/web/static/tests/core/model_field_selector_tests.js +++ b/addons/web/static/tests/core/model_field_selector_tests.js @@ -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` + + `; + 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"); + }); });