diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/control_panel | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/control_panel')
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, + }; +}); |
