[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) <dam@odoo.com>
This commit is contained in:
Julien Carion (juca)
2023-07-19 18:24:07 +02:00
parent f96988cbe5
commit 731db3d459
6 changed files with 346 additions and 185 deletions
@@ -232,7 +232,7 @@
<field name="mailing_on_mailing_list" invisible="1"/>
<field name="mailing_model_real" invisible="1"/>
<div attrs="{'invisible': [('mailing_on_mailing_list', '=', True)]}">
<field name="mailing_domain" widget="domain" options="{'model': 'mailing_model_real'}"
<field name="mailing_domain" widget="domain" options="{'model': 'mailing_model_real', 'foldable': true}"
attrs="{'readonly': [('state', 'in', ('sending', 'done'))]}"/>
</div>
</div>
@@ -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
);
};
}
+2 -148
View File
@@ -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
//--------------------------------------------------------------------------
@@ -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,
};
@@ -5,45 +5,80 @@
<div t-att-class="{ o_inline_mode: !props.editInDialog }">
<t t-set="resModel" t-value="getResModel()"/>
<t t-if="resModel">
<DomainSelector
resModel="resModel"
domain="getDomain()"
readonly="props.readonly or props.editInDialog"
update.bind="update"
isDebugMode="!!env.debug"
className="props.readonly ? 'o_read_mode' : 'o_edit_mode'"
/>
<div class="o_field_domain_panel d-flex align-items-center gap-2 mt-1">
<i class="oi oi-arrow-right" role="img" aria-label="Domain" title="Domain" />
<t t-if="state.isValid === null and state.recordCount === null">
<i class="fa fa-circle-o-notch fa-spin ms-2" role="img" aria-label="Loading" title="Loading" />
</t>
<t t-else="">
<t t-if="state.isValid">
<button class="btn btn-sm btn-link o_domain_show_selection_button" type="button" t-on-click.stop="onButtonClick">
<t t-esc="state.recordCount" /> record(s)
</button>
<t t-if="props.isFoldable and state.folded">
<div class="d-flex align-items-center" t-on-click="() => state.folded = false">
<i data-tooltip="Modify filter" class="fa fa-lg fa-caret-right pe-2"/>
<div class="d-print-contents">
<t t-if="!state.facets.length">
<span>Match <strong>all records</strong></span>
</t>
<t t-foreach="state.facets" t-as="facet" t-key="facet_index">
<div class="d-inline-flex align-items-stretch text-nowrap px-1 position-relative"
role="listitem"
tabindex="0"
>
<div class="o_searchview_facet_label rounded-start-2 btn btn-primary rounded-end-0 p-0" role="button">
<i class="px-1 small fa fa-filter" role="image"/>
</div>
<div class="o_facet_values d-flex align-items-center px-2 bg-200 rounded-end-2">
<small class="o_facet_value" t-esc="facet"/>
</div>
</div>
</t>
<t t-if="state.isValid">
<button class="btn btn-sm btn-link o_domain_show_selection_button" data-tooltip="Show matching records" type="button" t-on-click.stop="onButtonClick">
<t t-esc="state.recordCount" /> record(s)
</button>
</t>
</div>
</div>
</t>
<t t-else="">
<div class="d-flex">
<a t-if="props.isFoldable" t-on-click="fold">
<i class="fa fa-lg fa-caret-down pe-2"></i>
</a>
<DomainSelector
resModel="resModel"
domain="getDomain()"
readonly="props.readonly or props.editInDialog"
update.bind="update"
isDebugMode="!!env.debug"
className="props.readonly ? 'o_read_mode' : 'o_edit_mode'"
/>
</div>
<div class="o_field_domain_panel d-flex align-items-center gap-2 mt-1">
<i class="oi oi-arrow-right" role="img" aria-label="Domain" title="Domain" />
<t t-if="state.isValid === null and state.recordCount === null">
<i class="fa fa-circle-o-notch fa-spin ms-2" role="img" aria-label="Loading" title="Loading" />
</t>
<t t-else="">
<span class="text-warning" role="alert">
<i class="fa fa-exclamation-triangle" role="img" aria-label="Warning" title="Warning" /> Invalid domain
</span>
</t>
<t t-if="!!env.debug and !props.readonly">
<button
class="btn btn-sm btn-icon fa fa-refresh o_refresh_count"
role="img"
aria-label="Refresh"
title="Refresh"
t-on-click="() => this.checkProps()"
/>
</t>
<t t-if="state.isValid">
<button class="btn btn-sm btn-link o_domain_show_selection_button" type="button" t-on-click.stop="onButtonClick">
<t t-esc="state.recordCount" /> record(s)
</button>
</t>
<t t-else="">
<span class="text-warning" role="alert">
<i class="fa fa-exclamation-triangle" role="img" aria-label="Warning" title="Warning" /> Invalid domain
</span>
</t>
<t t-if="!!env.debug and !props.readonly">
<button
class="btn btn-sm btn-icon fa fa-refresh o_refresh_count"
role="img"
aria-label="Refresh"
title="Refresh"
t-on-click="() => this.checkProps()"
/>
</t>
<t t-if="props.editInDialog and !props.readonly">
<button class="btn btn-sm btn-primary o_field_domain_dialog_button" t-on-click.prevent="onEditDialogBtnClick">Edit Domain</button>
<t t-if="props.editInDialog and !props.readonly">
<button class="btn btn-sm btn-primary o_field_domain_dialog_button" t-on-click.prevent="onEditDialogBtnClick">Edit Domain</button>
</t>
</t>
</t>
</div>
</div>
</t>
</t>
<t t-else="">
<div>Select a model to add a filter.</div>
@@ -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: `
<form>
<sheet>
<group>
<field name="foo" widget="domain" options="{'model': 'partner_type', 'foldable': true}" />
</group>
</sheet>
</form>`,
});
// 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')");
});
});