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 @@
-
-
-
-
-
-
-
-
-
- record(s)
-
+
+
+
+
+
+ Match all records
+
+
+
+
+
+
+ record(s)
+
+
+
+
+
+
+
+
+
+
+
-
- Invalid domain
-
-
-
-
-
+
+
+ record(s)
+
+
+
+
+ Invalid domain
+
+
+
+
+
-
- Edit Domain
+
+ Edit 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')");
+ });
});