Skip to content

Commit 1aee8b0

Browse files
committed
[IMP] spreadsheet: add selection filters
This commit introduces selection filters to the available global filters. This allows users to filter data based on `selection` fields. Selection filters are a bit tricky, as they are not shared across models unlike relation fields. Therefore, we need to handle them with some restrictions: - Users have to select the combination of model and field on which they want to filter. - Field matching has to be done on the same model as the one selected in the filter configuration. closes odoo#215182 Task: 3619413 Related: odoo/enterprise#88142 Signed-off-by: Rémi Rahir (rar) <[email protected]>
1 parent 8610eed commit 1aee8b0

File tree

11 files changed

+390
-7
lines changed

11 files changed

+390
-7
lines changed

addons/spreadsheet/static/src/@types/global_filter.d.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ declare module "@spreadsheet" {
7474
defaultValue?: string[];
7575
}
7676

77+
export interface SelectionGlobalFilter {
78+
type: "selection";
79+
id: string;
80+
label: string;
81+
resModel: string;
82+
selectionField: string;
83+
defaultValue?: string[];
84+
}
85+
7786
export interface CmdTextGlobalFilter extends TextGlobalFilter {
7887
rangesOfAllowedValues?: RangeData[];
7988
}
@@ -102,6 +111,6 @@ declare module "@spreadsheet" {
102111
defaultValue?: boolean[];
103112
}
104113

105-
export type GlobalFilter = TextGlobalFilter | DateGlobalFilter | RelationalGlobalFilter | BooleanGlobalFilter;
106-
export type CmdGlobalFilter = CmdTextGlobalFilter | DateGlobalFilter | RelationalGlobalFilter | BooleanGlobalFilter;
114+
export type GlobalFilter = TextGlobalFilter | DateGlobalFilter | RelationalGlobalFilter | BooleanGlobalFilter | SelectionGlobalFilter;
115+
export type CmdGlobalFilter = CmdTextGlobalFilter | DateGlobalFilter | RelationalGlobalFilter | BooleanGlobalFilter | SelectionGlobalFilter;
107116
}

addons/spreadsheet/static/src/global_filters/components/filter_value/filter_value.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { user } from "@web/core/user";
1212
import { TextFilterValue } from "../filter_text_value/filter_text_value";
1313
import { getFields, ModelNotFoundError } from "@spreadsheet/data_sources/data_source";
1414
import { BooleanMultiSelector } from "../boolean_multi_selector/boolean_multi_selector";
15+
import { SelectionFilterValue } from "../selection_filter_value/selection_filter_value";
1516

1617
const { ValidationMessages } = components;
1718

