[IMP] web: Advanced Search

The old submenu "Add Custom Filter" of the menu "Filters" is now replaced
by a menu item "Advanced Search". Click on that item will open an
"Advanced Search" dialog in which the current search domain is displayed
in a domain selector. It is then possible to easily add/remove/edit some
parts of that domain before make a new search with the edited domain.

Task ID: 3269923

Part-of: odoo/odoo#118057
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Michaël Mattiello <mcm@odoo.com>
This commit is contained in:
Mathieu Duckerts-Antoine
2023-04-13 17:57:13 +02:00
co-authored by Michaël Mattiello
parent 01f90daef0
commit 1c374b9b05
21 changed files with 1051 additions and 1357 deletions
@@ -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": '<list><field name="foo"/></list>',
"partner,false,search": "<search></search>",
"partner,false,search": `
<search>
<filter name="filter_on_a" string="Filter on a" domain="[['display_name', 'ilike', 'a']]"/>
<filter name="filter_on_b" string="Filter on b" domain="[['display_name', 'ilike', 'b']]"/>
</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": '<list><field name="foo"/></list>',
"partner,false,search": "<search></search>",
"partner,false,search": `
<search>
<filter name="filter" string="Filter" domain="[['display_name', 'ilike', 'b']]"/>
</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": '<pivot><field name="foo"/></pivot>',
"partner,false,search": '<search/>',
"partner,false,search": "<search/>",
};
registry.category("services").add("user", makeFakeUserService());
const webClient = await createWebClient({ serverData });
@@ -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,
};
@@ -10,7 +10,7 @@
<button class="btn btn-secondary" t-on-click="() => props.close()">Close</button>
</t>
<t t-else="">
<button class="btn btn-primary" t-on-click="onSave">Save</button>
<button class="btn btn-primary" t-on-click="onConfirm">Confirm</button>
<button class="btn btn-secondary" t-on-click="onDiscard">Discard</button>
</t>
</t>
@@ -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;
+108
View File
@@ -369,6 +369,114 @@
</div>
</t>
<t t-name="web.Legacy.CustomFilterItem" owl="1">
<Dropdown class="'o_add_custom_filter_menu'">
<t t-set-slot="toggler">
Add Custom Filter
</t>
<t t-foreach="conditions" t-as="condition" t-key="condition_index">
<div class=" o_filter_condition dropdown-item-text position-relative">
<t t-set="fieldType" t-value="fields[condition.field].type"/>
<t t-set="selectedOperator" t-value="OPERATORS[FIELD_TYPES[fieldType]][condition.operator]"/>
<span t-if="!condition_first" class="o_or_filter">or</span>
<select class="o_input o_generator_menu_field"
t-on-change="ev => this.onFieldSelect(condition, ev)"
>
<option t-foreach="fields" t-as="field" t-key="field_index"
t-att-value="field.name"
t-att-selected="field_index === condition.field"
t-esc="field.string"
/>
</select>
<select class="o_input o_generator_menu_operator"
t-on-change="ev => this.onOperatorSelect(condition, ev)"
>
<option t-foreach="OPERATORS[FIELD_TYPES[fieldType]]" t-as="operator" t-key="operator_index"
t-att-value="operator.symbol"
t-att-selected="operator_index === condition.operator"
t-esc="operator.description"
/>
</select>
<span t-if="!('value' in selectedOperator)" class="o_generator_menu_value">
<t t-if="fieldType === 'date'">
<DatePicker
date="condition.value[0]"
onDateTimeChanged="date => this.onDateTimeChanged(condition, 0, date)"
/>
<DatePicker t-if="selectedOperator.symbol === 'between'"
date="condition.value[1]"
onDateTimeChanged="date => this.onDateTimeChanged(condition, 1, date)"
/>
</t>
<t t-elif="fieldType === 'datetime'">
<DateTimePicker
date="condition.value[0]"
onDateTimeChanged="date => this.onDateTimeChanged(condition, 0, date)"
/>
<DateTimePicker t-if="selectedOperator.symbol === 'between'"
date="condition.value[1]"
onDateTimeChanged="date => this.onDateTimeChanged(condition, 1, date)"
/>
</t>
<select t-elif="fieldType === 'selection'" class="o_input"
t-on-change="ev => this.onValueChange(condition, ev)"
>
<option t-foreach="fields[condition.field].selection" t-as="option" t-key="option_index"
t-att-value="option[0]"
t-esc="option[1]"
/>
</select>
<!-- @todo (DAM) I think that the localization should be better consisered below -->
<input t-elif="fieldType === 'float'"
class="o_input"
step="0.01"
t-att-type="DECIMAL_POINT === '.' ? 'number' : 'text'"
t-attf-title="Number using {{ DECIMAL_POINT }} as decimal separator."
t-attf-pattern="[0-9]+([\\{{ DECIMAL_POINT }}][0-9]+)?"
t-model="condition.displayedValue"
t-on-change="ev => this.onValueChange(condition, ev)"
/>
<input t-elif="['integer', 'id'].includes(fieldType)"
class="o_input"
step="1"
type="number"
t-model="condition.displayedValue"
t-on-change="ev => this.onValueChange(condition, ev)"
/>
<input t-else=""
type="text"
class="o_input"
t-model="condition.displayedValue"
t-on-change="ev => this.onValueChange(condition, ev)"
/>
</span>
<i t-if="conditions.length gt 1"
class="fa fa-trash-o o_generator_menu_delete"
role="image"
aria-label="Delete"
title="Delete"
t-on-click="() => this.onRemoveCondition(condition_index)"
/>
</div>
</t>
<div class="px-3 py-2">
<button type="button"
class="btn btn-primary o_apply_filter me-2"
t-on-click="onApply"
>
Apply
</button>
<button type="button"
class="btn btn-secondary o_add_condition"
t-on-click.stop="addNewCondition"
>
<i class="fa fa-plus-circle"/>
<t>Add a condition</t>
</button>
</div>
</Dropdown>
</t>
<t t-name="web.Legacy.ActionMenus" owl="1">
<div class="o_cp_action_menus" t-on-item-selected.stop="_onItemSelected">
<DropdownMenu t-if="printItems.length"
@@ -23,8 +23,8 @@
</t>
<t t-name="web.legacy.FilterMenu" t-inherit="web.FilterMenu" t-inherit-mode="primary" owl="1">
<xpath expr="//CustomFilterItem" position="attributes">
<attribute name="fields">props.fields</attribute>
<xpath expr="//DropdownItem" position="replace">
<CustomFilterItem fields="props.fields"/>
</xpath>
</t>
@@ -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";
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.AdvancedSearchDialog" t-inherit="web.DomainSelectorDialog" owl="1">
<xpath expr="//t[@t-else='']/button[hasclass('btn-primary')]" position="replace">
<button class="btn btn-primary" t-on-click="onConfirm">Search</button>
</xpath>
</t>
</templates>
@@ -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";
@@ -1,112 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CustomFilterItem" owl="1">
<Dropdown class="'o_add_custom_filter_menu'">
<t t-set-slot="toggler">
Add Custom Filter
</t>
<t t-foreach="conditions" t-as="condition" t-key="condition_index">
<div class=" o_filter_condition dropdown-item-text position-relative">
<t t-set="fieldType" t-value="fields[condition.field].type"/>
<t t-set="selectedOperator" t-value="OPERATORS[FIELD_TYPES[fieldType]][condition.operator]"/>
<span t-if="!condition_first" class="o_or_filter">or</span>
<select class="o_input o_generator_menu_field"
t-on-change="ev => this.onFieldSelect(condition, ev)"
>
<option t-foreach="fields" t-as="field" t-key="field_index"
t-att-value="field.name"
t-att-selected="field_index === condition.field"
t-esc="field.string"
/>
</select>
<select class="o_input o_generator_menu_operator"
t-on-change="ev => this.onOperatorSelect(condition, ev)"
>
<option t-foreach="OPERATORS[FIELD_TYPES[fieldType]]" t-as="operator" t-key="operator_index"
t-att-value="operator.symbol"
t-att-selected="operator_index === condition.operator"
t-esc="operator.description"
/>
</select>
<span t-if="!('value' in selectedOperator)" class="o_generator_menu_value">
<t t-if="fieldType === 'date'">
<DatePicker
date="condition.value[0]"
onDateTimeChanged="date => this.onDateTimeChanged(condition, 0, date)"
/>
<DatePicker t-if="selectedOperator.symbol === 'between'"
date="condition.value[1]"
onDateTimeChanged="date => this.onDateTimeChanged(condition, 1, date)"
/>
</t>
<t t-elif="fieldType === 'datetime'">
<DateTimePicker
date="condition.value[0]"
onDateTimeChanged="date => this.onDateTimeChanged(condition, 0, date)"
/>
<DateTimePicker t-if="selectedOperator.symbol === 'between'"
date="condition.value[1]"
onDateTimeChanged="date => this.onDateTimeChanged(condition, 1, date)"
/>
</t>
<select t-elif="fieldType === 'selection'" class="o_input"
t-on-change="ev => this.onValueChange(condition, ev)"
>
<option t-foreach="fields[condition.field].selection" t-as="option" t-key="option_index"
t-att-value="option[0]"
t-esc="option[1]"
/>
</select>
<!-- @todo (DAM) I think that the localization should be better consisered below -->
<input t-elif="fieldType === 'float'"
class="o_input"
step="0.01"
t-att-type="DECIMAL_POINT === '.' ? 'number' : 'text'"
t-attf-title="Number using {{ DECIMAL_POINT }} as decimal separator."
t-attf-pattern="[0-9]+([\\{{ DECIMAL_POINT }}][0-9]+)?"
t-model="condition.displayedValue"
t-on-change="ev => this.onValueChange(condition, ev)"
/>
<input t-elif="['integer', 'id'].includes(fieldType)"
class="o_input"
step="1"
type="number"
t-model="condition.displayedValue"
t-on-change="ev => this.onValueChange(condition, ev)"
/>
<input t-else=""
type="text"
class="o_input"
t-model="condition.displayedValue"
t-on-change="ev => this.onValueChange(condition, ev)"
/>
</span>
<i t-if="conditions.length gt 1"
class="fa fa-trash-o o_generator_menu_delete"
role="image"
aria-label="Delete"
title="Delete"
t-on-click="() => this.onRemoveCondition(condition_index)"
/>
</div>
</t>
<div class="px-3 py-2">
<button type="button"
class="btn btn-primary o_apply_filter me-2"
t-on-click="onApply"
>
Apply
</button>
<button type="button"
class="btn btn-secondary o_add_condition"
t-on-click.stop="addNewCondition"
>
<i class="fa fa-plus-circle"/>
<t>Add a condition</t>
</button>
</div>
</Dropdown>
</t>
</templates>
@@ -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 },
@@ -45,7 +45,7 @@
<t t-if="items.length">
<div role="separator" class="dropdown-divider"/>
</t>
<CustomFilterItem/>
<DropdownItem onSelected.bind="onAdvancedSearchClick" parentClosingMode="'none'">Advanced Search</DropdownItem>
</Dropdown>
</t>
+76 -6
View File
@@ -20,6 +20,7 @@ import { FACET_ICONS } from "./utils/misc";
import { EventBus, toRaw } from "@odoo/owl";
const { DateTime } = luxon;
/** @typedef {import("@web/core/domain").DomainRepr} DomainRepr */
/** @typedef {import("../views/relational_model").OrderTerm} OrderTerm */
/**
@@ -414,6 +415,10 @@ export class SearchModel extends EventBus {
return deepCopy(this._domain);
}
get domainEvalContext() {
return Object.assign({}, this.globalContext, this.userService.context);
}
/**
* @returns {Comparison}
*/
@@ -534,6 +539,51 @@ export class SearchModel extends EventBus {
this._notify();
}
createAdvancedDomain(domain) {
this.blockNotification = true;
const groups = this._getGroups();
const context = this._getContext();
for (const group of groups) {
const firstSearchItem = this.searchItems[group.activeItems[0].searchItemId];
const { type } = firstSearchItem;
for (const activeItem of group.activeItems) {
if (type === "favorite") {
const activeItemGroupBys = this._getSearchItemGroupBys(activeItem);
for (const activeItemGroupBy of activeItemGroupBys) {
const [fieldName, interval] = activeItemGroupBy.split(":");
this.createNewGroupBy(fieldName, { interval, invisible: true });
}
const index = this.query.length - activeItemGroupBys.length;
this.query = [...this.query.slice(index), ...this.query.slice(0, index)];
}
}
if (
[
"comparison",
"field",
"field_property",
"filter",
"dateFilter",
"favorite",
].includes(type)
) {
this.deactivateGroup(group.id);
}
}
this.blockNotification = false;
this.createNewFilters([
{
description: `${this.env._t("Advanced Search")}`,
domain,
invisible: true,
type: "filter",
context,
},
]);
}
/**
* Create a new filter of type 'favorite' and activate it.
* A new group containing only that filter is created.
@@ -594,8 +644,11 @@ export class SearchModel extends EventBus {
* Create a new filter of type 'groupBy' or 'dateGroupBy' and activate it.
* It is added to the unique group of groupbys.
* @param {string} fieldName
* @param {Object} [param]
* @param {string} [param.interval=DEFAULT_INTERVAL]
* @param {boolean} [param.invisible=false]
*/
createNewGroupBy(fieldName) {
createNewGroupBy(fieldName, { interval, invisible } = {}) {
const field = this.searchViewFields[fieldName];
const { string, type: fieldType } = field;
const firstGroupBy = Object.values(this.searchItems).find((f) => f.type === "groupBy");
@@ -608,9 +661,12 @@ export class SearchModel extends EventBus {
id: this.nextId,
custom: true,
};
if (invisible) {
preSearchItem.invisible = true;
}
if (["date", "datetime"].includes(fieldType)) {
this.searchItems[this.nextId] = Object.assign(
{ type: "dateGroupBy", defaultIntervalId: DEFAULT_INTERVAL },
{ type: "dateGroupBy", defaultIntervalId: interval || DEFAULT_INTERVAL },
preSearchItem
);
this.toggleDateGroupBy(this.nextId);
@@ -769,6 +825,22 @@ export class SearchModel extends EventBus {
return sections.sort((s1, s2) => s1.index - s2.index);
}
/**
* @param {DomainRepr} domain
* @returns {boolean}
*/
async isValidDomain(domain) {
try {
domain = new Domain(domain);
domain = domain.toList(this.domainEvalContext);
await this.orm.silent.searchRead(this.resModel, domain, ["id"], { limit: 1 });
// TODO: invent a python method that check domain
} catch {
return false;
}
return true;
}
search() {
this.trigger("update");
}
@@ -1494,9 +1566,7 @@ export class SearchModel extends EventBus {
let domain;
try {
domain = Domain.and(domains);
return params.raw
? domain
: domain.toList(Object.assign({}, this.globalContext, this.userService.context));
return params.raw ? domain : domain.toList(this.domainEvalContext);
} catch (error) {
throw new Error(
`${this.env._t("Failed to evaluate the domain")} ${domain.toString()}.\n${
@@ -1854,7 +1924,7 @@ export class SearchModel extends EventBus {
*/
_getOrderBy() {
const groups = this._getGroups();
let orderBy = [];
const orderBy = [];
for (const group of groups) {
for (const activeItem of group.activeItems) {
const { searchItemId } = activeItem;
@@ -137,10 +137,10 @@ export class DomainField extends Component {
onEditDialogBtnClick() {
this.addDialog(DomainSelectorDialog, {
resModel: this.getResModel(this.props),
initialValue: this.props.record.data[this.props.name] || "[]",
domain: this.props.record.data[this.props.name] || "[]",
readonly: this.props.readonly,
isDebugMode: !!this.env.debug,
onSelected: (value) => this.props.record.update({ [this.props.name]: value }),
onConfirm: (value) => this.props.record.update({ [this.props.name]: value }),
});
}
}
@@ -1,14 +1,9 @@
/** @odoo-module **/
import { applyFilter, toggleMenu } from "@web/../tests/search/helpers";
import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import ActionModel from "web.ActionModel";
import CustomFilterItem from "web.CustomFilterItem";
import { createComponent } from "web.test_utils";
import { editSelect } from "web.test_utils_fields";
import { registerCleanup } from "../helpers/cleanup";
import { makeTestEnv } from "../helpers/mock_env";
import { makeFakeLocalizationService } from "../helpers/mock_services";
@@ -81,40 +76,43 @@ function useFRLocale() {
}
var symbolMap = {
'1': '૧',
'2': '૨',
'3': '૩',
'4': '૪',
'5': '૫',
'6': '૬',
'7': '૭',
'8': '૮',
'9': '૯',
'0': '૦'
1: "૧",
2: "૨",
3: "૩",
4: "૪",
5: "૫",
6: "૬",
7: "૭",
8: "૮",
9: "૯",
0: "૦",
};
var numberMap = {
'૧': '1',
'૨': '2',
'૩': '3',
'૪': '4',
'૫': '5',
'૬': '6',
'૭': '7',
'૮': '8',
'૯': '9',
'૦': '0'
"૧": "1",
"૨": "2",
"૩": "3",
"૪": "4",
"૫": "5",
"૬": "6",
"૭": "7",
"૮": "8",
"૯": "9",
"૦": "0",
};
function useGULocale() {
if (!window.moment.locales().includes("gu")) {
const originalLocale = window.moment.locale();
window.moment.defineLocale("gu", {
months: 'જાન્યુઆરી_ફેબ્રુઆરી_માર્ચ_એપ્રિલ_મે_જૂન_જુલાઈ_ઑગસ્ટ_સપ્ટેમ્બર_ઑક્ટ્બર_નવેમ્બર_ડિસેમ્બર'.split('_'),
monthsShort: 'જાન્યુ._ફેબ્રુ._માર્ચ_એપ્રિ._મે_જૂન_જુલા._ઑગ._સપ્ટે._ઑક્ટ્._નવે._ડિસે.'.split('_'),
months: "જાન્યુઆરી_ફેબ્રુઆરી_માર્ચ_એપ્રિલ_મે_જૂન_જુલાઈ_ઑગસ્ટ_સપ્ટેમ્બર_ઑક્ટ્બર_નવેમ્બર_ડિસેમ્બર".split(
"_"
),
monthsShort:
"જાન્યુ._ફેબ્રુ._માર્ચ_એપ્રિ._મે_જૂન_જુલા._ઑગ._સપ્ટે._ઑક્ટ્._નવે._ડિસે.".split("_"),
monthsParseExact: true,
week: {
dow: 0, // Sunday is the first day of the week.
doy: 6 // The week that contains Jan 1st is the first week of the year.
doy: 6, // The week that contains Jan 1st is the first week of the year.
},
preparse: function (string) {
return string.replace(/[૧૨૩૪૫૬૭૮૯૦]/g, function (match) {
@@ -284,9 +282,9 @@ QUnit.module("Components", ({ beforeEach }) => {
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", {
zone: "utc" ,
zone: "utc",
locale: useGULocale(),
}),
}),
format: "dd MMM, yyyy",
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
@@ -623,7 +621,7 @@ QUnit.module("Components", ({ beforeEach }) => {
date: DateTime.fromFormat("10/03/2023 13:14:27", "dd/MM/yyyy HH:mm:ss"),
format: "dd.MM,yyyy",
});
let input = target.querySelector(".o_datepicker_input");
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "10.03,2023");
@@ -632,41 +630,6 @@ QUnit.module("Components", ({ beforeEach }) => {
assert.strictEqual(input.value, "10.03,2023");
});
QUnit.test("custom filter date", async function (assert) {
assert.expect(3);
class MockedSearchModel extends ActionModel {
dispatch(method, ...args) {
assert.strictEqual(method, "createNewFilters");
const preFilters = args[0];
const preFilter = preFilters[0];
assert.strictEqual(
preFilter.description,
'A date is equal to "05/05/2005"',
"description should be in localized format"
);
assert.deepEqual(
preFilter.domain,
'[["date_field","=","2005-05-05"]]',
"domain should be in UTC format"
);
}
}
const searchModel = new MockedSearchModel();
const date_field = { name: "date_field", string: "A date", type: "date", searchable: true };
await createComponent(CustomFilterItem, {
props: {
fields: { date_field },
},
env: { searchModel },
});
await toggleMenu(target, "Add Custom Filter");
await editSelect(target.querySelector(".o_generator_menu_field"), "date_field");
const valueInput = target.querySelector(".o_generator_menu_value .o_input");
await click(valueInput);
await editSelect(valueInput, "05/05/2005");
await applyFilter(target);
});
QUnit.test("start with no value", async function (assert) {
assert.expect(6);
@@ -0,0 +1,659 @@
/** @odoo-module **/
import { Component, xml } from "@odoo/owl";
import { ControlPanel } from "@web/search/control_panel/control_panel";
import { MainComponentsContainer } from "@web/core/main_components_container";
import {
click,
editInput,
getFixture,
getNodesTextContent,
patchDate,
patchWithCleanup,
} from "@web/../tests/helpers/utils";
import {
editFavoriteName,
getFacetTexts,
makeWithSearch,
openAdvancedSearchDialog,
saveFavorite,
setupControlPanelFavoriteMenuRegistry,
setupControlPanelServiceRegistry,
toggleComparisonMenu,
toggleFavoriteMenu,
toggleFilterMenu,
toggleGroupByMenu,
toggleMenuItem,
toggleMenuItemOption,
toggleSaveFavorite,
} from "./helpers";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { popoverService } from "@web/core/popover/popover_service";
import { fieldService } from "@web/core/field_service";
async function makeTestComponent(props) {
class TestComponent extends Component {
setup() {
this.id = 1; // used for t-key. We want to be sure that the control panel is rendered.
}
}
TestComponent.components = { ControlPanel, MainComponentsContainer };
TestComponent.template = xml`
<div class="o_test_component">
<MainComponentsContainer/>
<ControlPanel t-key="id++"/>
</div>
`;
return makeWithSearch({
serverData,
resModel: "foo",
Component: TestComponent,
searchViewId: 1,
searchMenuTypes: ["filter", "groupBy", "comparison", "favorite"],
...props,
});
}
let target;
let serverData;
QUnit.module("Search", (hooks) => {
hooks.beforeEach(async () => {
serverData = {
models: {
foo: {
fields: {
date_field: {
name: "date_field",
string: "A date",
type: "date",
searchable: true,
},
date_time_field: {
name: "date_time_field",
string: "DateTime",
type: "datetime",
searchable: true,
},
boolean_field: {
name: "boolean_field",
string: "Boolean Field",
type: "boolean",
default: true,
searchable: true,
},
binary_field: {
name: "binary_field",
string: "Binary Field",
type: "binary",
searchable: true,
},
char_field: {
name: "char_field",
string: "Char Field",
type: "char",
default: "foo",
trim: true,
searchable: true,
},
float_field: {
name: "float_field",
string: "Floaty McFloatface",
type: "float",
searchable: true,
},
color: {
name: "color",
string: "Color",
type: "selection",
selection: [
["black", "Black"],
["white", "White"],
],
searchable: true,
},
user_id: {
string: "User",
type: "many2one",
relation: "user",
store: true,
sortable: true,
},
},
},
user: {
fields: {
id: { string: "Id", type: "integer" },
name: { string: "Product Name", type: "char" },
},
records: [
{
id: 37,
display_name: "Me",
},
],
},
},
views: {
"foo,1,search": `
<search>
<field name="char_field"/>
<filter name="simple_filter" string="Simple Filter" domain="[('float_field', '=', 2)]"/>
<filter name="dynamic_filter" string="Dynamic Filter" domain="[('user_id', '=', uid)]"/>
<filter name="date_filter" string="Date Filter" date="date_field"/>
<filter name="simple_group_by" string="Simple GroupBy" context="{'group_by': 'boolean_field'}"/>
<filter name="date_group_by" string="Date GroupBy" context="{'group_by': 'date_time_field'}"/>
</search>
`,
},
};
setupControlPanelServiceRegistry();
setupControlPanelFavoriteMenuRegistry();
registry.category("services").add("popover", popoverService);
registry.category("services").add("field", fieldService);
target = getFixture();
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
clearTimeout: () => {},
});
});
QUnit.module("AdvancedSearchDialog");
QUnit.test("basic rendering", async function (assert) {
await makeTestComponent();
await toggleFilterMenu(target);
assert.containsNone(target, ".modal");
await openAdvancedSearchDialog(target);
assert.containsOnce(target, ".modal");
assert.containsOnce(target, ".modal .modal-header h4");
assert.strictEqual(target.querySelector(".modal header h4").innerText, "Advanced Search");
assert.containsN(target, ".modal footer button", 2);
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".modal footer button")), [
"Search",
"Discard",
]);
await click(target, ".modal footer button:nth-child(2)");
assert.containsNone(target, ".modal");
assert.containsOnce(target, ".o_filter_menu .dropdown-menu");
await openAdvancedSearchDialog(target);
assert.containsOnce(target, ".modal");
await click(target, ".modal footer button:nth-child(1)");
assert.containsNone(target, ".modal");
assert.containsNone(target, ".o_filter_menu .dropdown-menu");
});
QUnit.test("start with an empty query", async function (assert) {
await makeTestComponent();
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
assert.containsOnce(target, ".modal");
assert.containsOnce(target, ".modal .modal-body .o_domain_selector");
assert.containsOnce(target, ".modal .modal-body .o_domain_selector span");
assert.strictEqual(
target.querySelector(".modal .modal-body .o_domain_selector span").innerText,
"Match all records"
);
assert.containsOnce(
target,
".modal .modal-body .o_domain_selector button.o_domain_add_first_node_button"
);
});
QUnit.test("start with an empty query and a global domain", async function (assert) {
await makeTestComponent({ domain: [[0, "=", 1]] });
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
// the global domain should not be part of the domain passed to domain selector
assert.containsOnce(target, ".modal");
assert.containsOnce(target, ".modal .modal-body .o_domain_selector");
assert.containsOnce(target, ".modal .modal-body .o_domain_selector span");
assert.strictEqual(
target.querySelector(".modal .modal-body .o_domain_selector span").innerText,
"Match all records"
);
assert.containsOnce(
target,
".modal .modal-body .o_domain_selector button.o_domain_add_first_node_button"
);
});
QUnit.test("start with a simple domain and modify it", async function (assert) {
const testComponent = await makeTestComponent({
context: { search_default_simple_filter: true },
});
await toggleFilterMenu(target);
assert.deepEqual(getFacetTexts(target), ["Simple Filter"]);
assert.containsNone(target, ".o_domain_selector");
await openAdvancedSearchDialog(target);
assert.containsOnce(target, ".modal");
assert.containsOnce(target, ".modal .modal-body .o_domain_selector");
assert.containsOnce(target, ".o_field_selector");
assert.strictEqual(
target.querySelector(".o_field_selector").innerText,
"Floaty McFloatface"
);
assert.containsOnce(target, ".o_domain_leaf_operator_select");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "equal");
assert.containsOnce(target, ".o_domain_leaf_value_input");
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "2");
await editInput(target, ".o_domain_leaf_value_input", 4);
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "4");
assert.deepEqual(testComponent.props.domain, [["float_field", "=", 2]]);
await click(target, ".modal footer button:nth-child(1)");
assert.deepEqual(getFacetTexts(target), ["Advanced Search"]);
assert.deepEqual(testComponent.props.domain, [["float_field", "=", 4]]);
});
QUnit.test("start with a dynamic domain (evaluation is done)", async function (assert) {
const testComponent = await makeTestComponent({
context: {
search_default_dynamic_filter: true,
},
mockRPC(_, { args, method }) {
if (method === "create_or_replace") {
assert.strictEqual(args[0].domain, `[("user_id", "=", 4)]`); // the domain is not saved dynamically
return 9; // fake serverId to simulate the creation of
// the favorite in db.
}
},
});
assert.deepEqual(getFacetTexts(target), ["Dynamic Filter"]);
assert.deepEqual(testComponent.props.domain, [["user_id", "=", 7]]);
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
assert.strictEqual(target.querySelector(".o_field_selector").innerText, "User");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "equal");
assert.strictEqual(target.querySelector(".o_ds_value_cell input").value, "7");
});
QUnit.test(
"start with a simple domain and a simple group by, group by not lost after search",
async function (assert) {
const testComponent = await makeTestComponent({
context: {
search_default_simple_filter: true,
search_default_simple_group_by: true,
},
});
assert.deepEqual(getFacetTexts(target), ["Simple Filter", "Simple GroupBy"]);
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "2");
assert.deepEqual(testComponent.props.domain, [["float_field", "=", 2]]);
assert.deepEqual(testComponent.props.groupBy, ["boolean_field"]);
await editInput(target, ".o_domain_leaf_value_input", 4);
await click(target, ".modal footer button:nth-child(1)");
assert.deepEqual(getFacetTexts(target), ["Simple GroupBy", "Advanced Search"]);
assert.deepEqual(testComponent.props.domain, [["float_field", "=", 4]]);
assert.deepEqual(testComponent.props.groupBy, ["boolean_field"]);
}
);
QUnit.test("start with a favorite only", async function (assert) {
const irFilters = [
{
context: "{ 'some_key': 'some_value', 'group_by': ['color'] }",
domain: "[('char_field', 'ilike', 'abc')]",
id: 1,
is_default: true,
name: "My favorite",
sort: "[]",
user_id: [2, "Mitchell Admin"],
},
];
const testComponent = await makeTestComponent({
irFilters,
});
assert.deepEqual(getFacetTexts(target), ["My favorite"]);
assert.deepEqual(testComponent.props.domain, [["char_field", "ilike", "abc"]]);
assert.deepEqual(testComponent.props.groupBy, ["color"]);
assert.strictEqual(testComponent.props.context.some_key, "some_value");
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
await editInput(target.querySelector(".o_domain_leaf_value_input"), null, "xyz");
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "xyz");
await click(target, ".modal footer button:nth-child(1)");
assert.deepEqual(getFacetTexts(target), ["Color", "Advanced Search"]);
assert.deepEqual(testComponent.props.domain, [["char_field", "ilike", "xyz"]]);
assert.deepEqual(testComponent.props.groupBy, ["color"]);
assert.strictEqual(testComponent.props.context.some_key, "some_value");
});
QUnit.test("group_bys retrieved from favorite", async function (assert) {
const irFilters = [
{
context: "{ 'some_key': 'some_value', 'group_by': ['color'] }",
domain: "[('char_field', 'ilike', 'abc')]",
id: 1,
is_default: true,
name: "My favorite",
sort: "[]",
user_id: [2, "Mitchell Admin"],
},
];
const testComponent = await makeTestComponent({
irFilters,
});
await toggleGroupByMenu(target);
await toggleMenuItem(target, "Simple GroupBy");
assert.deepEqual(getFacetTexts(target), ["My favorite", "Simple GroupBy"]);
await toggleFilterMenu(target);
await toggleMenuItem(target, "Simple Filter");
assert.deepEqual(getFacetTexts(target), ["My favorite", "Simple GroupBy", "Simple Filter"]);
assert.deepEqual(testComponent.props.domain, [
"&",
["char_field", "ilike", "abc"],
["float_field", "=", 2],
]);
assert.deepEqual(testComponent.props.groupBy, ["color", "boolean_field"]);
assert.strictEqual(testComponent.props.context.some_key, "some_value");
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
await editInput(target.querySelector(".o_domain_leaf_value_input"), null, "xyz");
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "xyz");
await click(target, ".modal footer button:nth-child(1)");
assert.deepEqual(getFacetTexts(target), ["Color>Simple GroupBy", "Advanced Search"]);
assert.deepEqual(testComponent.props.domain, [
"&",
["char_field", "ilike", "xyz"],
["float_field", "=", 2],
]);
assert.deepEqual(testComponent.props.groupBy, ["color", "boolean_field"]);
assert.strictEqual(testComponent.props.context.some_key, "some_value");
});
QUnit.test(
"comparison retrieved from favorite (comparison in searchMenuTypes)",
async function (assert) {
patchDate(2023, 2, 1, 14, 0, 0);
const testComponent = await makeTestComponent({
async mockRPC(_, { method }) {
if (method === "create_or_replace") {
return 9;
}
},
});
await toggleFilterMenu(target);
await toggleMenuItem(target, "Date Filter");
await toggleMenuItemOption(target, "Date Filter", "March");
await toggleComparisonMenu(target);
await toggleMenuItem(target, "Date Filter: Previous Period");
await toggleFavoriteMenu(target);
await toggleSaveFavorite(target);
await editFavoriteName(target, "My new favorite");
await saveFavorite(target, "My new favorite");
assert.deepEqual(getFacetTexts(target), ["My new favorite"]);
assert.deepEqual(testComponent.props.domain, []);
assert.deepEqual(testComponent.props.comparison, {
domains: [
{
arrayRepr: [
"&",
["date_field", ">=", "2023-03-01"],
["date_field", "<=", "2023-03-31"],
],
description: "March 2023",
},
{
arrayRepr: [
"&",
["date_field", ">=", "2023-02-01"],
["date_field", "<=", "2023-02-28"],
],
description: "February 2023",
},
],
fieldName: "date_field",
});
assert.deepEqual(testComponent.props.groupBy, []);
assert.strictEqual(testComponent.props.context.comparison, undefined);
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
await click(target, ".modal footer button:nth-child(1)");
assert.deepEqual(getFacetTexts(target), ["Advanced Search"]);
assert.deepEqual(testComponent.props.domain, [
"&",
["date_field", ">=", "2023-03-01"],
["date_field", "<=", "2023-03-31"],
]);
assert.deepEqual(testComponent.props.groupBy, []);
assert.strictEqual(testComponent.props.context.comparison, undefined);
}
);
QUnit.test(
"comparison retrieved from favorite (comparison not in searchMenuTypes)",
async function (assert) {
const testComponent = await makeTestComponent({
irFilters: [
{
context: `{
'group_by': [],
'comparison': {
'comparisonId': 'previous_period',
'fieldName': 'create_date',
'fieldDescription':'Creation Date',
'range': ['&', ['create_date', '>=', '2023-02-28 23:00:00'], ['create_date', '<=', '2023-03-31 21:59:59']],
'rangeDescription': 'March 2023',
'comparisonRange': ['&', ['create_date', '>=', '2023-01-31 23:00:00'], ['create_date', '<=', '2023-02-28 22:59:59']],
'comparisonRangeDescription': 'February 2023'}
}`,
domain: "[]",
id: 1,
is_default: true,
name: "My favorite",
sort: "[]",
user_id: [2, "Mitchell Admin"],
},
],
searchMenuTypes: ["filter", "favorite"],
});
assert.deepEqual(getFacetTexts(target), ["My favorite"]);
assert.deepEqual(testComponent.props.domain, []); // a problem with comparison?
assert.strictEqual(testComponent.props.comparison, null);
assert.deepEqual(testComponent.props.groupBy, []);
assert.strictEqual(testComponent.props.context.comparison, undefined);
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
await click(target, ".modal footer button:nth-child(1)");
assert.deepEqual(getFacetTexts(target), ["Advanced Search"]);
assert.deepEqual(testComponent.props.domain, []);
assert.deepEqual(testComponent.props.groupBy, []);
assert.strictEqual(testComponent.props.context.comparison, undefined);
}
);
QUnit.test("search items created for advanced search are invisible", async function (assert) {
const irFilters = [
{
context: "{ 'group_by': ['color'] }",
domain: "[]]",
id: 1,
is_default: true,
name: "My favorite",
sort: "[]",
user_id: [2, "Mitchell Admin"],
},
];
const testComponent = await makeTestComponent({
irFilters,
});
assert.deepEqual(getFacetTexts(target), ["My favorite"]);
assert.deepEqual(testComponent.props.domain, []);
assert.deepEqual(testComponent.props.groupBy, ["color"]);
await toggleGroupByMenu(target);
const visibleGroupBys = getNodesTextContent(
target.querySelectorAll(".o_group_by_menu .dropdown-item")
);
await toggleFilterMenu(target);
const visibleFilters = getNodesTextContent(
target.querySelectorAll(".o_filter_menu .dropdown-item")
);
await openAdvancedSearchDialog(target);
await click(target, ".modal footer button:nth-child(1)");
assert.deepEqual(getFacetTexts(target), ["Color", "Advanced Search"]);
assert.deepEqual(testComponent.props.domain, []);
assert.deepEqual(testComponent.props.groupBy, ["color"]);
await toggleGroupByMenu(target);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_group_by_menu .dropdown-item")),
visibleGroupBys
);
await toggleFilterMenu(target);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_filter_menu .dropdown-item")),
visibleFilters
);
});
QUnit.test("Several date filters and a comparison", async function (assert) {
serverData.views["foo,1,search"] = `
<search>
<filter name="date_filter" string="Date" date="date_field"/>
<filter name="date_time_filter" string="Datetime" date="date_time_field"/>
</search>
`;
const testComponent = await makeTestComponent({
domain: [[0, "=", 1]],
context: {
search_default_date_filter: true,
search_default_date_time_filter: true,
},
});
await toggleComparisonMenu(target);
await toggleMenuItem(target, "Date: Previous Period");
assert.deepEqual(getFacetTexts(target), [
"Date: April 2023orDatetime: April 2023",
"Date: Previous Period",
]);
assert.deepEqual(testComponent.props.domain, [
"&",
[0, "=", 1],
"&",
["date_time_field", ">=", "2023-03-31 23:00:00"],
["date_time_field", "<=", "2023-04-30 22:59:59"],
]);
assert.deepEqual(testComponent.props.comparison, {
domains: [
{
arrayRepr: [
"&",
"&",
[0, "=", 1],
"&",
["date_time_field", ">=", "2023-03-31 23:00:00"],
["date_time_field", "<=", "2023-04-30 22:59:59"],
"&",
["date_field", ">=", "2023-04-01"],
["date_field", "<=", "2023-04-30"],
],
description: "April 2023",
},
{
arrayRepr: [
"&",
"&",
[0, "=", 1],
"&",
["date_time_field", ">=", "2023-03-31 23:00:00"],
["date_time_field", "<=", "2023-04-30 22:59:59"],
"&",
["date_field", ">=", "2023-03-01"],
["date_field", "<=", "2023-03-31"],
],
description: "March 2023",
},
],
fieldName: "date_field",
});
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_field_selector_chain_part")),
["DateTime", "DateTime", "A date", "A date"]
); // 0 not found!
await click(target, ".modal footer button:nth-child(1)");
assert.deepEqual(testComponent.props.domain, [
"&",
[0, "=", 1],
"&",
"&",
["date_time_field", ">=", "2023-03-31 23:00:00"],
["date_time_field", "<=", "2023-04-30 22:59:59"],
"&",
["date_field", ">=", "2023-04-01"],
["date_field", "<=", "2023-04-30"],
]);
});
QUnit.test("an invalid domain cannot be saved", async function (assert) {
assert.expect(9);
patchWithCleanup(odoo, {
debug: true,
});
registry.category("services").add(
"notification",
{
start() {
return {
add(message, options) {
assert.strictEqual(message, "Domain is invalid. Please correct it");
assert.deepEqual(options, { type: "danger" });
},
};
},
},
{ force: true }
);
const testComponent = await makeTestComponent({});
assert.deepEqual(testComponent.props.domain, []);
assert.deepEqual(getFacetTexts(target), []);
assert.containsNone(target, ".modal");
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
assert.containsOnce(target, ".modal");
await editInput(target, "textarea.o_domain_debug_input", "[");
await click(target, ".modal footer button:nth-child(1)");
assert.containsOnce(target, ".modal");
assert.deepEqual(testComponent.props.domain, []);
assert.deepEqual(getFacetTexts(target), []);
});
});
@@ -1,777 +0,0 @@
/** @odoo-module **/
import {
click,
getFixture,
nextTick,
patchDate,
patchTimeZone,
patchWithCleanup,
} from "@web/../tests/helpers/utils";
import { localization } from "@web/core/l10n/localization";
import { ControlPanel } from "@web/search/control_panel/control_panel";
import { browser } from "@web/core/browser/browser";
import {
addCondition,
applyFilter,
editConditionField,
editConditionOperator,
editConditionValue,
getFacetTexts,
isItemSelected,
makeWithSearch,
removeFacet,
setupControlPanelServiceRegistry,
toggleAddCustomFilter,
toggleFilterMenu,
toggleMenuItem,
} from "./helpers";
function getDomain(controlPanel) {
return controlPanel.env.searchModel.domain;
}
let target;
let serverData;
QUnit.module("Search", (hooks) => {
hooks.beforeEach(async () => {
serverData = {
models: {
foo: {
fields: {
date_field: {
name: "date_field",
string: "A date",
type: "date",
searchable: true,
},
date_time_field: {
name: "date_time_field",
string: "DateTime",
type: "datetime",
searchable: true,
},
boolean_field: {
name: "boolean_field",
string: "Boolean Field",
type: "boolean",
default: true,
searchable: true,
},
char_field: {
name: "char_field",
string: "Char Field",
type: "char",
default: "foo",
trim: true,
searchable: true,
},
float_field: {
name: "float_field",
string: "Floaty McFloatface",
type: "float",
digits: [999, 1],
searchable: true,
},
color: {
name: "color",
string: "Color",
type: "selection",
selection: [
["black", "Black"],
["white", "White"],
],
searchable: true,
},
},
records: {},
},
},
views: {
"foo,false,search": `<search/>`,
},
};
setupControlPanelServiceRegistry();
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
clearTimeout: () => {},
});
target = getFixture();
});
QUnit.module("CustomFilterItem");
QUnit.test("basic rendering", async function (assert) {
assert.expect(14);
await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchViewId: false,
searchMenuTypes: ["filter"],
});
await toggleFilterMenu(target);
const customFilterItem = target.querySelector(".o_add_custom_filter_menu");
assert.strictEqual(customFilterItem.innerText.trim(), "Add Custom Filter");
await toggleAddCustomFilter(target);
// Single condition
assert.containsOnce(customFilterItem, ".o_filter_condition");
assert.containsOnce(
customFilterItem,
".o_filter_condition > select.o_generator_menu_field"
);
assert.containsOnce(
customFilterItem,
".o_filter_condition > select.o_generator_menu_operator"
);
assert.containsOnce(customFilterItem, ".o_filter_condition > span.o_generator_menu_value");
assert.containsNone(customFilterItem, ".o_filter_condition .o_or_filter");
assert.containsNone(customFilterItem, ".o_filter_condition .o_generator_menu_delete");
// no deletion allowed on single condition
assert.containsNone(customFilterItem, ".o_filter_condition > i.o_generator_menu_delete");
// Buttons
assert.containsOnce(customFilterItem, "button.o_apply_filter");
assert.containsOnce(customFilterItem, "button.o_add_condition");
assert.containsOnce(customFilterItem, ".o_filter_condition");
await click(customFilterItem, "button.o_add_condition");
assert.containsN(customFilterItem, ".o_filter_condition", 2);
assert.containsOnce(customFilterItem, ".o_filter_condition .o_or_filter");
assert.containsN(customFilterItem, ".o_filter_condition .o_generator_menu_delete", 2);
});
QUnit.test(
'should have Date and ID field proposed in that order in "Add custom Filter" submenu',
async function (assert) {
assert.expect(2);
await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchViewId: false,
searchMenuTypes: ["filter"],
searchViewFields: {
date_field: {
name: "date_field",
string: "Date",
type: "date",
store: true,
sortable: true,
searchable: true,
},
foo: { string: "Foo", type: "char", store: true, sortable: true },
},
});
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
const optionEls = target.querySelectorAll(
".o_filter_condition > select.o_generator_menu_field option"
);
assert.strictEqual(optionEls[0].innerText.trim(), "Date");
assert.strictEqual(optionEls[1].innerText.trim(), "ID");
}
);
QUnit.test("deactivate a new custom filter works", async function (assert) {
assert.expect(4);
patchDate(2020, 1, 5, 12, 20, 0);
await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchViewId: false,
searchMenuTypes: ["filter"],
searchViewFields: {
date_field: {
name: "date_field",
string: "Date",
type: "date",
store: true,
sortable: true,
searchable: true,
},
},
});
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
await applyFilter(target);
assert.ok(isItemSelected(target, 'Date is equal to "02/05/2020"'));
assert.deepEqual(getFacetTexts(target), ['Date is equal to "02/05/2020"']);
await toggleMenuItem(target, 'Date is equal to "02/05/2020"');
assert.notOk(isItemSelected(target, 'Date is equal to "02/05/2020"'));
assert.deepEqual(getFacetTexts(target), []);
});
QUnit.test("custom OR filter presets new condition from preceding", async function (assert) {
assert.expect(4);
await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchViewId: false,
searchMenuTypes: ["filter"],
});
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
// Retrieve second selectable values for field and operator dropdowns
const secondFieldName = target.querySelector(
".o_generator_menu_field option:nth-of-type(2)"
).value;
const secondOperator = target.querySelector(
".o_generator_menu_operator option:nth-of-type(2)"
).value;
// Check if they really exist…
assert.ok(!!secondFieldName);
assert.ok(!!secondOperator);
// Add first filter condition
await editConditionField(target, 0, secondFieldName);
await editConditionOperator(target, 0, secondOperator);
// Add a second conditon on the filter being created
await addCondition(target);
// Check the defaults for field and operator dropdowns
assert.strictEqual(
target.querySelector(".o_filter_condition:nth-of-type(2) .o_generator_menu_field")
.value,
secondFieldName
);
assert.strictEqual(
target.querySelector(".o_filter_condition:nth-of-type(2) .o_generator_menu_operator")
.value,
secondOperator
);
});
QUnit.test("add a custom filter works", async function (assert) {
assert.expect(2);
const controlPanel = await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchViewId: false,
searchMenuTypes: ["filter"],
searchViewFields: {},
});
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
// choose ID field in 'Add Custom filter' menu and value 1
await editConditionField(target, 0, "id");
await editConditionValue(target, 0, 1);
await applyFilter(target);
assert.deepEqual(getFacetTexts(target), ['ID is "1"']);
assert.deepEqual(getDomain(controlPanel), [["id", "=", 1]]);
});
QUnit.test("adding a simple filter works", async function (assert) {
assert.expect(11);
const controlPanel = await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchViewId: false,
searchMenuTypes: ["filter"],
searchViewFields: {
boolean_field: {
name: "boolean_field",
string: "Boolean Field",
type: "boolean",
default: true,
searchable: true,
},
},
});
await toggleFilterMenu(target);
assert.deepEqual(getFacetTexts(target), []);
assert.deepEqual(getDomain(controlPanel), []);
assert.containsNone(target, ".o_menu_item");
assert.containsOnce(target, ".o_add_custom_filter_menu button.dropdown-toggle");
// the 'Add Custom Filter' menu should be closed;
assert.containsNone(target, ".o_add_custom_filter_menu .dropdown-menu");
await toggleAddCustomFilter(target);
// the 'Add Custom Filter' menu should be open;
assert.containsOnce(target, ".o_add_custom_filter_menu .dropdown-menu");
await applyFilter(target);
assert.deepEqual(getFacetTexts(target), ["Boolean Field is Yes"]);
assert.deepEqual(getDomain(controlPanel), [["boolean_field", "=", true]]);
assert.containsOnce(target, ".o_menu_item");
assert.containsOnce(target, ".o_add_custom_filter_menu button.dropdown-toggle");
// the 'Add Custom Filter' menu should still be opened;
assert.containsOnce(target, ".o_add_custom_filter_menu .dropdown-menu");
});
QUnit.test("selection field: default and updated value", async function (assert) {
assert.expect(11);
const controlPanel = await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchViewId: false,
searchMenuTypes: ["filter"],
});
// Default value
await toggleFilterMenu(target);
assert.containsN(target, ".o_menu_item", 0);
assert.containsN(target, ".dropdown-divider", 0);
await toggleAddCustomFilter(target);
await editConditionField(target, 0, "color");
await applyFilter(target);
assert.deepEqual(getFacetTexts(target), ['Color is "black"']);
assert.deepEqual(getDomain(controlPanel), [["color", "=", "black"]]);
assert.containsN(target, ".o_menu_item", 1);
assert.containsN(target, ".dropdown-divider", 1);
// deactivate custom filter
await removeFacet(target);
// Updated value
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
await editConditionField(target, 0, "color");
await editConditionValue(target, 0, "white");
assert.strictEqual(
target.querySelector(".o_generator_menu_value input,.o_generator_menu_value select")
.value,
"white"
);
await applyFilter(target);
assert.deepEqual(getFacetTexts(target), ['Color is "white"']);
assert.deepEqual(getDomain(controlPanel), [["color", "=", "white"]]);
assert.containsN(target, ".o_menu_item", 2);
assert.containsN(target, ".dropdown-divider", 2);
});
QUnit.test(
"commit search with an extended proposition with field char does not cause a crash",
async function (assert) {
assert.expect(12);
const controlPanel = await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchViewId: false,
searchMenuTypes: ["filter"],
searchViewFields: {
many2one_field: {
name: "many2one_field",
string: "AAA",
type: "many2one",
searchable: true,
},
},
});
const steps = [
{
value: `a`,
domain: [["many2one_field", "ilike", `a`]],
facetContent: `AAA contains "a"`,
},
{
value: `"a"`,
domain: [["many2one_field", "ilike", `"a"`]],
facetContent: `AAA contains ""a""`,
},
{
value: `'a'`,
domain: [["many2one_field", "ilike", `'a'`]],
facetContent: `AAA contains "'a'"`,
},
{
value: `'`,
domain: [["many2one_field", "ilike", `'`]],
facetContent: `AAA contains "'"`,
},
{
value: `"`,
domain: [["many2one_field", "ilike", `"`]],
facetContent: `AAA contains """`,
},
{
value: `\\`,
domain: [["many2one_field", "ilike", `\\`]],
facetContent: `AAA contains "\\"`,
},
];
for (const step of steps) {
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
await editConditionValue(target, 0, step.value);
await applyFilter(target);
assert.deepEqual(getFacetTexts(target), [step.facetContent]);
assert.deepEqual(getDomain(controlPanel), step.domain);
await removeFacet(target);
}
}
);
QUnit.test("custom filter date with equal operator", async function (assert) {
patchTimeZone(-240);
patchDate(2017, 1, 22, 12, 30, 0);
const controlPanel = await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchViewId: false,
searchMenuTypes: ["filter"],
});
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
await editConditionField(target, 0, "date_field");
await editConditionOperator(target, 0, "=");
await editConditionValue(target, 0, "01/01/2017");
await applyFilter(target);
assert.deepEqual(getFacetTexts(target), ['A date is equal to "01/01/2017"']);
assert.deepEqual(getDomain(controlPanel), [["date_field", "=", "2017-01-01"]]);
});
QUnit.test("custom filter datetime with equal operator", async function (assert) {
assert.expect(5);
patchTimeZone(-240);
patchDate(2017, 1, 22, 12, 30, 0);
const controlPanel = await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchViewId: false,
searchMenuTypes: ["filter"],
});
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
await editConditionField(target, 0, "date_time_field");
assert.strictEqual(
target.querySelector(".o_generator_menu_field").value,
"date_time_field"
);
assert.strictEqual(target.querySelector(".o_generator_menu_operator").value, "between");
assert.deepEqual(
[...target.querySelectorAll(".o_generator_menu_value input")].map((v) => v.value),
["02/22/2017 00:00:00", "02/22/2017 23:59:59"]
);
await editConditionOperator(target, 0, "=");
await editConditionValue(target, 0, "02/22/2017 11:00:00"); // in TZ
await applyFilter(target);
assert.deepEqual(
getFacetTexts(target),
['DateTime is equal to "02/22/2017 11:00:00"'],
"description should be in localized format"
);
assert.deepEqual(
getDomain(controlPanel),
[["date_time_field", "=", "2017-02-22 15:00:00"]],
"domain should be in UTC format"
);
});
QUnit.test("custom filter datetime between operator", async function (assert) {
assert.expect(5);
patchTimeZone(-240);
patchDate(2017, 1, 22, 12, 30, 0);
const controlPanel = await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchViewId: false,
searchMenuTypes: ["filter"],
});
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
await editConditionField(target, 0, "date_time_field");
assert.strictEqual(
target.querySelector(".o_generator_menu_field").value,
"date_time_field"
);
assert.strictEqual(target.querySelector(".o_generator_menu_operator").value, "between");
assert.deepEqual(
[...target.querySelectorAll(".o_generator_menu_value input")].map((v) => v.value),
["02/22/2017 00:00:00", "02/22/2017 23:59:59"]
);
await editConditionValue(target, 0, "02/22/2017 11:00:00", 0); // in TZ
await editConditionValue(target, 0, "02-22-2017 17:00:00", 1); // in TZ
await applyFilter(target);
assert.deepEqual(
getFacetTexts(target),
['DateTime is between "02/22/2017 11:00:00 and 02/22/2017 17:00:00"'],
"description should be in localized format"
);
assert.deepEqual(
getDomain(controlPanel),
[
"&",
["date_time_field", ">=", "2017-02-22 15:00:00"],
["date_time_field", "<=", "2017-02-22 21:00:00"],
],
"domain should be in UTC format"
);
});
QUnit.test("input value parsing", async function (assert) {
assert.expect(7);
await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchViewId: false,
searchMenuTypes: ["filter"],
});
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
await addCondition(target);
await editConditionField(target, 0, "float_field");
await editConditionField(target, 1, "id");
const [floatInput, idInput] = target.querySelectorAll(".o_generator_menu_value .o_input");
// Default values
await editConditionValue(target, 0, "0.0");
assert.strictEqual(floatInput.value, "0.0");
await editConditionValue(target, 1, "0");
assert.strictEqual(idInput.value, "0");
// Float parsing
await editConditionValue(target, 0, "4.2");
assert.strictEqual(floatInput.value, "4.2");
await editConditionValue(target, 0, "DefinitelyValidFloat");
// "DefinitelyValidFloat" cannot be entered in a input type number so that the input value is reset to 0
assert.strictEqual(floatInput.value, "4.2");
// Number parsing
await editConditionValue(target, 1, "4");
assert.strictEqual(idInput.value, "4");
await editConditionValue(target, 1, "4.2");
assert.strictEqual(idInput.value, "4");
await editConditionValue(target, 1, "DefinitelyValidID");
// "DefinitelyValidID" cannot be entered in a input type number so that the input value is reset to 0
assert.strictEqual(idInput.value, "0");
});
QUnit.test("input value parsing with language", async function (assert) {
assert.expect(5);
await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchViewId: false,
searchMenuTypes: ["filter"],
});
// Needs to be done after services have been started
patchWithCleanup(localization, {
decimalPoint: ",",
thousandsSep: "",
grouping: [3, 0],
});
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
await editConditionField(target, 0, "float_field");
const [floatInput] = target.querySelectorAll(".o_generator_menu_value .o_input");
// Default values
assert.strictEqual(floatInput.value, "0,0");
// Float parsing
await editConditionValue(target, 0, "4,");
assert.strictEqual(floatInput.value, "4,0");
await editConditionValue(target, 0, "4,2");
assert.strictEqual(floatInput.value, "4,2");
await editConditionValue(target, 0, "4,2,");
assert.strictEqual(floatInput.value, "42,0"); // because of the en localization fallback in parsers.
await editConditionValue(target, 0, "DefinitelyValidFloat");
// The input here is a string, resulting in a parsing error instead of 0
assert.strictEqual(floatInput.value, "42,0");
});
QUnit.test("add custom filter with multiple values", async function (assert) {
assert.expect(2);
const controlPanel = await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchViewId: false,
searchMenuTypes: ["filter"],
});
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
for (let i = 0; i < 4; i++) {
await addCondition(target);
}
await editConditionField(target, 0, "date_field");
await editConditionValue(target, 0, "01/09/1997");
await editConditionField(target, 1, "boolean_field");
await editConditionOperator(target, 1, "!=");
await editConditionField(target, 2, "char_field");
await editConditionValue(target, 2, "I will be deleted anyway");
await editConditionField(target, 3, "float_field");
await editConditionValue(target, 3, 7.2);
await editConditionField(target, 4, "id");
await editConditionValue(target, 4, 9);
const thirdcondition = target.querySelectorAll(".o_filter_condition")[2];
await click(thirdcondition, ".o_generator_menu_delete");
await applyFilter(target);
assert.deepEqual(getFacetTexts(target), [
[
'A date is equal to "01/09/1997"',
"Boolean Field is No",
'Floaty McFloatface is equal to "7.2"',
'ID is "9"',
].join("or"),
]);
assert.deepEqual(getDomain(controlPanel), [
"|",
["date_field", "=", "1997-01-09"],
"|",
["boolean_field", "!=", true],
"|",
["float_field", "=", 7.2],
["id", "=", 9],
]);
});
QUnit.test("delete button is visible", async function (assert) {
await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchViewId: false,
searchMenuTypes: ["filter"],
});
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
assert.containsNone(
target,
".o_generator_menu_delete",
"There is no delete button by default"
);
await addCondition(target);
assert.containsN(
target,
".o_generator_menu_delete",
2,
"A delete button has been added to each condition"
);
assert.containsN(
target,
"i.o_generator_menu_delete.fa-trash-o",
2,
"The delete button is shown as a trash icon"
);
});
QUnit.test("condition value is not lost on deep render", async function (assert) {
const component = await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchViewId: false,
searchMenuTypes: ["filter"],
});
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
await editConditionField(target, 0, "char_field");
await editConditionValue(target, 0, "Coucou", 0, false);
let charInput = target.querySelector(".o_generator_menu_value .o_input");
assert.strictEqual(charInput.value, "Coucou");
component.render(true);
await nextTick();
charInput = target.querySelector(".o_generator_menu_value .o_input");
assert.strictEqual(charInput.value, "Coucou");
});
});
@@ -1,6 +1,11 @@
/** @odoo-module **/
import { getFixture, patchDate, patchWithCleanup } from "@web/../tests/helpers/utils";
import {
getFixture,
getNodesTextContent,
patchDate,
patchWithCleanup,
} from "@web/../tests/helpers/utils";
import { browser } from "@web/core/browser/browser";
import { ControlPanel } from "@web/search/control_panel/control_panel";
import {
@@ -8,11 +13,14 @@ import {
isItemSelected,
isOptionSelected,
makeWithSearch,
openAdvancedSearchDialog,
setupControlPanelServiceRegistry,
toggleFilterMenu,
toggleMenuItem,
toggleMenuItemOption,
} from "./helpers";
import { registry } from "@web/core/registry";
import { AdvancedSearchDialog } from "@web/search/filter_menu/advanced_search_dialog";
function getDomain(controlPanel) {
return controlPanel.env.searchModel.domain;
@@ -57,8 +65,6 @@ QUnit.module("Search", (hooks) => {
QUnit.module("FilterMenu");
QUnit.test("simple rendering with no filter", async function (assert) {
assert.expect(3);
await makeWithSearch({
serverData,
resModel: "foo",
@@ -69,7 +75,8 @@ QUnit.module("Search", (hooks) => {
await toggleFilterMenu(target);
assert.containsNone(target, ".o_menu_item");
assert.containsNone(target, ".dropdown-divider");
assert.containsOnce(target, ".o_add_custom_filter_menu");
assert.containsOnce(target, ".dropdown-item");
assert.strictEqual(target.querySelector(".dropdown-item").innerText, "Advanced Search");
});
QUnit.test("simple rendering with a single filter", async function (assert) {
@@ -91,7 +98,11 @@ QUnit.module("Search", (hooks) => {
assert.containsOnce(target, ".o_menu_item[role=menuitemcheckbox]");
assert.deepEqual(target.querySelector(".o_menu_item").ariaChecked, "false");
assert.containsOnce(target, ".dropdown-divider");
assert.containsOnce(target, ".o_add_custom_filter_menu");
assert.containsOnce(target, ".dropdown-item:not(.o_menu_item)");
assert.strictEqual(
target.querySelector(".dropdown-item:not(.o_menu_item)").innerText,
"Advanced Search"
);
});
QUnit.test('toggle a "simple" filter in filter menu works', async function (assert) {
@@ -584,4 +595,39 @@ QUnit.module("Search", (hooks) => {
assert.strictEqual(e.innerText.trim(), String(index + 1));
});
});
QUnit.test("Open 'Advanced Search' dialog", async function (assert) {
assert.expect(3);
registry.category("services").add(
"dialog",
{
start() {
return {
add: (dialogClass, props) => {
assert.strictEqual(dialogClass, AdvancedSearchDialog);
assert.deepEqual(Object.keys(props), [
"domain",
"onConfirm",
"resModel",
"isDebugMode",
]);
},
};
},
},
{ force: true }
);
await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchMenuTypes: ["filter"],
});
await toggleFilterMenu(target);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_filter_menu .dropdown-item")),
["Advanced Search"]
);
await openAdvancedSearchDialog(target);
});
});
@@ -198,6 +198,13 @@ export async function toggleFilterMenu(el) {
await click(findItem(el, `.o_filter_menu button.dropdown-toggle`));
}
export async function openAdvancedSearchDialog(el) {
await click(findItem(el, `.o_filter_menu .dropdown-item:not(.o_menu_item)`));
}
//////////////////////////////////////////////////////
// for legacy code
//////////////////////////////////////////////////////
export async function toggleAddCustomFilter(el) {
await mouseEnter(findItem(el, `.o_add_custom_filter_menu .dropdown-toggle`));
}
@@ -243,6 +250,8 @@ export async function removeCondition(el, index) {
await click(findItem(condition, ".o_generator_menu_delete"));
}
//////////////////////////////////////////////////////
/** Group by menu */
export async function toggleGroupByMenu(el) {
@@ -19,6 +19,8 @@ import {
} from "./helpers";
import { Component, onWillUpdateProps, onWillStart, useState, xml } from "@odoo/owl";
import { dialogService } from "@web/core/dialog/dialog_service";
import { notificationService } from "@web/core/notifications/notification_service";
const serviceRegistry = registry.category("services");
@@ -56,6 +58,7 @@ QUnit.module("Search", (hooks) => {
},
};
serviceRegistry.add("hotkey", hotkeyService);
serviceRegistry.add("notification", notificationService);
serviceRegistry.add("orm", ormService);
serviceRegistry.add("view", viewService);
target = getFixture();
@@ -111,7 +114,7 @@ QUnit.module("Search", (hooks) => {
});
assert.deepEqual(domain, [[0, "=", 1]]);
assert.deepEqual(groupBy, ["birthday"]);
assert.deepEqual(orderBy, [{name: "bar", asc: true}]);
assert.deepEqual(orderBy, [{ name: "bar", asc: true }]);
}
}
TestComponent.template = xml`<div class="o_test_component">Test component content</div>`;
@@ -123,7 +126,7 @@ QUnit.module("Search", (hooks) => {
domain: [[0, "=", 1]],
groupBy: ["birthday"],
context: { key: "val" },
orderBy: [{name: "bar", asc: true}],
orderBy: [{ name: "bar", asc: true }],
});
}
);
@@ -241,6 +244,8 @@ QUnit.module("Search", (hooks) => {
async function (assert) {
assert.expect(3);
serviceRegistry.add("dialog", dialogService);
class TestComponent extends Component {}
TestComponent.components = { FilterMenu, GroupByMenu };
TestComponent.template = xml`
@@ -274,6 +279,8 @@ QUnit.module("Search", (hooks) => {
async function (assert) {
assert.expect(2);
serviceRegistry.add("dialog", dialogService);
class TestComponent extends Component {
setup() {
owl.onWillStart(() => {
@@ -24,10 +24,8 @@ import {
triggerScroll,
} from "@web/../tests/helpers/utils";
import {
applyFilter,
editSearch,
getFacetTexts,
toggleAddCustomFilter,
toggleFilterMenu,
toggleGroupByMenu,
toggleMenuItem,
@@ -593,7 +591,7 @@ QUnit.module("Fields", (hooks) => {
serverData.views = {
"partner,false,search": `
<search>
<field name="datetime" />
<filter name="filter" string="Filter" domain="[[0, '=', 1]]"/>
</search>`,
"partner,false,list": `
<tree>
@@ -629,13 +627,7 @@ QUnit.module("Fields", (hooks) => {
const modal = target.querySelector(".modal");
await toggleFilterMenu(modal);
await toggleAddCustomFilter(modal);
assert.strictEqual(
modal.querySelector(".o_generator_menu_field").value,
"datetime",
"datetime field should be selected"
);
await applyFilter(modal);
await toggleMenuItem(modal, "Filter");
assert.strictEqual($("tr.o_data_row").length, 0, "should display 0 records");
});