summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/control_panel
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/control_panel
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/control_panel')
-rw-r--r--addons/web/static/src/js/control_panel/comparison_menu.js63
-rw-r--r--addons/web/static/src/js/control_panel/control_panel.js223
-rw-r--r--addons/web/static/src/js/control_panel/control_panel_model_extension.js1658
-rw-r--r--addons/web/static/src/js/control_panel/control_panel_x2many.js40
-rw-r--r--addons/web/static/src/js/control_panel/custom_favorite_item.js152
-rw-r--r--addons/web/static/src/js/control_panel/custom_filter_item.js275
-rw-r--r--addons/web/static/src/js/control_panel/custom_group_by_item.js46
-rw-r--r--addons/web/static/src/js/control_panel/favorite_menu.js107
-rw-r--r--addons/web/static/src/js/control_panel/filter_menu.js79
-rw-r--r--addons/web/static/src/js/control_panel/groupby_menu.js98
-rw-r--r--addons/web/static/src/js/control_panel/search_bar.js493
-rw-r--r--addons/web/static/src/js/control_panel/search_utils.js542
12 files changed, 3776 insertions, 0 deletions
diff --git a/addons/web/static/src/js/control_panel/comparison_menu.js b/addons/web/static/src/js/control_panel/comparison_menu.js
new file mode 100644
index 00000000..dee1c982
--- /dev/null
+++ b/addons/web/static/src/js/control_panel/comparison_menu.js
@@ -0,0 +1,63 @@
+odoo.define("web.ComparisonMenu", function (require) {
+ "use strict";
+
+ const DropdownMenu = require("web.DropdownMenu");
+ const { FACET_ICONS } = require("web.searchUtils");
+ const { useModel } = require("web/static/src/js/model.js");
+
+ /**
+ * "Comparison" menu
+ *
+ * Displays a set of comparison options related to the currently selected
+ * date filters.
+ * @extends DropdownMenu
+ */
+ class ComparisonMenu extends DropdownMenu {
+ constructor() {
+ super(...arguments);
+ this.model = useModel('searchModel');
+ }
+
+ //---------------------------------------------------------------------
+ // Getters
+ //---------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ get icon() {
+ return FACET_ICONS.comparison;
+ }
+
+ /**
+ * @override
+ */
+ get items() {
+ return this.model.get('filters', f => f.type === 'comparison');
+ }
+
+ /**
+ * @override
+ */
+ get title() {
+ return this.env._t("Comparison");
+ }
+
+ //---------------------------------------------------------------------
+ // Handlers
+ //---------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OwlEvent} ev
+ */
+ _onItemSelected(ev) {
+ ev.stopPropagation();
+ const { item } = ev.detail;
+ this.model.dispatch("toggleComparison", item.id);
+ }
+
+ }
+
+ return ComparisonMenu;
+});
diff --git a/addons/web/static/src/js/control_panel/control_panel.js b/addons/web/static/src/js/control_panel/control_panel.js
new file mode 100644
index 00000000..3841541e
--- /dev/null
+++ b/addons/web/static/src/js/control_panel/control_panel.js
@@ -0,0 +1,223 @@
+odoo.define('web.ControlPanel', function (require) {
+ "use strict";
+
+ const ActionMenus = require('web.ActionMenus');
+ const ComparisonMenu = require('web.ComparisonMenu');
+ const ActionModel = require('web/static/src/js/views/action_model.js');
+ const FavoriteMenu = require('web.FavoriteMenu');
+ const FilterMenu = require('web.FilterMenu');
+ const GroupByMenu = require('web.GroupByMenu');
+ const patchMixin = require('web.patchMixin');
+ const Pager = require('web.Pager');
+ const SearchBar = require('web.SearchBar');
+ const { useModel } = require('web/static/src/js/model.js');
+
+ const { Component, hooks } = owl;
+ const { useRef, useSubEnv } = hooks;
+
+ /**
+ * TODO: remove this whole mechanism as soon as `cp_content` is completely removed.
+ * Extract the 'cp_content' key of the given props and return them as well as
+ * the extracted content.
+ * @param {Object} props
+ * @returns {Object}
+ */
+ function getAdditionalContent(props) {
+ const additionalContent = {};
+ if ('cp_content' in props) {
+ const content = props.cp_content || {};
+ if ('$buttons' in content) {
+ additionalContent.buttons = content.$buttons;
+ }
+ if ('$searchview' in content) {
+ additionalContent.searchView = content.$searchview;
+ }
+ if ('$pager' in content) {
+ additionalContent.pager = content.$pager;
+ }
+ if ('$searchview_buttons' in content) {
+ additionalContent.searchViewButtons = content.$searchview_buttons;
+ }
+ }
+ return additionalContent;
+ }
+
+ /**
+ * Control panel
+ *
+ * The control panel of the action|view. In its standard form, it is composed of
+ * several sections/subcomponents. Here is a simplified graph representing the
+ * action|view and its control panel:
+ *
+ * ┌ View Controller | Action ----------------------------------------------------------┐
+ * | ┌ Control Panel ──────────────┬──────────────────────────────────────────────────┐ |
+ * | │ ┌ Breadcrumbs ────────────┐ │ ┌ Search View ─────────────────────────────────┐ │ |
+ * | │ │ [1] / [2] │ │ │ [3] [ ================ 4 ================= ] │ │ |
+ * | │ └─────────────────────────┘ │ └──────────────────────────────────────────────┘ │ |
+ * | ├─────────────────────────────┼──────────────────────────────────────────────────┤ |
+ * | │ ┌ Buttons ┐ ┌ ActionMenus ┐ │ ┌ Search Menus ─────┐ ┌ Pager ┐┌ View switcher ┐ │ |
+ * | │ │ [5] │ │ [6] │ │ │ [7] [8] [9] [10] │ │ [11] ││ [12] │ │ |
+ * | │ └─────────┘ └─────────────┘ │ └───────────────────┘ └───────┘└───────────────┘ │ |
+ * | └─────────────────────────────┴──────────────────────────────────────────────────┘ |
+ * | ┌ View Renderer | Action content ────────────────────────────────────────────────┐ |
+ * | │ │ |
+ * | │ ... │ |
+ * | │ │ |
+ * | │ │ |
+ * | │ │ |
+ * | └────────────────────────────────────────────────────────────────────────────────┘ |
+ * └------------------------------------------------------------------------------------┘
+ *
+ * 1. Breadcrumbs: list of links composed by the `props.breadcrumbs` collection.
+ * 2. Title: the title of the action|view. Can be empty and will yield 'Unnamed'.
+ * 3. Search facets: a collection of facet components generated by the `ControlPanelModel`
+ * and handled by the `SearchBar` component. @see SearchFacet
+ * 4. SearchBar: @see SearchBar
+ * 5. Buttons: section in which the action|controller is meant to inject its control
+ * buttons. The template provides a slot for this purpose.
+ * 6. Action menus: @see ActionMenus
+ * 7. Filter menu: @see FilterMenu
+ * 8. Group by menu: @see GroupByMenu
+ * 9. Comparison menu: @see ComparisonMenu
+ * 10. Favorite menu: @see FavoriteMenu
+ * 11. Pager: @see Pager
+ * 12. View switcher buttons: list of buttons composed by the `props.views` collection.
+ *
+ * Subcomponents (especially in the [Search Menus] section) will call
+ * the ControlPanelModel to get processed information about the current view|action.
+ * @see ControlPanelModel for more details.
+ *
+ * Note: an additional temporary (and ugly) mechanic allows to inject a jQuery element
+ * given in `props.cp_content` in a related section:
+ * $buttons -> [Buttons]
+ * $searchview -> [Search View]
+ * $searchview_buttons -> [Search Menus]
+ * $pager -> [Pager]
+ * This system must be replaced by proper slot usage and the static template
+ * inheritance mechanism when converting the views/actions.
+ * @extends Component
+ */
+ class ControlPanel extends Component {
+ constructor() {
+ super(...arguments);
+
+ this.additionalContent = getAdditionalContent(this.props);
+
+ useSubEnv({
+ action: this.props.action,
+ searchModel: this.props.searchModel,
+ view: this.props.view,
+ });
+
+ // Connect to the model
+ // TODO: move this in enterprise whenever possible
+ if (this.env.searchModel) {
+ this.model = useModel('searchModel');
+ }
+
+ // Reference hooks
+ this.contentRefs = {
+ buttons: useRef('buttons'),
+ pager: useRef('pager'),
+ searchView: useRef('searchView'),
+ searchViewButtons: useRef('searchViewButtons'),
+ };
+
+ this.fields = this._formatFields(this.props.fields);
+
+ this.sprintf = _.str.sprintf;
+ }
+
+ mounted() {
+ this._attachAdditionalContent();
+ }
+
+ patched() {
+ this._attachAdditionalContent();
+ }
+
+ async willUpdateProps(nextProps) {
+ // Note: action and searchModel are not likely to change during
+ // the lifespan of a ControlPanel instance, so we only need to update
+ // the view information.
+ if ('view' in nextProps) {
+ this.env.view = nextProps.view;
+ }
+ if ('fields' in nextProps) {
+ this.fields = this._formatFields(nextProps.fields);
+ }
+ this.additionalContent = getAdditionalContent(nextProps);
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * Attach additional content extracted from the props 'cp_content' key, if any.
+ * @private
+ */
+ _attachAdditionalContent() {
+ for (const key in this.additionalContent) {
+ if (this.additionalContent[key] && this.additionalContent[key].length) {
+ const target = this.contentRefs[key].el;
+ if (target) {
+ target.innerHTML = "";
+ target.append(...this.additionalContent[key]);
+ }
+ }
+ }
+ }
+
+ /**
+ * Give `name` and `description` keys to the fields given to the control
+ * panel.
+ * @private
+ * @param {Object} fields
+ * @returns {Object}
+ */
+ _formatFields(fields) {
+ const formattedFields = {};
+ for (const fieldName in fields) {
+ formattedFields[fieldName] = Object.assign({
+ description: fields[fieldName].string,
+ name: fieldName,
+ }, fields[fieldName]);
+ }
+ return formattedFields;
+ }
+ }
+ ControlPanel.modelExtension = "ControlPanel";
+
+ ControlPanel.components = {
+ SearchBar,
+ ActionMenus, Pager,
+ ComparisonMenu, FilterMenu, GroupByMenu, FavoriteMenu,
+ };
+ ControlPanel.defaultProps = {
+ breadcrumbs: [],
+ fields: {},
+ searchMenuTypes: [],
+ views: [],
+ withBreadcrumbs: true,
+ withSearchBar: true,
+ };
+ ControlPanel.props = {
+ action: Object,
+ breadcrumbs: Array,
+ searchModel: ActionModel,
+ cp_content: { type: Object, optional: 1 },
+ fields: Object,
+ pager: { validate: p => typeof p === 'object' || p === null, optional: 1 },
+ searchMenuTypes: Array,
+ actionMenus: { validate: s => typeof s === 'object' || s === null, optional: 1 },
+ title: { type: String, optional: 1 },
+ view: { type: Object, optional: 1 },
+ views: Array,
+ withBreadcrumbs: Boolean,
+ withSearchBar: Boolean,
+ };
+ ControlPanel.template = 'web.ControlPanel';
+
+ return patchMixin(ControlPanel);
+});
diff --git a/addons/web/static/src/js/control_panel/control_panel_model_extension.js b/addons/web/static/src/js/control_panel/control_panel_model_extension.js
new file mode 100644
index 00000000..57242caf
--- /dev/null
+++ b/addons/web/static/src/js/control_panel/control_panel_model_extension.js
@@ -0,0 +1,1658 @@
+odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js", function (require) {
+ "use strict";
+
+ const ActionModel = require("web/static/src/js/views/action_model.js");
+ const Domain = require('web.Domain');
+ const pyUtils = require('web.py_utils');
+
+ const { DEFAULT_INTERVAL, DEFAULT_PERIOD,
+ getComparisonOptions, getIntervalOptions, getPeriodOptions,
+ constructDateDomain, rankInterval, yearSelected } = require('web.searchUtils');
+
+ const FAVORITE_PRIVATE_GROUP = 1;
+ const FAVORITE_SHARED_GROUP = 2;
+ const DISABLE_FAVORITE = "search_disable_custom_filters";
+
+ let filterId = 1;
+ let groupId = 1;
+ let groupNumber = 0;
+
+ /**
+ * Control panel model
+ *
+ * The control panel model state is an object structured in the following way:
+ *
+ * {
+ * filters: Object{},
+ * query: Object[],
+ * }
+ *
+ *-------------------------------------------------------------------------
+ * Filters
+ *-------------------------------------------------------------------------
+ *
+ * The keys are stringified numbers called 'filter ids'.
+ * The values are objects called 'filters'.
+ *
+ * Each filter has the following properties:
+ * @prop {number} id unique identifier, also the filter's corresponding key
+ * @prop {number} groupId the id of some group, actually the group itself,
+ * the (active) 'groups' are reconstructed in _getGroups.
+ * @prop {string} description the description of the filter
+ * @prop {string} type 'filter'|'groupBy'|'comparison'|'field'|'favorite'
+ *
+ * Other properties can be present according to the corresponding filter type:
+ *
+ * • type 'comparison':
+ * @prop {string} comparisonOptionId option identifier (@see COMPARISON_OPTIONS).
+ * @prop {string} dateFilterId the id of a date filter (filter of type 'filter'
+ * with isDateFilter=true)
+ *
+ * • type 'filter':
+ * @prop {number} groupNumber used to separate items in the 'Filters' menu
+ * @prop {string} [context] context
+ * @prop {boolean} [invisible] determine if the filter is accessible in the interface
+ * @prop {boolean} [isDefault]
+ * @if isDefault = true:
+ * > @prop {number} [defaultRank=-5] used to determine the order of
+ * > activation of default filters
+ * @prop {boolean} [isDateFilter] true if the filter comes from an arch node
+ * with a valid 'date' attribute.
+ * @if isDateFilter = true
+ * > @prop {boolean} [hasOptions=true]
+ * > @prop {string} defaultOptionId option identifier determined by
+ * > default_period attribute (@see PERIOD_OPTIONS).
+ * > Default set to DEFAULT_PERIOD.
+ * > @prop {string} fieldName determined by the value of 'date' attribute
+ * > @prop {string} fieldType 'date' or 'datetime', type of the corresponding field
+ * @else
+ * > @prop {string} domain
+ *
+ * • type 'groupBy':
+ * @prop {string} fieldName
+ * @prop {string} fieldType
+ * @prop {number} groupNumber used to separate items in the 'Group by' menu
+ * @prop {boolean} [isDefault]
+ * @if isDefault = true:
+ * > @prop {number} defaultRank used to determine the order of activation
+ * > of default filters
+ * @prop {boolean} [invisible] determine if the filter is accessible in the interface
+ * @prop {boolean} [hasOptions] true if field type is 'date' or 'datetime'
+ * @if hasOptions=true
+ * > @prop {string} defaultOptionId option identifier (see INTERVAL_OPTIONS)
+ * default set to DEFAULT_INTERVAL.
+ *
+ * • type 'field':
+ * @prop {string} fieldName
+ * @prop {string} fieldType
+ * @prop {string} [context]
+ * @prop {string} [domain]
+ * @prop {string} [filterDomain]
+ * @prop {boolean} [invisible] determine if the filter is accessible in the interface
+ * @prop {boolean} [isDefault]
+ * @prop {string} [operator]
+ * @if isDefault = true:
+ * > @prop {number} [defaultRank=-10] used to determine the order of
+ * > activation of filters
+ * > @prop {Object} defaultAutocompleteValue of the form { value, label, operator }
+ *
+ * • type: 'favorite':
+ * @prop {Object} [comparison] of the form {comparisonId, fieldName, fieldDescription,
+ * range, rangeDescription, comparisonRange, comparisonRangeDescription, }
+ * @prop {Object} context
+ * @prop {string} domain
+ * @prop {string[]} groupBys
+ * @prop {number} groupNumber 1 | 2, 2 if the favorite is shared
+ * @prop {string[]} orderedBy
+ * @prop {boolean} [removable=true] indicates that the favorite can be deleted
+ * @prop {number} serverSideId
+ * @prop {number} userId
+ * @prop {boolean} [isDefault]
+ *
+ *-------------------------------------------------------------------------
+ * Query
+ *-------------------------------------------------------------------------
+ *
+ * The query elements are objects called 'query elements'.
+ *
+ * Each query element has the following properties:
+ * @prop {number} filterId the id of some filter
+ * @prop {number} groupId the id of some group (actually the group itself)
+ *
+ * Other properties must be defined according to the corresponding filter type.
+ *
+ * • type 'comparison':
+ * @prop {string} dateFilterId the id of a date filter (filter of type 'filter'
+ * with hasOptions=true)
+ * @prop {string} type 'comparison', help when searching if a comparison is active
+ *
+ * • type 'filter' with hasOptions=true:
+ * @prop {string} optionId option identifier (@see PERIOD_OPTIONS)
+ *
+ * • type 'groupBy' with hasOptions=true:
+ * @prop {string} optionId option identifier (@see INTERVAL_OPTIONS)
+ *
+ * • type 'field':
+ * @prop {string} label description put in the facet (can be temporarilly missing)
+ * @prop {(string|number)} value used as the value of the generated domain
+ * @prop {string} operator used as the operator of the generated domain
+ *
+ * The query elements indicates what are the active filters and 'how' they are active.
+ * The key groupId has been added for simplicity. It could have been removed from query elements
+ * since the information is available on the corresponding filters.
+ * @extends ActionModel.Extension
+ */
+ class ControlPanelModelExtension extends ActionModel.Extension {
+ /**
+ * @param {Object} config
+ * @param {(string|number)} config.actionId
+ * @param {Object} config.env
+ * @param {string} config.modelName
+ * @param {Object} [config.context={}]
+ * @param {Object[]} [config.archNodes=[]]
+ * @param {Object[]} [config.dynamicFilters=[]]
+ * @param {string[]} [config.searchMenuTypes=[]]
+ * @param {Object} [config.favoriteFilters={}]
+ * @param {Object} [config.fields={}]
+ * @param {boolean} [config.withSearchBar=true]
+ */
+ constructor() {
+ super(...arguments);
+
+ this.actionContext = Object.assign({}, this.config.context);
+ this.searchMenuTypes = this.config.searchMenuTypes || [];
+ this.favoriteFilters = this.config.favoriteFilters || [];
+ this.fields = this.config.fields || {};
+ this.searchDefaults = {};
+ for (const key in this.actionContext) {
+ const match = /^search_default_(.*)$/.exec(key);
+ if (match) {
+ const val = this.actionContext[key];
+ if (val) {
+ this.searchDefaults[match[1]] = val;
+ }
+ delete this.actionContext[key];
+ }
+ }
+ this.labelPromises = [];
+
+ this.referenceMoment = moment();
+ this.optionGenerators = getPeriodOptions(this.referenceMoment);
+ this.intervalOptions = getIntervalOptions();
+ this.comparisonOptions = getComparisonOptions();
+ }
+
+ //---------------------------------------------------------------------
+ // Public
+ //---------------------------------------------------------------------
+
+ /**
+ * @override
+ * @returns {any}
+ */
+ get(property, ...args) {
+ switch (property) {
+ case "context": return this.getContext();
+ case "domain": return this.getDomain();
+ case "facets": return this._getFacets();
+ case "filters": return this._getFilters(...args);
+ case "groupBy": return this.getGroupBy();
+ case "orderedBy": return this.getOrderedBy();
+ case "timeRanges": return this.getTimeRanges();
+ }
+ }
+
+ /**
+ * @override
+ */
+ async load() {
+ await Promise.all(this.labelPromises);
+ }
+
+ /**
+ * @override
+ */
+ prepareState() {
+ Object.assign(this.state, {
+ filters: {},
+ query: [],
+ });
+ if (this.config.withSearchBar !== false) {
+ this._addFilters();
+ this._activateDefaultFilters();
+ }
+ }
+
+ //---------------------------------------------------------------------
+ // Actions / Getters
+ //---------------------------------------------------------------------
+
+ /**
+ * @returns {Object | undefined}
+ */
+ get activeComparison() {
+ return this.state.query.find(queryElem => queryElem.type === 'comparison');
+ }
+
+ /**
+ * Activate a filter of type 'field' with given filterId with
+ * 'autocompleteValues' value, label, and operator.
+ * @param {Object}
+ */
+ addAutoCompletionValues({ filterId, label, value, operator }) {
+ const queryElem = this.state.query.find(queryElem =>
+ queryElem.filterId === filterId &&
+ queryElem.value === value &&
+ queryElem.operator === operator
+ );
+ if (!queryElem) {
+ const { groupId } = this.state.filters[filterId];
+ this.state.query.push({ filterId, groupId, label, value, operator });
+ } else {
+ queryElem.label = label;
+ }
+ }
+
+ /**
+ * Remove all the query elements from query.
+ */
+ clearQuery() {
+ this.state.query = [];
+ }
+
+ /**
+ * Create a new filter of type 'favorite' and activate it.
+ * A new group containing only that filter is created.
+ * The query is emptied before activating the new favorite.
+ * @param {Object} preFilter
+ * @returns {Promise}
+ */
+ async createNewFavorite(preFilter) {
+ const preFavorite = await this._saveQuery(preFilter);
+ this.clearQuery();
+ const filter = Object.assign(preFavorite, {
+ groupId,
+ id: filterId,
+ });
+ this.state.filters[filterId] = filter;
+ this.state.query.push({ groupId, filterId });
+ groupId++;
+ filterId++;
+ }
+
+ /**
+ * Create new filters of type 'filter' and activate them.
+ * A new group containing only those filters is created.
+ * @param {Object[]} filters
+ * @returns {number[]}
+ */
+ createNewFilters(prefilters) {
+ if (!prefilters.length) {
+ return [];
+ }
+ const newFilterIdS = [];
+ prefilters.forEach(preFilter => {
+ const filter = Object.assign(preFilter, {
+ groupId,
+ groupNumber,
+ id: filterId,
+ type: 'filter',
+ });
+ this.state.filters[filterId] = filter;
+ this.state.query.push({ groupId, filterId });
+ newFilterIdS.push(filterId);
+ filterId++;
+ });
+ groupId++;
+ groupNumber++;
+ return newFilterIdS;
+ }
+
+ /**
+ * Create a new filter of type 'groupBy' and activate it.
+ * It is added to the unique group of groupbys.
+ * @param {Object} field
+ */
+ createNewGroupBy(field) {
+ const groupBy = Object.values(this.state.filters).find(f => f.type === 'groupBy');
+ const filter = {
+ description: field.string || field.name,
+ fieldName: field.name,
+ fieldType: field.type,
+ groupId: groupBy ? groupBy.groupId : groupId++,
+ groupNumber,
+ id: filterId,
+ type: 'groupBy',
+ };
+ this.state.filters[filterId] = filter;
+ if (['date', 'datetime'].includes(field.type)) {
+ filter.hasOptions = true;
+ filter.defaultOptionId = DEFAULT_INTERVAL;
+ this.toggleFilterWithOptions(filterId);
+ } else {
+ this.toggleFilter(filterId);
+ }
+ groupNumber++;
+ filterId++;
+ }
+
+ /**
+ * Deactivate a group with provided groupId, i.e. delete the query elements
+ * with given groupId.
+ * @param {number} groupId
+ */
+ deactivateGroup(groupId) {
+ this.state.query = this.state.query.filter(
+ queryElem => queryElem.groupId !== groupId
+ );
+ this._checkComparisonStatus();
+ }
+
+ /**
+ * Delete a filter of type 'favorite' with given filterId server side and
+ * in control panel model. Of course the filter is also removed
+ * from the search query.
+ * @param {number} filterId
+ */
+ async deleteFavorite(filterId) {
+ const { serverSideId } = this.state.filters[filterId];
+ await this.env.dataManager.delete_filter(serverSideId);
+ const index = this.state.query.findIndex(
+ queryElem => queryElem.filterId === filterId
+ );
+ delete this.state.filters[filterId];
+ if (index >= 0) {
+ this.state.query.splice(index, 1);
+ }
+ }
+
+ /**
+ * @returns {Object}
+ */
+ getContext() {
+ const groups = this._getGroups();
+ return this._getContext(groups);
+ }
+
+ /**
+ * @returns {Array[]}
+ */
+ getDomain() {
+ const groups = this._getGroups();
+ const userContext = this.env.session.user_context;
+ try {
+ return Domain.prototype.stringToArray(this._getDomain(groups), userContext);
+ } catch (err) {
+ throw new Error(
+ `${this.env._t("Control panel model extension failed to evaluate domain")}:/n${JSON.stringify(err)}`
+ );
+ }
+ }
+
+ /**
+ * @returns {string[]}
+ */
+ getGroupBy() {
+ const groups = this._getGroups();
+ return this._getGroupBy(groups);
+ }
+
+ /**
+ * @returns {string[]}
+ */
+ getOrderedBy() {
+ const groups = this._getGroups();
+ return this._getOrderedBy(groups);
+ }
+
+ /**
+ * @returns {Object}
+ */
+ getTimeRanges() {
+ const requireEvaluation = true;
+ return this._getTimeRanges(requireEvaluation);
+ }
+
+ /**
+ * Used to call dispatch and trigger a 'search'.
+ */
+ search() {
+ /* ... */
+ }
+
+ /**
+ * Activate/Deactivate a filter of type 'comparison' with provided id.
+ * At most one filter of type 'comparison' can be activated at every time.
+ * @param {string} filterId
+ */
+ toggleComparison(filterId) {
+ const { groupId, dateFilterId } = this.state.filters[filterId];
+ const queryElem = this.state.query.find(queryElem =>
+ queryElem.type === 'comparison' &&
+ queryElem.filterId === filterId
+ );
+ // make sure only one comparison can be active
+ this.state.query = this.state.query.filter(queryElem => queryElem.type !== 'comparison');
+ if (!queryElem) {
+ this.state.query.push({ groupId, filterId, dateFilterId, type: 'comparison', });
+ }
+ }
+
+ /**
+ * Activate or deactivate the simple filter with given filterId, i.e.
+ * add or remove a corresponding query element.
+ * @param {string} filterId
+ */
+ toggleFilter(filterId) {
+ const index = this.state.query.findIndex(
+ queryElem => queryElem.filterId === filterId
+ );
+ if (index >= 0) {
+ this.state.query.splice(index, 1);
+ } else {
+ const { groupId, type } = this.state.filters[filterId];
+ if (type === 'favorite') {
+ this.state.query = [];
+ }
+ this.state.query.push({ groupId, filterId });
+ }
+ }
+
+ /**
+ * Used to toggle a query element { filterId, optionId, (groupId) }.
+ * This can impact the query in various form, e.g. add/remove other query elements
+ * in case the filter is of type 'filter'.
+ * @param {string} filterId
+ * @param {string} [optionId]
+ */
+ toggleFilterWithOptions(filterId, optionId) {
+ const filter = this.state.filters[filterId];
+ optionId = optionId || filter.defaultOptionId;
+ const option = this.optionGenerators.find(o => o.id === optionId);
+
+ const index = this.state.query.findIndex(
+ queryElem => queryElem.filterId === filterId && queryElem.optionId === optionId
+ );
+
+ if (index >= 0) {
+ this.state.query.splice(index, 1);
+ if (filter.type === 'filter' && !yearSelected(this._getSelectedOptionIds(filterId))) {
+ // This is the case where optionId was the last option
+ // of type 'year' to be there before being removed above.
+ // Since other options of type 'month' or 'quarter' do
+ // not make sense without a year we deactivate all options.
+ this.state.query = this.state.query.filter(
+ queryElem => queryElem.filterId !== filterId
+ );
+ }
+ } else {
+ this.state.query.push({ groupId: filter.groupId, filterId, optionId });
+ if (filter.type === 'filter' && !yearSelected(this._getSelectedOptionIds(filterId))) {
+ // Here we add 'this_year' as options if no option of type
+ // year is already selected.
+ this.state.query.push({
+ groupId: filter.groupId,
+ filterId,
+ optionId: option.defaultYearId,
+ });
+ }
+ }
+ if (filter.type === 'filter') {
+ this._checkComparisonStatus();
+ }
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * Activate the default favorite (if any) or all default filters.
+ * @private
+ */
+ _activateDefaultFilters() {
+ if (this.defaultFavoriteId) {
+ // Activate default favorite
+ this.toggleFilter(this.defaultFavoriteId);
+ } else {
+ // Activate default filters
+ Object.values(this.state.filters)
+ .filter((f) => f.isDefault && f.type !== 'favorite')
+ .sort((f1, f2) => (f1.defaultRank || 100) - (f2.defaultRank || 100))
+ .forEach(f => {
+ if (f.hasOptions) {
+ this.toggleFilterWithOptions(f.id);
+ } else if (f.type === 'field') {
+ let { operator, label, value } = f.defaultAutocompleteValue;
+ this.addAutoCompletionValues({
+ filterId: f.id,
+ value,
+ operator,
+ label,
+ });
+ } else {
+ this.toggleFilter(f.id);
+ }
+ });
+ }
+ }
+
+ /**
+ * This function populates the 'filters' object at initialization.
+ * The filters come from:
+ * - config.archNodes (types 'comparison', 'filter', 'groupBy', 'field'),
+ * - config.dynamicFilters (type 'filter'),
+ * - config.favoriteFilters (type 'favorite'),
+ * - code itself (type 'timeRange')
+ * @private
+ */
+ _addFilters() {
+ this._createGroupOfFavorites();
+ this._createGroupOfFiltersFromArch();
+ this._createGroupOfDynamicFilters();
+ }
+
+ /**
+ * If a comparison is active, check if it should become inactive.
+ * The comparison should become inactive if the corresponding date filter has become
+ * inactive.
+ * @private
+ */
+ _checkComparisonStatus() {
+ const activeComparison = this.activeComparison;
+ if (!activeComparison) {
+ return;
+ }
+ const { dateFilterId } = activeComparison;
+ const dateFilterIsActive = this.state.query.some(
+ queryElem => queryElem.filterId === dateFilterId
+ );
+ if (!dateFilterIsActive) {
+ this.state.query = this.state.query.filter(
+ queryElem => queryElem.type !== 'comparison'
+ );
+ }
+ }
+
+ /**
+ * Returns the active comparison timeRanges object.
+ * @private
+ * @param {Object} comparisonFilter
+ * @returns {Object | null}
+ */
+ _computeTimeRanges(comparisonFilter) {
+ const { filterId } = this.activeComparison;
+ if (filterId !== comparisonFilter.id) {
+ return null;
+ }
+ const { dateFilterId, comparisonOptionId } = comparisonFilter;
+ const {
+ fieldName,
+ fieldType,
+ description: dateFilterDescription,
+ } = this.state.filters[dateFilterId];
+
+ const selectedOptionIds = this._getSelectedOptionIds(dateFilterId);
+
+ // compute range and range description
+ const { domain: range, description: rangeDescription } = constructDateDomain(
+ this.referenceMoment, fieldName, fieldType, selectedOptionIds,
+ );
+
+ // compute comparisonRange and comparisonRange description
+ const {
+ domain: comparisonRange,
+ description: comparisonRangeDescription,
+ } = constructDateDomain(
+ this.referenceMoment, fieldName, fieldType, selectedOptionIds, comparisonOptionId
+ );
+
+ return {
+ comparisonId: comparisonOptionId,
+ fieldName,
+ fieldDescription: dateFilterDescription,
+ range,
+ rangeDescription,
+ comparisonRange,
+ comparisonRangeDescription,
+ };
+ }
+
+ /**
+ * Starting from the array of date filters, create the filters of type
+ * 'comparison'.
+ * @private
+ * @param {Object[]} dateFilters
+ */
+ _createGroupOfComparisons(dateFilters) {
+ const preFilters = [];
+ for (const dateFilter of dateFilters) {
+ for (const comparisonOption of this.comparisonOptions) {
+ const { id: dateFilterId, description } = dateFilter;
+ const preFilter = {
+ type: 'comparison',
+ comparisonOptionId: comparisonOption.id,
+ description: `${description}: ${comparisonOption.description}`,
+ dateFilterId,
+ };
+ preFilters.push(preFilter);
+ }
+ }
+ this._createGroupOfFilters(preFilters);
+ }
+
+ /**
+ * Add filters of type 'filter' determined by the key array dynamicFilters.
+ * @private
+ */
+ _createGroupOfDynamicFilters() {
+ const dynamicFilters = this.config.dynamicFilters || [];
+ const pregroup = dynamicFilters.map(filter => {
+ return {
+ description: filter.description,
+ domain: JSON.stringify(filter.domain),
+ isDefault: true,
+ type: 'filter',
+ };
+ });
+ this._createGroupOfFilters(pregroup);
+ }
+
+ /**
+ * Add filters of type 'favorite' determined by the array this.favoriteFilters.
+ * @private
+ */
+ _createGroupOfFavorites() {
+ const activateFavorite = DISABLE_FAVORITE in this.actionContext ?
+ !this.actionContext[DISABLE_FAVORITE] :
+ true;
+ this.favoriteFilters.forEach(irFilter => {
+ const favorite = this._irFilterToFavorite(irFilter);
+ this._createGroupOfFilters([favorite]);
+ if (activateFavorite && favorite.isDefault) {
+ this.defaultFavoriteId = favorite.id;
+ }
+ });
+ }
+
+ /**
+ * Using a list (a 'pregroup') of 'prefilters', create new filters in `state.filters`
+ * for each prefilter. The new filters belong to a same new group.
+ * @private
+ * @param {Object[]} pregroup, list of 'prefilters'
+ * @param {string} type
+ */
+ _createGroupOfFilters(pregroup) {
+ pregroup.forEach(preFilter => {
+ const filter = Object.assign(preFilter, { groupId, id: filterId });
+ this.state.filters[filterId] = filter;
+ if (!this.defaultFavoriteId && filter.isDefault && filter.type === 'field') {
+ this._prepareDefaultLabel(filter);
+ }
+ filterId++;
+ });
+ groupId++;
+ }
+
+ /**
+ * Parse the arch of a 'search' view and create corresponding filters and groups.
+ *
+ * A searchview arch may contain a 'searchpanel' node, but this isn't
+ * the concern of the ControlPanel (the SearchPanel will handle it).
+ * Ideally, this code should whitelist the tags to take into account
+ * instead of blacklisting the others, but with the current (messy)
+ * structure of a searchview arch, it's way simpler to do it that way.
+ * @private
+ */
+ _createGroupOfFiltersFromArch() {
+ const preFilters = this.config.archNodes.reduce(
+ (preFilters, child) => {
+ if (child.tag === 'group') {
+ return [...preFilters, ...child.children.map(c => this._evalArchChild(c))];
+ } else {
+ return [...preFilters, this._evalArchChild(child)];
+ }
+ },
+ []
+ );
+ preFilters.push({ tag: 'separator' });
+
+ // create groups and filters
+ let currentTag;
+ let currentGroup = [];
+ let pregroupOfGroupBys = [];
+
+ preFilters.forEach(preFilter => {
+ if (
+ preFilter.tag !== currentTag ||
+ ['separator', 'field'].includes(preFilter.tag)
+ ) {
+ if (currentGroup.length) {
+ if (currentTag === 'groupBy') {
+ pregroupOfGroupBys = [...pregroupOfGroupBys, ...currentGroup];
+ } else {
+ this._createGroupOfFilters(currentGroup);
+ }
+ }
+ currentTag = preFilter.tag;
+ currentGroup = [];
+ groupNumber++;
+ }
+ if (preFilter.tag !== 'separator') {
+ const filter = {
+ type: preFilter.tag,
+ // we need to codify here what we want to keep from attrs
+ // and how, for now I put everything.
+ // In some sence, some filter are active (totally determined, given)
+ // and others are passive (require input(s) to become determined)
+ // What is the right place to process the attrs?
+ };
+ if (preFilter.attrs && JSON.parse(preFilter.attrs.modifiers || '{}').invisible) {
+ filter.invisible = true;
+ let preFilterFieldName = null;
+ if (preFilter.tag === 'filter' && preFilter.attrs.date) {
+ preFilterFieldName = preFilter.attrs.date;
+ } else if (preFilter.tag === 'groupBy') {
+ preFilterFieldName = preFilter.attrs.fieldName;
+ }
+ if (preFilterFieldName && !this.fields[preFilterFieldName]) {
+ // In some case when a field is limited to specific groups
+ // on the model, we need to ensure to discard related filter
+ // as it may still be present in the view (in 'invisible' state)
+ return;
+ }
+ }
+ if (filter.type === 'filter' || filter.type === 'groupBy') {
+ filter.groupNumber = groupNumber;
+ }
+ this._extractAttributes(filter, preFilter.attrs);
+ currentGroup.push(filter);
+ }
+ });
+
+ if (pregroupOfGroupBys.length) {
+ this._createGroupOfFilters(pregroupOfGroupBys);
+ }
+ const dateFilters = Object.values(this.state.filters).filter(
+ (filter) => filter.isDateFilter
+ );
+ if (dateFilters.length) {
+ this._createGroupOfComparisons(dateFilters);
+ }
+ }
+
+ /**
+ * Returns null or a copy of the provided filter with additional information
+ * used only outside of the control panel model, like in search bar or in the
+ * various menus. The value null is returned if the filter should not appear
+ * for some reason.
+ * @private
+ * @param {Object} filter
+ * @param {Object[]} filterQueryElements
+ * @returns {Object | null}
+ */
+ _enrichFilterCopy(filter, filterQueryElements) {
+ const isActive = Boolean(filterQueryElements.length);
+ const f = Object.assign({ isActive }, filter);
+
+ function _enrichOptions(options) {
+ return options.map(o => {
+ const { description, id, groupNumber } = o;
+ const isActive = filterQueryElements.some(a => a.optionId === id);
+ return { description, id, groupNumber, isActive };
+ });
+ }
+
+ switch (f.type) {
+ case 'comparison': {
+ const { dateFilterId } = filter;
+ const dateFilterIsActive = this.state.query.some(
+ queryElem => queryElem.filterId === dateFilterId
+ );
+ if (!dateFilterIsActive) {
+ return null;
+ }
+ break;
+ }
+ case 'filter':
+ if (f.hasOptions) {
+ f.options = _enrichOptions(this.optionGenerators);
+ }
+ break;
+ case 'groupBy':
+ if (f.hasOptions) {
+ f.options = _enrichOptions(this.intervalOptions);
+ }
+ break;
+ case 'field':
+ f.autoCompleteValues = filterQueryElements.map(
+ ({ label, value, operator }) => ({ label, value, operator })
+ );
+ break;
+ }
+ return f;
+ }
+
+ /**
+ * Process a given arch node and enrich it.
+ * @private
+ * @param {Object} child
+ * @returns {Object}
+ */
+ _evalArchChild(child) {
+ if (child.attrs.context) {
+ try {
+ const context = pyUtils.eval('context', child.attrs.context);
+ child.attrs.context = context;
+ if (context.group_by) {
+ // let us extract basic data since we just evaluated context
+ // and use a correct tag!
+ child.attrs.fieldName = context.group_by.split(':')[0];
+ child.attrs.defaultInterval = context.group_by.split(':')[1];
+ child.tag = 'groupBy';
+ }
+ } catch (e) { }
+ }
+ if (child.attrs.name in this.searchDefaults) {
+ child.attrs.isDefault = true;
+ let value = this.searchDefaults[child.attrs.name];
+ if (child.tag === 'field') {
+ child.attrs.defaultValue = this.fields[child.attrs.name].type === 'many2one' && Array.isArray(value) ? value[0] : value;
+ } else if (child.tag === 'groupBy') {
+ child.attrs.defaultRank = typeof value === 'number' ? value : 100;
+ }
+ }
+ return child;
+ }
+
+ /**
+ * Process the attributes set on an arch node and adds various keys to
+ * the given filter.
+ * @private
+ * @param {Object} filter
+ * @param {Object} attrs
+ */
+ _extractAttributes(filter, attrs) {
+ if (attrs.isDefault) {
+ filter.isDefault = attrs.isDefault;
+ }
+ filter.description = attrs.string || attrs.help || attrs.name || attrs.domain || 'Ω';
+ switch (filter.type) {
+ case 'filter':
+ if (attrs.context) {
+ filter.context = attrs.context;
+ }
+ if (attrs.date) {
+ filter.isDateFilter = true;
+ filter.hasOptions = true;
+ filter.fieldName = attrs.date;
+ filter.fieldType = this.fields[attrs.date].type;
+ filter.defaultOptionId = attrs.default_period || DEFAULT_PERIOD;
+ } else {
+ filter.domain = attrs.domain || '[]';
+ }
+ if (filter.isDefault) {
+ filter.defaultRank = -5;
+ }
+ break;
+ case 'groupBy':
+ filter.fieldName = attrs.fieldName;
+ filter.fieldType = this.fields[attrs.fieldName].type;
+ if (['date', 'datetime'].includes(filter.fieldType)) {
+ filter.hasOptions = true;
+ filter.defaultOptionId = attrs.defaultInterval || DEFAULT_INTERVAL;
+ }
+ if (filter.isDefault) {
+ filter.defaultRank = attrs.defaultRank;
+ }
+ break;
+ case 'field': {
+ const field = this.fields[attrs.name];
+ filter.fieldName = attrs.name;
+ filter.fieldType = field.type;
+ if (attrs.domain) {
+ filter.domain = attrs.domain;
+ }
+ if (attrs.filter_domain) {
+ filter.filterDomain = attrs.filter_domain;
+ } else if (attrs.operator) {
+ filter.operator = attrs.operator;
+ }
+ if (attrs.context) {
+ filter.context = attrs.context;
+ }
+ if (filter.isDefault) {
+ let operator = filter.operator;
+ if (!operator) {
+ const type = attrs.widget || filter.fieldType;
+ // Note: many2one as a default filter will have a
+ // numeric value instead of a string => we want "="
+ // instead of "ilike".
+ if (["char", "html", "many2many", "one2many", "text"].includes(type)) {
+ operator = "ilike";
+ } else {
+ operator = "=";
+ }
+ }
+ filter.defaultRank = -10;
+ filter.defaultAutocompleteValue = {
+ operator,
+ value: attrs.defaultValue,
+ };
+ }
+ break;
+ }
+ }
+ if (filter.fieldName && !attrs.string) {
+ const { string } = this.fields[filter.fieldName];
+ filter.description = string;
+ }
+ }
+
+ /**
+ * Returns an object irFilter serving to create an ir_filte in db
+ * starting from a filter of type 'favorite'.
+ * @private
+ * @param {Object} favorite
+ * @returns {Object}
+ */
+ _favoriteToIrFilter(favorite) {
+ const irFilter = {
+ action_id: this.config.actionId,
+ model_id: this.config.modelName,
+ };
+
+ // ir.filter fields
+ if ('description' in favorite) {
+ irFilter.name = favorite.description;
+ }
+ if ('domain' in favorite) {
+ irFilter.domain = favorite.domain;
+ }
+ if ('isDefault' in favorite) {
+ irFilter.is_default = favorite.isDefault;
+ }
+ if ('orderedBy' in favorite) {
+ const sort = favorite.orderedBy.map(
+ ob => ob.name + (ob.asc === false ? " desc" : "")
+ );
+ irFilter.sort = JSON.stringify(sort);
+ }
+ if ('serverSideId' in favorite) {
+ irFilter.id = favorite.serverSideId;
+ }
+ if ('userId' in favorite) {
+ irFilter.user_id = favorite.userId;
+ }
+
+ // Context
+ const context = Object.assign({}, favorite.context);
+ if ('groupBys' in favorite) {
+ context.group_by = favorite.groupBys;
+ }
+ if ('comparison' in favorite) {
+ context.comparison = favorite.comparison;
+ }
+ if (Object.keys(context).length) {
+ irFilter.context = context;
+ }
+
+ return irFilter;
+ }
+
+ /**
+ * Return the domain resulting from the combination of the auto-completion
+ * values of a filter of type 'field'.
+ * @private
+ * @param {Object} filter
+ * @param {Object[]} filterQueryElements
+ * @returns {string}
+ */
+ _getAutoCompletionFilterDomain(filter, filterQueryElements) {
+ const domains = filterQueryElements.map(({ label, value, operator }) => {
+ let domain;
+ if (filter.filterDomain) {
+ domain = Domain.prototype.stringToArray(
+ filter.filterDomain,
+ {
+ self: label,
+ raw_value: value,
+ }
+ );
+ } else {
+ // Create new domain
+ domain = [[filter.fieldName, operator, value]];
+ }
+ return Domain.prototype.arrayToString(domain);
+ });
+ return pyUtils.assembleDomains(domains, 'OR');
+ }
+
+ /**
+ * Construct a single context from the contexts of
+ * filters of type 'filter', 'favorite', and 'field'.
+ * @private
+ * @returns {Object}
+ */
+ _getContext(groups) {
+ const types = ['filter', 'favorite', 'field'];
+ const contexts = groups.reduce(
+ (contexts, group) => {
+ if (types.includes(group.type)) {
+ contexts.push(...this._getGroupContexts(group));
+ }
+ return contexts;
+ },
+ []
+ );
+ const evaluationContext = this.env.session.user_context;
+ try {
+ return pyUtils.eval('contexts', contexts, evaluationContext);
+ } catch (err) {
+ throw new Error(
+ this.env._t("Failed to evaluate search context") + ":\n" +
+ JSON.stringify(err)
+ );
+ }
+ }
+
+ /**
+ * Compute the string representation or the description of the current domain associated
+ * with a date filter starting from its corresponding query elements.
+ * @private
+ * @param {Object} filter
+ * @param {Object[]} filterQueryElements
+ * @param {'domain'|'description'} [key='domain']
+ * @returns {string}
+ */
+ _getDateFilterDomain(filter, filterQueryElements, key = 'domain') {
+ const { fieldName, fieldType } = filter;
+ const selectedOptionIds = filterQueryElements.map(queryElem => queryElem.optionId);
+ const dateFilterRange = constructDateDomain(
+ this.referenceMoment, fieldName, fieldType, selectedOptionIds,
+ );
+ return dateFilterRange[key];
+ }
+
+ /**
+ * Return the string or array representation of a domain created by combining
+ * appropriately (with an 'AND') the domains coming from the active groups
+ * of type 'filter', 'favorite', and 'field'.
+ * @private
+ * @param {Object[]} groups
+ * @returns {string}
+ */
+ _getDomain(groups) {
+ const types = ['filter', 'favorite', 'field'];
+ const domains = [];
+ for (const group of groups) {
+ if (types.includes(group.type)) {
+ domains.push(this._getGroupDomain(group));
+ }
+ }
+ return pyUtils.assembleDomains(domains, 'AND');
+ }
+
+ /**
+ * Get the filter description to use in the search bar as a facet.
+ * @private
+ * @param {Object} activity
+ * @param {Object} activity.filter
+ * @param {Object[]} activity.filterQueryElements
+ * @returns {string}
+ */
+ _getFacetDescriptions(activities, type) {
+ const facetDescriptions = [];
+ if (type === 'field') {
+ for (const queryElem of activities[0].filterQueryElements) {
+ facetDescriptions.push(queryElem.label);
+ }
+ } else if (type === 'groupBy') {
+ for (const { filter, filterQueryElements } of activities) {
+ if (filter.hasOptions) {
+ for (const queryElem of filterQueryElements) {
+ const option = this.intervalOptions.find(
+ o => o.id === queryElem.optionId
+ );
+ facetDescriptions.push(filter.description + ': ' + option.description);
+ }
+ } else {
+ facetDescriptions.push(filter.description);
+ }
+ }
+ } else {
+ let facetDescription;
+ for (const { filter, filterQueryElements } of activities) {
+ // filter, favorite and comparison
+ facetDescription = filter.description;
+ if (filter.isDateFilter) {
+ const description = this._getDateFilterDomain(
+ filter, filterQueryElements, 'description'
+ );
+ facetDescription += `: ${description}`;
+ }
+ facetDescriptions.push(facetDescription);
+ }
+ }
+ return facetDescriptions;
+ }
+
+ /**
+ * @returns {Object[]}
+ */
+ _getFacets() {
+ const facets = this._getGroups().map(({ activities, type, id }) => {
+ const values = this._getFacetDescriptions(activities, type);
+ const title = activities[0].filter.description;
+ return { groupId: id, title, type, values };
+ });
+ return facets;
+ }
+
+ /**
+ * Return an array containing enriched copies of the filters of the provided type.
+ * @param {Function} predicate
+ * @returns {Object[]}
+ */
+ _getFilters(predicate) {
+ const filters = [];
+ Object.values(this.state.filters).forEach(filter => {
+ if ((!predicate || predicate(filter)) && !filter.invisible) {
+ const filterQueryElements = this.state.query.filter(
+ queryElem => queryElem.filterId === filter.id
+ );
+ const enrichedFilter = this._enrichFilterCopy(filter, filterQueryElements);
+ if (enrichedFilter) {
+ filters.push(enrichedFilter);
+ }
+ }
+ });
+ if (filters.some(f => f.type === 'favorite')) {
+ filters.sort((f1, f2) => f1.groupNumber - f2.groupNumber);
+ }
+ return filters;
+ }
+
+ /**
+ * Return the context of the provided (active) filter.
+ * @private
+ * @param {Object} filter
+ * @param {Object[]} filterQueryElements
+ * @returns {Object}
+ */
+ _getFilterContext(filter, filterQueryElements) {
+ let context = filter.context || {};
+ // for <field> nodes, a dynamic context (like context="{'field1': self}")
+ // should set {'field1': [value1, value2]} in the context
+ if (filter.type === 'field' && filter.context) {
+ context = pyUtils.eval('context',
+ filter.context,
+ { self: filterQueryElements.map(({ value }) => value) },
+ );
+ }
+ // the following code aims to remodel this:
+ // https://github.com/odoo/odoo/blob/12.0/addons/web/static/src/js/views/search/search_inputs.js#L498
+ // this is required for the helpdesk tour to pass
+ // this seems weird to only do that for m2o fields, but a test fails if
+ // we do it for other fields (my guess being that the test should simply
+ // be adapted)
+ if (filter.type === 'field' && filter.isDefault && filter.fieldType === 'many2one') {
+ context[`default_${filter.fieldName}`] = filter.defaultAutocompleteValue.value;
+ }
+ return context;
+ }
+
+ /**
+ * Return the domain of the provided filter.
+ * @private
+ * @param {Object} filter
+ * @param {Object[]} filterQueryElements
+ * @returns {string} domain, string representation of a domain
+ */
+ _getFilterDomain(filter, filterQueryElements) {
+ if (filter.type === 'filter' && filter.hasOptions) {
+ const { dateFilterId } = this.activeComparison || {};
+ if (this.searchMenuTypes.includes('comparison') && dateFilterId === filter.id) {
+ return "[]";
+ }
+ return this._getDateFilterDomain(filter, filterQueryElements);
+ } else if (filter.type === 'field') {
+ return this._getAutoCompletionFilterDomain(filter, filterQueryElements);
+ }
+ return filter.domain;
+ }
+
+ /**
+ * Return the groupBys of the provided filter.
+ * @private
+ * @param {Object} filter
+ * @param {Object[]} filterQueryElements
+ * @returns {string[]} groupBys
+ */
+ _getFilterGroupBys(filter, filterQueryElements) {
+ if (filter.type === 'groupBy') {
+ const fieldName = filter.fieldName;
+ if (filter.hasOptions) {
+ return filterQueryElements.map(
+ ({ optionId }) => `${fieldName}:${optionId}`
+ );
+ } else {
+ return [fieldName];
+ }
+ } else {
+ return filter.groupBys;
+ }
+ }
+
+ /**
+ * Return the concatenation of groupBys comming from the active filters of
+ * type 'favorite' and 'groupBy'.
+ * The result respects the appropriate logic: the groupBys
+ * coming from an active favorite (if any) come first, then come the
+ * groupBys comming from the active filters of type 'groupBy' in the order
+ * defined in this.state.query. If no groupBys are found, one tries to
+ * find some grouBys in the action context.
+ * @private
+ * @param {Object[]} groups
+ * @returns {string[]}
+ */
+ _getGroupBy(groups) {
+ const groupBys = groups.reduce(
+ (groupBys, group) => {
+ if (['groupBy', 'favorite'].includes(group.type)) {
+ groupBys.push(...this._getGroupGroupBys(group));
+ }
+ return groupBys;
+ },
+ []
+ );
+ const groupBy = groupBys.length ? groupBys : (this.actionContext.group_by || []);
+ return typeof groupBy === 'string' ? [groupBy] : groupBy;
+ }
+
+ /**
+ * Return the list of the contexts of the filters active in the given
+ * group.
+ * @private
+ * @param {Object} group
+ * @returns {Object[]}
+ */
+ _getGroupContexts(group) {
+ const contexts = group.activities.reduce(
+ (ctx, qe) => [...ctx, this._getFilterContext(qe.filter, qe.filterQueryElements)],
+ []
+ );
+ return contexts;
+ }
+
+ /**
+ * Return the string representation of a domain created by combining
+ * appropriately (with an 'OR') the domains coming from the filters
+ * active in the given group.
+ * @private
+ * @param {Object} group
+ * @returns {string} string representation of a domain
+ */
+ _getGroupDomain(group) {
+ const domains = group.activities.map(({ filter, filterQueryElements }) => {
+ return this._getFilterDomain(filter, filterQueryElements);
+ });
+ return pyUtils.assembleDomains(domains, 'OR');
+ }
+
+ /**
+ * Return the groupBys coming form the filters active in the given group.
+ * @private
+ * @param {Object} group
+ * @returns {string[]}
+ */
+ _getGroupGroupBys(group) {
+ const groupBys = group.activities.reduce(
+ (gb, qe) => [...gb, ...this._getFilterGroupBys(qe.filter, qe.filterQueryElements)],
+ []
+ );
+ return groupBys;
+ }
+
+ /**
+ * Reconstruct the (active) groups from the query elements.
+ * @private
+ * @returns {Object[]}
+ */
+ _getGroups() {
+ const groups = this.state.query.reduce(
+ (groups, queryElem) => {
+ const { groupId, filterId } = queryElem;
+ let group = groups.find(group => group.id === groupId);
+ const filter = this.state.filters[filterId];
+ if (!group) {
+ const { type } = filter;
+ group = {
+ id: groupId,
+ type,
+ activities: []
+ };
+ groups.push(group);
+ }
+ group.activities.push(queryElem);
+ return groups;
+ },
+ []
+ );
+ groups.forEach(g => this._mergeActivities(g));
+ return groups;
+ }
+
+ /**
+ * Used to get the key orderedBy of the active favorite.
+ * @private
+ * @param {Object[]} groups
+ * @returns {string[]} orderedBy
+ */
+ _getOrderedBy(groups) {
+ return groups.reduce(
+ (orderedBy, group) => {
+ if (group.type === 'favorite') {
+ const favoriteOrderedBy = group.activities[0].filter.orderedBy;
+ if (favoriteOrderedBy) {
+ // Group order is reversed but inner order is kept
+ orderedBy = [...favoriteOrderedBy, ...orderedBy];
+ }
+ }
+ return orderedBy;
+ },
+ []
+ );
+ }
+
+ /**
+ * Starting from the id of a date filter, returns the array of option ids currently selected
+ * for the corresponding filter.
+ * @private
+ * @param {string} dateFilterId
+ * @returns {string[]}
+ */
+ _getSelectedOptionIds(dateFilterId) {
+ const selectedOptionIds = [];
+ for (const queryElem of this.state.query) {
+ if (queryElem.filterId === dateFilterId) {
+ selectedOptionIds.push(queryElem.optionId);
+ }
+ }
+ return selectedOptionIds;
+ }
+
+ /**
+ * Returns the last timeRanges object found in the query.
+ * TimeRanges objects can be associated with filters of type 'favorite'
+ * or 'comparison'.
+ * @private
+ * @param {boolean} [evaluation=false]
+ * @returns {Object | null}
+ */
+ _getTimeRanges(evaluation) {
+ let timeRanges;
+ for (const queryElem of this.state.query.slice().reverse()) {
+ const filter = this.state.filters[queryElem.filterId];
+ if (filter.type === 'comparison') {
+ timeRanges = this._computeTimeRanges(filter);
+ break;
+ } else if (filter.type === 'favorite' && filter.comparison) {
+ timeRanges = filter.comparison;
+ break;
+ }
+ }
+ if (timeRanges) {
+ if (evaluation) {
+ timeRanges.range = Domain.prototype.stringToArray(timeRanges.range);
+ timeRanges.comparisonRange = Domain.prototype.stringToArray(timeRanges.comparisonRange);
+ }
+ return timeRanges;
+ }
+ return null;
+ }
+
+ /**
+ * Returns a filter of type 'favorite' starting from an ir_filter comming from db.
+ * @private
+ * @param {Object} irFilter
+ * @returns {Object}
+ */
+ _irFilterToFavorite(irFilter) {
+ let userId = irFilter.user_id || false;
+ if (Array.isArray(userId)) {
+ userId = userId[0];
+ }
+ const groupNumber = userId ? FAVORITE_PRIVATE_GROUP : FAVORITE_SHARED_GROUP;
+ const context = pyUtils.eval('context', irFilter.context, this.env.session.user_context);
+ let groupBys = [];
+ if (context.group_by) {
+ groupBys = context.group_by;
+ delete context.group_by;
+ }
+ let comparison;
+ if (context.comparison) {
+ comparison = context.comparison;
+ delete context.comparison;
+ }
+ let sort;
+ try {
+ sort = JSON.parse(irFilter.sort);
+ } catch (err) {
+ if (err instanceof SyntaxError) {
+ sort = [];
+ } else {
+ throw err;
+ }
+ }
+ const orderedBy = sort.map(order => {
+ let fieldName;
+ let asc;
+ const sqlNotation = order.split(' ');
+ if (sqlNotation.length > 1) {
+ // regex: \fieldName (asc|desc)?\
+ fieldName = sqlNotation[0];
+ asc = sqlNotation[1] === 'asc';
+ } else {
+ // legacy notation -- regex: \-?fieldName\
+ fieldName = order[0] === '-' ? order.slice(1) : order;
+ asc = order[0] === '-' ? false : true;
+ }
+ return {
+ asc: asc,
+ name: fieldName,
+ };
+ });
+ const favorite = {
+ context,
+ description: irFilter.name,
+ domain: irFilter.domain,
+ groupBys,
+ groupNumber,
+ orderedBy,
+ removable: true,
+ serverSideId: irFilter.id,
+ type: 'favorite',
+ userId,
+ };
+ if (irFilter.is_default) {
+ favorite.isDefault = irFilter.is_default;
+ }
+ if (comparison) {
+ favorite.comparison = comparison;
+ }
+ return favorite;
+ }
+
+ /**
+ * Group the query elements in group.activities by qe -> qe.filterId
+ * and changes the form of group.activities to make it more suitable for further
+ * computations.
+ * @private
+ * @param {Object} group
+ */
+ _mergeActivities(group) {
+ const { activities, type } = group;
+ let res = [];
+ switch (type) {
+ case 'filter':
+ case 'groupBy': {
+ for (const activity of activities) {
+ const { filterId } = activity;
+ let a = res.find(({ filter }) => filter.id === filterId);
+ if (!a) {
+ a = {
+ filter: this.state.filters[filterId],
+ filterQueryElements: []
+ };
+ res.push(a);
+ }
+ a.filterQueryElements.push(activity);
+ }
+ break;
+ }
+ case 'favorite':
+ case 'field':
+ case 'comparison': {
+ // all activities in the group have same filterId
+ const { filterId } = group.activities[0];
+ const filter = this.state.filters[filterId];
+ res.push({
+ filter,
+ filterQueryElements: group.activities
+ });
+ break;
+ }
+ }
+ if (type === 'groupBy') {
+ res.forEach(activity => {
+ activity.filterQueryElements.sort(
+ (qe1, qe2) => rankInterval(qe1.optionId) - rankInterval(qe2.optionId)
+ );
+ });
+ }
+ group.activities = res;
+ }
+
+ /**
+ * Set the key label in defaultAutocompleteValue used by default filters of
+ * type 'field'.
+ * @private
+ * @param {Object} filter
+ */
+ _prepareDefaultLabel(filter) {
+ const { id, fieldType, fieldName, defaultAutocompleteValue } = filter;
+ const { selection, context, relation } = this.fields[fieldName];
+ if (fieldType === 'selection') {
+ defaultAutocompleteValue.label = selection.find(
+ sel => sel[0] === defaultAutocompleteValue.value
+ )[1];
+ } else if (fieldType === 'many2one') {
+ const updateLabel = label => {
+ const queryElem = this.state.query.find(({ filterId }) => filterId === id);
+ if (queryElem) {
+ queryElem.label = label;
+ defaultAutocompleteValue.label = label;
+ }
+ };
+ const promise = this.env.services.rpc({
+ args: [defaultAutocompleteValue.value],
+ context: context,
+ method: 'name_get',
+ model: relation,
+ })
+ .then(results => updateLabel(results[0][1]))
+ .guardedCatch(() => updateLabel(defaultAutocompleteValue.value));
+ this.labelPromises.push(promise);
+ } else {
+ defaultAutocompleteValue.label = defaultAutocompleteValue.value;
+ }
+ }
+
+ /**
+ * Compute the search Query and save it as an ir_filter in db.
+ * No evaluation of domains is done in order to keep them dynamic.
+ * If the operation is successful, a new filter of type 'favorite' is
+ * created and activated.
+ * @private
+ * @param {Object} preFilter
+ * @returns {Promise<Object>}
+ */
+ async _saveQuery(preFilter) {
+ const groups = this._getGroups();
+
+ const userContext = this.env.session.user_context;
+ let controllerQueryParams;
+ this.config.trigger("get-controller-query-params", params => {
+ controllerQueryParams = params;
+ });
+ controllerQueryParams = controllerQueryParams || {};
+ controllerQueryParams.context = controllerQueryParams.context || {};
+
+ const queryContext = this._getContext(groups);
+ const context = pyUtils.eval(
+ 'contexts',
+ [userContext, controllerQueryParams.context, queryContext]
+ );
+ for (const key in userContext) {
+ delete context[key];
+ }
+
+ const requireEvaluation = false;
+ const domain = this._getDomain(groups);
+ const groupBys = this._getGroupBy(groups);
+ const timeRanges = this._getTimeRanges(requireEvaluation);
+ const orderedBy = controllerQueryParams.orderedBy ?
+ controllerQueryParams.orderedBy :
+ (this._getOrderedBy(groups) || []);
+
+ const userId = preFilter.isShared ? false : this.env.session.uid;
+ delete preFilter.isShared;
+
+ Object.assign(preFilter, {
+ context,
+ domain,
+ groupBys,
+ groupNumber: userId ? FAVORITE_PRIVATE_GROUP : FAVORITE_SHARED_GROUP,
+ orderedBy,
+ removable: true,
+ userId,
+ });
+ if (timeRanges) {
+ preFilter.comparison = timeRanges;
+ }
+ const irFilter = this._favoriteToIrFilter(preFilter);
+ const serverSideId = await this.env.dataManager.create_filter(irFilter);
+
+ preFilter.serverSideId = serverSideId;
+
+ return preFilter;
+ }
+
+ //---------------------------------------------------------------------
+ // Static
+ //---------------------------------------------------------------------
+
+ /**
+ * @override
+ * @returns {{ attrs: Object, children: Object[] }}
+ */
+ static extractArchInfo(archs) {
+ const { attrs, children } = archs.search;
+ const controlPanelInfo = {
+ attrs,
+ children: [],
+ };
+ for (const child of children) {
+ if (child.tag !== "searchpanel") {
+ controlPanelInfo.children.push(child);
+ }
+ }
+ return controlPanelInfo;
+ }
+ }
+
+ ActionModel.registry.add("ControlPanel", ControlPanelModelExtension, 10);
+
+ return ControlPanelModelExtension;
+});
diff --git a/addons/web/static/src/js/control_panel/control_panel_x2many.js b/addons/web/static/src/js/control_panel/control_panel_x2many.js
new file mode 100644
index 00000000..22b4ae56
--- /dev/null
+++ b/addons/web/static/src/js/control_panel/control_panel_x2many.js
@@ -0,0 +1,40 @@
+odoo.define('web.ControlPanelX2Many', function (require) {
+
+ const ControlPanel = require('web.ControlPanel');
+
+ /**
+ * Control panel (adaptation for x2many fields)
+ *
+ * Smaller version of the control panel with an abridged template (buttons and
+ * pager only). We still extend the main version for the injection of `cp_content`
+ * keys.
+ * The pager of this control panel is only displayed if the amount of records
+ * cannot be displayed in a single page.
+ * @extends ControlPanel
+ */
+ class ControlPanelX2Many extends ControlPanel {
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _shouldShowPager() {
+ if (!this.props.pager || !this.props.pager.limit) {
+ return false;
+ }
+ const { currentMinimum, limit, size } = this.props.pager;
+ const maximum = Math.min(currentMinimum + limit - 1, size);
+ const singlePage = (1 === currentMinimum) && (maximum === size);
+ return !singlePage;
+ }
+ }
+
+ ControlPanelX2Many.defaultProps = {};
+ ControlPanelX2Many.props = {
+ cp_content: { type: Object, optional: 1 },
+ pager: Object,
+ };
+ ControlPanelX2Many.template = 'web.ControlPanelX2Many';
+
+ return ControlPanelX2Many;
+});
diff --git a/addons/web/static/src/js/control_panel/custom_favorite_item.js b/addons/web/static/src/js/control_panel/custom_favorite_item.js
new file mode 100644
index 00000000..b7adb031
--- /dev/null
+++ b/addons/web/static/src/js/control_panel/custom_favorite_item.js
@@ -0,0 +1,152 @@
+odoo.define('web.CustomFavoriteItem', function (require) {
+ "use strict";
+
+ const DropdownMenuItem = require('web.DropdownMenuItem');
+ const FavoriteMenu = require('web.FavoriteMenu');
+ const { useAutofocus } = require('web.custom_hooks');
+ const { useModel } = require('web/static/src/js/model.js');
+
+ const { useRef } = owl.hooks;
+
+ let favoriteId = 0;
+
+ /**
+ * Favorite generator menu
+ *
+ * This component is used to add a new favorite linked which will take every
+ * information out of the current context and save it to a new `ir.filter`.
+ *
+ * There are 3 additional inputs to modify the filter:
+ * - a text input (mandatory): the name of the favorite (must be unique)
+ * - 'use by default' checkbox: if checked, the favorite will be the default
+ * filter of the current model (and will bypass
+ * any existing default filter). Cannot be checked
+ * along with 'share with all users' checkbox.
+ * - 'share with all users' checkbox: if checked, the favorite will be available
+ * with all users instead of the current
+ * one.Cannot be checked along with 'use
+ * by default' checkbox.
+ * Finally, there is a 'Save' button used to apply the current configuration
+ * and save the context to a new filter.
+ * @extends DropdownMenuItem
+ */
+ class CustomFavoriteItem extends DropdownMenuItem {
+ constructor() {
+ super(...arguments);
+
+ const favId = favoriteId++;
+ this.useByDefaultId = `o_favorite_use_by_default_${favId}`;
+ this.shareAllUsersId = `o_favorite_share_all_users_${favId}`;
+
+ this.descriptionRef = useRef('description');
+ this.model = useModel('searchModel');
+ this.interactive = true;
+ Object.assign(this.state, {
+ description: this.env.action.name || "",
+ isDefault: false,
+ isShared: false,
+ });
+
+ useAutofocus();
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _saveFavorite() {
+ if (!this.state.description.length) {
+ this.env.services.notification.notify({
+ message: this.env._t("A name for your favorite filter is required."),
+ type: 'danger',
+ });
+ return this.descriptionRef.el.focus();
+ }
+ const favorites = this.model.get('filters', f => f.type === 'favorite');
+ if (favorites.some(f => f.description === this.state.description)) {
+ this.env.services.notification.notify({
+ message: this.env._t("Filter with same name already exists."),
+ type: 'danger',
+ });
+ return this.descriptionRef.el.focus();
+ }
+ this.model.dispatch('createNewFavorite', {
+ type: 'favorite',
+ description: this.state.description,
+ isDefault: this.state.isDefault,
+ isShared: this.state.isShared,
+ });
+ // Reset state
+ Object.assign(this.state, {
+ description: this.env.action.name || "",
+ isDefault: false,
+ isShared: false,
+ open: false,
+ });
+ }
+
+ //---------------------------------------------------------------------
+ // Handlers
+ //---------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev change Event
+ */
+ _onCheckboxChange(ev) {
+ const { checked, id } = ev.target;
+ if (this.useByDefaultId === id) {
+ this.state.isDefault = checked;
+ if (checked) {
+ this.state.isShared = false;
+ }
+ } else {
+ this.state.isShared = checked;
+ if (checked) {
+ this.state.isDefault = false;
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onInputKeydown(ev) {
+ switch (ev.key) {
+ case 'Enter':
+ ev.preventDefault();
+ this._saveFavorite();
+ break;
+ case 'Escape':
+ // Gives the focus back to the component.
+ ev.preventDefault();
+ ev.target.blur();
+ break;
+ }
+ }
+
+ //---------------------------------------------------------------------
+ // Static
+ //---------------------------------------------------------------------
+
+ /**
+ * @param {Object} env
+ * @returns {boolean}
+ */
+ static shouldBeDisplayed(env) {
+ return true;
+ }
+ }
+
+ CustomFavoriteItem.props = {};
+ CustomFavoriteItem.template = 'web.CustomFavoriteItem';
+ CustomFavoriteItem.groupNumber = 3; // have 'Save Current Search' in its own group
+
+ FavoriteMenu.registry.add('favorite-generator-menu', CustomFavoriteItem, 0);
+
+ return CustomFavoriteItem;
+});
diff --git a/addons/web/static/src/js/control_panel/custom_filter_item.js b/addons/web/static/src/js/control_panel/custom_filter_item.js
new file mode 100644
index 00000000..0b9b1ccb
--- /dev/null
+++ b/addons/web/static/src/js/control_panel/custom_filter_item.js
@@ -0,0 +1,275 @@
+odoo.define('web.CustomFilterItem', function (require) {
+ "use strict";
+
+ const { DatePicker, DateTimePicker } = require('web.DatePickerOwl');
+ const Domain = require('web.Domain');
+ const DropdownMenuItem = require('web.DropdownMenuItem');
+ const { FIELD_OPERATORS, FIELD_TYPES } = require('web.searchUtils');
+ const field_utils = require('web.field_utils');
+ const patchMixin = require('web.patchMixin');
+ const { useModel } = require('web/static/src/js/model.js');
+
+ /**
+ * Filter generator menu
+ *
+ * Component which purpose is to generate new filters from a set of inputs.
+ * It is made of one or several `condition` objects that will be used to generate
+ * filters which will be added in the filter menu. Each condition is composed
+ * of 2, 3 or 4 inputs:
+ *
+ * 1. FIELD (select): the field used to form the base of the domain condition;
+ *
+ * 2. OPERATOR (select): the symbol determining the operator(s) of the domain
+ * condition, linking the field to one or several value(s).
+ * Some operators can have pre-defined values that will replace user inputs.
+ * @see searchUtils for the list of operators.
+ *
+ * 3. [VALUE] (input|select): the value of the domain condition. it will be parsed
+ * according to the selected field's type. Note that
+ * it is optional as some operators have defined values.
+ * The generated condition domain will be as following:
+ * [
+ * [field, operator, (operator_value|input_value)]
+ * ]
+ *
+ * 4. [VALUE] (input): for now, only date-typed fields with the 'between' operator
+ * allow for a second value. The given input values will then
+ * be taken as the borders of the date range (between x and y)
+ * and will be translated as the following domain form:
+ * [
+ * [date_field, '>=', x],
+ * [date_field, '<=', y],
+ * ]
+ * @extends DropdownMenuItem
+ */
+ class CustomFilterItem extends DropdownMenuItem {
+ constructor() {
+ super(...arguments);
+
+ this.model = useModel('searchModel');
+
+ this.canBeOpened = true;
+ this.state.conditions = [];
+ // Format, filter and sort the fields props
+ this.fields = Object.values(this.props.fields)
+ .filter(field => this._validateField(field))
+ .concat({ string: 'ID', type: 'id', name: 'id' })
+ .sort(({ string: a }, { string: b }) => a > b ? 1 : a < b ? -1 : 0);
+
+ // Give access to constants variables to the template.
+ this.DECIMAL_POINT = this.env._t.database.parameters.decimal_point;
+ this.OPERATORS = FIELD_OPERATORS;
+ this.FIELD_TYPES = FIELD_TYPES;
+
+ // Add default empty condition
+ this._addDefaultCondition();
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * Populate the conditions list with a default condition having as properties:
+ * - the first available field
+ * - the first available operator
+ * - a null or empty array value
+ * @private
+ */
+ _addDefaultCondition() {
+ const condition = {
+ field: 0,
+ operator: 0,
+ };
+ this._setDefaultValue(condition);
+ this.state.conditions.push(condition);
+ }
+
+ /**
+ * @private
+ * @param {Object} field
+ * @returns {boolean}
+ */
+ _validateField(field) {
+ return !field.deprecated &&
+ field.searchable &&
+ FIELD_TYPES[field.type] &&
+ field.name !== 'id';
+ }
+
+ /**
+ * @private
+ * @param {Object} condition
+ */
+ _setDefaultValue(condition) {
+ const fieldType = this.fields[condition.field].type;
+ const genericType = FIELD_TYPES[fieldType];
+ const operator = FIELD_OPERATORS[genericType][condition.operator];
+ // Logical value
+ switch (genericType) {
+ case 'id':
+ case 'number':
+ condition.value = 0;
+ break;
+ case 'date':
+ condition.value = [moment()];
+ if (operator.symbol === 'between') {
+ condition.value.push(moment());
+ }
+ break;
+ case 'datetime':
+ condition.value = [moment('00:00:00', 'hh:mm:ss')];
+ if (operator.symbol === 'between') {
+ condition.value.push(moment('23:59:59', 'hh:mm:ss'));
+ }
+ break;
+ case 'selection':
+ const [firstValue] = this.fields[condition.field].selection[0];
+ condition.value = firstValue;
+ break;
+ default:
+ condition.value = "";
+ }
+ // Displayed value
+ if (["float", "monetary"].includes(fieldType)) {
+ condition.displayedValue = `0${this.DECIMAL_POINT}0`;
+ } else {
+ condition.displayedValue = String(condition.value);
+ }
+ }
+
+ //---------------------------------------------------------------------
+ // Handlers
+ //---------------------------------------------------------------------
+
+ /**
+ * Convert all conditions to prefilters.
+ * @private
+ */
+ _onApply() {
+ const preFilters = this.state.conditions.map(condition => {
+ const field = this.fields[condition.field];
+ const type = this.FIELD_TYPES[field.type];
+ const operator = this.OPERATORS[type][condition.operator];
+ const descriptionArray = [field.string, operator.description];
+ const domainArray = [];
+ let domainValue;
+ // Field type specifics
+ if ('value' in operator) {
+ domainValue = [operator.value];
+ // No description to push here
+ } else if (['date', 'datetime'].includes(type)) {
+ domainValue = condition.value.map(
+ val => field_utils.parse[type](val, { type }, { timezone: true })
+ );
+ const dateValue = condition.value.map(
+ val => field_utils.format[type](val, { type }, { timezone: false })
+ );
+ descriptionArray.push(`"${dateValue.join(" " + this.env._t("and") + " ")}"`);
+ } else {
+ domainValue = [condition.value];
+ descriptionArray.push(`"${condition.value}"`);
+ }
+ // Operator specifics
+ if (operator.symbol === 'between') {
+ domainArray.push(
+ [field.name, '>=', domainValue[0]],
+ [field.name, '<=', domainValue[1]]
+ );
+ } else {
+ domainArray.push([field.name, operator.symbol, domainValue[0]]);
+ }
+ const preFilter = {
+ description: descriptionArray.join(" "),
+ domain: Domain.prototype.arrayToString(domainArray),
+ type: 'filter',
+ };
+ return preFilter;
+ });
+
+ this.model.dispatch('createNewFilters', preFilters);
+
+ // Reset state
+ this.state.open = false;
+ this.state.conditions = [];
+ this._addDefaultCondition();
+ }
+
+ /**
+ * @private
+ * @param {Object} condition
+ * @param {number} valueIndex
+ * @param {OwlEvent} ev
+ */
+ _onDateChanged(condition, valueIndex, ev) {
+ condition.value[valueIndex] = ev.detail.date;
+ }
+
+ /**
+ * @private
+ * @param {Object} condition
+ * @param {Event} ev
+ */
+ _onFieldSelect(condition, ev) {
+ Object.assign(condition, {
+ field: ev.target.selectedIndex,
+ operator: 0,
+ });
+ this._setDefaultValue(condition);
+ }
+
+ /**
+ * @private
+ * @param {Object} condition
+ * @param {Event} ev
+ */
+ _onOperatorSelect(condition, ev) {
+ condition.operator = ev.target.selectedIndex;
+ this._setDefaultValue(condition);
+ }
+
+ /**
+ * @private
+ * @param {Object} condition
+ */
+ _onRemoveCondition(conditionIndex) {
+ this.state.conditions.splice(conditionIndex, 1);
+ }
+
+ /**
+ * @private
+ * @param {Object} condition
+ * @param {Event} ev
+ */
+ _onValueInput(condition, ev) {
+ if (!ev.target.value) {
+ return this._setDefaultValue(condition);
+ }
+ let { type } = this.fields[condition.field];
+ if (type === "id") {
+ type = "integer";
+ }
+ if (FIELD_TYPES[type] === "number") {
+ try {
+ // Write logical value into the 'value' property
+ condition.value = field_utils.parse[type](ev.target.value);
+ // Write displayed value in the input and 'displayedValue' property
+ condition.displayedValue = ev.target.value;
+ } catch (err) {
+ // Parsing error: reverts to previous value
+ ev.target.value = condition.displayedValue;
+ }
+ } else {
+ condition.value = condition.displayedValue = ev.target.value;
+ }
+ }
+ }
+
+ CustomFilterItem.components = { DatePicker, DateTimePicker };
+ CustomFilterItem.props = {
+ fields: Object,
+ };
+ CustomFilterItem.template = 'web.CustomFilterItem';
+
+ return patchMixin(CustomFilterItem);
+});
diff --git a/addons/web/static/src/js/control_panel/custom_group_by_item.js b/addons/web/static/src/js/control_panel/custom_group_by_item.js
new file mode 100644
index 00000000..20a1c3c7
--- /dev/null
+++ b/addons/web/static/src/js/control_panel/custom_group_by_item.js
@@ -0,0 +1,46 @@
+odoo.define('web.CustomGroupByItem', function (require) {
+ "use strict";
+
+ const DropdownMenuItem = require('web.DropdownMenuItem');
+ const { useModel } = require('web/static/src/js/model.js');
+
+ /**
+ * Group by generator menu
+ *
+ * Component used to generate new filters of type 'groupBy'. It is composed
+ * of a button (used to toggle the rendering of the rest of the component) and
+ * an input (select) used to choose a new field name which will be used as a
+ * new groupBy value.
+ * @extends DropdownMenuItem
+ */
+ class CustomGroupByItem extends DropdownMenuItem {
+ constructor() {
+ super(...arguments);
+
+ this.canBeOpened = true;
+ this.state.fieldName = this.props.fields[0].name;
+
+ this.model = useModel('searchModel');
+ }
+
+ //---------------------------------------------------------------------
+ // Handlers
+ //---------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onApply() {
+ const field = this.props.fields.find(f => f.name === this.state.fieldName);
+ this.model.dispatch('createNewGroupBy', field);
+ this.state.open = false;
+ }
+ }
+
+ CustomGroupByItem.template = 'web.CustomGroupByItem';
+ CustomGroupByItem.props = {
+ fields: Array,
+ };
+
+ return CustomGroupByItem;
+});
diff --git a/addons/web/static/src/js/control_panel/favorite_menu.js b/addons/web/static/src/js/control_panel/favorite_menu.js
new file mode 100644
index 00000000..ff09109f
--- /dev/null
+++ b/addons/web/static/src/js/control_panel/favorite_menu.js
@@ -0,0 +1,107 @@
+odoo.define('web.FavoriteMenu', function (require) {
+ "use strict";
+
+ const Dialog = require('web.OwlDialog');
+ const DropdownMenu = require('web.DropdownMenu');
+ const { FACET_ICONS } = require("web.searchUtils");
+ const Registry = require('web.Registry');
+ const { useModel } = require('web/static/src/js/model.js');
+
+ /**
+ * 'Favorites' menu
+ *
+ * Simple rendering of the filters of type `favorites` given by the control panel
+ * model. It uses most of the behaviours implemented by the dropdown menu Component,
+ * with the addition of a submenu registry used to display additional components.
+ * Only the favorite generator (@see CustomFavoriteItem) is registered in
+ * the `web` module.
+ * @see DropdownMenu for additional details.
+ * @extends DropdownMenu
+ */
+ class FavoriteMenu extends DropdownMenu {
+ constructor() {
+ super(...arguments);
+
+ this.model = useModel('searchModel');
+ this.state.deletedFavorite = false;
+ }
+
+ //---------------------------------------------------------------------
+ // Getters
+ //---------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ get icon() {
+ return FACET_ICONS.favorite;
+ }
+
+ /**
+ * @override
+ */
+ get items() {
+ const favorites = this.model.get('filters', f => f.type === 'favorite');
+ const registryMenus = this.constructor.registry.values().reduce(
+ (menus, Component) => {
+ if (Component.shouldBeDisplayed(this.env)) {
+ menus.push({
+ key: Component.name,
+ groupNumber: Component.groupNumber,
+ Component,
+ });
+ }
+ return menus;
+ },
+ []
+ );
+ return [...favorites, ...registryMenus];
+ }
+
+ /**
+ * @override
+ */
+ get title() {
+ return this.env._t("Favorites");
+ }
+
+ //---------------------------------------------------------------------
+ // Handlers
+ //---------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OwlEvent} ev
+ */
+ _onItemRemoved(ev) {
+ const favorite = this.items.find(fav => fav.id === ev.detail.item.id);
+ this.state.deletedFavorite = favorite;
+ }
+
+ /**
+ * @private
+ * @param {OwlEvent} ev
+ */
+ _onItemSelected(ev) {
+ ev.stopPropagation();
+ this.model.dispatch('toggleFilter', ev.detail.item.id);
+ }
+
+ /**
+ * @private
+ */
+ async _onRemoveFavorite() {
+ this.model.dispatch('deleteFavorite', this.state.deletedFavorite.id);
+ this.state.deletedFavorite = false;
+ }
+ }
+
+ FavoriteMenu.registry = new Registry();
+
+ FavoriteMenu.components = Object.assign({}, DropdownMenu.components, {
+ Dialog,
+ });
+ FavoriteMenu.template = 'web.FavoriteMenu';
+
+ return FavoriteMenu;
+});
diff --git a/addons/web/static/src/js/control_panel/filter_menu.js b/addons/web/static/src/js/control_panel/filter_menu.js
new file mode 100644
index 00000000..da37eb23
--- /dev/null
+++ b/addons/web/static/src/js/control_panel/filter_menu.js
@@ -0,0 +1,79 @@
+odoo.define('web.FilterMenu', function (require) {
+ "use strict";
+
+ const CustomFilterItem = require('web.CustomFilterItem');
+ const DropdownMenu = require('web.DropdownMenu');
+ const { FACET_ICONS } = require("web.searchUtils");
+ const { useModel } = require('web/static/src/js/model.js');
+
+ /**
+ * 'Filters' menu
+ *
+ * Simple rendering of the filters of type `filter` given by the control panel
+ * model. It uses most of the behaviours implemented by the dropdown menu Component,
+ * with the addition of a filter generator (@see CustomFilterItem).
+ * @see DropdownMenu for additional details.
+ * @extends DropdownMenu
+ */
+ class FilterMenu extends DropdownMenu {
+
+ constructor() {
+ super(...arguments);
+
+ this.model = useModel('searchModel');
+ }
+
+ //---------------------------------------------------------------------
+ // Getters
+ //---------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ get icon() {
+ return FACET_ICONS.filter;
+ }
+
+ /**
+ * @override
+ */
+ get items() {
+ return this.model.get('filters', f => f.type === 'filter');
+ }
+
+ /**
+ * @override
+ */
+ get title() {
+ return this.env._t("Filters");
+ }
+
+ //---------------------------------------------------------------------
+ // Handlers
+ //---------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OwlEvent} ev
+ */
+ _onItemSelected(ev) {
+ ev.stopPropagation();
+ const { item, option } = ev.detail;
+ if (option) {
+ this.model.dispatch('toggleFilterWithOptions', item.id, option.id);
+ } else {
+ this.model.dispatch('toggleFilter', item.id);
+ }
+ }
+ }
+
+ FilterMenu.components = Object.assign({}, DropdownMenu.components, {
+ CustomFilterItem,
+ });
+ FilterMenu.props = Object.assign({}, DropdownMenu.props, {
+ fields: Object,
+ });
+ FilterMenu.template = 'web.FilterMenu';
+
+ return FilterMenu;
+});
diff --git a/addons/web/static/src/js/control_panel/groupby_menu.js b/addons/web/static/src/js/control_panel/groupby_menu.js
new file mode 100644
index 00000000..546952ad
--- /dev/null
+++ b/addons/web/static/src/js/control_panel/groupby_menu.js
@@ -0,0 +1,98 @@
+odoo.define('web.GroupByMenu', function (require) {
+ "use strict";
+
+ const CustomGroupByItem = require('web.CustomGroupByItem');
+ const DropdownMenu = require('web.DropdownMenu');
+ const { FACET_ICONS, GROUPABLE_TYPES } = require('web.searchUtils');
+ const { useModel } = require('web/static/src/js/model.js');
+
+ /**
+ * 'Group by' menu
+ *
+ * Simple rendering of the filters of type `groupBy` given by the control panel
+ * model. It uses most of the behaviours implemented by the dropdown menu Component,
+ * with the addition of a groupBy filter generator (@see CustomGroupByItem).
+ * @see DropdownMenu for additional details.
+ * @extends DropdownMenu
+ */
+ class GroupByMenu extends DropdownMenu {
+
+ constructor() {
+ super(...arguments);
+
+ this.fields = Object.values(this.props.fields)
+ .filter(field => this._validateField(field))
+ .sort(({ string: a }, { string: b }) => a > b ? 1 : a < b ? -1 : 0);
+
+ this.model = useModel('searchModel');
+ }
+
+ //---------------------------------------------------------------------
+ // Getters
+ //---------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ get icon() {
+ return FACET_ICONS.groupBy;
+ }
+
+ /**
+ * @override
+ */
+ get items() {
+ return this.model.get('filters', f => f.type === 'groupBy');
+ }
+
+ /**
+ * @override
+ */
+ get title() {
+ return this.env._t("Group By");
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} field
+ * @returns {boolean}
+ */
+ _validateField(field) {
+ return field.sortable &&
+ field.name !== "id" &&
+ GROUPABLE_TYPES.includes(field.type);
+ }
+
+ //---------------------------------------------------------------------
+ // Handlers
+ //---------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OwlEvent} ev
+ */
+ _onItemSelected(ev) {
+ ev.stopPropagation();
+ const { item, option } = ev.detail;
+ if (option) {
+ this.model.dispatch('toggleFilterWithOptions', item.id, option.id);
+ } else {
+ this.model.dispatch('toggleFilter', item.id);
+ }
+ }
+ }
+
+ GroupByMenu.components = Object.assign({}, DropdownMenu.components, {
+ CustomGroupByItem,
+ });
+ GroupByMenu.props = Object.assign({}, DropdownMenu.props, {
+ fields: Object,
+ });
+ GroupByMenu.template = 'web.GroupByMenu';
+
+ return GroupByMenu;
+});
diff --git a/addons/web/static/src/js/control_panel/search_bar.js b/addons/web/static/src/js/control_panel/search_bar.js
new file mode 100644
index 00000000..dd0d4460
--- /dev/null
+++ b/addons/web/static/src/js/control_panel/search_bar.js
@@ -0,0 +1,493 @@
+odoo.define('web.SearchBar', function (require) {
+ "use strict";
+
+ const Domain = require('web.Domain');
+ const field_utils = require('web.field_utils');
+ const { useAutofocus } = require('web.custom_hooks');
+ const { useModel } = require('web/static/src/js/model.js');
+
+ const CHAR_FIELDS = ['char', 'html', 'many2many', 'many2one', 'one2many', 'text'];
+ const { Component, hooks } = owl;
+ const { useExternalListener, useRef, useState } = hooks;
+
+ let sourceId = 0;
+
+ /**
+ * Search bar
+ *
+ * This component has two main roles:
+ * 1) Display the current search facets
+ * 2) Create new search filters using an input and an autocompletion values
+ * generator.
+ *
+ * For the first bit, the core logic can be found in the XML template of this
+ * component, searchfacet components or in the ControlPanelModel itself.
+ *
+ * The autocompletion mechanic works with transient subobjects called 'sources'.
+ * Sources contain the information that will be used to generate new search facets.
+ * A source is generated either:
+ * a. From an undetermined user input: the user will give a string and select
+ * a field from the autocompletion dropdown > this will search the selected
+ * field records with the given pattern (with an 'ilike' operator);
+ * b. From a given selection: when given an input by the user, the searchbar
+ * will pre-fetch 'many2one' field records matching the input value and filter
+ * 'select' fields with the same value. If the user clicks on one of these
+ * fetched/filtered values, it will generate a matching search facet targeting
+ * records having this exact value.
+ * @extends Component
+ */
+ class SearchBar extends Component {
+ constructor() {
+ super(...arguments);
+
+ this.focusOnUpdate = useAutofocus();
+ this.inputRef = useRef('search-input');
+ this.model = useModel('searchModel');
+ this.state = useState({
+ sources: [],
+ focusedItem: 0,
+ inputValue: "",
+ });
+
+ this.autoCompleteSources = this.model.get('filters', f => f.type === 'field').map(
+ filter => this._createSource(filter)
+ );
+ this.noResultItem = [null, this.env._t("(no result)")];
+
+ useExternalListener(window, 'click', this._onWindowClick);
+ useExternalListener(window, 'keydown', this._onWindowKeydown);
+ }
+
+ mounted() {
+ // 'search' will always patch the search bar, 'focus' will never.
+ this.env.searchModel.on('search', this, this.focusOnUpdate);
+ this.env.searchModel.on('focus-control-panel', this, () => {
+ this.inputRef.el.focus();
+ });
+ }
+
+ willUnmount() {
+ this.env.searchModel.off('search', this);
+ this.env.searchModel.off('focus-control-panel', this);
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _closeAutoComplete() {
+ this.state.sources = [];
+ this.state.focusedItem = 0;
+ this.state.inputValue = "";
+ this.inputRef.el.value = "";
+ this.focusOnUpdate();
+ }
+
+ /**
+ * @private
+ * @param {Object} filter
+ * @returns {Object}
+ */
+ _createSource(filter) {
+ const field = this.props.fields[filter.fieldName];
+ const type = field.type === "reference" ? "char" : field.type;
+ const source = {
+ active: true,
+ description: filter.description,
+ filterId: filter.id,
+ filterOperator: filter.operator,
+ id: sourceId ++,
+ operator: CHAR_FIELDS.includes(type) ? 'ilike' : '=',
+ parent: false,
+ type,
+ };
+ switch (type) {
+ case 'selection': {
+ source.active = false;
+ source.selection = field.selection || [];
+ break;
+ }
+ case 'boolean': {
+ source.active = false;
+ source.selection = [
+ [true, this.env._t("Yes")],
+ [false, this.env._t("No")],
+ ];
+ break;
+ }
+ case 'many2one': {
+ source.expand = true;
+ source.expanded = false;
+ source.context = field.context;
+ source.relation = field.relation;
+ if (filter.domain) {
+ source.domain = filter.domain;
+ }
+ }
+ }
+ return source;
+ }
+
+ /**
+ * @private
+ * @param {Object} source
+ * @param {[any, string]} values
+ * @param {boolean} [active=true]
+ */
+ _createSubSource(source, [value, label], active = true) {
+ const subSource = {
+ active,
+ filterId: source.filterId,
+ filterOperator: source.filterOperator,
+ id: sourceId ++,
+ label,
+ operator: '=',
+ parent: source,
+ value,
+ };
+ return subSource;
+ }
+
+ /**
+ * @private
+ * @param {Object} source
+ * @param {boolean} shouldExpand
+ */
+ async _expandSource(source, shouldExpand) {
+ source.expanded = shouldExpand;
+ if (shouldExpand) {
+ let args = source.domain;
+ if (typeof args === 'string') {
+ try {
+ args = Domain.prototype.stringToArray(args);
+ } catch (err) {
+ args = [];
+ }
+ }
+ const results = await this.rpc({
+ kwargs: {
+ args,
+ context: source.context,
+ limit: 8,
+ name: this.state.inputValue.trim(),
+ },
+ method: 'name_search',
+ model: source.relation,
+ });
+ const options = results.map(result => this._createSubSource(source, result));
+ const parentIndex = this.state.sources.indexOf(source);
+ if (!options.length) {
+ options.push(this._createSubSource(source, this.noResultItem, false));
+ }
+ this.state.sources.splice(parentIndex + 1, 0, ...options);
+ } else {
+ this.state.sources = this.state.sources.filter(src => src.parent !== source);
+ }
+ }
+
+ /**
+ * @private
+ * @param {string} query
+ */
+ _filterSources(query) {
+ return this.autoCompleteSources.reduce(
+ (sources, source) => {
+ // Field selection or boolean.
+ if (source.selection) {
+ const options = [];
+ source.selection.forEach(result => {
+ if (fuzzy.test(query, result[1].toLowerCase())) {
+ options.push(this._createSubSource(source, result));
+ }
+ });
+ if (options.length) {
+ sources.push(source, ...options);
+ }
+ // Any other type.
+ } else if (this._validateSource(query, source)) {
+ sources.push(source);
+ }
+ // Fold any expanded item.
+ if (source.expanded) {
+ source.expanded = false;
+ }
+ return sources;
+ },
+ []
+ );
+ }
+
+ /**
+ * Focus the search facet at the designated index if any.
+ * @private
+ */
+ _focusFacet(index) {
+ const facets = this.el.getElementsByClassName('o_searchview_facet');
+ if (facets.length) {
+ facets[index].focus();
+ }
+ }
+
+ /**
+ * Try to parse the given rawValue according to the type of the given
+ * source field type. The returned formatted value is the one that will
+ * supposedly be sent to the server.
+ * @private
+ * @param {string} rawValue
+ * @param {Object} source
+ * @returns {string}
+ */
+ _parseWithSource(rawValue, { type }) {
+ const parser = field_utils.parse[type];
+ let parsedValue;
+ switch (type) {
+ case 'date':
+ case 'datetime': {
+ const parsedDate = parser(rawValue, { type }, { timezone: true });
+ const dateFormat = type === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD';
+ const momentValue = moment(parsedDate, dateFormat);
+ if (!momentValue.isValid()) {
+ throw new Error('Invalid date');
+ }
+ parsedValue = parsedDate.toJSON();
+ break;
+ }
+ case 'many2one': {
+ parsedValue = rawValue;
+ break;
+ }
+ default: {
+ parsedValue = parser(rawValue);
+ }
+ }
+ return parsedValue;
+ }
+
+ /**
+ * @private
+ * @param {Object} source
+ */
+ _selectSource(source) {
+ // Inactive sources are:
+ // - Selection sources
+ // - "no result" items
+ if (source.active) {
+ const labelValue = source.label || this.state.inputValue;
+ this.model.dispatch('addAutoCompletionValues', {
+ filterId: source.filterId,
+ value: "value" in source ? source.value : this._parseWithSource(labelValue, source),
+ label: labelValue,
+ operator: source.filterOperator || source.operator,
+ });
+ }
+ this._closeAutoComplete();
+ }
+
+ /**
+ * @private
+ * @param {string} query
+ * @param {Object} source
+ * @returns {boolean}
+ */
+ _validateSource(query, source) {
+ try {
+ this._parseWithSource(query, source);
+ } catch (err) {
+ return false;
+ }
+ return true;
+ }
+
+ //---------------------------------------------------------------------
+ // Handlers
+ //---------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} facet
+ * @param {number} facetIndex
+ * @param {KeyboardEvent} ev
+ */
+ _onFacetKeydown(facet, facetIndex, ev) {
+ switch (ev.key) {
+ case 'ArrowLeft':
+ if (facetIndex === 0) {
+ this.inputRef.el.focus();
+ } else {
+ this._focusFacet(facetIndex - 1);
+ }
+ break;
+ case 'ArrowRight':
+ const facets = this.el.getElementsByClassName('o_searchview_facet');
+ if (facetIndex === facets.length - 1) {
+ this.inputRef.el.focus();
+ } else {
+ this._focusFacet(facetIndex + 1);
+ }
+ break;
+ case 'Backspace':
+ this._onFacetRemove(facet);
+ break;
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} facet
+ */
+ _onFacetRemove(facet) {
+ this.model.dispatch('deactivateGroup', facet.groupId);
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onSearchKeydown(ev) {
+ if (ev.isComposing) {
+ // This case happens with an IME for example: we let it handle all key events.
+ return;
+ }
+ const currentItem = this.state.sources[this.state.focusedItem] || {};
+ switch (ev.key) {
+ case 'ArrowDown':
+ ev.preventDefault();
+ if (Object.keys(this.state.sources).length) {
+ let nextIndex = this.state.focusedItem + 1;
+ if (nextIndex >= this.state.sources.length) {
+ nextIndex = 0;
+ }
+ this.state.focusedItem = nextIndex;
+ } else {
+ this.env.bus.trigger('focus-view');
+ }
+ break;
+ case 'ArrowLeft':
+ if (currentItem.expanded) {
+ // Priority 1: fold expanded item.
+ ev.preventDefault();
+ this._expandSource(currentItem, false);
+ } else if (currentItem.parent) {
+ // Priority 2: focus parent item.
+ ev.preventDefault();
+ this.state.focusedItem = this.state.sources.indexOf(currentItem.parent);
+ // Priority 3: Do nothing (navigation inside text).
+ } else if (ev.target.selectionStart === 0) {
+ // Priority 4: navigate to rightmost facet.
+ this._focusFacet(this.model.get("facets").length - 1);
+ }
+ break;
+ case 'ArrowRight':
+ if (ev.target.selectionStart === this.state.inputValue.length) {
+ // Priority 1: Do nothing (navigation inside text).
+ if (currentItem.expand) {
+ // Priority 2: go to first child or expand item.
+ ev.preventDefault();
+ if (currentItem.expanded) {
+ this.state.focusedItem ++;
+ } else {
+ this._expandSource(currentItem, true);
+ }
+ } else if (ev.target.selectionStart === this.state.inputValue.length) {
+ // Priority 3: navigate to leftmost facet.
+ this._focusFacet(0);
+ }
+ }
+ break;
+ case 'ArrowUp':
+ ev.preventDefault();
+ let previousIndex = this.state.focusedItem - 1;
+ if (previousIndex < 0) {
+ previousIndex = this.state.sources.length - 1;
+ }
+ this.state.focusedItem = previousIndex;
+ break;
+ case 'Backspace':
+ if (!this.state.inputValue.length) {
+ const facets = this.model.get("facets");
+ if (facets.length) {
+ this._onFacetRemove(facets[facets.length - 1]);
+ }
+ }
+ break;
+ case 'Enter':
+ if (!this.state.inputValue.length) {
+ this.model.dispatch('search');
+ break;
+ }
+ /* falls through */
+ case 'Tab':
+ if (this.state.inputValue.length) {
+ ev.preventDefault(); // keep the focus inside the search bar
+ this._selectSource(currentItem);
+ }
+ break;
+ case 'Escape':
+ if (this.state.sources.length) {
+ this._closeAutoComplete();
+ }
+ break;
+ }
+ }
+
+ /**
+ * @private
+ * @param {InputEvent} ev
+ */
+ _onSearchInput(ev) {
+ this.state.inputValue = ev.target.value;
+ const wasVisible = this.state.sources.length;
+ const query = this.state.inputValue.trim().toLowerCase();
+ if (query.length) {
+ this.state.sources = this._filterSources(query);
+ } else if (wasVisible) {
+ this._closeAutoComplete();
+ }
+ }
+
+ /**
+ * Only handled if the user has moved its cursor at least once after the
+ * results are loaded and displayed.
+ * @private
+ * @param {number} resultIndex
+ */
+ _onSourceMousemove(resultIndex) {
+ this.state.focusedItem = resultIndex;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onWindowClick(ev) {
+ if (this.state.sources.length && !this.el.contains(ev.target)) {
+ this._closeAutoComplete();
+ }
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onWindowKeydown(ev) {
+ if (ev.key === 'Escape' && this.state.sources.length) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this._closeAutoComplete();
+ }
+ }
+ }
+
+ SearchBar.defaultProps = {
+ fields: {},
+ };
+ SearchBar.props = {
+ fields: Object,
+ };
+ SearchBar.template = 'web.SearchBar';
+
+ return SearchBar;
+});
diff --git a/addons/web/static/src/js/control_panel/search_utils.js b/addons/web/static/src/js/control_panel/search_utils.js
new file mode 100644
index 00000000..8fce5b23
--- /dev/null
+++ b/addons/web/static/src/js/control_panel/search_utils.js
@@ -0,0 +1,542 @@
+odoo.define('web.searchUtils', function (require) {
+ "use strict";
+
+ const { _lt, _t } = require('web.core');
+ const Domain = require('web.Domain');
+ const pyUtils = require('web.py_utils');
+
+ //-------------------------------------------------------------------------
+ // Constants
+ //-------------------------------------------------------------------------
+
+ // Filter menu parameters
+ const FIELD_OPERATORS = {
+ boolean: [
+ { symbol: "=", description: _lt("is true"), value: true },
+ { symbol: "!=", description: _lt("is false"), value: true },
+ ],
+ char: [
+ { symbol: "ilike", description: _lt("contains") },
+ { symbol: "not ilike", description: _lt("doesn't contain") },
+ { symbol: "=", description: _lt("is equal to") },
+ { symbol: "!=", description: _lt("is not equal to") },
+ { symbol: "!=", description: _lt("is set"), value: false },
+ { symbol: "=", description: _lt("is not set"), value: false },
+ ],
+ date: [
+ { symbol: "=", description: _lt("is equal to") },
+ { symbol: "!=", description: _lt("is not equal to") },
+ { symbol: ">", description: _lt("is after") },
+ { symbol: "<", description: _lt("is before") },
+ { symbol: ">=", description: _lt("is after or equal to") },
+ { symbol: "<=", description: _lt("is before or equal to") },
+ { symbol: "between", description: _lt("is between") },
+ { symbol: "!=", description: _lt("is set"), value: false },
+ { symbol: "=", description: _lt("is not set"), value: false },
+ ],
+ datetime: [
+ { symbol: "between", description: _lt("is between") },
+ { symbol: "=", description: _lt("is equal to") },
+ { symbol: "!=", description: _lt("is not equal to") },
+ { symbol: ">", description: _lt("is after") },
+ { symbol: "<", description: _lt("is before") },
+ { symbol: ">=", description: _lt("is after or equal to") },
+ { symbol: "<=", description: _lt("is before or equal to") },
+ { symbol: "!=", description: _lt("is set"), value: false },
+ { symbol: "=", description: _lt("is not set"), value: false },
+ ],
+ id: [
+ { symbol: "=", description: _lt("is") },
+ { symbol: "<=", description: _lt("less than or equal to")},
+ { symbol: ">", description: _lt("greater than")},
+ ],
+ number: [
+ { symbol: "=", description: _lt("is equal to") },
+ { symbol: "!=", description: _lt("is not equal to") },
+ { symbol: ">", description: _lt("greater than") },
+ { symbol: "<", description: _lt("less than") },
+ { symbol: ">=", description: _lt("greater than or equal to") },
+ { symbol: "<=", description: _lt("less than or equal to") },
+ { symbol: "!=", description: _lt("is set"), value: false },
+ { symbol: "=", description: _lt("is not set"), value: false },
+ ],
+ selection: [
+ { symbol: "=", description: _lt("is") },
+ { symbol: "!=", description: _lt("is not") },
+ { symbol: "!=", description: _lt("is set"), value: false },
+ { symbol: "=", description: _lt("is not set"), value: false },
+ ],
+ };
+ const FIELD_TYPES = {
+ boolean: 'boolean',
+ char: 'char',
+ date: 'date',
+ datetime: 'datetime',
+ float: 'number',
+ id: 'id',
+ integer: 'number',
+ html: 'char',
+ many2many: 'char',
+ many2one: 'char',
+ monetary: 'number',
+ one2many: 'char',
+ text: 'char',
+ selection: 'selection',
+ };
+ const DEFAULT_PERIOD = 'this_month';
+ const QUARTERS = {
+ 1: { description: _lt("Q1"), coveredMonths: [0, 1, 2] },
+ 2: { description: _lt("Q2"), coveredMonths: [3, 4, 5] },
+ 3: { description: _lt("Q3"), coveredMonths: [6, 7, 8] },
+ 4: { description: _lt("Q4"), coveredMonths: [9, 10, 11] },
+ };
+ const MONTH_OPTIONS = {
+ this_month: {
+ id: 'this_month', groupNumber: 1, format: 'MMMM',
+ addParam: {}, granularity: 'month',
+ },
+ last_month: {
+ id: 'last_month', groupNumber: 1, format: 'MMMM',
+ addParam: { months: -1 }, granularity: 'month',
+ },
+ antepenultimate_month: {
+ id: 'antepenultimate_month', groupNumber: 1, format: 'MMMM',
+ addParam: { months: -2 }, granularity: 'month',
+ },
+ };
+ const QUARTER_OPTIONS = {
+ fourth_quarter: {
+ id: 'fourth_quarter', groupNumber: 1, description: QUARTERS[4].description,
+ setParam: { quarter: 4 }, granularity: 'quarter',
+ },
+ third_quarter: {
+ id: 'third_quarter', groupNumber: 1, description: QUARTERS[3].description,
+ setParam: { quarter: 3 }, granularity: 'quarter',
+ },
+ second_quarter: {
+ id: 'second_quarter', groupNumber: 1, description: QUARTERS[2].description,
+ setParam: { quarter: 2 }, granularity: 'quarter',
+ },
+ first_quarter: {
+ id: 'first_quarter', groupNumber: 1, description: QUARTERS[1].description,
+ setParam: { quarter: 1 }, granularity: 'quarter',
+ },
+ };
+ const YEAR_OPTIONS = {
+ this_year: {
+ id: 'this_year', groupNumber: 2, format: 'YYYY',
+ addParam: {}, granularity: 'year',
+ },
+ last_year: {
+ id: 'last_year', groupNumber: 2, format: 'YYYY',
+ addParam: { years: -1 }, granularity: 'year',
+ },
+ antepenultimate_year: {
+ id: 'antepenultimate_year', groupNumber: 2, format: 'YYYY',
+ addParam: { years: -2 }, granularity: 'year',
+ },
+ };
+ const PERIOD_OPTIONS = Object.assign({}, MONTH_OPTIONS, QUARTER_OPTIONS, YEAR_OPTIONS);
+
+ // GroupBy menu parameters
+ const GROUPABLE_TYPES = [
+ 'boolean',
+ 'char',
+ 'date',
+ 'datetime',
+ 'integer',
+ 'many2one',
+ 'selection',
+ ];
+ const DEFAULT_INTERVAL = 'month';
+ const INTERVAL_OPTIONS = {
+ year: { description: _lt("Year"), id: 'year', groupNumber: 1 },
+ quarter: { description: _lt("Quarter"), id: 'quarter', groupNumber: 1 },
+ month: { description: _lt("Month"), id: 'month', groupNumber: 1 },
+ week: { description: _lt("Week"), id: 'week', groupNumber: 1 },
+ day: { description: _lt("Day"), id: 'day', groupNumber: 1 }
+ };
+
+ // Comparison menu parameters
+ const COMPARISON_OPTIONS = {
+ previous_period: {
+ description: _lt("Previous Period"), id: 'previous_period',
+ },
+ previous_year: {
+ description: _lt("Previous Year"), id: 'previous_year', addParam: { years: -1 },
+ },
+ };
+ const PER_YEAR = {
+ year: 1,
+ quarter: 4,
+ month: 12,
+ };
+ // Search bar
+ const FACET_ICONS = {
+ filter: 'fa fa-filter',
+ groupBy: 'fa fa-bars',
+ favorite: 'fa fa-star',
+ comparison: 'fa fa-adjust',
+ };
+
+ //-------------------------------------------------------------------------
+ // Functions
+ //-------------------------------------------------------------------------
+
+ /**
+ * Constructs the string representation of a domain and its description. The
+ * domain is of the form:
+ * ['|',..., '|', d_1,..., d_n]
+ * where d_i is a time range of the form
+ * ['&', [fieldName, >=, leftBound_i], [fieldName, <=, rightBound_i]]
+ * where leftBound_i and rightBound_i are date or datetime computed accordingly
+ * to the given options and reference moment.
+ * (@see constructDateRange).
+ * @param {moment} referenceMoment
+ * @param {string} fieldName
+ * @param {string} fieldType
+ * @param {string[]} selectedOptionIds
+ * @param {string} [comparisonOptionId]
+ * @returns {{ domain: string, description: string }}
+ */
+ function constructDateDomain(
+ referenceMoment,
+ fieldName,
+ fieldType,
+ selectedOptionIds,
+ comparisonOptionId
+ ) {
+ let addParam;
+ let selectedOptions;
+ if (comparisonOptionId) {
+ [addParam, selectedOptions] = getComparisonParams(
+ referenceMoment,
+ selectedOptionIds,
+ comparisonOptionId);
+ } else {
+ selectedOptions = getSelectedOptions(referenceMoment, selectedOptionIds);
+ }
+
+ const yearOptions = selectedOptions.year;
+ const otherOptions = [
+ ...(selectedOptions.quarter || []),
+ ...(selectedOptions.month || [])
+ ];
+
+ sortPeriodOptions(yearOptions);
+ sortPeriodOptions(otherOptions);
+
+ const ranges = [];
+ for (const yearOption of yearOptions) {
+ const constructRangeParams = {
+ referenceMoment,
+ fieldName,
+ fieldType,
+ addParam,
+ };
+ if (otherOptions.length) {
+ for (const option of otherOptions) {
+ const setParam = Object.assign({},
+ yearOption.setParam,
+ option ? option.setParam : {}
+ );
+ const { granularity } = option;
+ const range = constructDateRange(Object.assign(
+ { granularity, setParam },
+ constructRangeParams
+ ));
+ ranges.push(range);
+ }
+ } else {
+ const { granularity, setParam } = yearOption;
+ const range = constructDateRange(Object.assign(
+ { granularity, setParam },
+ constructRangeParams
+ ));
+ ranges.push(range);
+ }
+ }
+
+ const domain = pyUtils.assembleDomains(ranges.map(range => range.domain), 'OR');
+ const description = ranges.map(range => range.description).join("/");
+
+ return { domain, description };
+ }
+
+ /**
+ * Constructs the string representation of a domain and its description. The
+ * domain is a time range of the form:
+ * ['&', [fieldName, >=, leftBound],[fieldName, <=, rightBound]]
+ * where leftBound and rightBound are some date or datetime determined by setParam,
+ * addParam, granularity and the reference moment.
+ * @param {Object} params
+ * @param {moment} params.referenceMoment
+ * @param {string} params.fieldName
+ * @param {string} params.fieldType
+ * @param {string} params.granularity
+ * @param {Object} params.setParam
+ * @param {Object} [params.addParam]
+ * @returns {{ domain: string, description: string }}
+ */
+ function constructDateRange({
+ referenceMoment,
+ fieldName,
+ fieldType,
+ granularity,
+ setParam,
+ addParam,
+ }) {
+ const date = referenceMoment.clone().set(setParam).add(addParam || {});
+
+ // compute domain
+ let leftBound = date.clone().locale('en').startOf(granularity);
+ let rightBound = date.clone().locale('en').endOf(granularity);
+ if (fieldType === 'date') {
+ leftBound = leftBound.format('YYYY-MM-DD');
+ rightBound = rightBound.format('YYYY-MM-DD');
+ } else {
+ leftBound = leftBound.utc().format('YYYY-MM-DD HH:mm:ss');
+ rightBound = rightBound.utc().format('YYYY-MM-DD HH:mm:ss');
+ }
+ const domain = Domain.prototype.arrayToString([
+ '&',
+ [fieldName, '>=', leftBound],
+ [fieldName, '<=', rightBound]
+ ]);
+
+ // compute description
+ const descriptions = [date.format("YYYY")];
+ const method = _t.database.parameters.direction === "rtl" ? "push" : "unshift";
+ if (granularity === "month") {
+ descriptions[method](date.format("MMMM"));
+ } else if (granularity === "quarter") {
+ descriptions[method](QUARTERS[date.quarter()].description);
+ }
+ const description = descriptions.join(" ");
+
+ return { domain, description, };
+ }
+
+ /**
+ * Returns a version of the options in COMPARISON_OPTIONS with translated descriptions.
+ * @see getOptionsWithDescriptions
+ */
+ function getComparisonOptions() {
+ return getOptionsWithDescriptions(COMPARISON_OPTIONS);
+ }
+
+ /**
+ * Returns the params addParam and selectedOptions necessary for the computation
+ * of a comparison domain.
+ * @param {moment} referenceMoment
+ * @param {string{}} selectedOptionIds
+ * @param {string} comparisonOptionId
+ * @returns {Object[]}
+ */
+ function getComparisonParams(referenceMoment, selectedOptionIds, comparisonOptionId) {
+ const comparisonOption = COMPARISON_OPTIONS[comparisonOptionId];
+ const selectedOptions = getSelectedOptions(referenceMoment, selectedOptionIds);
+ let addParam = comparisonOption.addParam;
+ if (addParam) {
+ return [addParam, selectedOptions];
+ }
+ addParam = {};
+
+ let globalGranularity = 'year';
+ if (selectedOptions.month) {
+ globalGranularity = 'month';
+ } else if (selectedOptions.quarter) {
+ globalGranularity = 'quarter';
+ }
+ const granularityFactor = PER_YEAR[globalGranularity];
+ const years = selectedOptions.year.map(o => o.setParam.year);
+ const yearMin = Math.min(...years);
+ const yearMax = Math.max(...years);
+
+ let optionMin = 0;
+ let optionMax = 0;
+ if (selectedOptions.quarter) {
+ const quarters = selectedOptions.quarter.map(o => o.setParam.quarter);
+ if (globalGranularity === 'month') {
+ delete selectedOptions.quarter;
+ for (const quarter of quarters) {
+ for (const month of QUARTERS[quarter].coveredMonths) {
+ const monthOption = selectedOptions.month.find(
+ o => o.setParam.month === month
+ );
+ if (!monthOption) {
+ selectedOptions.month.push({
+ setParam: { month, }, granularity: 'month',
+ });
+ }
+ }
+ }
+ } else {
+ optionMin = Math.min(...quarters);
+ optionMax = Math.max(...quarters);
+ }
+ }
+ if (selectedOptions.month) {
+ const months = selectedOptions.month.map(o => o.setParam.month);
+ optionMin = Math.min(...months);
+ optionMax = Math.max(...months);
+ }
+
+ addParam[globalGranularity] = -1 +
+ granularityFactor * (yearMin - yearMax) +
+ optionMin - optionMax;
+
+ return [addParam, selectedOptions];
+ }
+
+ /**
+ * Returns a version of the options in INTERVAL_OPTIONS with translated descriptions.
+ * @see getOptionsWithDescriptions
+ */
+ function getIntervalOptions() {
+ return getOptionsWithDescriptions(INTERVAL_OPTIONS);
+ }
+
+ /**
+ * Returns a version of the options in PERIOD_OPTIONS with translated descriptions
+ * and a key defautlYearId used in the control panel model when toggling a period option.
+ * @param {moment} referenceMoment
+ * @returns {Object[]}
+ */
+ function getPeriodOptions(referenceMoment) {
+ const options = [];
+ for (const option of Object.values(PERIOD_OPTIONS)) {
+ const { id, groupNumber, description, } = option;
+ const res = { id, groupNumber, };
+ const date = referenceMoment.clone().set(option.setParam).add(option.addParam);
+ if (description) {
+ res.description = description.toString();
+ } else {
+ res.description = date.format(option.format.toString());
+ }
+ res.setParam = getSetParam(option, referenceMoment);
+ res.defaultYear = date.year();
+ options.push(res);
+ }
+ for (const option of options) {
+ const yearOption = options.find(
+ o => o.setParam && o.setParam.year === option.defaultYear
+ );
+ option.defaultYearId = yearOption.id;
+ delete option.defaultYear;
+ delete option.setParam;
+ }
+ return options;
+ }
+
+ /**
+ * Returns a version of the options in OPTIONS with translated descriptions (if any).
+ * @param {Object{}} OPTIONS
+ * @returns {Object[]}
+ */
+ function getOptionsWithDescriptions(OPTIONS) {
+ const options = [];
+ for (const option of Object.values(OPTIONS)) {
+ const { id, groupNumber, description, } = option;
+ const res = { id, };
+ if (description) {
+ res.description = description.toString();
+ }
+ if (groupNumber) {
+ res.groupNumber = groupNumber;
+ }
+ options.push(res);
+ }
+ return options;
+ }
+
+ /**
+ * Returns a version of the period options whose ids are in selectedOptionIds
+ * partitioned by granularity.
+ * @param {moment} referenceMoment
+ * @param {string[]} selectedOptionIds
+ * @param {Object}
+ */
+ function getSelectedOptions(referenceMoment, selectedOptionIds) {
+ const selectedOptions = { year: [] };
+ for (const optionId of selectedOptionIds) {
+ const option = PERIOD_OPTIONS[optionId];
+ const setParam = getSetParam(option, referenceMoment);
+ const granularity = option.granularity;
+ if (!selectedOptions[granularity]) {
+ selectedOptions[granularity] = [];
+ }
+ selectedOptions[granularity].push({ granularity, setParam });
+ }
+ return selectedOptions;
+ }
+
+ /**
+ * Returns the setParam object associated with the given periodOption and
+ * referenceMoment.
+ * @param {Object} periodOption
+ * @param {moment} referenceMoment
+ * @returns {Object}
+ */
+ function getSetParam(periodOption, referenceMoment) {
+ if (periodOption.setParam) {
+ return periodOption.setParam;
+ }
+ const date = referenceMoment.clone().add(periodOption.addParam);
+ const setParam = {};
+ setParam[periodOption.granularity] = date[periodOption.granularity]();
+ return setParam;
+ }
+
+ /**
+ * @param {string} intervalOptionId
+ * @returns {number} index
+ */
+ function rankInterval(intervalOptionId) {
+ return Object.keys(INTERVAL_OPTIONS).indexOf(intervalOptionId);
+ }
+
+ /**
+ * Sorts in place an array of 'period' options.
+ * @param {Object[]} options supposed to be of the form:
+ * { granularity, setParam, }
+ */
+ function sortPeriodOptions(options) {
+ options.sort((o1, o2) => {
+ const granularity1 = o1.granularity;
+ const granularity2 = o2.granularity;
+ if (granularity1 === granularity2) {
+ return o1.setParam[granularity1] - o2.setParam[granularity1];
+ }
+ return granularity1 < granularity2 ? -1 : 1;
+ });
+ }
+
+ /**
+ * Checks if a year id is among the given array of period option ids.
+ * @param {string[]} selectedOptionIds
+ * @returns {boolean}
+ */
+ function yearSelected(selectedOptionIds) {
+ return selectedOptionIds.some(optionId => !!YEAR_OPTIONS[optionId]);
+ }
+
+ return {
+ COMPARISON_OPTIONS,
+ DEFAULT_INTERVAL,
+ DEFAULT_PERIOD,
+ FACET_ICONS,
+ FIELD_OPERATORS,
+ FIELD_TYPES,
+ GROUPABLE_TYPES,
+ INTERVAL_OPTIONS,
+ PERIOD_OPTIONS,
+
+ constructDateDomain,
+ getComparisonOptions,
+ getIntervalOptions,
+ getPeriodOptions,
+ rankInterval,
+ yearSelected,
+ };
+});