summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJulien Mougenot <jum@odoo.com>2020-07-13 13:17:14 +0000
committerMathieu Duckerts-Antoine <dam@odoo.com>2020-07-23 08:21:10 +0000
commit7ff2e5822f08616dd22c142e0e05ed4355801760 (patch)
treed0ec500ac5aa91a12f5421e73139469a83ecd93c
parentd6ffbfd479fcd235e6e8fdad2cbd84286ca359b6 (diff)
[IMP] web: Search panel Owl
Refactoring of the search panel and its inner logic to Owl. The previous search panel widget has been divided in 2 parts: 1. Component The "Search Panel" component's responsibilities are now to render the search panel data given by its model, display some rendering logic (expanded categories, checked groups) and dispatch the events triggered on it. 2. Model Extension The "Search Panel Model Extension" holds all the search logic of the search panel. Its responsibilities are calling the server to update its data and to provide them to its component and other search extensions. Part of task 2228968 Co-authored-by: Julien Mougenot <jum@odoo.com> Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
-rw-r--r--addons/web/__manifest__.py1
-rw-r--r--addons/web/static/src/js/views/search_panel.js926
-rw-r--r--addons/web/static/src/js/views/search_panel_model_extension.js730
-rw-r--r--addons/web/static/src/scss/search_panel.scss180
-rw-r--r--addons/web/static/src/xml/kanban.xml126
-rw-r--r--addons/web/static/src/xml/search_panel.xml132
-rw-r--r--addons/web/views/webclient_templates.xml1
7 files changed, 1138 insertions, 958 deletions
diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py
index e72fabaff2a..ed939d80836 100644
--- a/addons/web/__manifest__.py
+++ b/addons/web/__manifest__.py
@@ -32,6 +32,7 @@ This module provides the core of the Odoo Web Client.
"static/src/xml/pivot.xml",
"static/src/xml/rainbow_man.xml",
"static/src/xml/report.xml",
+ "static/src/xml/search_panel.xml",
"static/src/xml/web_calendar.xml",
],
'bootstrap': True, # load translations for login screen
diff --git a/addons/web/static/src/js/views/search_panel.js b/addons/web/static/src/js/views/search_panel.js
index 84c1889cc5e..bd6bda889ab 100644
--- a/addons/web/static/src/js/views/search_panel.js
+++ b/addons/web/static/src/js/views/search_panel.js
@@ -1,789 +1,219 @@
-odoo.define('web.SearchPanel', function (require) {
-"use strict";
+odoo.define("web/static/src/js/views/search_panel.js", function (require) {
+ "use strict";
-/**
- * This file defines the SearchPanel widget for Kanban. It allows to
- * filter/manage data easily.
- */
+ const { Model, useModel } = require("web/static/src/js/model.js");
+ const patchMixin = require("web.patchMixin");
-const core = require('web.core');
-const Domain = require('web.Domain');
-const pyUtils = require('web.py_utils');
-const { sortBy } = require('web.utils');
-const viewUtils = require('web.viewUtils');
-const Widget = require('web.Widget');
-
-const qweb = core.qweb;
-
-// defaultViewTypes is the list of view types for which the searchpanel is
-// present by default (if not explicitly stated in the 'view_types' attribute
-// in the arch)
-const defaultViewTypes = ['kanban', 'tree'];
-let nextSectionId = 1;
-
-const SEARCH_PANEL_DEFAULT_LIMIT = 200;
-
-/**
- * Given a <searchpanel> arch node, iterate over its children to generate the
- * description of each section (being either a category or a filter).
- *
- * @param {Object} node a <searchpanel> arch node
- * @param {Object} fields the fields of the model
- * @returns {Object}
- */
-function _processSearchPanelNode(node, fields) {
- const sections = {};
- node.children.forEach(({ attrs, tag }, index) => {
- if (tag !== 'field' || attrs.invisible === "1") {
- return;
- }
- const fieldName = attrs.name;
- const type = attrs.select === 'multi' ? 'filter' : 'category';
-
- const sectionId = `section_${nextSectionId++}`;
- const section = {
- color: attrs.color,
- description: attrs.string || fields[fieldName].string,
- enableCounters: !!pyUtils.py_eval(attrs.enable_counters || '0'),
- expand: !!pyUtils.py_eval(attrs.expand || '0'),
- fieldName,
- icon: attrs.icon,
- id: sectionId,
- index,
- limit: attrs.limit ?
- pyUtils.py_eval(attrs.limit) :
- SEARCH_PANEL_DEFAULT_LIMIT,
- type,
- };
- if (section.type === 'category') {
- section.icon = section.icon || 'fa-folder';
- section.hierarchize = !!pyUtils.py_eval(attrs.hierarchize || '1');
- } else if (section.type === 'filter') {
- section.domain = attrs.domain || '[]';
- section.groupBy = attrs.groupby;
- section.icon = section.icon || 'fa-filter';
- }
- sections[sectionId] = section;
- });
- return sections;
-}
-
-const SearchPanel = Widget.extend({
- className: 'o_search_panel',
- events: {
- 'click .o_search_panel_category_value header': '_onCategoryValueClicked',
- 'change .o_search_panel_filter_value > header > div > input': '_onFilterValueChanged',
- 'change .o_search_panel_filter_group > header > div > input': '_onFilterGroupChanged',
- },
+ const { Component, hooks } = owl;
+ const { useState, useSubEnv } = hooks;
/**
- * @constructor
- * @param {Object} params
- * @param {Object} [params.defaultValues={}] the value(s) to activate by
- * default, for each filter and category
- * @param {boolean} [params.defaultNoFilter=false] if true, select 'All' as
- * value for each category that has no value specified in defaultValues
- * (instead of looking in the localStorage for the last selected value)
- * @param {Object} params.fields
- * @param {string} params.model
- * @param {Array[]} params.searchDomain domain coming from controlPanel
- * @param {Array[]} params.viewDomain domain coming from the view (ill advised)
- * @param {Object} params.sections
- * @param {Object} [params.state] state exported by another searchpanel
- * instance
- */
- init(parent, params) {
- this._super(...arguments);
+ * Search panel
+ *
+ * Represent an extension of the search interface located on the left side of
+ * the view. It is divided in sections defined in a "<searchpanel>" node located
+ * inside of a "<search>" arch. Each section is represented by a list of different
+ * values (categories or ungrouped filters) or groups of values (grouped filters).
+ * Its state is directly affected by its model (@see SearchPanelModelExtension).
+ * @extends Component
+ */
+ class SearchPanel extends Component {
+ constructor() {
+ super(...arguments);
+
+ useSubEnv({ searchModel: this.props.searchModel });
+
+ this.state = useState({
+ active: {},
+ expanded: {},
+ });
+ this.model = useModel("searchModel");
+ this.scrollTop = 0;
+ this.hasImportedState = false;
- this.categories = {};
- this.filters = {};
- for (const section of Object.values(params.sections)) {
- const key = section.type === 'category' ? 'categories' : 'filters';
- this[key][section.id] = section;
+ this.importState(this.props.importedState);
}
- this.initialState = params.state;
- this.scrollTop = this.initialState && this.initialState.scrollTop || null;
- this.defaultValues = params.defaultValues || {};
- if (params.defaultNoFilter) {
- Object.values(this.categories).forEach(({ fieldName }) => {
- this.defaultValues[fieldName] = this.defaultValues[fieldName] || false;
- });
- }
- this.fields = params.fields;
- this.model = params.model;
- this.className = params.classes.concat(['o_search_panel']).join(' ');
- this.internalChangeOccurred = false;
- this.searchDomain = params.searchDomain;
- this.viewDomain = params.viewDomain;
- },
- /**
- * @override
- */
- async willStart() {
- const _super = this._super;
- if (this.initialState) {
- this.filters = this.initialState.filters;
- this.categories = this.initialState.categories;
- } else {
- await this._fetchCategories({ initialLoad: true });
- await this._fetchFilters();
- await this._applyDefaultFilterValues();
+ async willStart() {
+ this._expandDefaultValue();
+ this._updateActiveValues();
}
- return _super.call(this, ...arguments);
- },
- /**
- * @override
- */
- start() {
- this._render();
- return this._super(...arguments);
- },
- /**
- * Called each time the searchPanel is attached into the DOM.
- */
- on_attach_callback() {
- if (this.scrollTop !== null) {
- this.el.scrollTop = this.scrollTop;
- }
- },
-
- //-------------------------------------------------------------------------
- // Public
- //-------------------------------------------------------------------------
- /**
- * Parse a given search view arch to extract the searchpanel information
- * (i.e. a description of each filter/category). Note that according to the
- * 'view_types' attribute on the <searchpanel> node, and the given viewType,
- * it may return undefined, meaning that no searchpanel should be rendered
- * for the current view.
- *
- * Note that this is static method, called by AbstractView, *before*
- * instantiating the SearchPanel, as depending on what it returns, we may
- * or may not instantiate a SearchPanel.
- *
- * @static
- * @param {Object} viewInfo the viewInfo of a search view
- * @param {string} viewInfo.arch
- * @param {Object} viewInfo.fields
- * @param {string} viewType the type of the current view (e.g. 'kanban')
- * @returns {Object}
- */
- computeSearchPanelParams(arch, fields, viewType) {
- const searchPanelParams = {};
- if (arch && fields) {
- const parsedArch = viewUtils.parseArch(arch);
- const searchPanelNode = parsedArch.children.find(child => child.tag === 'searchpanel');
- if (searchPanelNode) {
- const { attrs } = searchPanelNode;
- const type = viewType === 'list' ? 'tree' : viewType;
- let viewTypes = defaultViewTypes;
- if (attrs.view_types) {
- viewTypes = attrs.view_types.split(',');
- }
- if (attrs.class) {
- searchPanelParams.classes = attrs.class.split(' ');
- }
- if (viewTypes.includes(type)) {
- searchPanelParams.sections = _processSearchPanelNode(searchPanelNode, fields);
- }
+ mounted() {
+ this._updateGroupHeadersChecked();
+ if (this.hasImportedState) {
+ this.el.scroll({ top: this.scrollTop });
}
}
- return searchPanelParams;
- },
- /**
- * Export the current state (categories and filters) of the searchpanel.
- *
- * @returns {Object}
- */
- exportState() {
- return {
- categories: this.categories,
- filters: this.filters,
- scrollTop: this.el ? this.el.scrollTop : null,
- };
- },
- /**
- * @returns {Array[]} the current searchPanel domain based on active
- * categories and checked filters
- */
- getDomain() {
- return this._getCategoryDomain().concat(this._getFilterDomain());
- },
- /**
- * Import a previously exported state (see exportState).
- *
- * @param {Object} state
- * @param {Object} state.filters.
- * @param {Object} state.categories
- */
- importState(state) {
- this.categories = state.categories || this.categories;
- this.filters = state.filters || this.filters;
- this.scrollTop = state.scrollTop;
- this._render();
- },
- /**
- * Reload the filters and categories and re-render. Note that we only reload them
- * if the controlPanel domain or searchPanel domain has changed.
- *
- * @param {Object} params
- * @param {Array[]} params.searchDomain domain coming from controlPanel
- * @param {Array[]} params.viewDomain domain coming from view
- * @returns {Promise}
- */
- async update(params) {
- const currentDomain = JSON.stringify([...this.searchDomain, ...this.viewDomain]);
- const newDomain = JSON.stringify([...params.searchDomain, ...params.viewDomain]);
- const externalDomainUpdated = currentDomain !== newDomain;
- if (this.internalChangeOccurred || externalDomainUpdated) {
- this.searchDomain = params.searchDomain;
- this.viewDomain = params.viewDomain;
- await this._fetchCategories({ externalDomainUpdated });
- await this._fetchFilters();
- this.internalChangeOccurred = false;
- }
- return this._render();
- },
- //-------------------------------------------------------------------------
- // Private
- //-------------------------------------------------------------------------
-
- /**
- * Set active values for each filter (coming from context). This needs to be
- * done only once, at widget startup.
- *
- * @private
- */
- _applyDefaultFilterValues() {
- Object.values(this.filters).forEach(filter => {
- const defaultValues = this.defaultValues[filter.fieldName] || [];
- defaultValues.forEach(value => {
- if (filter.values[value]) {
- filter.values[value].checked = true;
- }
- });
- Object.values(filter.groups || []).forEach(this._updateFilterGroupState);
- });
- },
- /**
- * @private
- * @param {string} categoryId
- * @param {Object} result
- */
- _createCategoryTree(categoryId, result) {
- const category = this.categories[categoryId];
- let { error_msg, parent_field: parentField, values } = result;
- if (error_msg) {
- category.errorMsg = error_msg;
- values = [];
- }
- if (category.hierarchize) {
- category.parentField = parentField;
+ async willUpdateProps() {
+ this._updateActiveValues();
}
- const unfoldedIds = Object.values(category.values || {})
- .filter(c => c.folded === false)
- .map(c => c.id);
+ //---------------------------------------------------------------------
+ // Public
+ //---------------------------------------------------------------------
- category.values = {};
- values.forEach(value => {
- category.values[value.id] = Object.assign({}, value, {
- childrenIds: [],
- folded: !unfoldedIds.includes(value.id),
- parentId: value[parentField] || false,
- });
- });
- values.forEach(value => {
- const { parentId: parentCategoryId } = category.values[value.id];
- if (parentCategoryId && parentCategoryId in category.values) {
- category.values[parentCategoryId].childrenIds.push(value.id);
- }
- });
- category.rootIds = [];
- for (const value of values) {
- const { parentId } = category.values[value.id];
- if (!parentId) {
- category.rootIds.push(value.id);
- }
+ exportState() {
+ const exported = {
+ expanded: this.state.expanded,
+ scrollTop: this.el.scrollTop,
+ };
+ return JSON.stringify(exported);
}
- // set active value
- const validValues = [...Object.values(category.values).map(v => v.id), false];
- const value = this._getCategoryDefaultValue(category, validValues);
- category.activeValueId = validValues.includes(value) ? value : false;
-
- // unfold ancestor values of active value to make it is visible
- if (category.activeValueId) {
- const parentValueIds = this._getAncestorValueIds(category, category.activeValueId);
- parentValueIds.forEach(parentValue => {
- category.values[parentValue].folded = false;
- });
- }
- },
- /**
- * @private
- * @param {string} filterId
- * @param {Object} result
- */
- _createFilterTree(filterId, result) {
- const filter = this.filters[filterId];
- let { error_msg, values } = result;
- if (error_msg) {
- filter.errorMsg = error_msg;
- values = [];
+ importState(stringifiedState) {
+ this.hasImportedState = Boolean(stringifiedState);
+ if (this.hasImportedState) {
+ const state = JSON.parse(stringifiedState);
+ this.state.expanded = state.expanded;
+ this.scrollTop = state.scrollTop;
+ }
}
- // restore checked property
- values.forEach(value => {
- const oldValue = filter.values && filter.values[value.id];
- value.checked = oldValue && oldValue.checked || false;
- });
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
- filter.values = {};
- const groupIds = [];
- if (filter.groupBy) {
- const groups = {};
- values.forEach(value => {
- const groupId = JSON.stringify(value.group_id);
- if (!groups[groupId]) {
- if (groupId !== 'false') {
- groupIds.push(groupId);
+ /**
+ * Expands category values holding the default value of a category.
+ * @private
+ */
+ _expandDefaultValue() {
+ if (this.hasImportedState) {
+ return;
+ }
+ const categories = this.model.get("sections", s => s.type === "category");
+ for (const category of categories) {
+ this.state.expanded[category.id] = {};
+ if (category.activeValueId) {
+ const ancestorIds = this._getAncestorValueIds(category, category.activeValueId);
+ for (const ancestorId of ancestorIds) {
+ this.state.expanded[category.id][ancestorId] = true;
}
- groups[groupId] = {
- folded: false,
- id: groupId,
- name: value.group_name,
- values: {},
- tooltip: value.group_tooltip,
- sequence: value.group_sequence,
- hex_color: value.group_hex_color,
- sortedValueIds: [],
- };
- // restore former checked and folded state
- const oldGroup = filter.groups && filter.groups[groupId];
- groups[groupId].state = oldGroup && oldGroup.state || false;
- groups[groupId].folded = oldGroup && oldGroup.folded || false;
}
- groups[groupId].values[value.id] = value;
- groups[groupId].sortedValueIds.push(value.id);
- });
- filter.groups = groups;
- filter.sortedGroupIds = sortBy(groupIds,
- id => groups[id].sequence || groups[id].name
- );
- Object.values(filter.groups).forEach(group => {
- Object.assign(filter.values, group.values);
- });
- } else {
- values.forEach(value => {
- filter.values[value.id] = value;
- });
- filter.sortedValueIds = values.map(value => value.id);
- }
- },
- /**
- * Fetch values for each category at startup. At reload a category is only
- * fetched if the situation requires it.
- *
- * @private
- * @param {Object} [options={}]
- * @param {boolean} [options.initialLoad]
- * @param {boolean} [options.externalDomainUpdated]
- * @returns {Promise} resolved when all categories have been fetched
- */
- _fetchCategories(options = {}) {
- const proms = [];
- for (const category of Object.values(this.categories)) {
- const { enableCounters, expand, hierarchize, limit } = category;
- if (options.initialLoad || enableCounters || (options.externalDomainUpdated && !expand)) {
- const prom = this._rpc({
- method: 'search_panel_select_range',
- model: this.model,
- args: [category.fieldName],
- kwargs: {
- category_domain: this._getCategoryDomain(category.id),
- enable_counters: enableCounters,
- expand,
- filter_domain: this._getFilterDomain(),
- hierarchize,
- limit,
- search_domain: this.searchDomain,
- },
- }).then(
- (result) => this._createCategoryTree(category.id, result)
- );
- proms.push(prom);
}
}
- return Promise.all(proms);
- },
- /**
- * Fetch values for each filter. This is done at startup, and at each reload
- * (when the controlPanel or searchPanel domain changes).
- *
- * @private
- * @returns {Promise} resolved when all filters have been fetched
- */
- _fetchFilters() {
- const evalContext = {};
- for (const category of Object.values(this.categories)) {
- evalContext[category.fieldName] = category.activeValueId;
- }
- const categoryDomain = this._getCategoryDomain();
- const proms = [];
- for (const filter of Object.values(this.filters)) {
- const { enableCounters, expand, groupBy, limit } = filter;
- const prom = this._rpc({
- method: 'search_panel_select_multi_range',
- model: this.model,
- args: [filter.fieldName],
- kwargs: {
- category_domain: categoryDomain,
- comodel_domain: Domain.prototype.stringToArray(filter.domain, evalContext),
- enable_counters: enableCounters,
- filter_domain: this._getFilterDomain(filter.id),
- expand,
- group_by: groupBy || false,
- group_domain: this._getGroupDomain(filter),
- limit,
- search_domain: [...this.searchDomain, ...this.viewDomain],
- },
- }).then(
- (result) => this._createFilterTree(filter.id, result)
- );
- proms.push(prom);
- }
- return Promise.all(proms);
- },
- /**
- * @private
- * @param {Object} category
- * @param {Array} validValues
- * @returns {(number|boolean)} id of the default item of the category or false
- */
- _getCategoryDefaultValue(category, validValues) {
- // set active value from context
- const value = this.defaultValues[category.fieldName];
- // if not set in context, or set to an unknown value, set active value
- // from localStorage
- if (!validValues.includes(value)) {
- const storageKey = this._getLocalStorageKey(category);
- return this.call('local_storage', 'getItem', storageKey);
- }
- return value;
- },
- /**
- * Compute and return the domain based on the current active categories.
- *
- * @private
- * @param {string} [excludedCategoryId]
- * @returns {Array[]}
- */
- _getCategoryDomain(excludedCategoryId) {
- const domain = [];
- for (const category of Object.values(this.categories)) {
- if (category.id === excludedCategoryId) {
- continue;
- }
- if (category.activeValueId) {
- const field = this.fields[category.fieldName];
- const op = (field.type === 'many2one' && category.parentField) ? 'child_of' : '=';
- domain.push([category.fieldName, op, category.activeValueId]);
- }
- }
- return domain;
- },
- /**
- * Compute and return the domain based on the current checked filters.
- * The values of a single filter are combined using a simple rule: checked values within
- * a same group are combined with an 'OR' (this is expressed as single condition using a list)
- * and groups are combined with an 'AND' (expressed by concatenation of conditions).
- * If a filter has no groups, its checked values are implicitely considered as forming
- * a group (and grouped using an 'OR').
- *
- * @private
- * @param {string} [excludedFilterId]
- * @returns {Array[]}
- */
- _getFilterDomain(excludedFilterId) {
- const domain = [];
- function addCondition(fieldName, checkedValues) {
- if (checkedValues.length) {
- const ids = checkedValues.map(v => v.id);
- domain.push([fieldName, 'in', ids]);
- }
- }
-
- for (const filter of Object.values(this.filters)) {
- if (filter.id === excludedFilterId) {
- continue;
- }
- const { fieldName } = filter;
- let checkedValues;
- if (filter.groups) {
- for (const group of Object.values(filter.groups)) {
- checkedValues = Object.values(group.values).filter(v => v.checked);
- addCondition(fieldName, checkedValues);
+ /**
+ * @private
+ * @param {Object} category
+ * @param {number} categoryValueId
+ * @returns {number[]} list of ids of the ancestors of the given value in
+ * the given category.
+ */
+ _getAncestorValueIds(category, categoryValueId) {
+ const { parentId } = category.values.get(categoryValueId);
+ return parentId ? [...this._getAncestorValueIds(category, parentId), parentId] : [];
+ }
+
+ /**
+ * Prevent unnecessary calls to the model by ensuring a different category
+ * is clicked.
+ * @private
+ * @param {Object} category
+ * @param {Object} value
+ */
+ async _toggleCategory(category, value) {
+ if (value.childrenIds.length) {
+ const categoryState = this.state.expanded[category.id];
+ if (categoryState[value.id] && category.activeValueId === value.id) {
+ delete categoryState[value.id];
+ } else {
+ categoryState[value.id] = true;
}
- } else if (filter.values) {
- checkedValues = Object.values(filter.values).filter(v => v.checked);
- addCondition(fieldName, checkedValues);
}
- }
- return domain;
- },
- /**
- * Returns a domain or an object of domains used to complement
- * the filter domains to accurately describe the constraints on
- * records when computing record counts associated with the filter
- * values (if a groupBy is provided). The idea is that the checked values
- * within a group should not impact the counts for the other values
- * in the same group.
- *
- * @private
- * @param {string} filter
- * @returns {(Array{}|Array[]|undefined)}
- */
- _getGroupDomain(filter) {
- const { fieldName, groups, enableCounters } = filter;
- const { type: fieldType } = this.fields[fieldName];
-
- if (!enableCounters || !groups) {
- switch (fieldType) {
- case 'many2one': return [];
- case 'many2many': return {};
- default: return;
+ if (category.activeValueId !== value.id) {
+ this.state.active[category.id] = value.id;
+ this.model.dispatch("toggleCategoryValue", category.id, value.id);
}
}
- let groupDomain;
- if (fieldType === 'many2one') {
- for (const group of Object.values(groups)) {
- const valueIds = [];
- let active = false;
- for (const value of Object.values(group.values || {})) {
- const { id, checked } = value;
- valueIds.push(id);
- if (checked) {
- active = true;
- }
- }
- if (active) {
- if (groupDomain) {
- groupDomain = Domain.FALSE_DOMAIN;
- break;
- } else {
- groupDomain = [[fieldName, 'in', valueIds]];
- }
- }
- }
- } else if (fieldType === 'many2many') {
- const checkedValueIds = {};
- for (const group of Object.values(groups)) {
- for (const value of Object.values(group.values || {})) {
- const { id, checked } = value;
- if (checked) {
- if (!checkedValueIds[group.id]) {
- checkedValueIds[group.id] = [];
+ /**
+ * @private
+ * @param {number} filterId
+ * @param {{ values: Map<Object> }} group
+ */
+ _toggleFilterGroup(filterId, { values }) {
+ const valueIds = [];
+ const checked = [...values.values()].every(
+ (value) => this.state.active[filterId][value.id]
+ );
+ values.forEach(({ id }) => {
+ valueIds.push(id);
+ this.state.active[filterId][id] = !checked;
+ });
+ this.model.dispatch("toggleFilterValues", filterId, valueIds, !checked);
+ }
+
+ /**
+ * @private
+ * @param {number} filterId
+ * @param {Object} [group]
+ * @param {number} valueId
+ * @param {MouseEvent} ev
+ */
+ _toggleFilterValue(filterId, valueId, { currentTarget }) {
+ this.state.active[filterId][valueId] = currentTarget.checked;
+ this._updateGroupHeadersChecked();
+ this.model.dispatch("toggleFilterValues", filterId, [valueId]);
+ }
+
+ _updateActiveValues() {
+ for (const section of this.model.get("sections")) {
+ if (section.type === "category") {
+ this.state.active[section.id] = section.activeValueId;
+ } else {
+ this.state.active[section.id] = {};
+ if (section.groups) {
+ for (const group of section.groups.values()) {
+ for (const value of group.values.values()) {
+ this.state.active[section.id][value.id] = value.checked;
+ }
}
- checkedValueIds[group.id].push(id);
}
- }
- }
- groupDomain = {};
- for (const gId in checkedValueIds) {
- const ids = checkedValueIds[gId];
- for (const group of Object.values(groups)) {
- if (gId !== group.id) {
- if (!groupDomain[group.id]) {
- groupDomain[group.id] = [];
- }
- groupDomain[group.id].push(
- [fieldName, 'in', ids]
- );
+ for (const value of section.values.values()) {
+ this.state.active[section.id][value.id] = value.checked;
}
}
}
}
- return groupDomain;
- },
- /**
- * The active id of each category is stored in the localStorage, s.t. it
- * can be restored afterwards (when the action is reloaded, for instance).
- * This function returns the key in the sessionStorage for a given category.
- *
- * @param {Object} category
- * @returns {string}
- */
- _getLocalStorageKey(category) {
- return 'searchpanel_' + this.model + '_' + category.fieldName;
- },
- /**
- * @private
- * @param {Object} category
- * @param {number} categoryValueId
- * @returns {number[]} list of ids of the ancestors of the given value in
- * the given category
- */
- _getAncestorValueIds(category, categoryValueId) {
- const { parentId } = category.values[categoryValueId];
- if (parentId) {
- return [parentId].concat(this._getAncestorValueIds(category, parentId));
- }
- return [];
- },
- /**
- * Compute the current searchPanel domain based on categories and filters,
- * and notify environment of the domain change.
- *
- * Note that this assumes that the environment will update the searchPanel.
- * This is done as such to ensure the coordination between the reloading of
- * the searchPanel and the reloading of the data.
- *
- * @private
- */
- _notifyDomainUpdated() {
- this.internalChangeOccurred = true;
- this.trigger_up('search_panel_domain_updated', {
- domain: this.getDomain(),
- });
- },
- /**
- * @private
- */
- _render() {
- this.$el.empty();
- // sort categories and filters according to their index
- const categories = Object.values(this.categories);
- const filters = Object.values(this.filters);
- const sections = categories.concat(filters).sort((s1, s2) => s1.index - s2.index);
-
- sections.forEach(section => {
- if (Object.keys(section.values).length || section.errorMsg) {
- if (section.type === 'category') {
- this.$el.append(this._renderCategory(section));
- } else {
- this.$el.append(this._renderFilter(section));
+ /**
+ * Updates the "checked" or "indeterminate" state of each of the group
+ * headers according to the state of their values.
+ * @private
+ */
+ _updateGroupHeadersChecked() {
+ const groups = this.el.querySelectorAll(":scope .o_search_panel_filter_group");
+ for (const group of groups) {
+ const header = group.querySelector(":scope .o_search_panel_group_header input");
+ const vals = [...group.querySelectorAll(":scope .o_search_panel_filter_value input")];
+ header.checked = false;
+ header.indeterminate = false;
+ if (vals.every((v) => v.checked)) {
+ header.checked = true;
+ } else if (vals.some((v) => v.checked)) {
+ header.indeterminate = true;
}
}
- });
- },
- /**
- * @private
- * @param {Object} category
- * @returns {string}
- */
- _renderCategory(category) {
- return qweb.render('SearchPanel.Category', { category });
- },
- /**
- * @private
- * @param {Object} filter
- * @returns {HTMLElement}
- */
- _renderFilter(filter) {
- const filterElement = $(qweb.render('SearchPanel.Filter', { filter }))[0];
-
- // set group inputs in indeterminate state when necessary
- Object.keys(filter.groups || {}).forEach(groupId => {
- const group = filter.groups[groupId];
- const { state } = group;
- // group 'false' is not displayed
- if (groupId !== 'false' && state === 'indeterminate') {
- const sel = `.o_search_panel_filter_group[data-group-id="${groupId}"] input`;
- filterElement.querySelector(sel).indeterminate = true;
- }
- });
-
- return filterElement;
- },
- /**
- * Updates the state property of a given filter's group according to the
- * checked property of its values.
- *
- * @private
- * @param {Object} group
- */
- _updateFilterGroupState(group) {
- if (Object.values(group.values).some(v => v.checked)) {
- if (Object.values(group.values).some(v => !v.checked)) {
- group.state = 'indeterminate';
- } else {
- group.state = 'checked';
- }
- } else {
- group.state = 'unchecked';
- }
- },
-
- //-------------------------------------------------------------------------
- // Handlers
- //-------------------------------------------------------------------------
-
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onCategoryValueClicked(ev) {
- ev.stopPropagation();
- const item = ev.currentTarget.closest('.o_search_panel_category_value');
- const category = this.categories[item.dataset.categoryId];
- const valueId = !isNaN(item.dataset.id) ?
- Number(item.dataset.id) :
- item.dataset.id || false;
- const hasChanged = category.activeValueId !== valueId;
- category.activeValueId = valueId;
- const value = category.values[category.activeValueId];
- if (value) {
- value.folded = value.folded ? false : !hasChanged;
}
- if (hasChanged) {
- const storageKey = this._getLocalStorageKey(category);
- this.call('local_storage', 'setItem', storageKey, valueId);
- this._notifyDomainUpdated();
- } else {
- this._render();
- }
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onFilterGroupChanged(ev) {
- ev.stopPropagation();
- const item = ev.target.closest('.o_search_panel_filter_group');
- const filter = this.filters[item.dataset.filterId];
- const group = filter.groups[item.dataset.groupId];
- group.state = group.state === 'checked' ? 'unchecked' : 'checked';
- Object.values(group.values).forEach(value => {
- value.checked = group.state === 'checked';
- });
- this._notifyDomainUpdated();
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onFilterValueChanged(ev) {
- ev.stopPropagation();
- const item = ev.target.closest('.o_search_panel_filter_value');
- const filter = this.filters[item.dataset.filterId];
- const value = filter.values[item.dataset.valueId];
- value.checked = !value.checked;
- const groupId = JSON.stringify(value.group_id);
- const group = filter.groups && filter.groups[groupId];
- if (group) {
- this._updateFilterGroupState(group);
- }
- this._notifyDomainUpdated();
- },
-});
-
-return SearchPanel;
-
+ }
+ SearchPanel.modelExtension = "SearchPanel";
+
+ SearchPanel.props = {
+ className: { type: String, optional: 1 },
+ importedState: {
+ type: Object,
+ optional: true,
+ shape: {
+ scrollTop: { type: Number, optional: 1 },
+ expanded: { type: Object, optional: 1 },
+ },
+ },
+ searchModel: Model,
+ };
+ SearchPanel.template = "web.SearchPanel";
+
+ return patchMixin(SearchPanel);
});
diff --git a/addons/web/static/src/js/views/search_panel_model_extension.js b/addons/web/static/src/js/views/search_panel_model_extension.js
new file mode 100644
index 00000000000..3641c425ba7
--- /dev/null
+++ b/addons/web/static/src/js/views/search_panel_model_extension.js
@@ -0,0 +1,730 @@
+odoo.define("web/static/src/js/views/search_panel_model_extension.js", function (require) {
+ "use strict";
+
+ const ActionModel = require("web/static/src/js/views/action_model.js");
+ const { sortBy } = require("web.utils");
+ const Domain = require("web.Domain");
+ const pyUtils = require("web.py_utils");
+
+ // DefaultViewTypes is the list of view types for which the searchpanel is
+ // present by default (if not explicitly stated in the "view_types" attribute
+ // in the arch).
+ const DEFAULT_VIEW_TYPES = ["kanban", "tree"];
+ const DEFAULT_LIMIT = 200;
+ let nextSectionId = 1;
+
+ /**
+ * @param {Section} section
+ * @returns {boolean}
+ */
+ function hasValues({ errorMsg, groups, type, values }) {
+ if (errorMsg) {
+ return true;
+ } else if (groups) {
+ return [...groups.values()].some((g) => g.values.size);
+ } else if (type === "category") {
+ return values && values.size > 1; // false item ignored
+ } else {
+ return values && values.size > 0;
+ }
+ }
+
+ /**
+ * @typedef Section
+ * @prop {string} color
+ * @prop {string} description
+ * @prop {boolean} enableCounters
+ * @prop {boolean} expand
+ * @prop {string} fieldName
+ * @prop {string} icon
+ * @prop {number} id
+ * @prop {number} index
+ * @prop {number} limit
+ * @prop {string} type
+ */
+
+ /**
+ * @typedef {Section} Category
+ * @prop {boolean} hierarchize
+ */
+
+ /**
+ * @typedef {Section} Filter
+ * @prop {string} domain
+ * @prop {string} groupBy
+ */
+
+ /**
+ * @function sectionPredicate
+ * @param {Section} section
+ * @returns {boolean}
+ */
+
+ /**
+ * @property {{ sections: Map<number, Section> }} state
+ * @extends ActionModel.Extension
+ */
+ class SearchPanelModelExtension extends ActionModel.Extension {
+ constructor() {
+ super(...arguments);
+
+ this.searchDomain = null;
+ this.DEFAULT_VALUE_INDEX = 0;
+ }
+
+ //---------------------------------------------------------------------
+ // Public
+ //---------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ exportState() {
+ const state = super.exportState();
+ state.sections = [...state.sections];
+ for (const [id, section] of state.sections) {
+ section.values = [...section.values];
+ if (section.groups) {
+ section.groups = [...section.groups];
+ for (const [id, group] of section.groups) {
+ group.values = [...group.values];
+ }
+ }
+ }
+ return state;
+ }
+
+ /**
+ * @override
+ * @returns {any}
+ */
+ get(property, ...args) {
+ switch (property) {
+ case "domain": return this.getDomain();
+ case "sections": return this.getSections(...args);
+ }
+ }
+
+ /**
+ * @override
+ */
+ importState(importedState) {
+ super.importState(...arguments);
+ if (importedState) {
+ this.state.sections = new Map(this.state.sections);
+ for (const section of this.state.sections.values()) {
+ section.values = new Map(section.values);
+ if (section.groups) {
+ section.groups = new Map(section.groups);
+ for (const group of section.groups.values()) {
+ group.values = new Map(group.values);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * @override
+ */
+ async load() {
+ await this._fetchSections({ initialLoad: true });
+ }
+
+ /**
+ * @override
+ */
+ prepareState() {
+ Object.assign(this.state, { sections: new Map() });
+ this._createSectionsFromArch();
+ }
+
+ /**
+ * @override
+ */
+ async reloadAfterDispatch() {
+ await this._fetchSections();
+ }
+
+ //---------------------------------------------------------------------
+ // Actions / Getters
+ //---------------------------------------------------------------------
+
+ /**
+ * Returns the concatenation of the category domain ad the filter
+ * domain.
+ * @returns {Array[]}
+ */
+ getDomain() {
+ return Domain.prototype.normalizeArray([
+ ...this._getCategoryDomain(),
+ ...this._getFilterDomain(),
+ ]);
+ }
+
+ /**
+ * Returns a sorted list of a copy of all sections. This list can be
+ * filtered by a given predicate.
+ * @param {sectionPredicate} [predicate] used to determine
+ * which subsets of sections is wanted
+ * @returns {Section[]}
+ */
+ getSections(predicate) {
+ let sections = [...this.state.sections.values()].map((section) =>
+ Object.assign({}, section, { empty: !hasValues(section) })
+ );
+ if (predicate) {
+ sections = sections.filter(predicate);
+ }
+ return sections.sort((s1, s2) => s1.index - s2.index);
+ }
+
+ /**
+ * Sets the active value id of a given category. This will also update
+ * the local storage value.
+ * @param {number} sectionId
+ * @param {number} valueId
+ */
+ toggleCategoryValue(sectionId, valueId) {
+ const category = this.state.sections.get(sectionId);
+ category.activeValueId = valueId;
+ const storageKey = `searchpanel_${this.config.modelName}_${category.fieldName}`;
+ this.env.services.local_storage.setItem(storageKey, valueId);
+ // FIXME this should be removed (in another commit): should no longer (re)store
+ // active category in localStorage
+ }
+
+ /**
+ * Toggles a the filter value of a given section. The value will be set
+ * to "forceTo" if provided, else it will be its own opposed value.
+ * @param {number} sectionId
+ * @param {number[]} valueIds
+ * @param {boolean} [forceTo=null]
+ */
+ toggleFilterValues(sectionId, valueIds, forceTo = null) {
+ const section = this.state.sections.get(sectionId);
+ for (const valueId of valueIds) {
+ const value = section.values.get(valueId);
+ value.checked = forceTo === null ? !value.checked : forceTo;
+ }
+ }
+
+ //---------------------------------------------------------------------
+ // Internal getters
+ //---------------------------------------------------------------------
+
+ /**
+ * Shorthand access to sections of type "category".
+ * @returns {Category[]}
+ */
+ get categories() {
+ return [...this.state.sections.values()].filter(s => s.type === "category");
+ }
+
+ /**
+ * Shorthand access to sections of type "filter".
+ * @returns {Filter[]}
+ */
+ get filters() {
+ return [...this.state.sections.values()].filter(s => s.type === "filter");
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * Sets active values for each filter (coming from context). This needs
+ * to be done only once at initialization.
+ * @private
+ */
+ _applyDefaultFilterValues() {
+ for (const { fieldName, values } of this.filters) {
+ const defaultValues = this.config.defaultValues[fieldName] || [];
+ for (const valueId of defaultValues) {
+ const value = values.get(valueId);
+ if (value) {
+ value.checked = true;
+ }
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {string} sectionId
+ * @param {Object} result
+ */
+ _createCategoryTree(sectionId, result) {
+ const category = this.state.sections.get(sectionId);
+
+ let { error_msg, parent_field: parentField, values, } = result;
+ if (error_msg) {
+ category.errorMsg = error_msg;
+ values = [];
+ }
+ if (category.hierarchize) {
+ category.parentField = parentField;
+ }
+
+ const allItem = {
+ childrenIds: [],
+ display_name: this.env._t("All"),
+ id: false,
+ bold: true,
+ parentId: false,
+ };
+ category.values = new Map([[false, allItem]]);
+ for (const value of values) {
+ category.values.set(
+ value.id,
+ Object.assign({}, value, {
+ childrenIds: [],
+ parentId: value[parentField] || false,
+ })
+ );
+ }
+ for (const value of values) {
+ const { parentId } = category.values.get(value.id);
+ if (parentId && category.values.has(parentId)) {
+ category.values.get(parentId).childrenIds.push(value.id);
+ }
+ }
+ // collect rootIds
+ category.rootIds = [false];
+ for (const value of values) {
+ const { parentId } = category.values.get(value.id);
+ if (!parentId) {
+ category.rootIds.push(value.id);
+ }
+ }
+ // Set active value from context
+ category.activeValueId = this._getCategoryDefaultValue(values, category);
+ }
+
+ /**
+ * @private
+ * @param {string} sectionId
+ * @param {Object} result
+ */
+ _createFilterTree(sectionId, result) {
+ const filter = this.state.sections.get(sectionId);
+
+ let { error_msg, values, } = result;
+ if (error_msg) {
+ filter.errorMsg = error_msg;
+ values = [];
+ }
+
+ // restore checked property
+ values.forEach((value) => {
+ const oldValue = filter.values && filter.values.get(value.id);
+ value.checked = (oldValue && oldValue.checked) || false;
+ });
+
+ filter.values = new Map();
+ const groupIds = [];
+ if (filter.groupBy) {
+ const groups = new Map();
+ for (const value of values) {
+ const groupId = value.group_id;
+ if (!groups.has(groupId)) {
+ if (groupId) {
+ groupIds.push(groupId);
+ }
+ groups.set(groupId, {
+ id: groupId,
+ name: value.group_name,
+ values: new Map(),
+ tooltip: value.group_tooltip,
+ sequence: value.group_sequence,
+ hex_color: value.group_hex_color,
+ });
+ // restore former checked state
+ const oldGroup =
+ filter.groups && filter.groups.get(groupId);
+ groups.get(groupId).state =
+ (oldGroup && oldGroup.state) || false;
+ }
+ groups.get(groupId).values.set(value.id, value);
+ }
+ filter.groups = groups;
+ filter.sortedGroupIds = sortBy(
+ groupIds,
+ (id) => groups.get(id).sequence || groups.get(id).name
+ );
+ for (const group of filter.groups.values()) {
+ for (const [valueId, value] of group.values) {
+ filter.values.set(valueId, value);
+ }
+ }
+ } else {
+ for (const value of values) {
+ filter.values.set(value.id, value);
+ }
+ }
+ }
+
+ /**
+ * Adds a section in this.state.sections for each visible field found
+ * in the search panel arch.
+ * @private
+ */
+ _createSectionsFromArch() {
+ this.config.archNodes.forEach(({ attrs, tag }, index) => {
+ if (tag !== "field" || attrs.invisible === "1") {
+ return;
+ }
+ const type = attrs.select === "multi" ? "filter" : "category";
+ const section = {
+ color: attrs.color,
+ description:
+ attrs.string || this.config.fields[attrs.name].string,
+ enableCounters: !!pyUtils.py_eval(
+ attrs.enable_counters || "0"
+ ),
+ expand: !!pyUtils.py_eval(attrs.expand || "0"),
+ fieldName: attrs.name,
+ icon: attrs.icon,
+ id: nextSectionId++,
+ index,
+ limit: pyUtils.py_eval(attrs.limit || String(DEFAULT_LIMIT)),
+ type,
+ };
+ if (type === "category") {
+ section.icon = section.icon || "fa-folder";
+ section.hierarchize = !!pyUtils.py_eval(
+ attrs.hierarchize || "1"
+ );
+ } else {
+ section.domain = attrs.domain || "[]";
+ section.groupBy = attrs.groupby;
+ section.icon = section.icon || "fa-filter";
+ }
+ this.state.sections.set(section.id, section);
+ });
+ }
+
+ /**
+ * Fetches values for each category at startup. At reload a category is
+ * only fetched if needed.
+ * @private
+ * @param {{
+ * initialLoad: boolean,
+ * searchDomainHasChanged: boolean
+ * }} options
+ * @returns {Promise} resolved when all categories have been fetched
+ */
+ async _fetchCategories({ initialLoad, searchDomainHasChanged }) {
+ const proms = [];
+ for (const category of this.categories) {
+ const { enableCounters, expand, hierarchize, limit } = category;
+ if (initialLoad || enableCounters || (searchDomainHasChanged && !expand)) {
+ const prom = this.env.services.rpc({
+ method: "search_panel_select_range",
+ model: this.config.modelName,
+ args: [category.fieldName],
+ kwargs: {
+ category_domain: this._getCategoryDomain(
+ category.id
+ ),
+ enable_counters: enableCounters,
+ expand,
+ filter_domain: this._getFilterDomain(),
+ hierarchize,
+ limit,
+ search_domain: this.searchDomain,
+ },
+ }).then(result => this._createCategoryTree(category.id, result));
+ proms.push(prom);
+ }
+ }
+ await Promise.all(proms);
+ }
+
+ /**
+ * Fetches values for each filter. This is done at startup and at each
+ * reload if needed.
+ * @private
+ * @param {{
+ * initialLoad: boolean,
+ * searchDomainHasChanged: boolean
+ * }} options
+ * @returns {Promise} resolved when all filters have been fetched
+ */
+ async _fetchFilters({ initialLoad, searchDomainHasChanged }) {
+ const evalContext = {};
+ for (const category of this.categories) {
+ evalContext[category.fieldName] = category.activeValueId;
+ }
+ const categoryDomain = this._getCategoryDomain();
+ const proms = [];
+ for (const filter of this.filters) {
+ const { enableCounters, expand, groupBy, limit } = filter;
+ if (initialLoad || enableCounters || (searchDomainHasChanged && !expand)) {
+ const prom = this.env.services.rpc({
+ method: "search_panel_select_multi_range",
+ model: this.config.modelName,
+ args: [filter.fieldName],
+ kwargs: {
+ category_domain: categoryDomain,
+ comodel_domain: Domain.prototype.stringToArray(
+ filter.domain,
+ evalContext
+ ),
+ enable_counters: enableCounters,
+ filter_domain: this._getFilterDomain(filter.id),
+ expand,
+ group_by: groupBy || false,
+ group_domain: this._getGroupDomain(filter),
+ limit,
+ search_domain: this.searchDomain,
+ },
+ }).then(result => this._createFilterTree(filter.id, result));
+ proms.push(prom);
+ }
+ }
+ await Promise.all(proms);
+ }
+
+ /**
+ * Fetches categories and filters at startup or if needed.
+ * @private
+ * @param {Object} [options={}]
+ * @param {boolean} [options.initialLoad=false]
+ */
+ async _fetchSections(options = {}) {
+ const searchDomain = this._getExternalDomain();
+ if (!options.initialLoad) {
+ options.searchDomainHasChanged =
+ JSON.stringify(this.searchDomain) !== JSON.stringify(searchDomain);
+ }
+ this.searchDomain = searchDomain;
+ await this._fetchCategories(options);
+ await this._fetchFilters(options);
+ if (options.initialLoad) {
+ this._applyDefaultFilterValues();
+ }
+ }
+
+ /**
+ * Computes and returns the domain based on the current active
+ * categories. If "excludedCategoryId" is provided, the category with
+ * that id is not taken into account in the domain computation.
+ * @private
+ * @param {string} [excludedCategoryId]
+ * @returns {Array[]}
+ */
+ _getCategoryDomain(excludedCategoryId) {
+ const domain = [];
+ for (const category of this.categories) {
+ if (
+ category.id === excludedCategoryId ||
+ category.activeValueId === false
+ ) {
+ continue;
+ }
+ if (category.activeValueId) {
+ const field = this.config.fields[category.fieldName];
+ const operator =
+ field.type === "many2one" && category.parentField ? "child_of" : "=";
+ domain.push([
+ category.fieldName,
+ operator,
+ category.activeValueId,
+ ]);
+ }
+ }
+ return domain;
+ }
+
+ /**
+ * @private
+ * @param {Object[]} values
+ * @param {Category} category
+ * @returns {number | false}
+ */
+ _getCategoryDefaultValue(values, { fieldName }) {
+ const validValues = [false, ...values.map((val) => val.id)];
+ const defaultValue = this.config.defaultValues[fieldName];
+ if (validValues.includes(defaultValue)) {
+ return defaultValue;
+ }
+ if (this.config.defaultNoFilter) {
+ return false;
+ }
+ // If not set in context, or set to an unknown value, set active value
+ // from localStorage
+ const storageKey = `searchpanel_${this.config.modelName}_${fieldName}`;
+ const storageValue = this.env.services.local_storage.getItem(storageKey);
+ if (validValues.includes(storageValue)) {
+ return storageValue;
+ }
+ // If still not a valid value, get the search panel default value
+ // from the given valid values.
+ return validValues[
+ Math.min(validValues.length - 1, this.DEFAULT_VALUE_INDEX)
+ ];
+ }
+
+ /**
+ * Returns the domain retrieved from the other model extensions.
+ * @private
+ * @returns {Array[]}
+ */
+ _getExternalDomain() {
+ const domains = this.config.get("domain");
+ const domain = domains.reduce((acc, dom) => [...acc, ...dom], []);
+ return Domain.prototype.normalizeArray(domain);
+ }
+
+ /**
+ * Computes and returns the domain based on the current checked
+ * filters. The values of a single filter are combined using a simple
+ * rule: checked values within a same group are combined with an "OR"
+ * operator (this is expressed as single condition using a list) and
+ * groups are combined with an "AND" operator (expressed by
+ * concatenation of conditions).
+ * If a filter has no group, its checked values are implicitely
+ * considered as forming a group (and grouped using an "OR").
+ * If excludedFilterId is provided, the filter with that id is not
+ * taken into account in the domain computation.
+ * @private
+ * @param {string} [excludedFilterId]
+ * @returns {Array[]}
+ */
+ _getFilterDomain(excludedFilterId) {
+ const domain = [];
+
+ function addCondition(fieldName, valueMap) {
+ const ids = [];
+ for (const [valueId, value] of valueMap) {
+ if (value.checked) {
+ ids.push(valueId);
+ }
+ }
+ if (ids.length) {
+ domain.push([fieldName, "in", ids]);
+ }
+ }
+
+ for (const filter of this.filters) {
+ if (filter.id === excludedFilterId) {
+ continue;
+ }
+ const { fieldName, groups, values } = filter;
+ if (groups) {
+ for (const group of groups.values()) {
+ addCondition(fieldName, group.values);
+ }
+ } else if (values) {
+ addCondition(fieldName, values);
+ }
+ }
+ return domain;
+ }
+
+ /**
+ * Returns a domain or an object of domains used to complement
+ * the filter domains to accurately describe the constrains on
+ * records when computing record counts associated to the filter
+ * values (if a groupBy is provided). The idea is that the checked
+ * values within a group should not impact the counts for the other
+ * values in the same group.
+ * @private
+ * @param {string} filter
+ * @returns {Object<string, Array[]> | Array[] | null}
+ */
+ _getGroupDomain(filter) {
+ const { fieldName, groups, enableCounters } = filter;
+ const { type: fieldType } = this.config.fields[fieldName];
+
+ if (!enableCounters || !groups) {
+ return {
+ many2one: [],
+ many2many: {},
+ }[fieldType];
+ }
+ let groupDomain = null;
+ if (fieldType === "many2one") {
+ for (const group of groups.values()) {
+ const valueIds = [];
+ let active = false;
+ for (const [valueId, value] of group.values) {
+ const { checked } = value;
+ valueIds.push(valueId);
+ if (checked) {
+ active = true;
+ }
+ }
+ if (active) {
+ if (groupDomain) {
+ groupDomain = [[0, "=", 1]];
+ break;
+ } else {
+ groupDomain = [[fieldName, "in", valueIds]];
+ }
+ }
+ }
+ } else if (fieldType === "many2many") {
+ const checkedValueIds = new Map();
+ groups.forEach(({ values }, groupId) => {
+ values.forEach(({ checked }, valueId) => {
+ if (checked) {
+ if (!checkedValueIds.has(groupId)) {
+ checkedValueIds.set(groupId, []);
+ }
+ checkedValueIds.get(groupId).push(valueId);
+ }
+ });
+ });
+ groupDomain = {};
+ for (const [gId, ids] of checkedValueIds.entries()) {
+ for (const groupId of groups.keys()) {
+ if (gId !== groupId) {
+ const key = JSON.stringify(groupId);
+ if (!groupDomain[key]) {
+ groupDomain[key] = [];
+ }
+ groupDomain[key].push([fieldName, "in", ids]);
+ }
+ }
+ }
+ }
+ return groupDomain;
+ }
+
+ //---------------------------------------------------------------------
+ // Static
+ //---------------------------------------------------------------------
+
+ /**
+ * @override
+ * @returns {{ attrs: Object, children: Object[] } | null}
+ */
+ static extractArchInfo(archs, viewType) {
+ const { children } = archs.search;
+ const spNode = children.find(c => c.tag === "searchpanel");
+ const isObject = (obj) => typeof obj === "object" && obj !== null;
+ if (spNode) {
+ const actualType = viewType === "list" ? "tree" : viewType;
+ const { view_types } = spNode.attrs;
+ const viewTypes = view_types ?
+ view_types.split(",") :
+ DEFAULT_VIEW_TYPES;
+ if (viewTypes.includes(actualType)) {
+ return {
+ attrs: spNode.attrs,
+ children: spNode.children.filter(isObject),
+ };
+ }
+ }
+ return null;
+ }
+ }
+ SearchPanelModelExtension.layer = 1;
+
+ ActionModel.registry.add("SearchPanel", SearchPanelModelExtension, 30);
+
+ return SearchPanelModelExtension;
+});
diff --git a/addons/web/static/src/scss/search_panel.scss b/addons/web/static/src/scss/search_panel.scss
index c40686804ea..079154803c3 100644
--- a/addons/web/static/src/scss/search_panel.scss
+++ b/addons/web/static/src/scss/search_panel.scss
@@ -16,116 +16,128 @@ $o-searchpanel-filter-default-color: #D59244;
overflow: auto; // make the renderer and search panel scroll individually
max-height: 100%;
}
- .o_search_panel {
- flex: 0 0 220px;
- overflow: auto;
- height: 100%;
- padding: $o-searchpanel-p-small $o-searchpanel-p-small $o-searchpanel-p*2 $o-searchpanel-p;
- border-right: 1px solid $gray-300;
- background-color: white;
-
- .o_search_panel_category .o_search_panel_section_icon {
- color: $o-brand-odoo;
+}
+
+.o_search_panel {
+ flex: 0 0 220px;
+ overflow: auto;
+ height: 100%;
+ padding: $o-searchpanel-p-small $o-searchpanel-p-small $o-searchpanel-p*2 $o-searchpanel-p;
+ border-right: 1px solid $gray-300;
+ background-color: white;
+
+ .o_search_panel_category .o_search_panel_section_icon {
+ color: $o-brand-odoo;
+ }
+
+ .o_search_panel_filter .o_search_panel_section_icon {
+ color: $o-searchpanel-filter-default-color;
+ }
+
+ .o_toggle_fold {
+ text-align: center;
+ width: 1.5rem;
+ }
+
+ .o_search_panel_label {
+ align-items: center;
+ cursor: pointer;
+ display: flex;
+ justify-content: space-between;
+ user-select: none;
+ width: 100%;
+ }
+
+ .o_search_panel_section_header {
+ cursor: default;
+ padding: $o-searchpanel-p-small 0;
+ }
+
+ .list-group {
+ padding-bottom: $o-searchpanel-p-tiny;
+ }
+
+ .list-group-item {
+ padding: 0;
+
+ .list-group-item {
+ padding: 0 0 0 $custom-control-gutter;
+ margin-bottom: $o-searchpanel-p-tiny*0.5;
}
- .o_search_panel_filter .o_search_panel_section_icon {
- color: $o-searchpanel-filter-default-color;
+ .o_search_panel_label_title {
+ color: $headings-color;
+ width: 100%;
+ @include o-text-overflow;
}
- .o_toggle_fold {
- text-align: center;
- width: 1.5rem;
+ header.active {
+ background-color: $o-searchpanel-active-bg;
}
+ }
+
+ .o_search_panel_category_value {
+ cursor: pointer;
- .o_search_panel_label {
+ header {
align-items: center;
- cursor: pointer;
display: flex;
justify-content: space-between;
- user-select: none;
+ padding: 4px 6px 4px 0px;
}
- .o_search_panel_section_header {
- padding: $o-searchpanel-p-small 0;
- cursor: default;
+ .o_search_panel_label.o_with_counters {
+ overflow: hidden;
}
- .list-group {
- padding-bottom: $o-searchpanel-p-tiny;
- }
-
- .list-group-item {
- padding: 0;
-
- .list-group-item {
- padding: 0 0 0 $custom-control-gutter;
- margin-bottom: $o-searchpanel-p-tiny*0.5;
+ .o_search_panel_category_value {
+ margin-bottom: 0;
+ padding-left: $o-searchpanel-p;
+ position: relative;
+
+ &:before,
+ &:after {
+ content: '';
+ background: $gray-500;
+ margin-left: 4px;
+ @include o-position-absolute(0, $left: $o-searchpanel-p-tiny);
+ @include size(1px, 100%);
}
- span.o_search_panel_label_title {
- color: $headings-color;
- @include o-text-overflow;
+ &:after {
+ top: 12px;
+ @include size(8px, 1px);
}
- header.active {
- background-color: $o-searchpanel-active-bg;
+ &:last-child:before {
+ height: 12px;
}
}
+ }
- .o_search_panel_category_value {
- cursor: pointer;
-
- header {
- align-items: center;
- display: flex;
- justify-content: space-between;
- padding: 4px 6px 4px 0px;
- }
- .o_search_panel_label.o_with_counters {
- overflow: hidden;
- }
- .o_search_panel_category_value {
- position: relative;
- padding-left: $o-searchpanel-p;
- margin-bottom: 0;
-
- &:before, &:after {
- @include o-position-absolute(0, $left: $o-searchpanel-p-tiny);
- @include size(1px, 100%);
- background: $gray-500;
- content: '';
- margin-left: 4px;
- }
- &:after {
- top: 12px;
- @include size(8px, 1px);
- }
- &:last-child:before {
- height: 12px;
- }
- }
- }
+ .o_search_panel_group_header .custom-control {
+ width: 100%;
+ }
- .o_search_panel_filter_value,
- .o_search_panel_filter_group {
- cursor: pointer;
- padding-bottom: $o-searchpanel-p-small;
+ .o_search_panel_filter_value,
+ .o_search_panel_filter_group {
+ cursor: pointer;
+ padding-bottom: $o-searchpanel-p-small;
- .o_search_panel_label,
- .o_search_panel_label_title {
- padding-right: 6px;
- }
+ .o_search_panel_label,
+ .o_search_panel_label_title {
+ padding-right: 6px;
}
+ }
- .o_search_panel_filter_group {
+ .o_search_panel_filter_group {
- header {
- display: flex;
- }
+ header {
+ display: flex;
+ }
- .o_search_panel_label.o_with_counters {
- justify-content: flex-start;
- }
+ .o_search_panel_label.o_with_counters {
+ justify-content: flex-start;
}
}
}
diff --git a/addons/web/static/src/xml/kanban.xml b/addons/web/static/src/xml/kanban.xml
index f4657d4b3a3..2fd7f8ca211 100644
--- a/addons/web/static/src/xml/kanban.xml
+++ b/addons/web/static/src/xml/kanban.xml
@@ -209,132 +209,6 @@
</div>
</t>
-<!-- Kanban SearchPanel -->
-<t t-name="SearchPanel.SectionHeader">
- <header class="o_search_panel_section_header text-uppercase">
- <i t-attf-class="mr-1 fa #{section.icon} o_search_panel_section_icon" t-attf-style="#{section.color ? ('color: ' + section.color) : ''}"/>
- <b><t t-esc="section.description"/></b>
- </header>
-</t>
-
-<t t-name="SearchPanel.Category">
- <section class="o_search_panel_section o_search_panel_category">
- <t t-call="SearchPanel.SectionHeader">
- <t t-set="section" t-value="category"/>
- </t>
- <div t-if="category.errorMsg" class="alert alert-warning">
- <span><t t-esc="category.errorMsg"/></span>
- </div>
- <ul t-else="" class="list-group d-block o_search_panel_field" t-att-data-field-name="category.fieldName">
- <li class="o_search_panel_category_value border-0 list-group-item" t-att-data-category-id="category.id">
- <header t-att-class="'list-group-item-action' + (!category.activeValueId ? ' active' : '')">
- <label class="o_search_panel_label mb0">
- <div class="o_toggle_fold"/>
- <span class="o_search_panel_label_title"><b>All</b></span>
- </label>
- </header>
- </li>
- <t t-call="SearchPanel.CategoryValues">
- <t t-set="values" t-value="category.rootIds"/>
- </t>
- </ul>
- </section>
-</t>
-<t t-name="SearchPanel.CategoryValues">
- <t t-foreach="values" t-as="valueId">
- <t t-set="value" t-value="category.values[valueId]"/>
- <li class="o_search_panel_category_value border-0 list-group-item" t-att-data-id="value.id" t-att-data-category-id="category.id">
- <header t-att-class="'list-group-item-action' + (value.id === category.activeValueId ? ' active' : '')">
- <label t-attf-class="o_search_panel_label mb0{{ category.enableCounters ? ' o_with_counters' : '' }}">
- <div class="o_toggle_fold">
- <i t-if="value.childrenIds.length" t-attf-class="fa fa-caret-{{ value.folded ? 'right' : 'down' }}"/>
- </div>
- <span class="o_search_panel_label_title"><t t-esc="value.display_name"/></span>
- </label>
- <span t-if="category.enableCounters and value.__count > 0"
- class="o_search_panel_counter text-muted ml-2 small"
- t-esc="value.__count"
- />
- </header>
- <ul t-if="!value.folded and value.childrenIds.length" class="list-group d-block">
- <t t-call="SearchPanel.CategoryValues">
- <t t-set="values" t-value="value.childrenIds"/>
- </t>
- </ul>
- </li>
- </t>
-</t>
-
-<t t-name="SearchPanel.Filter">
- <section class="o_search_panel_section o_search_panel_filter">
- <t t-call="SearchPanel.SectionHeader">
- <t t-set="section" t-value="filter"/>
- </t>
- <div t-if="filter.errorMsg" class="alert alert-warning">
- <span><t t-esc="filter.errorMsg"/></span>
- </div>
- <ul t-else="" class="list-group d-block o_search_panel_field" t-att-data-field-name="filter.fieldName">
- <t t-if="filter.groups" t-call="SearchPanel.FilterGroups">
- <t t-set="groups" t-value="filter.groups"/>
- </t>
- <t t-else="" t-call="SearchPanel.FilterValues">
- <t t-set="sortedValueIds" t-value="filter.sortedValueIds"/>
- <t t-set="values" t-value="filter.values"/>
- </t>
- </ul>
- </section>
-</t>
-<t t-name="SearchPanel.FilterGroups">
- <li t-foreach="filter.sortedGroupIds" t-as="groupId" t-att-data-group-id="groupId" t-att-data-filter-id="filter.id"
- class="o_search_panel_filter_group list-group-item border-0">
- <t t-set="group" t-value="groups[groupId]"/>
- <t t-set="inputId" t-value="_.uniqueId('input_')"/>
- <header class="o_search_panel_group_header">
- <div class="custom-control custom-checkbox">
- <input type="checkbox" t-att-id="inputId" t-att-checked="group.state === 'checked' ? 'checked' : undefined" class="custom-control-input"/>
- <label t-att-for="inputId" t-attf-class="o_search_panel_label custom-control-label{{ group.enableCounters ? ' o_with_counters' : '' }}" t-att-title="group.tooltip">
- <span class="o_search_panel_label_title">
- <span t-if="group.hex_color" t-attf-style="color: {{ group.hex_color }};">●</span>
- <t t-esc="group.name"/>
- </span>
- </label>
- </div>
- </header>
- <ul t-if="!group.folded" class="list-group d-block">
- <t t-call="SearchPanel.FilterValues">
- <t t-set="sortedValueIds" t-value="group.sortedValueIds"/>
- <t t-set="values" t-value="group.values"/>
- </t>
- </ul>
- </li>
- <ul t-if="groups['false']" class="list-group d-block">
- <t t-call="SearchPanel.FilterValues">
- <t t-set="group" t-value="groups['false']"/>
- <t t-set="sortedValueIds" t-value="group.sortedValueIds"/>
- <t t-set="values" t-value="group.values"/>
- </t>
- </ul>
-</t>
-<t t-name="SearchPanel.FilterValues">
- <li t-foreach="sortedValueIds" t-as="valueId" t-att-data-value-id="valueId" t-att-data-filter-id="filter.id"
- class="o_search_panel_filter_value list-group-item border-0">
- <t t-set="value" t-value="values[valueId]"/>
- <t t-set="inputId" t-value="_.uniqueId('input_')"/>
- <header class="o_search_panel_group_header">
- <div class="custom-control custom-checkbox w-100">
- <input type="checkbox" t-att-id="inputId" t-att-checked="value.checked ? 'checked' : undefined" class="custom-control-input"/>
- <label t-att-for="inputId" class="o_search_panel_label custom-control-label" t-att-title="group &amp;&amp; group.tooltip">
- <span class="o_search_panel_label_title" t-esc="value.display_name"/>
- <span t-if="filter.enableCounters and value.__count > 0"
- class="o_search_panel_counter text-muted ml-2 small"
- t-esc="value.__count"
- />
- </label>
- </div>
- </header>
- </li>
-</t>
-
<!-- Mobile templates -->
<t t-name="KanbanView.MobileTabs">
<div class="o_kanban_mobile_tabs_container">
diff --git a/addons/web/static/src/xml/search_panel.xml b/addons/web/static/src/xml/search_panel.xml
new file mode 100644
index 00000000000..4b6dc4d7a60
--- /dev/null
+++ b/addons/web/static/src/xml/search_panel.xml
@@ -0,0 +1,132 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+<t t-name="web.SearchPanel" owl="1">
+ <div class="o_search_panel" t-att-class="props.className">
+ <section t-foreach="model.get('sections', s => !s.empty)" t-as="section" t-key="section.id"
+ t-attf-class="o_search_panel_section o_search_panel_{{ section.type }}"
+ >
+ <header class="o_search_panel_section_header text-uppercase">
+ <i t-attf-class="fa {{ section.icon }} o_search_panel_section_icon mr-2"
+ t-att-style="section.color and ('color: ' + section.color)"
+ />
+ <b t-esc="section.description"/>
+ </header>
+ <div t-if="section.errorMsg" class="alert alert-warning">
+ <span><t t-esc="section.errorMsg"/></span>
+ </div>
+ <ul t-else="" class="list-group d-block o_search_panel_field">
+ <t t-if="section.type === 'category'" t-call="web.SearchPanel.Category">
+ <t t-set="values" t-value="section.rootIds"/>
+ </t>
+ <t t-elif="section.groups">
+ <li t-foreach="section.sortedGroupIds" t-as="groupId" t-key="groupId"
+ class="o_search_panel_filter_group list-group-item border-0"
+ >
+ <!-- TODO: this is a workaround for issue https://github.com/odoo/owl/issues/695 (remove when solved) -->
+ <t t-set="_section" t-value="section"/>
+ <t t-set="group" t-value="section.groups.get(groupId)"/>
+ <header class="o_search_panel_group_header">
+ <div class="custom-control custom-checkbox">
+ <!-- TODO: "indeterminate" could not be set in the template and had to be set in
+ JS manually. See https://github.com/odoo/owl/issues/713 (adapt when solved)
+ -->
+ <input type="checkbox"
+ class="custom-control-input"
+ t-attf-id="{{ section.id }}_input_{{ groupId }})"
+ t-on-click="_toggleFilterGroup(section.id, group)"
+ />
+ <label t-attf-for="{{ section.id }}_input_{{ groupId }})"
+ class="o_search_panel_label custom-control-label"
+ t-att-class="{ o_with_counters: group.enableCounters }"
+ t-att-title="group.tooltip or false"
+ >
+ <span class="o_search_panel_label_title">
+ <span t-if="group.hex_color" class="mr-1" t-attf-style="color: {{ group.hex_color }};">●</span>
+ <t t-esc="group.name"/>
+ </span>
+ </label>
+ </div>
+ </header>
+ <ul class="list-group d-block">
+ <t t-call="web.SearchPanel.FiltersGroup">
+ <t t-set="values" t-value="group.values"/>
+ <!-- TODO: this is a workaround for issue https://github.com/odoo/owl/issues/695 (remove when solved) -->
+ <t t-set="section" t-value="_section"/>
+ </t>
+ </ul>
+ </li>
+ <ul t-if="section.groups.get(false)" class="list-group d-block">
+ <t t-call="web.SearchPanel.FiltersGroup">
+ <t t-set="group" t-value="section.groups.get(false)"/>
+ <t t-set="values" t-value="group.values"/>
+ <!-- TODO: this is a workaround for issue https://github.com/odoo/owl/issues/695 (remove when solved) -->
+ <t t-set="section" t-value="section"/>
+ </t>
+ </ul>
+ </t>
+ <t t-else="" t-call="web.SearchPanel.FiltersGroup">
+ <t t-set="values" t-value="section.values"/>
+ </t>
+ </ul>
+ </section>
+ </div>
+</t>
+
+<t t-name="web.SearchPanel.Category" owl="1">
+ <t t-foreach="values" t-as="valueId" t-key="valueId">
+ <t t-set="value" t-value="section.values.get(valueId)"/>
+ <li class="o_search_panel_category_value list-group-item border-0">
+ <header class="list-group-item-action"
+ t-att-class="{ active: state.active[section.id] === valueId }"
+ t-on-click="_toggleCategory(section, value)"
+ >
+ <label class="o_search_panel_label mb0" t-att-class="{ o_with_counters: section.enableCounters }">
+ <div class="o_toggle_fold">
+ <i t-if="value.childrenIds.length"
+ t-attf-class="fa fa-caret-{{ state.expanded[section.id][valueId] ? 'down' : 'right' }}"
+ />
+ </div>
+ <b t-if="value.bold" class="o_search_panel_label_title" t-esc="value.display_name"/>
+ <span t-else="" class="o_search_panel_label_title" t-esc="value.display_name"/>
+ </label>
+ <span t-if="section.enableCounters and value.__count gt 0"
+ class="o_search_panel_counter text-muted ml-2 small"
+ t-esc="value.__count"
+ />
+ </header>
+ <ul t-if="value.childrenIds.length and state.expanded[section.id][valueId]" class="list-group d-block">
+ <t t-call="web.SearchPanel.Category">
+ <t t-set="values" t-value="value.childrenIds"/>
+ </t>
+ </ul>
+ </li>
+ </t>
+</t>
+
+<t t-name="web.SearchPanel.FiltersGroup" owl="1">
+ <li t-foreach="[...values.keys()]" t-as="valueId" t-key="valueId"
+ class="o_search_panel_filter_value list-group-item border-0"
+ >
+ <t t-set="value" t-value="values.get(valueId)"/>
+ <div class="custom-control custom-checkbox w-100">
+ <input type="checkbox"
+ t-attf-id="{{ section.id }}_input_{{ valueId }}"
+ t-att-checked="state.active[section.id][valueId]"
+ class="custom-control-input"
+ t-on-click="_toggleFilterValue(section.id, valueId)"
+ />
+ <label class="o_search_panel_label custom-control-label"
+ t-attf-for="{{ section.id }}_input_{{ valueId }}"
+ t-att-title="(group and group.tooltip) or false">
+ <span class="o_search_panel_label_title" t-esc="value.display_name"/>
+ <span t-if="section.enableCounters and value.__count gt 0"
+ class="o_search_panel_counter text-muted ml-2 small"
+ t-esc="value.__count"
+ />
+ </label>
+ </div>
+ </li>
+</t>
+
+</templates>
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index e048691b37b..42492ede85a 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -333,6 +333,7 @@
<script type="text/javascript" src="/web/static/src/js/control_panel/custom_group_by_item.js"></script>
<script type="text/javascript" src="/web/static/src/js/control_panel/search_bar.js"></script>
<script type="text/javascript" src="/web/static/src/js/control_panel/search_utils.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/search_panel_model_extension.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/search_panel.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/action_model.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/field_manager_mixin.js"></script>