From 731db3d459a3ca46689a38f013bedb7fd11099ee Mon Sep 17 00:00:00 2001 From: "Julien Carion (juca)" Date: Thu, 29 Jun 2023 09:43:50 +0000 Subject: [PATCH] [IMP] web, mass_mailing: make domain field foldable This commit allows the domain field to become foldable such that it will display its domain in the form of facets (similar to the search bar's ones) when it is folded. Also applies this functionality directly to the mass_mailing form view. task-3339512 closes odoo/odoo#126789 Signed-off-by: Mathieu Duckerts-Antoine (dam) --- .../views/mailing_mailing_views.xml | 2 +- .../static/src/core/domain_selector/utils.js | 151 +++++++++++++++++- addons/web/static/src/search/search_model.js | 150 +---------------- .../src/views/fields/domain/domain_field.js | 52 ++++++ .../src/views/fields/domain/domain_field.xml | 105 ++++++++---- .../tests/views/fields/domain_field_tests.js | 71 ++++++++ 6 files changed, 346 insertions(+), 185 deletions(-) diff --git a/addons/mass_mailing/views/mailing_mailing_views.xml b/addons/mass_mailing/views/mailing_mailing_views.xml index 740e7ec61a8..067cf142572 100644 --- a/addons/mass_mailing/views/mailing_mailing_views.xml +++ b/addons/mass_mailing/views/mailing_mailing_views.xml @@ -232,7 +232,7 @@
-
diff --git a/addons/web/static/src/core/domain_selector/utils.js b/addons/web/static/src/core/domain_selector/utils.js index 977919ef717..e608a822e98 100644 --- a/addons/web/static/src/core/domain_selector/utils.js +++ b/addons/web/static/src/core/domain_selector/utils.js @@ -3,10 +3,19 @@ import { Domain } from "@web/core/domain"; import { getDefaultValue, getDefaultOperator } from "./domain_selector_fields"; import { useService } from "@web/core/utils/hooks"; -import { Expression, toValue, toDomain, toTree } from "@web/core/domain_tree"; import { _t } from "@web/core/l10n/translation"; import { unique, zip } from "@web/core/utils/arrays"; import { sprintf } from "@web/core/utils/strings"; +import { + Expression, + toValue, + toDomain, + toTree, + normalizeValue, + formatValue as toSring, +} from "@web/core/domain_tree"; +import { useLoadFieldInfo, useLoadPathDescription } from "@web/core/model_field_selector/utils"; +import { getOperatorInfo } from "@web/core/domain_selector/domain_selector_operators"; /** * @param {import("@web/core/domain_tree").Tree} tree @@ -276,3 +285,143 @@ export function useGetDefaultLeafDomain() { return getDefaultDomain(fieldDefs); }; } + +/** + * @param {Tree} tree + * @returns {tree} + */ +export function simplifyTree(tree, isRoot = true) { + if (tree.type === "condition") { + return tree; + } + const processedChildren = tree.children.map((c) => simplifyTree(c, false)); + if (tree.value === "&") { + return { ...tree, children: processedChildren }; + } + const children = []; + const childrenByPath = {}; + for (const child of processedChildren) { + if ( + child.type === "connector" || + typeof child.path !== "string" || + !["=", "in"].includes(child.operator) + ) { + children.push(child); + } else { + if (!childrenByPath[child.path]) { + childrenByPath[child.path] = []; + } + childrenByPath[child.path].push(child); + } + } + for (const path in childrenByPath) { + if (childrenByPath[path].length === 1) { + children.push(childrenByPath[path][0]); + continue; + } + const value = []; + for (const child of childrenByPath[path]) { + if (child.operator === "=") { + value.push(child.value); + } else { + value.push(...child.value); + } + } + children.push({ + type: "condition", + negate: false, + operator: "in", + path: path, + value: normalizeValue(value), + }); + } + if (children.length === 1 && !isRoot) { + return { ...children[0] }; + } + return { ...tree, children }; +} + +/** + * @param {Tree} tree + * @param {Function} getDescription + * @param {boolean} [isSubExpression=true] + * @returns {string} + */ +export function getDomainTreeDescription( + tree, + getFieldDef, + getDescription, + displayNames, + isSubExpression = true +) { + if (tree.type === "connector") { + // we assume that the domain tree is normalized (--> there is at least two children) + const childDescriptions = tree.children.map((c) => + getDomainTreeDescription(c, getFieldDef, getDescription, displayNames) + ); + const separator = tree.value === "&" ? _t("and") : _t("or"); + let description = childDescriptions.join(` ${separator} `); + if (isSubExpression || tree.negate) { + description = `( ${description} )`; + } + if (tree.negate) { + description = `! ${description}`; + } + return description; + } + const { negate, operator, path, value } = tree; + const fieldDef = getFieldDef(path); + const operatorInfo = getOperatorInfo(operator, negate); + const { operatorDescription, valueDescription } = leafToString( + fieldDef, + operatorInfo, + value, + displayNames[fieldDef?.relation] + ); + let description = `${getDescription(path)} ${operatorDescription} `; + if (valueDescription) { + const { values, join, addParenthesis } = valueDescription; + const jointedValues = values.join(` ${join} `); + description += addParenthesis ? `( ${jointedValues} )` : jointedValues; + } + return description; +} + +export function useGetDomainTreeDescription(fieldService, nameService) { + fieldService ||= useService("field"); + nameService ||= useService("name"); + const loadFieldInfo = useLoadFieldInfo(fieldService); + const loadPathDescription = useLoadPathDescription(fieldService); + const loadDisplayNames = useLoadDisplayNames(nameService); + return async (resModel, tree) => { + tree = simplifyTree(tree); + const domain = toDomain(tree); + const paths = extractPathsFromDomain(domain); + const promises = []; + const pathFieldDefs = {}; + const pathDescriptions = {}; + for (const path of paths) { + promises.push( + loadPathDescription(resModel, path).then(({ displayNames }) => { + pathDescriptions[toSring(path)] = displayNames.join(" 🠒 "); + }), + loadFieldInfo(resModel, path).then(({ fieldDef }) => { + pathFieldDefs[toSring(path)] = fieldDef; + }) + ); + } + await Promise.all(promises); + const getFieldDef = (path) => pathFieldDefs[toSring(path)]; + const getDescription = (path) => pathDescriptions[toSring(path)]; + const idsByModel = extractIdsFromDomain(domain, getFieldDef); + const treeWithVirtualOperators = createVirtualOperators(tree, getFieldDef); + const displayNames = await loadDisplayNames(idsByModel); + return getDomainTreeDescription( + treeWithVirtualOperators, + getFieldDef, + getDescription, + displayNames, + false + ); + }; +} diff --git a/addons/web/static/src/search/search_model.js b/addons/web/static/src/search/search_model.js index e2f4a5a32d1..3d631511688 100644 --- a/addons/web/static/src/search/search_model.js +++ b/addons/web/static/src/search/search_model.js @@ -19,17 +19,9 @@ import { import { FACET_ICONS, FACET_COLORS } from "./utils/misc"; import { EventBus, toRaw } from "@odoo/owl"; -import { toDomain, toTree, formatValue, normalizeValue } from "@web/core/domain_tree"; -import { - createVirtualOperators, - extractIdsFromDomain, - extractPathsFromDomain, - leafToString, - useLoadDisplayNames, -} from "@web/core/domain_selector/utils"; -import { useLoadFieldInfo, useLoadPathDescription } from "@web/core/model_field_selector/utils"; +import { toDomain, toTree } from "@web/core/domain_tree"; import { _t } from "@web/core/l10n/translation"; -import { getOperatorInfo } from "@web/core/domain_selector/domain_selector_operators"; +import { useGetDomainTreeDescription } from "@web/core/domain_selector/utils"; const { DateTime } = luxon; @@ -159,144 +151,6 @@ function execute(op, source, target) { } } -/** - * @param {Tree} tree - * @returns {tree} - */ -function simplifyTree(tree, isRoot = true) { - if (tree.type === "condition") { - return tree; - } - const processedChildren = tree.children.map((c) => simplifyTree(c, false)); - if (tree.value === "&") { - return { ...tree, children: processedChildren }; - } - const children = []; - const childrenByPath = {}; - for (const child of processedChildren) { - if ( - child.type === "connector" || - typeof child.path !== "string" || - !["=", "in"].includes(child.operator) - ) { - children.push(child); - } else { - if (!childrenByPath[child.path]) { - childrenByPath[child.path] = []; - } - childrenByPath[child.path].push(child); - } - } - for (const path in childrenByPath) { - if (childrenByPath[path].length === 1) { - children.push(childrenByPath[path][0]); - continue; - } - const value = []; - for (const child of childrenByPath[path]) { - if (child.operator === "=") { - value.push(child.value); - } else { - value.push(...child.value); - } - } - children.push({ - type: "condition", - negate: false, - operator: "in", - path: path, - value: normalizeValue(value), - }); - } - if (children.length === 1 && !isRoot) { - return { ...children[0] }; - } - return { ...tree, children }; -} - -/** - * @param {Tree} tree - * @param {Function} getDescription - * @param {boolean} [isSubExpression=true] - * @returns {string} - */ -function getDomainTreeDescription( - tree, - getFieldDef, - getDescription, - displayNames, - isSubExpression = true -) { - if (tree.type === "connector") { - // we assume that the domain tree is normalized (--> there is at least two children) - const childDescriptions = tree.children.map((c) => - getDomainTreeDescription(c, getFieldDef, getDescription, displayNames) - ); - const separator = tree.value === "&" ? _t("and") : _t("or"); - let description = childDescriptions.join(` ${separator} `); - if (isSubExpression || tree.negate) { - description = `( ${description} )`; - } - if (tree.negate) { - description = `! ${description}`; - } - return description; - } - const { negate, operator, path, value } = tree; - const fieldDef = getFieldDef(path); - const operatorInfo = getOperatorInfo(operator, negate); - const { operatorDescription, valueDescription } = leafToString( - fieldDef, - operatorInfo, - value, - displayNames[fieldDef?.relation] - ); - let description = `${getDescription(path)} ${operatorDescription} `; - if (valueDescription) { - const { values, join, addParenthesis } = valueDescription; - const jointedValues = values.join(` ${join} `); - description += addParenthesis ? `( ${jointedValues} )` : jointedValues; - } - return description; -} - -function useGetDomainTreeDescription(fieldService, nameService) { - const loadFieldInfo = useLoadFieldInfo(fieldService); - const loadPathDescription = useLoadPathDescription(fieldService); - const loadDisplayNames = useLoadDisplayNames(nameService); - return async (resModel, tree) => { - tree = simplifyTree(tree); - const domain = toDomain(tree); - const paths = extractPathsFromDomain(domain); - const promises = []; - const pathFieldDefs = {}; - const pathDescriptions = {}; - for (const path of paths) { - promises.push( - loadPathDescription(resModel, path).then(({ displayNames }) => { - pathDescriptions[formatValue(path)] = displayNames.join(" 🠒 "); - }), - loadFieldInfo(resModel, path).then(({ fieldDef }) => { - pathFieldDefs[formatValue(path)] = fieldDef; - }) - ); - } - await Promise.all(promises); - const getFieldDef = (path) => pathFieldDefs[formatValue(path)]; - const getDescription = (path) => pathDescriptions[formatValue(path)]; - const idsByModel = extractIdsFromDomain(domain, getFieldDef); - const treeWithVirtualOperators = createVirtualOperators(tree, getFieldDef); - const displayNames = await loadDisplayNames(idsByModel); - return getDomainTreeDescription( - treeWithVirtualOperators, - getFieldDef, - getDescription, - displayNames, - false - ); - }; -} - //-------------------------------------------------------------------------- // Global constants/variables //-------------------------------------------------------------------------- diff --git a/addons/web/static/src/views/fields/domain/domain_field.js b/addons/web/static/src/views/fields/domain/domain_field.js index 4456fc3d890..b5779faa866 100644 --- a/addons/web/static/src/views/fields/domain/domain_field.js +++ b/addons/web/static/src/views/fields/domain/domain_field.js @@ -10,6 +10,8 @@ import { registry } from "@web/core/registry"; import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog"; import { standardFieldProps } from "../standard_field_props"; import { useBus, useService, useOwnedDialogs } from "@web/core/utils/hooks"; +import { toTree } from "@web/core/domain_tree"; +import { useGetDomainTreeDescription } from "@web/core/domain_selector/utils"; export class DomainField extends Component { static template = "web.DomainField"; @@ -21,30 +23,41 @@ export class DomainField extends Component { context: { type: Object, optional: true }, editInDialog: { type: Boolean, optional: true }, resModel: { type: String, optional: true }, + isFoldable: { type: Boolean, optional: true }, }; static defaultProps = { editInDialog: false, + isFoldable: false, }; setup() { this.rpc = useService("rpc"); this.orm = useService("orm"); + this.getDomainTreeDescription = useGetDomainTreeDescription(); this.addDialog = useOwnedDialogs(); this.state = useState({ isValid: null, recordCount: null, + folded: this.props.isFoldable, + facets: [], }); this.isDebugEdited = false; onWillStart(() => { this.checkProps(); // not awaited + if (this.props.isFoldable) { + this.loadFacets(); + } }); onWillUpdateProps((nextProps) => { this.isDebugEdited = this.isDebugEdited && this.props.readonly === nextProps.readonly; if (!this.isDebugEdited) { this.checkProps(nextProps); // not awaited } + if (nextProps.isFoldable) { + this.loadFacets(nextProps); + } }); useBus(this.props.record.model.bus, "NEED_LOCAL_CHANGES", async (ev) => { @@ -98,6 +111,40 @@ export class DomainField extends Component { return resModel; } + async loadFacets(props = this.props) { + const resModel = this.getResModel(props); + + if (!resModel) { + this.state.facets = []; + this.state.folded = false; + return; + } + + if (typeof resModel !== "string") { + // we don't want to support invalid models + throw new Error(`Invalid model: ${resModel}`); + } + + let promises; + const domain = this.getDomain(props); + try { + const tree = toTree(domain, { distributeNot: !this.env.debug }); + const trees = !tree.negate && tree.value === "&" ? tree.children : [tree]; + promises = trees.map(async (tree) => { + const description = await this.getDomainTreeDescription(resModel, tree); + return description; + }); + } catch (error) { + if (error.data?.name === "builtins.KeyError" && error.data.message === resModel) { + // we don't want to support invalid models + throw new Error(`Invalid model: ${resModel}`); + } + this.state.facets = []; + this.state.folded = false; + } + this.state.facets = await Promise.all(promises); + } + async checkProps(props = this.props) { const resModel = this.getResModel(props); if (!resModel) { @@ -179,6 +226,10 @@ export class DomainField extends Component { return this.props.record.update({ [this.props.name]: domain }); } + fold() { + this.state.folded = true; + } + updateState(params = {}) { Object.assign(this.state, { isValid: "isValid" in params ? params.isValid : null, @@ -207,6 +258,7 @@ export const domainField = { extractProps({ options }, dynamicInfo) { return { editInDialog: options.in_dialog, + isFoldable: options.foldable, resModel: options.model, context: dynamicInfo.context, }; diff --git a/addons/web/static/src/views/fields/domain/domain_field.xml b/addons/web/static/src/views/fields/domain/domain_field.xml index 405de98d836..1098e6d8afa 100644 --- a/addons/web/static/src/views/fields/domain/domain_field.xml +++ b/addons/web/static/src/views/fields/domain/domain_field.xml @@ -5,45 +5,80 @@
- -
- - - - - - - + +
+ +
+ + Match all records + + +
+
+ +
+
+ +
+
+
+ + + +
+
+
+ +
+ + + + +
+
+ + + - - Invalid domain - - - - + + + + Invalid domain + + + + + + + - -
+
+
Select a model to add a filter.
diff --git a/addons/web/static/tests/views/fields/domain_field_tests.js b/addons/web/static/tests/views/fields/domain_field_tests.js index 098101e76f2..53d4694dc47 100644 --- a/addons/web/static/tests/views/fields/domain_field_tests.js +++ b/addons/web/static/tests/views/fields/domain_field_tests.js @@ -994,4 +994,75 @@ QUnit.module("Fields", (hooks) => { ); } ); + QUnit.test("domain field can be foldable", async function (assert) { + serverData.models.partner.records[0].foo = "[]"; + + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: ` +
+ + + + + +
`, + }); + + // As the domain is empty, the "Match all records" span should be visible + assert.strictEqual( + target.querySelector(".o_field_domain span").textContent, + "Match all records" + ); + + // Unfold the domain + await click(target, ".o_field_domain > div > div"); + + // As the domain is empty, there should be a button to add the first + // domain part + assert.containsOnce(target, ".o_domain_add_first_node_button"); + + // 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_model_field_selector"); + + // Focusing the field selector input should open the field selector + // popover + await click(target, ".o_model_field_selector"); + assert.containsOnce(document.body, ".o_model_field_selector_popover"); + assert.containsOnce(document.body, ".o_model_field_selector_popover_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_model_field_selector_popover_item_name").textContent, + "Color index" + ); + + // Clicking on this field should close the popover, then changing the + // associated value should reveal one matched record + await click(document.body.querySelector(".o_model_field_selector_popover_item_name")); + + await editInput(target, ".o_domain_leaf_value_input", 2); + + assert.strictEqual( + target.querySelector(".o_domain_show_selection_button").textContent.trim().substr(0, 2), + "1 ", + "changing color value to 2 should reveal only one record" + ); + + // Saving the form view should show a readonly domain containing the + // "color" field + await clickSave(target); + assert.ok(target.querySelector(".o_field_domain").textContent.includes("Color index")); + + // Fold domain selector + await click(target, ".o_field_domain a i"); + + assert.containsOnce(target, ".o_field_domain .o_facet_values:contains('Color index = 2')"); + }); });