diff --git a/addons/board/static/tests/add_to_dashboard_tests.js b/addons/board/static/tests/add_to_dashboard_tests.js index c4dc1f907df..ddea0e7e56c 100644 --- a/addons/board/static/tests/add_to_dashboard_tests.js +++ b/addons/board/static/tests/add_to_dashboard_tests.js @@ -9,10 +9,8 @@ import { triggerEvent, } from "@web/../tests/helpers/utils"; import { - applyFilter, applyGroup, - editConditionValue, - toggleAddCustomFilter, + removeFacet, toggleAddCustomGroup, toggleComparisonMenu, toggleFavoriteMenu, @@ -82,11 +80,7 @@ QUnit.module("Board", (hooks) => { }; LegacyFavoriteMenu.registry.add("add-to-board-menu", LegacyAddToBoard, 10); - favoriteMenuRegistry.add( - "add-to-board", - addToBoardItem, - { sequence: 10 } - ); + favoriteMenuRegistry.add("add-to-board", addToBoardItem, { sequence: 10 }); serverData = { models }; target = getFixture(); }); @@ -167,7 +161,12 @@ QUnit.module("Board", (hooks) => { patchWithCleanup(browser, { setTimeout: (fn) => fn() }); serverData.views = { "partner,false,list": '', - "partner,false,search": "", + "partner,false,search": ` + + + + + `, }; const mockRPC = (route, args) => { @@ -204,9 +203,7 @@ QUnit.module("Board", (hooks) => { var filter_count = 0; // Add a first filter await toggleFilterMenu(target); - await toggleAddCustomFilter(target); - await editConditionValue(target, 0, "a"); - await applyFilter(target); + await toggleMenuItem(target, "Filter on a"); // Add it to dashboard await toggleFavoriteMenu(target); @@ -214,13 +211,12 @@ QUnit.module("Board", (hooks) => { await testUtils.dom.click($(".o_add_to_board .dropdown-menu button")); // Remove it - await testUtils.dom.click(target.querySelector(".o_facet_remove")); + await removeFacet(target); // Add the second filter await toggleFilterMenu(target); - await toggleAddCustomFilter(target); - await editConditionValue(target, 0, "b"); - await applyFilter(target); + await toggleMenuItem(target, "Filter on b"); + // Add it to dashboard await toggleFavoriteMenu(target); await testUtils.dom.triggerEvent( @@ -230,7 +226,7 @@ QUnit.module("Board", (hooks) => { await testUtils.dom.click(target.querySelector(".o_add_to_board .dropdown-menu button")); }); - QUnit.test("save a action domain to dashboard", async function (assert) { + QUnit.test("save an action domain to dashboard", async function (assert) { // View domains are to be added to the dashboard domain assert.expect(1); @@ -242,7 +238,11 @@ QUnit.module("Board", (hooks) => { serverData.views = { "partner,false,list": '', - "partner,false,search": "", + "partner,false,search": ` + + + + `, }; const mockRPC = (route, args) => { @@ -264,9 +264,8 @@ QUnit.module("Board", (hooks) => { // Add a filter await toggleFilterMenu(target); - await toggleAddCustomFilter(target); - await editConditionValue(target, 0, "b"); - await applyFilter(target); + await toggleMenuItem(target, "Filter"); + // Add it to dashboard await toggleFavoriteMenu(target); await testUtils.dom.triggerEvent( @@ -282,7 +281,7 @@ QUnit.module("Board", (hooks) => { serverData.views = { "partner,false,pivot": '', - "partner,false,search": '', + "partner,false,search": "", }; registry.category("services").add("user", makeFakeUserService()); const webClient = await createWebClient({ serverData }); diff --git a/addons/web/static/src/core/domain_selector_dialog/domain_selector_dialog.js b/addons/web/static/src/core/domain_selector_dialog/domain_selector_dialog.js index 2be8cab429b..48350533110 100644 --- a/addons/web/static/src/core/domain_selector_dialog/domain_selector_dialog.js +++ b/addons/web/static/src/core/domain_selector_dialog/domain_selector_dialog.js @@ -1,15 +1,14 @@ /** @odoo-module **/ +import { _t } from "../l10n/translation"; +import { Component, useState } from "@odoo/owl"; import { Dialog } from "../dialog/dialog"; import { DomainSelector } from "../domain_selector/domain_selector"; -import { _t } from "../l10n/translation"; - -import { Component, useState } from "@odoo/owl"; export class DomainSelectorDialog extends Component { setup() { this.state = useState({ - value: this.props.initialValue, + domain: this.props.domain, }); } @@ -24,15 +23,15 @@ export class DomainSelectorDialog extends Component { readonly: this.props.readonly, isDebugMode: this.props.isDebugMode, defaultLeafValue: this.props.defaultLeafValue, - value: this.state.value, - update: (value) => { - this.state.value = value; + value: this.state.domain, + update: (domain) => { + this.state.domain = domain; }, }; } - async onSave() { - await this.props.onSelected(this.state.value); + async onConfirm() { + await this.props.onConfirm(this.state.domain); this.props.close(); } onDiscard() { @@ -46,18 +45,17 @@ DomainSelectorDialog.components = { }; DomainSelectorDialog.props = { close: Function, - className: { type: String, optional: true }, + onConfirm: Function, resModel: String, - readonly: { type: Boolean, optional: true }, - isDebugMode: { type: Boolean, optional: true }, + className: { type: String, optional: true }, defaultLeafValue: { type: Array, optional: true }, - initialValue: { type: String, optional: true }, - onSelected: { type: Function, optional: true }, + domain: { type: String, optional: true }, + isDebugMode: { type: Boolean, optional: true }, + readonly: { type: Boolean, optional: true }, text: { type: String, optional: true }, }; DomainSelectorDialog.defaultProps = { - initialValue: "", - onSelected: () => {}, - readonly: true, + domain: "[]", isDebugMode: false, + readonly: false, }; diff --git a/addons/web/static/src/core/domain_selector_dialog/domain_selector_dialog.xml b/addons/web/static/src/core/domain_selector_dialog/domain_selector_dialog.xml index 923066f2c4d..482e64f1f7c 100644 --- a/addons/web/static/src/core/domain_selector_dialog/domain_selector_dialog.xml +++ b/addons/web/static/src/core/domain_selector_dialog/domain_selector_dialog.xml @@ -10,7 +10,7 @@ - + diff --git a/addons/web/static/src/legacy/js/control_panel/custom_filter_item.js b/addons/web/static/src/legacy/js/control_panel/custom_filter_item.js index 878264a121d..430d16da76b 100644 --- a/addons/web/static/src/legacy/js/control_panel/custom_filter_item.js +++ b/addons/web/static/src/legacy/js/control_panel/custom_filter_item.js @@ -276,6 +276,6 @@ CustomFilterItem.components = { DatePicker, DateTimePicker, Dropdown }; CustomFilterItem.props = { fields: Object }; - CustomFilterItem.template = "web.CustomFilterItem"; + CustomFilterItem.template = "web.Legacy.CustomFilterItem"; export default CustomFilterItem; diff --git a/addons/web/static/src/legacy/xml/base.xml b/addons/web/static/src/legacy/xml/base.xml index 25ce87cc3d2..4b9a50cb009 100644 --- a/addons/web/static/src/legacy/xml/base.xml +++ b/addons/web/static/src/legacy/xml/base.xml @@ -369,6 +369,114 @@ + + + + Add Custom Filter + + + + +
+ + +
+
+
+
- - props.fields + + diff --git a/addons/web/static/src/search/filter_menu/advanced_search_dialog.js b/addons/web/static/src/search/filter_menu/advanced_search_dialog.js new file mode 100644 index 00000000000..79f5dbefaf0 --- /dev/null +++ b/addons/web/static/src/search/filter_menu/advanced_search_dialog.js @@ -0,0 +1,18 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { DomainSelectorDialog } from "@web/core/domain_selector_dialog/domain_selector_dialog"; + +export class AdvancedSearchDialog extends DomainSelectorDialog { + get dialogTitle() { + return _t("Advanced Search"); + } + + async onConfirm() { + const confirmed = await this.props.onConfirm(this.state.domain); + if (confirmed) { + this.props.close(); + } + } +} +AdvancedSearchDialog.template = "web.AdvancedSearchDialog"; diff --git a/addons/web/static/src/search/filter_menu/advanced_search_dialog.xml b/addons/web/static/src/search/filter_menu/advanced_search_dialog.xml new file mode 100644 index 00000000000..5d4ae52a069 --- /dev/null +++ b/addons/web/static/src/search/filter_menu/advanced_search_dialog.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/web/static/src/search/filter_menu/custom_filter_item.js b/addons/web/static/src/search/filter_menu/custom_filter_item.js deleted file mode 100644 index 66c6cd2ef9e..00000000000 --- a/addons/web/static/src/search/filter_menu/custom_filter_item.js +++ /dev/null @@ -1,325 +0,0 @@ -/** @odoo-module **/ - -import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker"; -import { Domain } from "@web/core/domain"; -import { Dropdown } from "@web/core/dropdown/dropdown"; -import { serializeDate, serializeDateTime } from "@web/core/l10n/dates"; -import { _lt } from "@web/core/l10n/translation"; -import { registry } from "@web/core/registry"; - -import { Component, useState } from "@odoo/owl"; - -const { DateTime } = luxon; - -const formatters = registry.category("formatters"); -const parsers = registry.category("parsers"); - -const FIELD_TYPES = { - boolean: "boolean", - char: "char", - date: "date", - datetime: "datetime", - float: "number", - id: "id", - integer: "number", - html: "char", - many2many: "char", - many2one: "char", - monetary: "number", - one2many: "char", - text: "char", - selection: "selection", -}; - -// FilterMenu parameters -const FIELD_OPERATORS = { - boolean: [ - { symbol: "=", description: _lt("is Yes"), value: true }, - { symbol: "!=", description: _lt("is No"), value: true }, - ], - char: [ - { symbol: "ilike", description: _lt("contains") }, - { symbol: "not ilike", description: _lt("doesn't contain") }, - { symbol: "=", description: _lt("is equal to") }, - { symbol: "!=", description: _lt("is not equal to") }, - { symbol: "!=", description: _lt("is set"), value: false }, - { symbol: "=", description: _lt("is not set"), value: false }, - ], - date: [ - { symbol: "=", description: _lt("is equal to") }, - { symbol: "!=", description: _lt("is not equal to") }, - { symbol: ">", description: _lt("is after") }, - { symbol: "<", description: _lt("is before") }, - { symbol: ">=", description: _lt("is after or equal to") }, - { symbol: "<=", description: _lt("is before or equal to") }, - { symbol: "between", description: _lt("is between") }, - { symbol: "!=", description: _lt("is set"), value: false }, - { symbol: "=", description: _lt("is not set"), value: false }, - ], - datetime: [ - { symbol: "between", description: _lt("is between") }, - { symbol: "=", description: _lt("is equal to") }, - { symbol: "!=", description: _lt("is not equal to") }, - { symbol: ">", description: _lt("is after") }, - { symbol: "<", description: _lt("is before") }, - { symbol: ">=", description: _lt("is after or equal to") }, - { symbol: "<=", description: _lt("is before or equal to") }, - { symbol: "!=", description: _lt("is set"), value: false }, - { symbol: "=", description: _lt("is not set"), value: false }, - ], - id: [{ symbol: "=", description: _lt("is") }], - number: [ - { symbol: "=", description: _lt("is equal to") }, - { symbol: "!=", description: _lt("is not equal to") }, - { symbol: ">", description: _lt("greater than") }, - { symbol: "<", description: _lt("less than") }, - { symbol: ">=", description: _lt("greater than or equal to") }, - { symbol: "<=", description: _lt("less than or equal to") }, - { symbol: "!=", description: _lt("is set"), value: false }, - { symbol: "=", description: _lt("is not set"), value: false }, - ], - selection: [ - { symbol: "=", description: _lt("is") }, - { symbol: "!=", description: _lt("is not") }, - { symbol: "!=", description: _lt("is set"), value: false }, - { symbol: "=", description: _lt("is not set"), value: false }, - ], -}; - -function parseField(field, value) { - if (FIELD_TYPES[field.type] === "char") { - return value; - } - const type = field.type === "id" ? "integer" : field.type; - const parse = parsers.contains(type) ? parsers.get(type) : (v) => v; - return parse(value); -} - -function formatField(field, value) { - if (FIELD_TYPES[field.type] === "char") { - return value; - } - const type = field.type === "id" ? "integer" : field.type; - const format = formatters.contains(type) ? formatters.get(type) : (v) => v; - return format(value, { digits: field.digits }); -} - -export class CustomFilterItem extends Component { - setup() { - this.conditions = useState([]); - // Format, filter and sort the fields props - this.fields = Object.values(this.env.searchModel.searchViewFields) - .filter((field) => this.validateField(field)) - .concat({ string: "ID", type: "id", name: "id" }) - .sort(({ string: a }, { string: b }) => (a > b ? 1 : a < b ? -1 : 0)); - - // Give access to constants variables to the template. - this.OPERATORS = FIELD_OPERATORS; - this.FIELD_TYPES = FIELD_TYPES; - - // Add first condition - this.addNewCondition(); - } - - //--------------------------------------------------------------------- - // Protected - //--------------------------------------------------------------------- - - /** - * Populate the conditions list with a new condition having as properties: - * - the last condition or the first available field - * - the last condition or the first available operator - * - a null or empty array value - */ - addNewCondition() { - const lastCondition = [...this.conditions].pop(); - const condition = lastCondition - ? Object.assign({}, lastCondition) - : { - field: 0, - operator: 0, - }; - this.setDefaultValue(condition); - this.conditions.push(condition); - } - - /** - * @param {Object} field - * @returns {boolean} - */ - validateField(field) { - return ( - !field.deprecated && field.searchable && FIELD_TYPES[field.type] && field.name !== "id" - ); - } - - /** - * @param {Object} condition - */ - setDefaultValue(condition) { - const field = this.fields[condition.field]; - const genericType = FIELD_TYPES[field.type]; - const operator = FIELD_OPERATORS[genericType][condition.operator]; - // Logical value - switch (genericType) { - case "id": - case "number": { - condition.value = 0; - break; - } - case "date": - case "datetime": { - condition.value = [DateTime.local()]; - if (operator.symbol === "between") { - condition.value.push(DateTime.local()); - } - if (genericType === "datetime") { - condition.value[0] = condition.value[0].set({ hour: 0, minute: 0, second: 0 }); - if (operator.symbol === "between") { - condition.value[1] = condition.value[1].set({ - hour: 23, - minute: 59, - second: 59, - }); - } - } - break; - } - case "selection": { - const [firstValue] = this.fields[condition.field].selection[0]; - condition.value = firstValue; - break; - } - default: { - condition.value = ""; - } - } - // Displayed value (no needed for dates: they are handled by the DatePicker component) - if (!["date", "datetime"].includes(field.type)) { - condition.displayedValue = formatField(field, condition.value); - } - } - - //--------------------------------------------------------------------- - // Handlers - //--------------------------------------------------------------------- - - /** - * Convert all conditions to prefilters. - */ - onApply() { - const preFilters = this.conditions.map((condition) => { - const field = this.fields[condition.field]; - const genericType = this.FIELD_TYPES[field.type]; - const operator = this.OPERATORS[genericType][condition.operator]; - const descriptionArray = [field.string, operator.description.toString()]; - const domainArray = []; - let domainValue; - // Field type specifics - if ("value" in operator) { - domainValue = [operator.value]; - // No description to push here - } else if (["date", "datetime"].includes(genericType)) { - const serialize = genericType === "date" ? serializeDate : serializeDateTime; - domainValue = condition.value.map(serialize); - descriptionArray.push( - `"${condition.value - .map((val) => formatField(field, val)) - .join(" " + this.env._t("and") + " ")}"` - ); - } else { - domainValue = [condition.value]; - descriptionArray.push(`"${condition.value}"`); - } - // Operator specifics - if (operator.symbol === "between") { - domainArray.push( - [field.name, ">=", domainValue[0]], - [field.name, "<=", domainValue[1]] - ); - } else { - domainArray.push([field.name, operator.symbol, domainValue[0]]); - } - const preFilter = { - description: descriptionArray.join(" "), - domain: new Domain(domainArray).toString(), - type: "filter", - }; - return preFilter; - }); - - this.env.searchModel.createNewFilters(preFilters); - - // remove conditions - while (this.conditions.length) { - this.conditions.pop(); - } - - this.addNewCondition(); - } - - /** - * @param {Object} condition - * @param {number} valueIndex - * @param {Date} ev - */ - onDateTimeChanged(condition, valueIndex, date) { - condition.value[valueIndex] = date; - } - - /** - * @param {Object} condition - * @param {Event} ev - */ - onFieldSelect(condition, ev) { - Object.assign(condition, { - field: ev.target.selectedIndex, - operator: 0, - }); - this.setDefaultValue(condition); - } - - /** - * @param {Object} condition - * @param {Event} ev - */ - onOperatorSelect(condition, ev) { - condition.operator = ev.target.selectedIndex; - this.setDefaultValue(condition); - } - - /** - * @param {Object} condition - */ - onRemoveCondition(conditionIndex) { - this.conditions.splice(conditionIndex, 1); - } - - /** - * @param {Object} condition - * @param {Event} ev - */ - onValueChange(condition, ev) { - if (!ev.target.value) { - return this.setDefaultValue(condition); - } - const field = this.fields[condition.field]; - try { - const parsed = parseField(field, ev.target.value); - const formatted = formatField(field, parsed); - // Only updates values if it can be correctly parsed and formatted. - condition.value = parsed; - condition.displayedValue = formatted; - } catch { - // Parsing error: nothing is done - } - // Only reset the target's value if it is not a selection field. - if (field.type !== "selection") { - ev.target.value = condition.displayedValue; - } - } -} - -CustomFilterItem.props = {}; -CustomFilterItem.components = { DatePicker, DateTimePicker, Dropdown }; -CustomFilterItem.template = "web.CustomFilterItem"; diff --git a/addons/web/static/src/search/filter_menu/custom_filter_item.xml b/addons/web/static/src/search/filter_menu/custom_filter_item.xml deleted file mode 100644 index 74917d2622b..00000000000 --- a/addons/web/static/src/search/filter_menu/custom_filter_item.xml +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - Add Custom Filter - - - - -
- - -
-
-
- -
diff --git a/addons/web/static/src/search/filter_menu/filter_menu.js b/addons/web/static/src/search/filter_menu/filter_menu.js index e178802197c..a92ad348db5 100644 --- a/addons/web/static/src/search/filter_menu/filter_menu.js +++ b/addons/web/static/src/search/filter_menu/filter_menu.js @@ -1,17 +1,19 @@ /** @odoo-module **/ -import { Dropdown } from "@web/core/dropdown/dropdown"; -import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item"; -import { CustomFilterItem } from "./custom_filter_item"; -import { FACET_ICONS } from "../utils/misc"; -import { useBus } from "@web/core/utils/hooks"; - +import { AdvancedSearchDialog } from "./advanced_search_dialog"; import { Component } from "@odoo/owl"; +import { Domain } from "@web/core/domain"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item"; +import { FACET_ICONS } from "../utils/misc"; +import { useBus, useService } from "@web/core/utils/hooks"; export class FilterMenu extends Component { setup() { this.icon = FACET_ICONS.filter; - + this.dialogService = useService("dialog"); + this.notification = useService("notification"); useBus(this.env.searchModel, "update", this.render); } @@ -24,6 +26,21 @@ export class FilterMenu extends Component { ); } + onAdvancedSearchClick() { + const domains = [this.env.searchModel._getDomain({ withGlobal: false })]; + if (this.env.searchModel.comparison && !this.env.searchModel.globalComparison) { + const { range } = this.env.searchModel.getFullComparison(); + domains.push(range); + } + const domain = Domain.and(domains).toString(); + this.dialogService.add(AdvancedSearchDialog, { + domain, + onConfirm: (domain) => this.onConfirm(domain), + resModel: this.env.searchModel.resModel, + isDebugMode: !!this.env.debug, + }); + } + /** * @param {Object} param0 * @param {number} param0.itemId @@ -36,9 +53,21 @@ export class FilterMenu extends Component { this.env.searchModel.toggleSearchItem(itemId); } } + + async onConfirm(domain) { + const isValid = await this.env.searchModel.isValidDomain(domain); + if (!isValid) { + this.notification.add(this.env._t("Domain is invalid. Please correct it"), { + type: "danger", + }); + return false; + } + this.env.searchModel.createAdvancedDomain(domain); + return true; + } } -FilterMenu.components = { CustomFilterItem, Dropdown, SearchDropdownItem }; +FilterMenu.components = { Dropdown, DropdownItem, SearchDropdownItem }; FilterMenu.template = "web.FilterMenu"; FilterMenu.props = { class: { type: String, optional: true }, diff --git a/addons/web/static/src/search/filter_menu/filter_menu.xml b/addons/web/static/src/search/filter_menu/filter_menu.xml index 809c2463a93..e1bda7f359d 100644 --- a/addons/web/static/src/search/filter_menu/filter_menu.xml +++ b/addons/web/static/src/search/filter_menu/filter_menu.xml @@ -45,7 +45,7 @@