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 arch node, iterate over its children to generate the - * description of each section (being either a category or a filter). - * - * @param {Object} node a 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 + * 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 "" node located + * inside of a "" 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 */ - init(parent, params) { - this._super(...arguments); + class SearchPanel extends Component { + constructor() { + super(...arguments); - this.categories = {}; - this.filters = {}; - for (const section of Object.values(params.sections)) { - const key = section.type === 'category' ? 'categories' : 'filters'; - this[key][section.id] = section; - } + useSubEnv({ searchModel: this.props.searchModel }); - 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.state = useState({ + active: {}, + expanded: {}, }); - } - 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(); - } - 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; - } - }, + this.model = useModel("searchModel"); + this.scrollTop = 0; + this.hasImportedState = false; - //------------------------------------------------------------------------- - // 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 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); - } - } - } - 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; + this.importState(this.props.importedState); } - const unfoldedIds = Object.values(category.values || {}) - .filter(c => c.folded === false) - .map(c => c.id); + async willStart() { + this._expandDefaultValue(); + this._updateActiveValues(); + } - 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); + mounted() { + this._updateGroupHeadersChecked(); + if (this.hasImportedState) { + this.el.scroll({ top: this.scrollTop }); } } - // 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 = []; + async willUpdateProps() { + this._updateActiveValues(); } - // restore checked property - values.forEach(value => { - const oldValue = filter.values && filter.values[value.id]; - value.checked = oldValue && oldValue.checked || false; - }); + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- - 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); - } - 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); + exportState() { + const exported = { + expanded: this.state.expanded, + scrollTop: this.el.scrollTop, + }; + return JSON.stringify(exported); } - }, - /** - * 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]); + importState(stringifiedState) { + this.hasImportedState = Boolean(stringifiedState); + if (this.hasImportedState) { + const state = JSON.parse(stringifiedState); + this.state.expanded = state.expanded; + this.scrollTop = state.scrollTop; } } - 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); - } - } 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]; + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- - if (!enableCounters || !groups) { - switch (fieldType) { - case 'many2one': return []; - case 'many2many': return {}; - default: return; + /** + * Expands category values holding the default value of a category. + * @private + */ + _expandDefaultValue() { + if (this.hasImportedState) { + return; } - } - - 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] = []; - } - 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] - ); + 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; } } } } - 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)); + + /** + * @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] : []; } - 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)); + /** + * 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 { - this.$el.append(this._renderFilter(section)); + categoryState[value.id] = 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; + if (category.activeValueId !== value.id) { + this.state.active[category.id] = value.id; + this.model.dispatch("toggleCategoryValue", category.id, value.id); } - }); + } - 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'; + /** + * @private + * @param {number} filterId + * @param {{ values: Map }} 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; + } + } + } + for (const value of section.values.values()) { + this.state.active[section.id][value.id] = value.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; + /** + * 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; + } + } + } + } + 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 }} 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 | 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_filter .o_search_panel_section_icon { - color: $o-searchpanel-filter-default-color; - } + .o_search_panel_category .o_search_panel_section_icon { + color: $o-brand-odoo; + } - .o_toggle_fold { - text-align: center; - width: 1.5rem; - } + .o_search_panel_filter .o_search_panel_section_icon { + color: $o-searchpanel-filter-default-color; + } - .o_search_panel_label { - align-items: center; - cursor: pointer; - display: flex; - justify-content: space-between; - user-select: none; - } + .o_toggle_fold { + text-align: center; + width: 1.5rem; + } - .o_search_panel_section_header { - padding: $o-searchpanel-p-small 0; - cursor: default; - } + .o_search_panel_label { + align-items: center; + cursor: pointer; + display: flex; + justify-content: space-between; + user-select: none; + width: 100%; + } - .list-group { - padding-bottom: $o-searchpanel-p-tiny; - } + .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; + padding: 0 0 0 $custom-control-gutter; + margin-bottom: $o-searchpanel-p-tiny*0.5; + } - .list-group-item { - padding: 0 0 0 $custom-control-gutter; - margin-bottom: $o-searchpanel-p-tiny*0.5; - } + .o_search_panel_label_title { + color: $headings-color; + width: 100%; + @include o-text-overflow; + } - span.o_search_panel_label_title { - color: $headings-color; - @include o-text-overflow; - } + header.active { + background-color: $o-searchpanel-active-bg; + } + } - header.active { - background-color: $o-searchpanel-active-bg; - } + .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 { - cursor: pointer; + margin-bottom: 0; + padding-left: $o-searchpanel-p; + position: relative; - 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_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_filter_group { - - header { - display: flex; + &:before, + &:after { + content: ''; + background: $gray-500; + margin-left: 4px; + @include o-position-absolute(0, $left: $o-searchpanel-p-tiny); + @include size(1px, 100%); } - .o_search_panel_label.o_with_counters { - justify-content: flex-start; + &: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_label, + .o_search_panel_label_title { + padding-right: 6px; + } + } + + .o_search_panel_filter_group { + + header { + display: flex; + } + + .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 @@ - - -
- - -
-
- - -
- - - -
- -
-
    -
  • -
    -
    -
  • - - - -
-
-
- - - -
  • -
    - - -
    -
      - - - -
    -
  • -
    -
    - - -
    - - - -
    - -
    -
      - - - - - - - -
    -
    -
    - -
  • - - -
    -
    - - -
    -
    -
      - - - - -
    -
  • -
      - - - - - -
    -
    - -
  • - - -
    -
    - - -
    -
    -
  • -
    -
    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 @@ + + + + +
    +
    +
    + + +
    +
    + +
    +
      + + + + +
    • + + + +
      +
      + + + +
      +
      +
        + + + + + +
      +
    • +
        + + + + + + +
      +
      + + + +
    +
    +
    +
    + + + + +
  • +
    + + +
    +
      + + + +
    +
  • +
    +
    + + +
  • + +
    + + +
    +
  • +
    + +
    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 @@ +