diff options
| author | Julien Mougenot <jum@odoo.com> | 2020-03-11 09:38:28 +0000 |
|---|---|---|
| committer | Julien Mougenot <jum@odoo.com> | 2020-03-13 14:08:33 +0000 |
| commit | fbf347498f1cc7b74ef373179b7bcae201715c24 (patch) | |
| tree | b8d1eb29ec93b6c2e43dbade712e636d32fe6716 /addons/web/static/src/js/control_panel | |
| parent | 0a129284e4856b6485c48a194fd86c3ab5c736e7 (diff) | |
[REF] web: Control panel Owl
Refactoring of the control panel with the Owl framework.
The MVC structure of the control panel has been changed to a 'component <-> model'
relationship. As such:
- Removed the old control panel: view/model/controller/renderer
- Created a new Owl Component (ControlPanel)
- Created a new class: the Model (an Owl Store did not fit here) and a newly
designed ControlPanelModel extending it
Refactored all inner subcomponents, including:
- Pager
- ActionMenus (peviously SideBar)
- Filter menu
> Custom filter item
- Group by menu
> Custom group by item
> Removal of groupby_menu_mixin (and refactored graph controller in sibling commit)
- Time range menu
- Favorite menu
> Custom favorite item
> Moved the favorite menu registry to the favorite_menu file
- Search bar
> Removed and splitted auto-completion dropdown logic between the new SearchBar
Component (descriptions) and to the new ControlPanelModel (domains).
The templates (all in web/base.xml) have been adapted to the new standards. Most
of the DOM structure remains unchanged with some occasional improvements (replace
hiding with not rendering, removed pointless wrapper divs, etc.).
Part of task 2196029
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Diffstat (limited to 'addons/web/static/src/js/control_panel')
12 files changed, 3462 insertions, 0 deletions
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 00000000000..d7105806de4 --- /dev/null +++ b/addons/web/static/src/js/control_panel/control_panel.js @@ -0,0 +1,225 @@ +odoo.define('web.ControlPanel', function (require) { + "use strict"; + + const ActionMenus = require('web.ActionMenus'); + const ControlPanelModel = require('web.ControlPanelModel'); + const FavoriteMenu = require('web.FavoriteMenu'); + const FilterMenu = require('web.FilterMenu'); + const GroupByMenu = require('web.GroupByMenu'); + const Pager = require('web.Pager'); + const SearchBar = require('web.SearchBar'); + const TimeRangeMenu = require('web.TimeRangeMenu'); + const { useModel } = require('web.model'); + + const { Component, hooks } = owl; + const { useRef, useState, 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 =============== ] [5] │ │ | + * | │ └─────────────────────────┘ │ └──────────────────────────────────────────────┘ │ | + * | ├─────────────────────────────┼──────────────────────────────────────────────────┤ | + * | │ ┌ Buttons ┐ ┌ ActionMenus ┐ │ ┌ Search Menus ─────┐ ┌ Pager ┐┌ View switcher ┐ │ | + * | │ │ [6] │ │ [7] │ │ │ [8] [9] [10] [11] │ │ [12] ││ [13] │ │ | + * | │ └─────────┘ └─────────────┘ │ └───────────────────┘ └───────┘└───────────────┘ │ | + * | └─────────────────────────────┴──────────────────────────────────────────────────┘ | + * | ┌ 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. Advanced search: small buttons used to toggle the [Search Menus] section. + * Will not be displayed if the allowed `props.searchMenuTypes` list is empty. + * 6. Buttons: section in which the action|controller is meant to inject its control + * buttons. The template provides a slot for this purpose. + * 7. Action menus: @see ActionMenus + * 8. Filter menu: @see FilterMenu + * 9. Group by menu: @see GroupByMenu + * 10. Time range menu: @see TimeRangeMenu + * 11. Favorite menu: @see FavoriteMenu + * 12. Pager: @see Pager + * 13. 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, + controlPanelModel: this.props.controlPanelModel, + view: this.props.view, + }); + + // Connect to the model + // TODO: move this in enterprise whenever possible + if (this.env.controlPanelModel) { + this.model = useModel('controlPanelModel'); + } + this.state = useState({ + displayDropdowns: true, + }); + + // Reference hooks + this.contentRefs = { + buttons: useRef('buttons'), + pager: useRef('pager'), + searchView: useRef('searchView'), + searchViewButtons: useRef('searchViewButtons'), + }; + + this.fields = this._formatFields(this.props.fields); + } + + mounted() { + this._attachAdditionalContent(); + } + + patched() { + this._attachAdditionalContent(); + } + + async willUpdateProps(nextProps) { + // Note: action and controlPanelModel 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.components = { + SearchBar, + ActionMenus, Pager, + FilterMenu, GroupByMenu, TimeRangeMenu, FavoriteMenu, + }; + ControlPanel.defaultProps = { + breadcrumbs: [], + fields: {}, + searchMenuTypes: [], + views: [], + withBreadcrumbs: true, + withSearchBar: true, + }; + ControlPanel.props = { + action: Object, + breadcrumbs: Array, + controlPanelModel: ControlPanelModel, + 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 ControlPanel; +}); diff --git a/addons/web/static/src/js/control_panel/control_panel_model.js b/addons/web/static/src/js/control_panel/control_panel_model.js new file mode 100644 index 00000000000..8ffe53ced6f --- /dev/null +++ b/addons/web/static/src/js/control_panel/control_panel_model.js @@ -0,0 +1,1721 @@ +odoo.define('web.ControlPanelModel', function (require) { + "use strict"; + + const Domain = require('web.Domain'); + const { Model } = require('web.model'); + const { parseArch } = require('web.viewUtils'); + const pyUtils = require('web.py_utils'); + + const { COMPARISON_TIME_RANGE_OPTIONS, + DEFAULT_INTERVAL, DEFAULT_PERIOD, FACET_ICONS, + INTERVAL_OPTIONS, OPTION_GENERATORS, + TIME_RANGE_OPTIONS, YEAR_OPTIONS, + rankPeriod, rankInterval } = require('web.searchUtils'); + + const FAVORITE_PRIVATE_GROUP = 1; + const FAVORITE_SHARED_GROUP = 2; + + let filterId = 0; + let groupId = 0; + 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'|'timeRange'|'field'|'favorite' + * + * Other properties can be present according to the corresponding filter type: + * + * • 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} [hasOptions] true if the filter comes from an arch node + * with a valid 'date' attribute. + * @if hasOptions = true + * > @prop {string} defaultOptionId option identifier determined by + * > default_period attribute (@see OPTION_GENERATORS). + * > 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 + * > @prop {Objecŧ[]} basicDomains of the form { description, domain }[] + * @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 'timeRange': + * no extra key, a single filter has that type + * + * • 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} 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] + * @prop {Object} [timeRanges] of the form { fieldName, rangeId[, comparisonRangeId] } + * + *------------------------------------------------------------------------- + * 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 'filter' with hasOptions=true: + * @prop {string} optionId option identifier (@see OPTION_GENERATORS) + * + * • type 'groupBy' with hasOptions=true: + * @prop {string} optionId option identifier (@see INTERVAL_OPTIONS) + * + * • type 'timeRange': + * @prop {string} fieldName name of a field + * @prop {string} rangeId option identifier (see TIME_RANGE_OPTIONS) + * @prop {string} [comparisonRangeId] option identifier (see COMPARISON_TIME_RANGE_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 Model + */ + class ControlPanelModel extends Model { + /** + * @param {Object} config + * @param {(string|number)} config.actionId + * @param {Object} config.env + * @param {string} config.modelName + * @param {Object} [config.importedState] + * @param {Array[]} [config.actionDomain=[]] + * @param {Object} [config.actionContext={}] + * @param {Object[]} [config.dynamicFilters=[]] + * @param {string[]} [config.searchMenuTypes=[]] + * @param {Object} [config.viewInfo={}] + * @param {boolean} [config.withSearchBar=true] + * + */ + constructor(config) { + super(); + + this._setProperties(config); + + if (this.withSearchBar) { + if (config.importedState) { + this.importState(config.importedState); + } else { + this._prepareInitialState(); + } + } + + this.isReady = Promise.all(this.labelPromises); + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * 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; + } + } + + /** + * Activate the unique filter of type 'timeRange' with provided 'options' fieldName, rangeId, + * and optional comparisonRangeId. + * @param {string} fieldName + * @param {string} rangeId + * @param {string} [comparisonRangeId] + */ + activateTimeRange(fieldName, rangeId, comparisonRangeId) { + const filter = Object.values(this.state.filters).find(f => f.type === 'timeRange'); + const detail = { fieldName, rangeId }; + if (comparisonRangeId) { + detail.comparisonRangeId = comparisonRangeId; + } + const queryElem = this.state.query.find(queryElem => queryElem.filterId === filter.id); + if (queryElem) { + Object.assign(queryElem, detail); + if (!comparisonRangeId) { + delete queryElem.comparisonRangeId; + } + } else { + this.state.query.push(Object.assign({ groupId: filter.groupId, filterId: filter.id }, detail)); + } + } + + /** + * 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 + ); + } + + /** + * 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); + } + } + + /** + * Return the state of the control panel model (the filters and the + * current query). This state can then be used in an other control panel + * model (with same key modelName). See importedState. + * @returns {Object} + */ + exportState() { + return JSON.parse(JSON.stringify(this.state)); + } + + /** + * Called by search bar to render the facets + * @returns {Object[]} + */ + getFacets() { + const groups = this._getGroups(); + return groups.map(group => { + const { activities, type, id } = group; + const facet = { + groupId: id, + separator: type === 'groupBy' ? ">" : this.env._t("or"), + values: this._getFacetDescriptions(activities, type), + }; + const icon = FACET_ICONS[type]; + if (icon) { + facet.icon = icon; + } else { + facet.title = activities[0].filter.description; + } + return facet; + }); + } + + /** + * Return an array containing enriched copies of the filters of the provided type. + * @param {string} type + * @returns {Object[]} + */ + getFiltersOfType(type) { + const filters = Object.values(this.state.filters).reduce( + (filters, filter) => { + if (filter.type === type && !filter.invisible) { + const activities = this.state.query.filter( + queryElem => queryElem.filterId === filter.id + ); + const enrichedFilter = this._enrichFilterCopy(filter, activities); + filters.push(enrichedFilter); + } + return filters; + }, + [] + ); + if (type === 'favorite') { + filters.sort((f1, f2) => f1.groupNumber - f2.groupNumber); + } + return filters; + } + + /** + * Principal objects used by controllers/models to fetch data. + * @returns {Object} An object called search query with keys domain, groupBy, + * context, orderedBy, and (optionally) timeRanges. + */ + getQuery() { + const requireEvaluation = true; + const groups = this._getGroups(); + const query = { + context: this._getContext(groups), + domain: this._getDomain(groups, requireEvaluation), + groupBy: this._getGroupBy(groups), + orderedBy: this._getOrderedBy(groups) + }; + if (this.searchMenuTypes.includes('timeRange')) { + const timeRanges = this._getTimeRanges(requireEvaluation); + query.timeRanges = timeRanges || {}; + } + return query; + } + + /** + * Allow to reuse the state of a previous control panel model with same modelName. + * This is mainly used when switching views. + * @param {Object} state + */ + importState(state) { + Object.assign(this.state, state); + } + + /** + * This function won't do anything: its purpose is to call the dispatch + * method to trigger a 'search' event + reload the components. + */ + search() { } + + /** + * 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 noYearSelected = (filterId) => !this.state.query.some(queryElem => { + return queryElem.filterId === filterId && YEAR_OPTIONS[queryElem.optionId]; + }); + + const defaultYearId = (optionId) => { + const year = this.optionGenerators.find(o => o.id === optionId).defaultYear; + return this.optionGenerators.find(o => o.setParam.year === year).id; + }; + + 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' && noYearSelected(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' && noYearSelected(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: defaultYearId(optionId), + }); + } + } + } + + /** + * TODO: the way it is done could be improved, but the actual state of the + * searchView doesn't allow to do much better. + * + * Update the domain of the search view by adding and/or removing filters. + * @param {Object[]} newFilters list of filters to add, described by + * objects with keys domain (the domain as an Array), description (the text + * to display in the facet) and type with value 'filter'. + * @param {number[]} filtersToRemove list of filter ids to remove + * (previously added ones) + * @returns {number[]} list of added filter ids (to pass as filtersToRemove + * for a further call to this function) + */ + updateFilters(newFilters, filtersToRemove) { + const newFilterIdS = this.createNewFilters(newFilters); + this.state.query = this.state.query.filter( + queryElem => !filtersToRemove.includes(queryElem.filterId) + ); + return newFilterIdS; + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Activate the filter of type timeRange with the options set in the key + * time_ranges of actionContext (if any). + * @private + */ + _activateDefaultTimeRanges() { + const { field, range, comparisonRange } = this.actionContext.time_ranges; + this.activateTimeRange(field, range, comparisonRange); + } + + /** + * Activate the default favorite (if any) or all default filters. + * @private + */ + _activateDefaultFilters() { + const defaultFilters = []; + const defaultFavorites = []; + for (const fId in this.state.filters) { + if (this.state.filters[fId].isDefault) { + if (this.state.filters[fId].type === 'favorite') { + defaultFavorites.push(this.state.filters[fId]); + } else { + defaultFilters.push(this.state.filters[fId]); + } + } + } + if (this.activateDefaultFavorite && defaultFavorites.length) { + // Activate default favorite + this.toggleFilter(defaultFavorites[0].id); + } else { + // Activate default filters + defaultFilters + .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); + } + }); + } + if (this.actionContext.time_ranges) { + this._activateDefaultTimeRanges(); + } + } + + /** + * This function populates the 'filters' object at initialization. + * The filters come from: + * - config.viewInfo.arch (types 'filter', 'groupBy', 'field'), + * - config.dynamicFilters (type 'filter'), + * - config.viewInfo.favoriteFilters (type 'favorite'), + * - code itself (type 'timeRange') + * @private + */ + _addFilters() { + this._createGroupOfFiltersFromArch(); + this._createGroupOfDynamicFilters(); + this._createGroupOfFavorites(); + this._createGroupOfTimeRanges(); + } + + /** + * Add filters of type 'filter' determined by the key array this.dynamicFilters. + * @private + */ + _createGroupOfDynamicFilters() { + const pregroup = this.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 array this.favoriteFilters. + * @private + */ + _createGroupOfFavorites() { + this.favoriteFilters.forEach(irFilter => { + const favorite = this._irFilterToFavorite(irFilter); + this._createGroupOfFilters([favorite]); + }); + } + + /** + * 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 (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 children = this.parsedArch.children.filter( + child => child instanceof Object && child.tag !== 'searchpanel' + ); + const preFilters = children.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 (filter.type === 'filter' || filter.type === 'groupBy') { + filter.groupNumber = groupNumber; + } + this._extractAttributes(filter, preFilter.attrs); + currentGroup.push(filter); + } + }); + + if (pregroupOfGroupBys.length) { + this._createGroupOfFilters(pregroupOfGroupBys); + } + } + + /** + * Add a single filter of type 'timeRange' to filters. + * @private + */ + _createGroupOfTimeRanges() { + const pregroup = [{ type: 'timeRange' }]; + this._createGroupOfFilters(pregroup); + } + + /** + * The parent of the control panel (an action or a controller) is not + * necessarily a component. So we need to notify it through a + * mechanism different from __notifyComponents. Here we use the fact + * that the controlPanelModel is an (owl) EventBus to communicate with + * the parent. + */ + _dispatch() { + this.trigger('search', this.getQuery()); + } + + /** + * Returns 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. + * @private + * @param {Object} filter + * @param {Object[]} filterQueryElements + * @returns {Object} + */ + _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 '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; + case 'timeRange': + if (filterQueryElements.length) { + const timeRange = this._extractTimeRange(filterQueryElements[0]); + Object.assign(f, timeRange); + } + 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 (this.searchDefaults.hasOwnProperty(child.attrs.name)) { + child.attrs.isDefault = true; + let value = this.searchDefaults[child.attrs.name]; + if (child.tag === 'field') { + if (value instanceof Array) { + value = value[0]; + } + child.attrs.defaultAutocompleteValue = { value, operator: '=' }; + } 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 || 'Ω'; + if (attrs.invisible) { + filter.invisible = true; + } + switch (filter.type) { + case 'filter': + if (attrs.context) { + filter.context = attrs.context; + } + if (attrs.date) { + filter.hasOptions = true; + filter.fieldName = attrs.date; + filter.fieldType = this.fields[attrs.date].type; + filter.defaultOptionId = attrs.default_period || DEFAULT_PERIOD; + filter.basicDomains = this._getDateFilterBasicDomains(filter); + } 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) { + filter.defaultRank = -10; + filter.defaultAutocompleteValue = attrs.defaultAutocompleteValue; + } + break; + } + if (filter.fieldName && !attrs.string) { + const { string } = this.fields[filter.fieldName]; + filter.description = string; + } + } + + /** + * For filter of type 'filter' with hasOptions=true, + * returns an array of domains or descriptions according to key. + * @param {Object} filter + * @param {Object[]} filterQueryElements + * @param {'domain'|'description'} key + * @returns {string[]} + */ + _extractInfoFromBasicDomains(filter, filterQueryElements, key) { + const results = []; + const yearIds = []; + const otherOptionIds = []; + filterQueryElements.forEach(({ optionId }) => { + if (YEAR_OPTIONS[optionId]) { + yearIds.push(optionId); + } else { + otherOptionIds.push(optionId); + } + }); + + const sortOptionIds = (a, b) => rankPeriod(a) - rankPeriod(b); + yearIds.sort(sortOptionIds); + otherOptionIds.sort(sortOptionIds); + + // the following case corresponds to years selected only + if (otherOptionIds.length === 0) { + yearIds.forEach(yearId => { + const d = filter.basicDomains[yearId]; + results.push(d[key]); + }); + } else { + otherOptionIds.forEach(optionId => { + yearIds.forEach(yearId => { + const d = filter.basicDomains[`${yearId}__${optionId}`]; + results.push(d[key]); + }); + }); + } + return results; + } + + /** + * Construct a timeRange object from the given fieldName, rangeId, comparisonRangeId + * parameters. + * @private + * @param {string} fieldName + * @param {string} rangeId + * @param {string} comparisonRangeId + * @returns {Object} + */ + _extractTimeRange({ fieldName, rangeId, comparisonRangeId }) { + const field = this.fields[fieldName]; + const timeRange = { + fieldName, + fieldDescription: field.string || fieldName, + rangeId, + range: Domain.prototype.constructDomain(fieldName, rangeId, field.type), + rangeDescription: TIME_RANGE_OPTIONS[rangeId].description.toString(), + }; + if (comparisonRangeId) { + timeRange.comparisonRangeId = comparisonRangeId; + timeRange.comparisonRange = Domain.prototype.constructDomain( + fieldName, rangeId, field.type, comparisonRangeId + ); + const { description } = COMPARISON_TIME_RANGE_OPTIONS[comparisonRangeId]; + timeRange.comparisonRangeDescription = description.toString(); + } + return timeRange; + } + + /** + * 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.actionId, + model_id: this.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 ('timeRanges' in favorite) { + const { fieldName, rangeId, comparisonRangeId } = favorite.timeRanges; + context.time_ranges = { + field: fieldName, + range: rangeId, + comparisonRange: comparisonRangeId, + }; + } + 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, withActionContext = true) { + const types = ['filter', 'favorite', 'field']; + const contexts = groups.reduce( + (contexts, group) => { + if (types.includes(group.type)) { + contexts.push(...this._getGroupContexts(group)); + } + return contexts; + }, + [] + ); + if (withActionContext) { + contexts.unshift(this.actionContext); + } + 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) + ); + } + } + + /** + * Construct an object containing domains based on this.referenceMoment and + * the field associated with the provided date filter. + * @private + * @param {Object} filter + * @returns {Object} + */ + _getDateFilterBasicDomains({ fieldName, fieldType }) { + const _constructBasicDomain = (y, o) => { + const setParam = Object.assign({}, y.setParam, o ? o.setParam : {}); + const granularity = o ? o.granularity : y.granularity; + const date = this.referenceMoment.clone().set(setParam); + let leftBound = date.clone().startOf(granularity).locale('en'); + let rightBound = date.clone().endOf(granularity).locale('en'); + 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] + ]); + const description = o ? o.description + " " + y.description : y.description; + return { domain, description }; + }; + + const domains = {}; + this.optionGenerators.filter(y => y.groupNumber === 2).forEach(y => { + domains[y.id] = _constructBasicDomain(y); + this.optionGenerators.filter(y => y.groupNumber === 1).forEach(o => { + domains[y.id + '__' + o.id] = _constructBasicDomain(y, o); + }); + }); + return domains; + } + + /** + * Compute the string representation of the current domain associated to a date filter + * starting from its corresponding query elements. + * @private + * @param {Object} filter + * @param {Objec[]} filterQueryElements + * @returns {string} + */ + _getDateFilterDomain(filter, filterQueryElements) { + const domains = this._extractInfoFromBasicDomains(filter, filterQueryElements, 'domain'); + return pyUtils.assembleDomains(domains, 'OR'); + } + + /** + * 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 + * @param {boolean} [evaluation=true] + * @returns {string} + */ + _getDomain(groups, evaluation = true) { + const types = ['filter', 'favorite', 'field']; + const domains = groups.reduce( + (domains, group) => { + if (types.includes(group.type)) { + domains.push(this._getGroupDomain(group)); + } + return domains; + }, + [] + ); + let filterDomain = pyUtils.assembleDomains(domains, 'AND'); + + if (evaluation) { + const userContext = this.env.session.user_context; + try { + return pyUtils.eval('domains', [this.actionDomain, filterDomain], userContext); + } catch (err) { + throw new Error( + this.env._t("Failed to evaluate search domain") + ":\n" + + JSON.stringify(err) + ); + } + } else { + return filterDomain; + } + } + + /** + * 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) { + 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 {Objec[]} + */ + _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; + }, + [] + ); + } + + /** + * Returns the last timeRanges object found in the query. + * TimeRanges objects can be associated with filters of type 'favorite' + * or 'timeRange'. + * @private + * @param {boolean} [evaluation=false] + * @returns {(Object|undefined)} + */ + _getTimeRanges(evaluation = false) { + let timeRanges; + for (const queryElem of this.state.query.slice().reverse()) { + const filter = this.state.filters[queryElem.filterId]; + if (filter.type === 'timeRange') { + timeRanges = this._extractTimeRange(queryElem); + break; + } else if (filter.type === 'favorite' && filter.timeRanges) { + // we want to make sure that last is not observed! (it is change below in case of evaluation) + timeRanges = this._extractTimeRange(filter.timeRanges); + break; + } + } + if (timeRanges) { + if (evaluation) { + timeRanges.range = Domain.prototype.stringToArray(timeRanges.range); + if (timeRanges.comparisonRangeId) { + timeRanges.comparisonRange = Domain.prototype.stringToArray( + timeRanges.comparisonRange + ); + } + } + return timeRanges; + } + } + + /** + * 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 timeRanges; + if (context.time_ranges) { + const { field, range, comparisonRange } = context.time_ranges; + timeRanges = this._extractTimeRange({ + fieldName: field, + rangeId: range, + comparisonRangeId: comparisonRange, + }); + delete context.time_ranges; + } + 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 (timeRanges) { + favorite.timeRanges = timeRanges; + } + 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. + * @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 'timeRange': + // 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); + 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; + } + } + + /** + * @private + */ + _prepareInitialState() { + this._addFilters(); + this._activateDefaultFilters(); + } + + /** + * 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.trigger( + 'get-controller-query-params', + p => { + controllerQueryParams = p; + } + ); + controllerQueryParams = controllerQueryParams || {}; + controllerQueryParams.context = controllerQueryParams.context || {}; + + const withoutActiveContext = false; + const queryContext = this._getContext(groups, withoutActiveContext); + 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, requireEvaluation); + 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.timeRanges = timeRanges; + } + const irFilter = this._favoriteToIrFilter(preFilter); + const serverSideId = await this.env.dataManager.create_filter(irFilter); + + preFilter.serverSideId = serverSideId; + + return preFilter; + } + + /** + * 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) { + if (type === 'timeRange') { + const tr = this._extractTimeRange(filterQueryElements[0]); + facetDescription = `${tr.fieldDescription}: ${tr.rangeDescription}`; + if (tr.comparisonRangeDescription) { + facetDescription += ` / ${tr.comparisonRangeDescription}`; + } + } else { // filter and favorite + facetDescription = filter.description; + if (filter.hasOptions) { + const descriptions = this._extractInfoFromBasicDomains( + filter, filterQueryElements, 'description' + ); + facetDescription += `: ${descriptions.join(" / ")}`; + } + } + facetDescriptions.push(facetDescription); + } + } + return facetDescriptions; + } + + /** + * Using the constructor parameter object config, set most of the properties + * of the control panel model. + * @private + * @param {Object} config + */ + _setProperties(config) { + this.state = { + filters: {}, + query: [], + }; + this.env = config.env; + this.modelName = config.modelName; + this.actionDomain = config.actionDomain || []; + this.actionContext = config.actionContext || {}; + this.actionId = config.actionId; + this.withSearchBar = 'withSearchBar' in config ? config.withSearchBar : true; + this.searchMenuTypes = config.searchMenuTypes || []; + + this.searchDefaults = []; + for (const key in this.actionContext) { + const match = /^search_default_(.*)$/.exec(key); + if (match) { + this.searchDefaults[match[1]] = this.actionContext[key]; + delete this.actionContext[key]; + } + } + this.labelPromises = []; + + const viewInfo = config.viewInfo || {}; + + this.parsedArch = parseArch(viewInfo.arch || '<search/>'); + this.fields = viewInfo.fields || {}; + this.favoriteFilters = viewInfo.favoriteFilters || []; + this.activateDefaultFavorite = 'search_disable_custom_filters' in this.actionContext ? + !this.actionContext.search_disable_custom_filters : + true; + + this.dynamicFilters = config.dynamicFilters || []; + + this.referenceMoment = moment(); + const setDescriptions = options => { + return Object.values(options).map(o => { + const oClone = JSON.parse(JSON.stringify(o)); + const description = o.description ? + o.description.toString() : + this.referenceMoment.clone().add(o.addParam).format(o.format); + return Object.assign(oClone, { description }); + }); + }; + const process = (options) => { + return options.map(o => { + const date = this.referenceMoment.clone().set(o.setParam).add(o.addParam); + delete o.addParam; + o.setParam[o.granularity] = date[o.granularity](); + o.defaultYear = date.year(); + return o; + }); + }; + this.optionGenerators = process(setDescriptions(OPTION_GENERATORS)); + this.intervalOptions = setDescriptions(INTERVAL_OPTIONS); + } + } + + return ControlPanelModel; +}); 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 00000000000..22b4ae565da --- /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 00000000000..f8493d73c69 --- /dev/null +++ b/addons/web/static/src/js/control_panel/custom_favorite_item.js @@ -0,0 +1,153 @@ +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.model'); + + 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('controlPanelModel'); + 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({ + title: this.env._t("Error"), + message: this.env._t("A name for your favorite filter is required."), + type: 'danger', + }); + return this.descriptionRef.el.focus(); + } + const favorites = this.model.getFiltersOfType('favorite'); + if (favorites.some(f => f.description === this.state.description)) { + this.env.services.notification.notify({ + title: this.env._t("Error"), + 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'; + + 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 00000000000..40265e95fe2 --- /dev/null +++ b/addons/web/static/src/js/control_panel/custom_filter_item.js @@ -0,0 +1,258 @@ +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 { useModel } = require('web.model'); + + /** + * 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('controlPanelModel'); + + 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]; + 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; + default: + 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) { + const type = this.fields[condition.field].type; + if (['float', 'integer', 'id'].includes(type)) { + const previousValue = condition.value; + const parser = field_utils.parse[type === 'float' ? 'float' : 'integer']; + try { + const parsed = parser(ev.target.value || 0); + // Force parsed value in the input. + ev.target.value = condition.value = (parsed || 0); + } catch (err) { + // Force previous value if non-parseable. + ev.target.value = previousValue || 0; + } + } else { + condition.value = ev.target.value || ""; + } + } + } + + CustomFilterItem.components = { DatePicker, DateTimePicker }; + CustomFilterItem.props = { + fields: Object, + }; + CustomFilterItem.template = 'web.CustomFilterItem'; + + return 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 00000000000..8082608d3ff --- /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.model'); + + /** + * 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('controlPanelModel'); + } + + //--------------------------------------------------------------------- + // 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 00000000000..8ebf910da08 --- /dev/null +++ b/addons/web/static/src/js/control_panel/favorite_menu.js @@ -0,0 +1,101 @@ +odoo.define('web.FavoriteMenu', function (require) { + "use strict"; + + const Dialog = require('web.OwlDialog'); + const DropdownMenu = require('web.DropdownMenu'); + const Registry = require('web.Registry'); + const { useModel } = require('web.model'); + + /** + * '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('controlPanelModel'); + this.state.deletedFavorite = false; + } + + //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + /** + * @override + */ + get items() { + const favorites = this.model.getFiltersOfType('favorite'); + const registryMenus = this.constructor.registry.values().reduce( + (menus, Component) => { + if (Component.shouldBeDisplayed(this.env)) { + menus.push({ + key: Component.name, + 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() { + await this.model.dispatch('deleteFavorite', this.state.deletedFavorite.id); + this.state.deletedFavorite = false; + } + } + + FavoriteMenu.registry = new Registry(); + + FavoriteMenu.components = Object.assign({}, DropdownMenu.components, { + Dialog, + }); + FavoriteMenu.defaultProps = Object.assign({}, DropdownMenu.defaultProps, { + icon: 'fa fa-star', + }); + 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 00000000000..47f01d22681 --- /dev/null +++ b/addons/web/static/src/js/control_panel/filter_menu.js @@ -0,0 +1,74 @@ +odoo.define('web.FilterMenu', function (require) { + "use strict"; + + const DropdownMenu = require('web.DropdownMenu'); + const CustomFilterItem = require('web.CustomFilterItem'); + const { useModel } = require('web.model'); + + /** + * '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('controlPanelModel'); + } + + //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + /** + * @override + */ + get items() { + return this.model.getFiltersOfType('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.defaultProps = Object.assign({}, DropdownMenu.defaultProps, { + icon: 'fa fa-filter', + }); + 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 00000000000..858f2a7953e --- /dev/null +++ b/addons/web/static/src/js/control_panel/groupby_menu.js @@ -0,0 +1,94 @@ +odoo.define('web.GroupByMenu', function (require) { + "use strict"; + + const { GROUPABLE_TYPES } = require('web.searchUtils'); + const DropdownMenu = require('web.DropdownMenu'); + const CustomGroupByItem = require('web.CustomGroupByItem'); + const { useModel } = require('web.model'); + + /** + * '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('controlPanelModel'); + } + + //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + /** + * @override + */ + get items() { + return this.model.getFiltersOfType('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.defaultProps = Object.assign({}, DropdownMenu.defaultProps, { + icon: 'fa fa-bars', + }); + 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 00000000000..45ad67e2fb7 --- /dev/null +++ b/addons/web/static/src/js/control_panel/search_bar.js @@ -0,0 +1,480 @@ +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.model'); + + 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('controlPanelModel'); + this.state = useState({ + sources: [], + focusedItem: 0, + inputValue: "", + }); + + this.autoCompleteSources = this.model.getFiltersOfType('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.controlPanelModel.on('search', this, this.focusOnUpdate); + this.env.controlPanelModel.on('focus-control-panel', this, () => { + this.inputRef.el.focus(); + }); + } + + willUnmount() { + this.env.controlPanelModel.off('search', this); + this.env.controlPanelModel.off('focus-control-panel', this); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @private + */ + _closeAutoComplete() { + this.state.sources = []; + this.state.focusedItem = 0; + this.focusOnUpdate(); + } + + /** + * @private + * @param {Object} filter + * @returns {Object} + */ + _createSource(filter) { + const source = { + active: true, + description: filter.description, + fieldName: filter.fieldName, + filterId: filter.id, + filterOperator: filter.operator, + id: sourceId ++, + operator: CHAR_FIELDS.includes(filter.fieldType) ? 'ilike' : '=', + parent: false, + }; + switch (filter.fieldType) { + case 'selection': + source.active = false; + source.selection = this.props.fields[filter.fieldName].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; + 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, + fieldName: source.fieldName, + 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 { context, relation } = this.props.fields[source.fieldName]; + const results = await this.rpc({ + kwargs: { + args, + context, + limit: 8, + name: this.state.inputValue.trim(), + }, + method: 'name_search', + model: 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, source) { + const { type } = this.props.fields[source.fieldName]; + const parser = field_utils.parse[type]; + let parsedValue; + if (['date', 'datetime'].includes(type)) { + 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(); + } else { + 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: source.value || this._parseWithSource(labelValue, source), + label: labelValue, + operator: source.filterOperator || source.operator, + }); + } + this.state.inputValue = ""; + this.inputRef.el.value = ""; + 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) { + const facets = this.model.getFacets(); + // Priority 4: navigate to rightmost facet. + this._focusFacet(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.getFacets(); + 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) { + 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 00000000000..da886004669 --- /dev/null +++ b/addons/web/static/src/js/control_panel/search_utils.js @@ -0,0 +1,161 @@ +odoo.define('web.searchUtils', function (require) { + "use strict"; + + const { _lt } = require('web.core'); + + // 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")}, + ], + 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 MONTH_OPTIONS = { + this_month: { id: 'this_month', groupNumber: 1, format: 'MMMM', addParam: {}, setParam: {}, granularity: 'month' }, + last_month: { id: 'last_month', groupNumber: 1, format: 'MMMM', addParam: { months: -1 }, setParam: {}, granularity: 'month' }, + antepenultimate_month: { id: 'antepenultimate_month', groupNumber: 1, format: 'MMMM', addParam: { months: -2 }, setParam: {}, granularity: 'month' }, + }; + const QUARTER_OPTIONS = { + fourth_quarter: { id: 'fourth_quarter', groupNumber: 1, description: _lt("Q4"), addParam: {}, setParam: { quarter: 4 }, granularity: 'quarter' }, + third_quarter: { id: 'third_quarter', groupNumber: 1, description: _lt("Q3"), addParam: {}, setParam: { quarter: 3 }, granularity: 'quarter' }, + second_quarter: { id: 'second_quarter', groupNumber: 1, description: _lt("Q2"), addParam: {}, setParam: { quarter: 2 }, granularity: 'quarter' }, + first_quarter: { id: 'first_quarter', groupNumber: 1, description: _lt("Q1"), addParam: {}, setParam: { quarter: 1 }, granularity: 'quarter' }, + }; + const YEAR_OPTIONS = { + this_year: { id: 'this_year', groupNumber: 2, format: 'YYYY', addParam: {}, setParam: {}, granularity: 'year' }, + last_year: { id: 'last_year', groupNumber: 2, format: 'YYYY', addParam: { years: -1 }, setParam: {}, granularity: 'year' }, + antepenultimate_year: { id: 'antepenultimate_year', groupNumber: 2, format: 'YYYY', addParam: { years: -2 }, setParam: {}, granularity: 'year' }, + }; + const OPTION_GENERATORS = Object.assign({}, MONTH_OPTIONS, QUARTER_OPTIONS, YEAR_OPTIONS); + + function rankPeriod(oId) { + return Object.keys(OPTION_GENERATORS).indexOf(oId); + } + + // GroupBy menu parameters + const GROUPABLE_TYPES = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime', 'integer']; + 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 } + }; + function rankInterval(oId) { + return Object.keys(INTERVAL_OPTIONS).indexOf(oId); + } + + // TimeRange menu parameters + const TIME_RANGE_OPTIONS = { + last_7_days: { description: _lt("Last 7 Days"), id: 'last_7_days', groupNumber: 1 }, + last_30_days: { description: _lt("Last 30 Days"), id: 'last_30_days', groupNumber: 1 }, + last_365_days: { description: _lt("Last 365 Days"), id: 'last_365_days', groupNumber: 1 }, + last_5_years: { description: _lt("Last 5 Years"), id: 'last_5_years', groupNumber: 1 }, + today: { description: _lt("Today"), id: 'today', groupNumber: 2 }, + this_week: { description: _lt("This Week"), id: 'this_week', groupNumber: 2 }, + this_month: { description: _lt("This Month"), id: 'this_month', groupNumber: 2 }, + this_quarter: { description: _lt("This Quarter"), id: 'this_quarter', groupNumber: 2 }, + this_year: { description: _lt("This Year"), id: 'this_year', groupNumber: 2 }, + yesterday: { description: _lt("Yesterday"), id: 'yesterday', groupNumber: 3 }, + last_week: { description: _lt("Last Week"), id: 'last_week', groupNumber: 3 }, + last_month: { description: _lt("Last Month"), id: 'last_month', groupNumber: 3 }, + last_quarter: { description: _lt("Last Quarter"), id: 'last_quarter', groupNumber: 3 }, + last_year: { description: _lt("Last Year"), id: 'last_year', groupNumber: 3 }, + }; + const COMPARISON_TIME_RANGE_OPTIONS = { + previous_period: { description: _lt("Previous Period"), id: 'previous_period' }, + previous_year: { description: _lt("Previous Year"), id: 'previous_year' }, + }; + + // Search bar + const FACET_ICONS = { + filter: 'fa-filter', + groupBy: 'fa-bars', + favorite: 'fa-star', + timeRange: 'fa-calendar', + }; + + return { + COMPARISON_TIME_RANGE_OPTIONS, + DEFAULT_INTERVAL, + DEFAULT_PERIOD, + FACET_ICONS, + FIELD_OPERATORS, + FIELD_TYPES, + GROUPABLE_TYPES, + INTERVAL_OPTIONS, + OPTION_GENERATORS, + TIME_RANGE_OPTIONS, + YEAR_OPTIONS, + + rankInterval, + rankPeriod, + }; +}); diff --git a/addons/web/static/src/js/control_panel/time_range_menu.js b/addons/web/static/src/js/control_panel/time_range_menu.js new file mode 100644 index 00000000000..b18b376d40b --- /dev/null +++ b/addons/web/static/src/js/control_panel/time_range_menu.js @@ -0,0 +1,109 @@ +odoo.define('web.TimeRangeMenu', function (require) { + "use strict"; + + const { COMPARISON_TIME_RANGE_OPTIONS, DEFAULT_PERIOD, TIME_RANGE_OPTIONS } = require('web.searchUtils'); + const DropdownMenu = require('web.DropdownMenu'); + const { useModel } = require('web.model'); + + // Used to provide unique ids to its template elements. + let timeRangeMenuId = 0; + + /** + * 'Time ranges' menu + * + * Component used to create a time range from a field, a given range and optionally + * another field to make a comparison. + * + * The component template overrides the dropdownmenu to keep the basic behaviours + * (opening/closing, layout). The template itself iis a set of labels/inputs + * used to select the field and range. There is also a checkbox used to determine + * whether to render the comparison range field selection (input). + * @extends DropdownMenu + */ + class TimeRangeMenu extends DropdownMenu { + constructor() { + super(...arguments); + this.model = useModel('controlPanelModel'); + + this.domId = timeRangeMenuId++; + this.fields = Object.values(this.props.fields) + .filter(field => this._validateField(field)); + this.periodOptions = TIME_RANGE_OPTIONS; + this.comparisonTimeRangeOptions = COMPARISON_TIME_RANGE_OPTIONS; + this.periodGroups = Object.values(this.periodOptions).reduce((acc, o) => { + if (!acc.includes(o.groupNumber)) { + acc.push(o.groupNumber); + } + return acc; + }, []); + const activeTimeRange = this.model.getFiltersOfType('timeRange').find( + timeRange => timeRange.isActive + ); + Object.assign(this.state, { + fieldName: this.fields[0] && this.fields[0].name, + rangeId: DEFAULT_PERIOD, + }, activeTimeRange); + } + + //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + /** + * @override + */ + get title() { + return this.env._t("Time Ranges"); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @private + * @param {Object} field + * @returns {boolean} + */ + _validateField(field) { + return field.sortable && + ['date', 'datetime'].includes(field.type); + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @private + */ + _onApply() { + this.model.dispatch('activateTimeRange', + this.state.fieldName, + this.state.rangeId, + this.state.comparisonRangeId + ); + } + + /** + * @private + */ + _onCheckboxClick() { + if (!this.state.comparisonRangeId) { + this.state.comparisonRangeId = 'previous_period'; // default + } else { + delete this.state.comparisonRangeId; + } + } + } + + TimeRangeMenu.defaultProps = Object.assign({}, DropdownMenu.defaultProps, { + icon: 'fa fa-calendar', + }); + TimeRangeMenu.props = Object.assign({}, DropdownMenu.props, { + fields: Object, + }); + TimeRangeMenu.template = 'web.TimeRangeMenu'; + + return TimeRangeMenu; +}); |