@@ -22,6 +23,7 @@ export class FilterValue extends Component {
2223
DateFilterValue,
2324
MultiRecordSelector,
2425
BooleanMultiSelector,
26+
SelectionFilterValue,
2527
ValidationMessages,
2628
};
2729
static props = {
@@ -106,6 +108,14 @@ export class FilterValue extends Component {
106108
this.props.setGlobalFilterValue(id, value);
107109
}
108110

111+
onSelectionInput(id, value) {
112+
if (Array.isArray(value) && value.length === 0) {
113+
this.clear(id);
114+
return;
115+
}
116+
this.props.setGlobalFilterValue(id, value);
117+
}
118+
109119
async onTagSelected(id, resIds) {
110120
if (!resIds.length) {
111121
// force clear, even automatic default values

addons/spreadsheet/static/src/global_filters/components/filter_value/filter_value.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@
1515
onValueChanged="(value) => this.onTextInput(filter.id, value)"
1616
/>
1717
</div>
18+
<div t-if="filter.type === 'selection'" class="w-100">
19+
<SelectionFilterValue
20+
resModel="filter.resModel"
21+
field="filter.selectionField"
22+
value="filterValue"
23+
onValueChanged="(value) => this.onSelectionInput(filter.id, value)"
24+
/>
25+
</div>
1826
<span t-if="filter.type === 'relation'" class="w-100">
1927
<MultiRecordSelector
2028
t-if="isValid"
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/** @ts-check */
2+
3+
import { Component, onWillStart, onWillUpdateProps, useEffect } from "@odoo/owl";
4+
import { useChildRef, useService } from "@web/core/utils/hooks";
5+
6+
import { TagsList } from "@web/core/tags_list/tags_list";
7+
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
8+
9+
export class SelectionFilterValue extends Component {
10+
static template = "spreadsheet.SelectionFilterValue";
11+
static components = {
12+
TagsList,
13+
AutoComplete,
14+
};
15+
static props = {
16+
resModel: String,
17+
field: String,
18+
value: { type: Array, optional: true },
19+
onValueChanged: Function,
20+
};
21+
static defaultProps = {
22+
value: [],
23+
};
24+
25+
setup() {
26+
this.inputRef = useChildRef();
27+
useEffect(
28+
() => {
29+
if (this.inputRef.el) {
30+
// Prevent the user from typing free-text by setting the maxlength to 0
31+
this.inputRef.el.setAttribute("maxlength", 0);
32+
}
33+
},
34+
() => [this.inputRef.el]
35+
);
36+
this.tags = [];
37+
this.sources = [];
38+
this.fields = useService("field");
39+
onWillStart(() => this._computeTagsAndSources(this.props));
40+
onWillUpdateProps((nextProps) => this._computeTagsAndSources(nextProps));
41+
}
42+
43+
async _computeTagsAndSources(props) {
44+
const fields = await this.fields.loadFields(props.resModel);
45+
const field = fields[props.field];
46+
if (!field) {
47+
throw new Error(`Field "${props.field}" not found in model "${props.resModel}"`);
48+
}
49+
const selection = field.selection;
50+
this.tags = props.value.map((value) => ({
51+
id: value,
52+
text: selection.find((option) => option[0] === value)?.[1] ?? value,
53+
onDelete: () => {
54+
props.onValueChanged(props.value.filter((v) => v !== value));
55+
},
56+
}));
57+
const alreadySelected = new Set(props.value);
58+
this.sources = [
59+
{
60+
options: selection
61+
.filter((option) => !alreadySelected.has(option[0]))
62+
.map(([value, formattedValue]) => ({
63+
label: formattedValue,
64+
onSelect: () => {
65+
props.onValueChanged([...props.value, value]);
66+
},
67+
})),
68+
},
69+
];
70+
}
71+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<templates>
3+
<t t-name="spreadsheet.SelectionFilterValue">
4+
<div class="o_input o-global-filter-selection-value d-flex flex-wrap gap-1">
5+
<TagsList tags="tags"/>
6+
<AutoComplete input="inputRef" sources="sources"/>
7+
</div>
8+
</t>
9+
</templates>

addons/spreadsheet/static/src/global_filters/helpers.js

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ export function checkFilterDefaultValueIsValid(filter, defaultValue) {
8989
}
9090
switch (filter.type) {
9191
case "text":
92-
return isTextFilterValueValid(defaultValue);
92+
case "selection":
93+
return isTextSelectionFilterValueValid(defaultValue);
9394
case "date":
9495
return isDateFilterDefaultValueValid(defaultValue);
9596
case "relation":
@@ -112,7 +113,8 @@ export function checkFilterValueIsValid(filter, value) {
112113
}
113114
switch (filter.type) {
114115
case "text":
115-
return isTextFilterValueValid(value);
116+
case "selection":
117+
return isTextSelectionFilterValueValid(value);
116118
case "date":
117119
return isDateFilterValueValid(value);
118120
case "relation":
@@ -124,11 +126,11 @@ export function checkFilterValueIsValid(filter, value) {
124126
}
125127

126128
/**
127-
* A text filter value is valid if it is an array of strings. It's the same
128-
* for the default value.
129+
* A text or selection filter value is valid if it is an array of strings. It's
130+
* the same for the default value.
129131
* @returns {boolean}
130132
*/
131-
function isTextFilterValueValid(value) {
133+
function isTextSelectionFilterValueValid(value) {
132134
return Array.isArray(value) && value.length && value.every((text) => typeof text === "string");
133135
}
134136

@@ -596,6 +598,20 @@ export async function getFacetInfo(filter, filterValues, nameService) {
596598
typeof value === "string" ? value : _t("Inaccessible/missing record ID")
597599
);
598600
break;
601+
case "selection": {
602+
const fields = await this.fields.loadFields(filter.resModel);
603+
const field = fields[filter.selectionField];
604+
if (!field) {
605+
throw new Error(
606+
`Field ${filter.selectionField} not found in model ${filter.resModel}`
607+
);
608+
}
609+
values = filterValues.map((value) => {
610+
const option = field.selection.find((option) => option[0] === value);
611+
return option ? option[1] : value;
612+
});
613+
break;
614+
}
599615
}
600616
return {
601617
title: filter.label,

addons/spreadsheet/static/src/global_filters/plugins/global_filters_core_view_plugin.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ export class GlobalFiltersCoreViewPlugin extends OdooCoreViewPlugin {
132132
return this._getRelationDomain(filter, fieldMatching);
133133
case "boolean":
134134
return this._getBooleanDomain(filter, fieldMatching);
135+
case "selection":
136+
return this._getSelectionDomain(filter, fieldMatching);
135137
}
136138
}
137139

@@ -156,6 +158,7 @@ export class GlobalFiltersCoreViewPlugin extends OdooCoreViewPlugin {
156158
switch (filter.type) {
157159
case "text":
158160
case "boolean":
161+
case "selection":
159162
return filter.defaultValue;
160163
case "date":
161164
return this._getDateValueFromDefaultValue(filter.defaultValue);
@@ -198,6 +201,7 @@ export class GlobalFiltersCoreViewPlugin extends OdooCoreViewPlugin {
198201
switch (filter.type) {
199202
case "text":
200203
case "boolean":
204+
case "selection":
201205
return [[{ value: value?.length ? value.join(", ") : "" }]];
202206
case "date":
203207
return this._getDateFilterDisplayValue(filter);
@@ -441,6 +445,15 @@ export class GlobalFiltersCoreViewPlugin extends OdooCoreViewPlugin {
441445
return new Domain([[field, "in", [toBoolean(value[0]), toBoolean(value[1])]]]);
442446
}
443447

448+
_getSelectionDomain(filter, fieldMatching) {
449+
const values = this.getGlobalFilterValue(filter.id);
450+
if (!values || !values.length || !fieldMatching.chain) {
451+
return new Domain();
452+
}
453+
const field = fieldMatching.chain;
454+
return new Domain([[field, "in", values]]);
455+
}
456+
444457
/**
445458
* Adds all active filters (and their values) at the time of export in a dedicated sheet
446459
*

addons/spreadsheet/static/tests/global_filters/filter_value_component.test.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,19 @@ test("boolean filter", async function () {
259259
await contains("a:first").click();
260260
expect(model.getters.getGlobalFilterValue("42")).toEqual([true], { message: "value is set" });
261261
});
262+
263+
test("selection filter", async function () {
264+
const env = await makeMockEnv();
265+
const model = new Model({}, { custom: { odooDataProvider: new OdooDataProvider(env) } });
266+
await addGlobalFilter(model, {
267+
id: "42",
268+
type: "selection",
269+
label: "Selection Filter",
270+
resModel: "res.currency",
271+
selectionField: "position",
272+
});
273+
await mountFilterValueComponent({ model, filter: model.getters.getGlobalFilter("42") });
274+
await contains("input").click();
275+
await contains("a:first").click();
276+
expect(model.getters.getGlobalFilterValue("42")).toEqual(["after"]);
277+
});

addons/spreadsheet/static/tests/global_filters/global_filters_core_view.test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,53 @@ test("Value of text filter", () => {
6262
expect(result.reasons).toEqual(["InvalidValueTypeCombination"]);
6363
});
6464

65+
test("Value of selection filter", () => {
66+
const model = new Model();
67+
addGlobalFilterWithoutReload(model, {
68+
id: "1",
69+
type: "selection",
70+
label: "selection Filter",
71+
resModel: "res.currency",
72+
selectionField: "position",
73+
});
74+
75+
let result = setGlobalFilterValueWithoutReload(model, {
76+
id: "1",
77+
value: "test",
78+
});
79+
expect(result.isSuccessful).toBe(false);
80+
expect(result.reasons).toEqual(["InvalidValueTypeCombination"]);
81+
82+
result = addGlobalFilterWithoutReload(model, {
83+
id: "2",
84+
type: "selection",
85+
label: "Default value is an array",
86+
resModel: "res.currency",
87+
selectionField: "position",
88+
defaultValue: ["default value"],
89+
});
90+
expect(result.isSuccessful).toBe(true);
91+
92+
result = setGlobalFilterValueWithoutReload(model, {
93+
id: "1",
94+
});
95+
expect(result.isSuccessful).toBe(true);
96+
97+
result = setGlobalFilterValueWithoutReload(model, {
98+
id: "1",
99+
value: 5,
100+
});
101+
expect(result.isSuccessful).toBe(false);
102+
expect(result.reasons).toEqual(["InvalidValueTypeCombination"]);
103+
104+
result = setGlobalFilterValueWithoutReload(model, {
105+
id: "1",
106+
value: false,
107+
});
108+
expect(result.isSuccessful).toBe(false);
109+
expect(result.reasons).toEqual(["InvalidValueTypeCombination"]);
110+
});
111+
65112
test("Value of date filter", () => {
66113
const model = new Model();
67114
addGlobalFilterWithoutReload(model, {

0 commit comments

Comments
 (0)