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')"); + }); });