diff options
Diffstat (limited to 'addons/web/static/src')
46 files changed, 5227 insertions, 6137 deletions
diff --git a/addons/web/static/src/js/chrome/pager.js b/addons/web/static/src/js/chrome/pager.js deleted file mode 100644 index 86700d93374..00000000000 --- a/addons/web/static/src/js/chrome/pager.js +++ /dev/null @@ -1,276 +0,0 @@ -odoo.define('web.Pager', function (require) { -"use strict"; - -var utils = require('web.utils'); -var Widget = require('web.Widget'); - -var direction = { - previous: -1, - next: 1, -}; - -var Pager = Widget.extend({ - template: "Pager", - events: { - 'click .o_pager_next': '_onNext', - 'click .o_pager_previous': '_onPrevious', - 'click .o_pager_value': '_onEdit', - }, - /** - * The pager goes from 1 to size (included). - * The current value is current_min if limit === 1 - * or the interval [current_min, current_min + limit[ if limit > 1 - * - * @param {Widget} [parent] the parent widget - * @param {int} [size] the total number of elements - * @param {int} [current_min] the first element of the current_page - * @param {int} [limit] the number of elements per page - * @param {boolean} [options.can_edit] editable feature of the pager - * @param {boolean} [options.single_page_hidden] (not) to display the pager - * if only one page - * @param {function} [options.validate] callback returning a Promise to - * validate changes - */ - init: function (parent, size, current_min, limit, options) { - this.state = { - size: size, - current_min: current_min, - limit: limit, - }; - Object.defineProperty(this.state, 'current_max', { - get: function() { - return Math.min(this.current_min + this.limit - 1, this.size); - } - }); - this.options = _.defaults({}, options, { - can_edit: true, // editable - single_page_hidden: false, // displayed even if there is a single page - validate: function() { - return Promise.resolve(); - }, - withAccessKey: true, // can be disabled, for example, for x2m widgets - }); - this._super(parent); - }, - /** - * Renders the pager - * - * @returns {Promise} - */ - start: function () { - this.$value = this.$('.o_pager_value'); - this.$limit = this.$('.o_pager_limit'); - this._render(); - return this._super(); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Disables the pager's arrows and the edition - */ - disable: function () { - this.disabled = true; - this._updateArrows(); - }, - /** - * Enables the pager's arrows and the edition - */ - enable: function () { - this.disabled = false; - this._updateArrows(); - }, - /** - * Executes the next action on the pager - */ - next: function () { - this._changeSelection(direction.next); - }, - /** - * Executes the previous action on the pager - */ - previous: function () { - this._changeSelection(direction.previous); - }, - /** - * Sets the state of the pager and renders it - * @param {Object} [state] the values to update (size, current_min and limit) - * @param {Object} [options] - * @param {boolean} [options.notifyChange] set to true to make the pager - * notify the environment that its state changed - */ - updateState: function (state, options) { - _.extend(this.state, state); - this._render(); - if (options && options.notifyChange) { - this.trigger('pager_changed', _.clone(this.state)); - } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Private function that updates the pager's state according to a pager action - * - * @param {int} [direction] the action (previous or next) on the pager - */ - _changeSelection: function (direction) { - var self = this; - this.options.validate().then(function() { - var size = self.state.size; - var current_min = self.state.current_min; - var limit = self.state.limit; - - // Compute the new current_min - current_min = (current_min + limit*direction); - if (current_min > size) { - current_min = 1; - } else if ((current_min < 1) && (limit === 1)) { - current_min = size; - } else if ((current_min < 1) && (limit > 1)) { - current_min = size - ((size % limit) || limit) + 1; - } - - self.state.current_min = current_min; - // The re-rendering of the pager must be done before the trigger of - // event 'pager_changed' as the rendering may enable the pager - // (and a common use is to disable the pager when this event is - // triggered, and to re-enable it when the data have been reloaded) - self._render(); - self.trigger('pager_changed', _.clone(self.state)); - }); - }, - /** - * Private function that displays an input to edit the pager's state - */ - _edit: function () { - if (this.options.can_edit) { - var self = this; - var $input = $('<input>', {class: 'o_input', type: 'text', value: this.$value.html()}); - - this.$value.html($input); - $input.focus(); - - // Event handlers - $input.click(function(ev) { - ev.stopPropagation(); // ignore clicks on the input - }); - $input.blur(function(ev) { - self._save($(ev.target)); // save the state when leaving the input - }); - $input.on('keydown', function (ev) { - ev.stopPropagation(); - if (ev.which === $.ui.keyCode.ENTER) { - self._save($(ev.target)); // save on enter - } else if (ev.which === $.ui.keyCode.ESCAPE) { - self._render(); // leave on escape - } - }); - } - }, - /** - * Private function that renders the pager's state - */ - _render: function () { - var size = this.state.size; - var current_min = this.state.current_min; - var current_max = this.state.current_max; - - if (size === 0 || (this.options.single_page_hidden && this._singlePage())) { - this.do_hide(); - } else { - this.do_show(); - this._updateArrows(); - - var value = "" + current_min; - if (this.state.limit > 1) { - value += "-" + current_max; - } - this.$value.html(value); - this.$limit.html(size); - } - }, - /** - * Private function that saves the state from the content of the input - * - * @param {jQuery} [$input] the jQuery element containing the new state - */ - _save: function ($input) { - var self = this; - this.options.validate().then(function() { - var value = $input.val().split("-"); - var min = utils.confine(parseInt(value[0], 10), 1, self.state.size); - var max = utils.confine(parseInt(value[1], 10), 1, self.state.size); - - if (!isNaN(min)) { - self.state.current_min = min; - if (!isNaN(max)) { - self.state.limit = utils.confine(max-min+1, 1, self.state.size); - } else { - // The state has been given as a single value -> set the limit to 1 - self.state.limit = 1; - } - self.trigger('pager_changed', _.clone(self.state)); - } - // Render the pager's new state (removes the input) - self._render(); - }); - }, - /** - * @private - * @returns {boolean} true iff there is only one page - */ - _singlePage: function () { - var state = this.state; - return (1 === state.current_min) && (state.current_max === state.size); - }, - /** - * Updates the arrows' disable attribute: true iff the pager is disabed or - * if there is only one page - * - * @private - */ - _updateArrows: function () { - var disabled = this.disabled || this._singlePage(); - this.$('button').prop('disabled', disabled); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {MouseEvent} event - */ - _onEdit: function (event) { - event.stopPropagation(); - if (!this.disabled) { - this._edit(); - } - }, - /** - * @private - * @param {MouseEvent} event - */ - _onNext: function (event) { - event.stopPropagation(); - this.next(); - }, - /** - * @private - * @param {MouseEvent} event - */ - _onPrevious: function (event) { - event.stopPropagation(); - this.previous(); - }, -}); - -return Pager; - -}); diff --git a/addons/web/static/src/js/chrome/sidebar.js b/addons/web/static/src/js/chrome/sidebar.js deleted file mode 100644 index ce24bf31f60..00000000000 --- a/addons/web/static/src/js/chrome/sidebar.js +++ /dev/null @@ -1,219 +0,0 @@ -odoo.define('web.Sidebar', function (require) { -"use strict"; - -var Context = require('web.Context'); -var core = require('web.core'); -var pyUtils = require('web.py_utils'); -var Widget = require('web.Widget'); - -var QWeb = core.qweb; -var _t = core._t; - -var Sidebar = Widget.extend({ - events: { - "click a.dropdown-item": "_onDropdownClicked" - }, - /** - * @override - * - * @param {Object} options - * @param {Object} options.items - * @param {Object} options.sections - * @param {Object} options.env - * @param {Object} options.actions - * - */ - init: function (parent, options) { - this._super.apply(this, arguments); - this.options = _.defaults(options || {}, { - 'editable': true - }); - this.env = options.env; - this.sections = options.sections || [ - {name: 'print', label: _t('Print')}, - {name: 'other', label: _t('Action')}, - ]; - this.items = options.items || { - print: [], - other: [], - }; - if (options.actions) { - this._addToolbarActions(options.actions); - } - }, - /** - * @override - */ - start: function () { - this._super.apply(this, arguments); - this.$el.addClass('btn-group'); - this._redraw(); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Update the env for the sidebar then rerender it. - * - * @param {Object} env - */ - updateEnv: function (env) { - this.env = env; - this._redraw(); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * For each item added to the section: - * - * ``label`` - * will be used as the item's name in the sidebar, can be html - * - * ``action`` - * descriptor for the action which will be executed, ``action`` and - * ``callback`` should be exclusive - * - * ``callback`` - * function to call when the item is clicked in the sidebar, called - * with the item descriptor as its first argument (so information - * can be stored as additional keys on the object passed to - * ``_addItems``) - * - * ``classname`` (optional) - * ``@class`` set on the sidebar serialization of the item - * - * ``title`` (optional) - * will be set as the item's ``@title`` (tooltip) - * - * @private - * @param {String} sectionCode - * @param {Array<{label, action | callback[, classname][, title]}>} items - */ - _addItems: function (sectionCode, items) { - if (items) { - this.items[sectionCode].unshift.apply(this.items[sectionCode], items); - } - }, - /** - * Method that will add the custom actions to the toolbar - * - * @private - * @param {Object} toolbarActions - */ - _addToolbarActions: function (toolbarActions) { - var self = this; - _.each(['print','action','relate'], function (type) { - if (type in toolbarActions) { - var actions = toolbarActions[type]; - if (actions && actions.length) { - var items = _.map(actions, function (action) { - return { - label: action.name, - action: action, - }; - }); - self._addItems(type === 'print' ? 'print' : 'other', items); - } - } - }); - if ('other' in toolbarActions) { - this._addItems('other', toolbarActions.other); - } - }, - /** - * Performs the action for the item clicked after getting the data - * necessary with a trigger up - * - * @private - * @param {Object} item - */ - _onItemActionClicked: function (item) { - var self = this; - this.trigger_up('sidebar_data_asked', { - callback: function (env) { - self.env = env; - var activeIdsContext = { - active_id: env.activeIds[0], - active_ids: env.activeIds, - active_model: env.model, - select_all: !!env.selectAll, - }; - if (env.domain) { - activeIdsContext.active_domain = env.domain; - } - - var context = pyUtils.eval('context', new Context(env.context, activeIdsContext)); - self._rpc({ - route: '/web/action/load', - params: { - action_id: item.action.id, - context: context, - }, - }).then(function (result) { - result.context = new Context( - result.context || {}, activeIdsContext) - .set_eval_context(context); - result.flags = result.flags || {}; - result.flags.new_window = true; - self.do_action(result, { - on_close: function () { - self.trigger_up('reload'); - }, - }); - }); - } - }); - }, - /** - * Method that renders the sidebar when there is a data update - * - * @private - */ - _redraw: function () { - this.$el.html(QWeb.render('Sidebar', {widget: this})); - - // Hides Sidebar sections when item list is empty - _.each(this.$('.o_dropdown'), function (el) { - var $dropdown = $(el); - if (!$dropdown.find('.dropdown-item').length) { - $dropdown.hide(); - } - }); - this.$("[title]").tooltip({ - delay: { show: 500, hide: 0} - }); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Method triggered when the user clicks on a toolbar dropdown - * - * @private - * @param {MouseEvent} event - */ - _onDropdownClicked: function (event) { - var section = $(event.currentTarget).data('section'); - var index = $(event.currentTarget).data('index'); - var item = this.items[section][index]; - if (item.callback) { - item.callback.apply(this, [item]); - } else if (item.action) { - this._onItemActionClicked(item); - } else if (item.url) { - return true; - } - event.preventDefault(); - }, -}); - -return Sidebar; - -}); diff --git a/addons/web/static/src/js/components/action_menus.js b/addons/web/static/src/js/components/action_menus.js new file mode 100644 index 00000000000..d94e162ca44 --- /dev/null +++ b/addons/web/static/src/js/components/action_menus.js @@ -0,0 +1,171 @@ +odoo.define('web.ActionMenus', function (require) { + "use strict"; + + const Context = require('web.Context'); + const DropdownMenu = require('web.DropdownMenu'); + const pyUtils = require('web.py_utils'); + const Registry = require('web.Registry'); + + const { Component } = owl; + + /** + * Action menus (or Action/Print bar, previously called 'Sidebar') + * + * The side bar is the group of dropdown menus located on the left side of the + * control panel. Its role is to display a list of items depending on the view + * type and selected records and to execute a set of actions on active records. + * It is made out of 2 dropdown menus: Print and Action. + * + * This component also provides a registry to use custom components in the ActionMenus's + * Action menu. + * @extends Component + */ + class ActionMenus extends Component { + + mounted() { + this._addTooltips(); + } + + patched() { + this._addTooltips(); + } + + //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + /** + * @returns {Object[]} + */ + get actionItems() { + // Callback based actions + const callbackActions = (this.props.items.other || []).map( + action => Object.assign({ key: `action-${action.description}` }, action) + ); + // Action based actions + const actionActions = this.props.items.action || []; + const relateActions = this.props.items.relate || []; + const formattedActions = [...actionActions, ...relateActions].map( + action => ({ action, description: action.name, key: action.id }) + ); + // ActionMenus action registry components + const registryActions = this.constructor.registry.values().map( + ({ Component, getProps }, index) => ({ + key: `registry-action-${index}`, + Component, + props: getProps(this.props), + }) + ); + return [...callbackActions, ...formattedActions, ...registryActions]; + } + + /** + * @returns {Object[]} + */ + get printItems() { + const printActions = this.props.items.print || []; + const printItems = printActions.map( + action => ({ action, description: action.name, key: action.id }) + ); + return printItems; + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Add teh tooltips to the items + * @private + */ + _addTooltips() { + $(this.el.querySelectorAll('[title]')).tooltip({ + delay: { show: 500, hide: 0 } + }); + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * Perform the action for the item clicked after getting the data + * necessary with a trigger. + * @private + * @param {OwlEvent} ev + */ + async _executeAction(action) { + const activeIdsContext = { + active_id: this.props.activeIds[0], + active_ids: this.props.activeIds, + active_model: this.env.action.res_model, + select_all: this.props.selectAll, + }; + if (this.props.domain) { + activeIdsContext.active_domain = this.props.domain; + } + + const context = pyUtils.eval('context', new Context(this.props.context, activeIdsContext)); + const result = await this.rpc({ + route: '/web/action/load', + params: { + action_id: action.id, + context: context, + }, + }); + result.context = new Context(result.context || {}, activeIdsContext) + .set_eval_context(context); + result.flags = result.flags || {}; + result.flags.new_window = true; + this.trigger('do-action', { + action: result, + options: { + on_close: () => this.trigger('reload'), + }, + }); + } + + /** + * Handler used to determine which way must be used to execute a selected + * action: it will be either: + * - a callback (function given by the view controller); + * - an action ID (string); + * - an URL (string). + * @private + * @param {OwlEvent} ev + */ + _onItemSelected(ev) { + ev.stopPropagation(); + const { item } = ev.detail; + if (item.callback) { + item.callback([item]); + } else if (item.action) { + this._executeAction(item.action); + } else if (item.url) { + // Event has been prevented at its source: we need to redirect manually. + this.env.services.navigate(item.url); + } + } + } + + ActionMenus.registry = new Registry(); + + ActionMenus.components = { DropdownMenu }; + ActionMenus.props = { + activeIds: { type: Array, element: [Number|String] }, // virtual IDs are strings. + context: Object, + domain: { type: Array, optional: 1 }, + items: { + type: Object, + shape: { + action: { type: Array, optional: 1 }, + print: { type: Array, optional: 1 }, + other: { type: Array, optional: 1 }, + }, + }, + selectAll: { type: Boolean, optional: 1 }, + }; + ActionMenus.template = 'web.ActionMenus'; + + return ActionMenus; +}); diff --git a/addons/web/static/src/js/components/datepicker.js b/addons/web/static/src/js/components/datepicker.js new file mode 100644 index 00000000000..715e594e077 --- /dev/null +++ b/addons/web/static/src/js/components/datepicker.js @@ -0,0 +1,244 @@ +odoo.define('web.DatePickerOwl', function (require) { + "use strict"; + + const field_utils = require('web.field_utils'); + const time = require('web.time'); + const { useAutofocus } = require('web.custom_hooks'); + + const { Component, hooks } = owl; + const { useExternalListener, useRef, useState } = hooks; + + let datePickerId = 0; + + /** + * Date picker + * + * This component exposes the API of the tempusdominus datepicker library. + * As such, its template is a simple input that will open the TD datepicker + * when clicked on. The component will also synchronize any user-input value + * with the library widget and vice-vera. + * + * For further details regarding the implementation of the picker itself, please + * refer to the official tempusdominus documentation (note: all props given + * to this component will be passed as arguments to instantiate the picker widget). + * @extends Component + */ + class DatePicker extends Component { + constructor() { + super(...arguments); + + this.inputRef = useRef('input'); + this.state = useState({ warning: false }); + + this.datePickerId = `o_datepicker_${datePickerId++}`; + this.typeOfDate = 'date'; + + useAutofocus(); + useExternalListener(window, 'scroll', this._onWindowScroll); + } + + mounted() { + $(this.el).on('show.datetimepicker', this._onDateTimePickerShow.bind(this)); + $(this.el).on('hide.datetimepicker', this._onDateTimePickerHide.bind(this)); + $(this.el).on('error.datetimepicker', () => false); + + this._datetimepicker(this.props); + this.inputRef.el.value = this._formatDate(this.props.date); + } + + willUnmount() { + this._datetimepicker('destroy'); + } + + willUpdateProps(nextProps) { + if (nextProps.date) { + this._datetimepicker('date', nextProps.date); + this.inputRef.el.value = this._formatDate(nextProps.date); + } + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Handle bootstrap datetimepicker calls. + * @private + * @param {...any} args anything that will be passed to the datetimepicker function. + */ + _datetimepicker(...args) { + $(this.el).datetimepicker(...args); + } + + /** + * @private + * @param {moment} date + * @returns {string} + */ + _formatDate(date) { + try { + return field_utils.format[this.typeOfDate](date, null, { timezone: false }); + } catch (err) { + return false; + } + } + + /** + * @private + * @param {string|false} value + * @returns {moment} + */ + _parseInput(inputValue) { + try { + return field_utils.parse[this.typeOfDate](inputValue, null, { timezone: false }); + } catch (err) { + return false; + } + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * Reacts to the datetimepicker being hidden + * Used to unbind the scroll event from the datetimepicker + * @private + */ + _onDateTimePickerHide() { + const date = this._parseInput(this.inputRef.el.value); + this.state.warning = date.format('YYYY-MM-DD') > moment().format('YYYY-MM-DD'); + this.trigger('datetime-changed', { date }); + } + + /** + * Reacts to the datetimepicker being shown + * Could set/verify our widget value + * And subsequently update the datetimepicker + * @private + */ + _onDateTimePickerShow() { + this.inputRef.el.select(); + } + + /** + * @private + */ + _onInputClick() { + this._datetimepicker('toggle'); + } + + /** + * @private + */ + _onInputChange() { + const date = this._parseInput(this.inputRef.el.value); + if (date) { + this.state.warning = date.format('YYYY-MM-DD') > moment().format('YYYY-MM-DD'); + this.trigger('datetime-changed', { date }); + } else { + this.inputRef.el.value = this._formatDate(this.props.date); + } + } + + /** + * @private + */ + _onWindowScroll(ev) { + if (ev.target !== this.inputRef.el) { + this._datetimepicker('hide'); + } + } + } + + DatePicker.defaultProps = { + calendarWeeks: true, + format: time.getLangDateFormat(), + icons: { + clear: 'fa fa-delete', + close: 'fa fa-check primary', + date: 'fa fa-calendar', + down: 'fa fa-chevron-down', + next: 'fa fa-chevron-right', + previous: 'fa fa-chevron-left', + time: 'fa fa-clock-o', + today: 'fa fa-calendar-check-o', + up: 'fa fa-chevron-up', + }, + locale: moment.locale(), + maxDate: moment({ y: 9999, M: 11, d: 31 }), + minDate: moment({ y: 1900 }), + useCurrent: false, + widgetParent: 'body', + }; + DatePicker.props = { + // Actual date value + date: moment, + // Other props + buttons: { + type: Object, + shape: { + showClear: Boolean, + showClose: Boolean, + showToday: Boolean, + }, + optional: 1, + }, + calendarWeeks: Boolean, + format: String, + icons: { + type: Object, + shape: { + clear: String, + close: String, + date: String, + down: String, + next: String, + previous: String, + time: String, + today: String, + up: String, + }, + }, + keyBinds: { validate: kb => typeof kb === 'object' || kb === null, optional: 1 }, + locale: String, + maxDate: moment, + minDate: moment, + readonly: { type: Boolean, optional: 1 }, + useCurrent: Boolean, + widgetParent: String, + }; + DatePicker.template = 'web.DatePicker'; + + /** + * Date/time picker + * + * Similar to the DatePicker component, adding the handling of more specific + * time values: hour-minute-second. + * + * Once again, refer to the tempusdominus documentation for implementation + * details. + * @extends DatePicker + */ + class DateTimePicker extends DatePicker { + constructor() { + super(...arguments); + + this.typeOfDate = 'datetime'; + } + } + + DateTimePicker.defaultProps = Object.assign({}, DatePicker.defaultProps, { + buttons: { + showClear: false, + showClose: true, + showToday: false, + }, + format: time.getLangDatetimeFormat(), + }); + + return { + DatePicker, + DateTimePicker, + }; +}); diff --git a/addons/web/static/src/js/components/dropdown_menu.js b/addons/web/static/src/js/components/dropdown_menu.js new file mode 100644 index 00000000000..df7172ae1d4 --- /dev/null +++ b/addons/web/static/src/js/components/dropdown_menu.js @@ -0,0 +1,133 @@ +odoo.define('web.DropdownMenu', function (require) { + "use strict"; + + const DropdownMenuItem = require('web.DropdownMenuItem'); + + const { Component, hooks } = owl; + const { useExternalListener, useRef, useState } = hooks; + + /** + * Dropdown menu + * + * Generic component used to generate a list of interactive items. It uses some + * bootstrap classes but most interactions are handled in here or in the dropdown + * menu item class definition, including some keyboard navigation and escaping + * system (click outside to close the dropdown). + * + * The layout of a dropdown menu is as following: + * > a Button (always rendered) with a `title` and an optional `icon`; + * > a Dropdown (rendered when open) containing a collection of given items. + * These items must be objects and can have two shapes: + * 1. item.Component & item.props > will instantiate the given Component with + * the given props. Any additional key will be useless. + * 2. any other shape > will instantiate a DropdownMenuItem with the item + * object being its props. There is no props validation as this object + * will be passed as-is when `selected` and can contain additional meta-keys + * that will not affect the displayed item. For more information regarding + * the behaviour of these items, @see DropdownMenuItem. + * @extends Component + */ + class DropdownMenu extends Component { + constructor() { + super(...arguments); + + this.dropdownMenu = useRef('dropdown'); + this.state = useState({ open: false }); + + this.symbol = this.env.device.isMobile ? 'fa fa-chevron-right float-right mt4' : false; + + useExternalListener(window, 'click', this._onWindowClick); + useExternalListener(window, 'keydown', this._onWindowKeydown); + } + + //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + /** + * Overriden in case we want to keep the caret style on the button in mobile. + * @returns {boolean} + */ + get displayCaret() { + return !this.env.device.isMobile; + } + + /** + * Meant to be overriden to provide the list of items to display. + * @returns {Object[]} + */ + get items() { + return this.props.items; + } + + /** + * @returns {string} + */ + get title() { + return this.props.title; + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onButtonKeydown(ev) { + switch (ev.key) { + case 'ArrowLeft': + case 'ArrowRight': + case 'ArrowUp': + case 'ArrowDown': + const firstItem = this.el.querySelector('.dropdown-item'); + if (firstItem) { + ev.preventDefault(); + firstItem.focus(); + } + } + } + + /** + * @private + * @param {OwlEvent} ev + */ + _onItemSelected(ev) { } + + /** + * @private + * @param {MouseEvent} ev + */ + _onWindowClick(ev) { + if (this.state.open && !this.el.contains(ev.target)) { + this.state.open = false; + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onWindowKeydown(ev) { + if (this.state.open && ev.key === 'Escape') { + this.state.open = false; + } + } + } + + DropdownMenu.components = { DropdownMenuItem }; + DropdownMenu.defaultProps = { items: [] }; + DropdownMenu.props = { + icon: { type: String, optional: 1 }, + items: { + type: Array, + element: Object, + optional: 1, + }, + title: { type: String, optional: 1 }, + }; + DropdownMenu.template = 'web.DropdownMenu'; + + return DropdownMenu; +}); diff --git a/addons/web/static/src/js/components/dropdown_menu_item.js b/addons/web/static/src/js/components/dropdown_menu_item.js new file mode 100644 index 00000000000..bc028e17719 --- /dev/null +++ b/addons/web/static/src/js/components/dropdown_menu_item.js @@ -0,0 +1,98 @@ +odoo.define('web.DropdownMenuItem', function (require) { + "use strict"; + + const { useListener } = require('web.custom_hooks'); + + const { Component, hooks } = owl; + const { useExternalListener, useRef, useState } = hooks; + + /** + * Dropdown menu item + * + * Generic component instantiated by a dropdown menu (@see DropdownMenu) in + * the absence of `Component` and `props` keys in a given item object. + * + * In its simplest form, a dropdown menu item will be given a description (optional, + * but highly recommended) and will trigger a 'select-item' when clicked on. + * Additionaly it can receive the following props: + * - isActive: will add a `checked` symbol on the left side of the item + * - removable: will add a `remove` trash icon on the right side of the item. + * when clicked, will trigger a 'remove-item' event. + * - options: will change the behaviour of the item ; instead of triggering + * an event, the item will act as a nested dropdown menu and display + * its given options. These will have the same definition as another + * dropdown item but cannot have options of their own. + * + * It is recommended to extend this class when defining a Component which will + * be put inside of a dropdown menu (@see CustomFilterItem as example). + * @extends Component + */ + class DropdownMenuItem extends Component { + constructor() { + super(...arguments); + + this.canBeOpened = Boolean(this.props.options && this.props.options.length); + + this.fallbackFocusRef = useRef('fallback-focus'); + this.state = useState({ open: false }); + + useExternalListener(window, 'click', this._onWindowClick); + useListener('keydown', this._onKeydown); + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) { + return; + } + switch (ev.key) { + case 'ArrowLeft': + if (this.canBeOpened && this.state.open) { + ev.preventDefault(); + if (this.fallbackFocusRef.el) { + this.fallbackFocusRef.el.focus(); + } + this.state.open = false; + } + break; + case 'ArrowRight': + if (this.canBeOpened && !this.state.open) { + ev.preventDefault(); + this.state.open = true; + } + break; + case 'Escape': + ev.target.blur(); + if (this.canBeOpened && this.state.open) { + ev.preventDefault(); + ev.stopPropagation(); + if (this.fallbackFocusRef.el) { + this.fallbackFocusRef.el.focus(); + } + this.state.open = false; + } + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onWindowClick(ev) { + if (this.state.open && !this.el.contains(ev.target)) { + this.state.open = false; + } + } + } + + DropdownMenuItem.template = 'web.DropdownMenuItem'; + + return DropdownMenuItem; +}); diff --git a/addons/web/static/src/js/components/pager.js b/addons/web/static/src/js/components/pager.js new file mode 100644 index 00000000000..cada405ac7d --- /dev/null +++ b/addons/web/static/src/js/components/pager.js @@ -0,0 +1,221 @@ +odoo.define('web.Pager', function (require) { + "use strict"; + + const { useAutofocus } = require('web.custom_hooks'); + + const { Component, hooks } = owl; + const { useState } = hooks; + + /** + * Pager + * + * The pager goes from 1 to size (included). + * The current value is currentMinimum if limit === 1 or the interval: + * [currentMinimum, currentMinimum + limit[ if limit > 1]. + * The value can be manually changed by clicking on the pager value and giving + * an input matching the pattern: min[,max] (in which the comma can be a dash + * or a semicolon). + * The pager also provides two buttons to quickly change the current page (next + * or previous). + * @extends Component + */ + class Pager extends Component { + /** + * @param {Object} [props] + * @param {int} [props.size] the total number of elements + * @param {int} [props.currentMinimum] the first element of the current_page + * @param {int} [props.limit] the number of elements per page + * @param {boolean} [props.editable] editable feature of the pager + * @param {function} [props.validate] callback returning a Promise to + * validate changes + * @param {boolean} [props.withAccessKey] can be disabled, for example, + * for x2m widgets + */ + constructor() { + super(...arguments); + + this.state = useState({ + disabled: false, + editing: false, + }); + + useAutofocus(); + } + + async willUpdateProps(nextProps) { + this.state.editing = false; + if ( + this.props.currentMinimum !== nextProps.currentMinimum || + this.props.limit !== nextProps.limit || + this.props.size !== nextProps.size + ) { + this.state.disabled = false; + } + } + + //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + /** + * @returns {number} + */ + get maximum() { + return Math.min(this.props.currentMinimum + this.props.limit - 1, this.props.size); + } + + /** + * @returns {boolean} true iff there is only one page + */ + get singlePage() { + const { currentMinimum, size } = this.props; + return (1 === currentMinimum) && (this.maximum === size); + } + + /** + * @returns {number} + */ + get value() { + return this.props.currentMinimum + (this.props.limit > 1 ? `-${this.maximum}` : ''); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Update the pager's state according to a pager action + * @private + * @param {number} [direction] the action (previous or next) on the pager + */ + async _changeSelection(direction) { + try { + await this.props.validate(); + } catch (err) { + return; + } + const { limit, size } = this.props; + + // Compute the new currentMinimum + let currentMinimum = (this.props.currentMinimum + limit * direction); + if (currentMinimum > size) { + currentMinimum = 1; + } else if ((currentMinimum < 1) && (limit === 1)) { + currentMinimum = size; + } else if ((currentMinimum < 1) && (limit > 1)) { + currentMinimum = size - ((size % limit) || limit) + 1; + } + + // The re-rendering of the pager must be done before the trigger of + // event 'pager-changed' as the rendering may enable the pager + // (and a common use is to disable the pager when this event is + // triggered, and to re-enable it when the data have been reloaded). + this._updateAndDisable(currentMinimum, limit); + } + + /** + * Save the state from the content of the input + * @private + * @param {string} value the new raw pager value + * @returns {Promise} + */ + async _saveValue(value) { + try { + await this.props.validate(); + } catch (err) { + return; + } + const [min, max] = value.trim().split(/\s*[\-\s,;]\s*/); + + let currentMinimum = Math.max(Math.min(parseInt(min, 10), this.props.size), 1); + let maximum = max ? Math.max(Math.min(parseInt(max, 10), this.props.size), 1) : min; + + if ( + !isNaN(currentMinimum) && + !isNaN(maximum) && + currentMinimum <= maximum + ) { + const limit = Math.max(maximum - currentMinimum) + 1; + this._updateAndDisable(currentMinimum, limit); + } + } + + /** + * @private + * @param {Object} payload + */ + _updateAndDisable(currentMinimum, limit) { + if ( + currentMinimum !== this.props.currentMinimum || + limit !== this.props.limit + ) { + this.state.disabled = true; + this.trigger('pager-changed', { currentMinimum, limit }); + } + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @private + */ + _onEdit() { + if ( + this.props.editable && // editable + !this.state.editing && // not already editing + !this.state.disabled // not being changed already + ) { + this.state.editing = true; + } + } + + /** + * @private + * @param {InputEvent} ev + */ + _onValueChange(ev) { + this._saveValue(ev.currentTarget.value); + if (!this.state.disabled) { + ev.preventDefault(); + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onValueKeydown(ev) { + switch (ev.key) { + case 'Enter': + ev.preventDefault(); + ev.stopPropagation(); + this._saveValue(ev.currentTarget.value); + break; + case 'Escape': + ev.preventDefault(); + ev.stopPropagation(); + this.state.editing = false; + break; + } + } + } + + Pager.defaultProps = { + editable: true, + validate: async () => { }, + withAccessKey: true, + }; + Pager.props = { + currentMinimum: { type: Number, optional: 1 }, + editable: Boolean, + limit: { validate: l => !isNaN(l), optional: 1 }, + size: { type: Number, optional: 1 }, + validate: Function, + withAccessKey: Boolean, + }; + Pager.template = 'web.Pager'; + + return Pager; +}); 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; +}); diff --git a/addons/web/static/src/js/model.js b/addons/web/static/src/js/model.js new file mode 100644 index 00000000000..8e3842e5175 --- /dev/null +++ b/addons/web/static/src/js/model.js @@ -0,0 +1,157 @@ +odoo.define('web.model', function () { + "use strict"; + + const { Component, core } = owl; + const { EventBus, Observer } = core; + + // TODO: remove this if made available in owl.utils + function partitionBy(arr, fn) { + let lastGroup = false; + let lastValue; + return arr.reduce((acc, cur) => { + let curVal = fn(cur); + if (lastGroup) { + if (curVal === lastValue) { + lastGroup.push(cur); + } + else { + lastGroup = false; + } + } + if (!lastGroup) { + lastGroup = [cur]; + acc.push(lastGroup); + } + lastValue = curVal; + return acc; + }, []); + } + + /** + * Model + * + * The purpose of the class Model and the associated hook useModel + * is to offer something similar to an owl store but with no automatic + * notification (and rendering) of components when the 'state' used in the model + * would change. Instead, one should call the __notifyComponents function whenever + * it is useful to alert registered component. Nevertheless, + * when calling a method throught the dispatch method, a notifcation + * does take place automatically, and registered components (via useModel) are rendered. + * + * it is highly expected that this class will change in a near future. We don't have + * the necessary hindsight to be sure its actual form is good. + * @extends EventBus + */ + class Model extends EventBus { + + constructor() { + super(); + this.rev = 1; + this.mapping = {}; // could be a weak map + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * Call the base model method with given name with the arguments + * determined by the dispatch extra arguments. + * + * @param {string} action + * @param {...any} args + * @returns {Promise<any>} + */ + async dispatch(action, ...args) { + const result = await this[action](...args); + // TODO try to put it in promise.resolve() + this._dispatch(...arguments); + let rev = this.rev; + await Promise.resolve(); + if (rev === this.rev) { + await this._notifyComponents(); + } + return result; + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @private + * @param {string} action + * @param {...any} args + */ + _dispatch(action, ...args) { } + + /** + * see Context method in owl.js for explanation + * @private + */ + async _notifyComponents() { + const rev = ++this.rev; + const subscriptions = this.subscriptions.update; + const groups = partitionBy(subscriptions, s => (s.owner ? s.owner.__owl__.depth : -1)); + for (let group of groups) { + const proms = group.map(sub => sub.callback.call(sub.owner, rev)); + Component.scheduler.flush(); + await Promise.all(proms); + } + } + } + + /** + * This is more or less the hook 'useContextWithCB' from owl only slightly simplified. + * + * @param {string} modelName + */ + function useModel(modelName) { + const component = Component.current; + const model = component.env[modelName]; + if (!(model instanceof Model)) { + throw new Error(`No Model found when connecting '${component.constructor.name}'`); + } + + const mapping = model.mapping; + const __owl__ = component.__owl__; + const componentId = __owl__.id; + if (!__owl__.observer) { + __owl__.observer = new Observer(); + __owl__.observer.notifyCB = component.render.bind(component); + } + const currentCB = __owl__.observer.notifyCB; + __owl__.observer.notifyCB = function () { + if (model.rev > mapping[componentId]) { + return; + } + currentCB(); + }; + mapping[componentId] = 0; + const renderFn = __owl__.renderFn; + __owl__.renderFn = function (comp, params) { + mapping[componentId] = model.rev; + return renderFn(comp, params); + }; + + model.on('update', component, async modelRev => { + if (mapping[componentId] < modelRev) { + mapping[componentId] = modelRev; + await component.render(); + } + }); + + const __destroy = component.__destroy; + component.__destroy = parent => { + model.off("update", component); + __destroy.call(component, parent); + }; + + return model; + } + + return { + Model, + useModel, + }; +}); diff --git a/addons/web/static/src/js/views/control_panel/control_panel_controller.js b/addons/web/static/src/js/views/control_panel/control_panel_controller.js deleted file mode 100644 index 3c23e681631..00000000000 --- a/addons/web/static/src/js/views/control_panel/control_panel_controller.js +++ /dev/null @@ -1,269 +0,0 @@ -odoo.define('web.ControlPanelController', function (require) { -"use strict"; - -var mvc = require('web.mvc'); - -var ControlPanelController = mvc.Controller.extend({ - className: 'o_cp_controller', - custom_events: { - facet_removed: '_onFacetRemoved', - get_search_query: '_onGetSearchQuery', - item_option_clicked: '_onItemOptionClicked', - item_trashed: '_onItemTrashed', - menu_item_clicked: '_onMenuItemClicked', - new_favorite: '_onNewFavorite', - new_filters: '_onNewFilters', - new_groupBy: '_onNewGroupBy', - activate_time_range: '_onActivateTimeRange', - autocompletion_filter: '_onAutoCompletionFilter', - reload: '_onReload', - reset: '_onReset', - }, - - /** - * @override - * @param {Object} params - * @param {string} params.modelName - */ - init: function (parent, model, renderer, params) { - this._super.apply(this, arguments); - - this.modelName = params.modelName; - }, - /** - * Called when the control panel is inserted into the DOM. - */ - on_attach_callback: function () { - this.renderer.on_attach_callback(); - }, - /** - * Called when the control panel is remove form the DOM. - */ - on_detach_callback: function () { - this.renderer.on_detach_callback(); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @see ControlPanelModel (exportState) - * @returns {Object} - */ - exportState: function () { - return this.model.exportState(); - }, - /** - * Called by the abstract controller to give focus to the searchbar - */ - focusSearchBar: function () { - if (this.renderer.searchBar) { - this.renderer.searchBar.focus(); - } - }, - /** - * Compute the search related values that will be used to fetch data. - * - * @returns {Object} object with keys 'context', 'domain', 'groupBy' - */ - getSearchQuery: function () { - return this.model.getQuery(); - }, - /** - * @param {Object} state a ControlPanelModel state - * @returns {Promise<Object>} the result of `getSearchState` - */ - importState: function (state) { - var defs = []; - this.model.importState(state); - defs.push(this.getSearchQuery()); - defs.push(this.renderer.updateState(this.model.get())); - return Promise.all(defs).then(function (defsResults) { - return defsResults[0]; - }); - }, - /** - * Called at each switch view. This is required until the control panel is - * shared between controllers of an action. - * - * @param {string} controllerID - */ - setControllerID: function (controllerID) { - this.controllerID = controllerID; - }, - /** - * Update the content and displays the ControlPanel. - * - * @see ControlPanelRenderer (updateContents) - * @param {Object} status - * @param {Object} [options] - */ - updateContents: function (status, options) { - this.renderer.updateContents(status, options); - }, - /** - * Update the domain of the search view by adding and/or removing filters. - * - * @todo: the way it is done could be improved, but the actual state of the - * searchview doesn't allow to do much better. - - * @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 {string[]} filtersToRemove list of filter ids to remove - * (previously added ones) - * @returns {string[]} list of added filters (to pass as filtersToRemove - * for a further call to this function) - */ - updateFilters: function (newFilters, filtersToRemove) { - var newFilterIDS = this.model.createNewFilters(newFilters); - this.model.deactivateFilters(filtersToRemove); - this._reportNewQueryAndRender(); - return newFilterIDS; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - * @returns {jQuery} - */ - _getSubMenus: function () { - return this.renderer.$subMenus; - }, - /** - * @private - * @returns {Promise} - */ - _reportNewQueryAndRender: function () { - this.trigger_up('search', this.model.getQuery()); - var state = this.model.get(); - return this.renderer.updateState(state); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {OdooEvent} ev - */ - _onActivateTimeRange: function (ev) { - ev.stopPropagation(); - this.model.activateTimeRange( - ev.data.id, - ev.data.timeRangeId, - ev.data.comparisonTimeRangeId - ); - this._reportNewQueryAndRender(); - }, - /** - * @private - * @param {OdooEvent} ev - */ - _onAutoCompletionFilter: function (ev) { - ev.stopPropagation(); - this.model.toggleAutoCompletionFilter(ev.data); - this._reportNewQueryAndRender(); - }, - /** - * @private - * @param {OdooEvent} ev - */ - _onFacetRemoved: function (ev) { - ev.stopPropagation(); - var group = ev.data.group || this.renderer.getLastFacet(); - if (group) { - this.model.deactivateGroup(group.id); - this._reportNewQueryAndRender(); - } - }, - /** - * @private - * @param {OdooEvent} ev - */ - _onGetSearchQuery: function (ev) { - ev.stopPropagation(); - var query = this.getSearchQuery(); - ev.data.callback(query); - }, - /** - * @private - * @param {OdooEvent} ev - */ - _onItemOptionClicked: function (ev) { - ev.stopPropagation(); - this.model.toggleFilterWithOptions(ev.data.id, ev.data.optionId); - this._reportNewQueryAndRender(); - }, - /** - * @private - * @param {OdooEvent} ev - */ - _onItemTrashed: function (ev) { - ev.stopPropagation(); - var def = this.model.deleteFilterEverywhere(ev.data.id); - def.then(this._reportNewQueryAndRender.bind(this)); - }, - /** - * @private - * @param {OdooEvent} ev - */ - _onMenuItemClicked: function (ev) { - ev.stopPropagation(); - this.model.toggleFilter(ev.data.id); - this._reportNewQueryAndRender(); - }, - /** - * @private - * @param {OdooEvent} ev - */ - _onNewFavorite: function (ev) { - ev.stopPropagation(); - var def = this.model.createNewFavorite(ev.data); - def.then(this._reportNewQueryAndRender.bind(this)); - }, - /** - * @private - * @param {OdooEvent} ev - */ - _onNewFilters: function (ev) { - ev.stopPropagation(); - this.model.createNewFilters(ev.data.filters); - this._reportNewQueryAndRender(); - }, - /** - * @private - * @param {OdooEvent} ev - */ - _onNewGroupBy: function (ev) { - ev.stopPropagation(); - this.model.createNewGroupBy(ev.data); - this._reportNewQueryAndRender(); - }, - /** - * @private - * @param {OdooEvent} ev - */ - _onReload: function (ev) { - ev.stopPropagation(); - this.trigger_up('search', this.model.getQuery()); - }, - /** - * @private - * @param {OdooEvent} ev - */ - _onReset: function (ev) { - ev.stopPropagation(); - var state = this.model.get(); - this.renderer.updateState(state); - }, -}); - -return ControlPanelController; - -}); diff --git a/addons/web/static/src/js/views/control_panel/control_panel_model.js b/addons/web/static/src/js/views/control_panel/control_panel_model.js deleted file mode 100644 index 40a84db99c6..00000000000 --- a/addons/web/static/src/js/views/control_panel/control_panel_model.js +++ /dev/null @@ -1,1269 +0,0 @@ -odoo.define('web.ControlPanelModel', function (require) { -"use strict"; - -var controlPanelViewParameters = require('web.controlPanelViewParameters'); -var core = require('web.core'); -var Domain = require('web.Domain'); -var mvc = require('web.mvc'); -var pyUtils = require('web.py_utils'); -var searchBarAutocompleteRegistry = require('web.search_bar_autocomplete_sources_registry'); -var session = require('web.session'); - -var _t = core._t; - -const DEFAULT_TIMERANGE = controlPanelViewParameters.DEFAULT_TIMERANGE; -let TIME_RANGE_OPTIONS = controlPanelViewParameters.TIME_RANGE_OPTIONS; -let COMPARISON_TIME_RANGE_OPTIONS = controlPanelViewParameters.COMPARISON_TIME_RANGE_OPTIONS; -const OPTION_GENERATORS = controlPanelViewParameters.OPTION_GENERATORS; -const YEAR_OPTIONS = controlPanelViewParameters.YEAR_OPTIONS; -const { rank } = controlPanelViewParameters; - -// Returns a predicate used to test if two arrays (of maximal length 2) have the same basic content. -function isEqualTo (array1) { - if (array1.length === 1) { - return array2 => array2.length === 1 && array2[0] === array1[0]; - } else { - return array2 => array2.length === 2 && array2[0] === array1[0] && array2[1] === array1[1]; - } -} - -var ControlPanelModel = mvc.Model.extend({ - /** - * @override - * @param {string} [params.actionId] - * @param {string} [params.context={}] - * @param {string} [params.domain=[]] - * @param {string} [params.field={}] - * @param {string} [params.modelName] - */ - init: function (parent, params) { - this._super.apply(this, arguments); - - // Tricks to avoid losing information on filter descriptions in control panel model configuration - TIME_RANGE_OPTIONS = TIME_RANGE_OPTIONS.map(function (option) { - return _.extend({}, option, {description: option.description.toString()}); - }); - COMPARISON_TIME_RANGE_OPTIONS = COMPARISON_TIME_RANGE_OPTIONS.map(function (option) { - return _.extend({}, option, {description: option.description.toString()}); - }); - - - this.modelName = params.modelName; - // info on fields of model this.modelName - this.fields = params.fields || {}; - - // info on current action - this.actionId = params.actionId; - this.actionContext = params.context || {}; - this.actionDomain = params.domain || []; - - // triple determining a control panel model configuration - this.filters = {}; - this.groups = {}; - this.query = []; - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Activate a given filter of type 'timeRange' with a timeRangeId - * and optionaly a comparsionTimeRangeId - * - * @param {string} filterId - * @param {string} timeRangeId - * @param {string} [comparisonTimeRangeId] - */ - activateTimeRange: function (filterId, timeRangeId, comparisonTimeRangeId) { - var filter = this.filters[filterId]; - filter.timeRangeId = timeRangeId || filter.defaultTimeRangeId; - filter.comparisonTimeRangeId = comparisonTimeRangeId; - var group = this.groups[filter.groupId]; - var groupActive = group.activeFilterIds.length; - if (groupActive) { - group.activeFilterIds = [[filterId]]; - } else { - this.toggleFilter(filterId); - } - }, - /** - * Ccreate an ir_filter server side. If the operation is successful, a new - * filter of type 'favorite' is created and activated. - * - * @param {Object} newFavorite - */ - createNewFavorite: function (newFavorite) { - return this._saveQuery(_.pick( - newFavorite, - ['description', 'isDefault', 'isShared', 'type'] - )).then(function () { - newFavorite.on_success(); - }); - }, - /** - * Create new filters of type 'filter' with same new groupId and groupNumber. - * They are activated. - * - * @param {Object[]} newFilters - * @returns {string[]} filterIds, ids of the newly created filters - */ - createNewFilters: function (newFilters) { - var self = this; - var filterIds = []; - var groupNumber = this._generateNewGroupNumber(); - this._createGroupOfFilters(newFilters); - newFilters.forEach(function (filter) { - filter.groupNumber = groupNumber; - self.toggleFilter(filter.id); - filterIds.push(filter.id); - }); - return filterIds; - }, - /** - * Create a new groupBy with the groupId shared by all filters of type 'groupBy' - * but a new groupNumber - * It is activated. - * - * @param {Object} newGroupBy - */ - createNewGroupBy: function (newGroupBy) { - var id = _.uniqueId('__filter__'); - newGroupBy.id = id; - newGroupBy.groupId = this._getGroupIdOfType('groupBy'); - newGroupBy.groupNumber = this._generateNewGroupNumber(); - this.filters[id] = newGroupBy; - if (_.contains(['date', 'datetime'], newGroupBy.fieldType)) { - this.toggleFilterWithOptions(newGroupBy.id); - } else { - this.toggleFilter(newGroupBy.id); - } - }, - /** - * Ensure that the filters determined by the given filterIds are - * deactivated (if one or many of them are already deactivated, nothing bad happens) - * - * @param {string[]} filterIds - */ - deactivateFilters: function (filterIds) { - var self = this; - filterIds.forEach(function (filterId) { - var filter = self.filters[filterId]; - var group = self.groups[filter.groupId]; - if (group.activeFilterIds.some(isEqualTo([filterId]))) { - self.toggleFilter(filterId); - } - }); - }, - /** - * Deactivate all filters in a given group with given id. - * - * @param {string} groupId - */ - deactivateGroup: function (groupId) { - var self = this; - var group = this.groups[groupId]; - _.each(group.activeFilterIds, id => { - var filter = self.filters[id[0]]; - // TODO: put this logic in toggleFilter 'field' type - if (filter.autoCompleteValues) { - filter.autoCompleteValues = []; - } - if (filter.currentOptionIds) { - filter.currentOptionIds.clear(); - } - }); - // TODO: use toggleFilter here - group.activeFilterIds = []; - this.query.splice(this.query.indexOf(groupId), 1); - }, - /** - * Delete a filter of type 'favorite' with given filterId server side and in control panel model. - * Of course this forces the filter to be removed from the search query. - * - * @param {string} filterId - */ - deleteFilterEverywhere: function (filterId) { - var self = this; - var filter = this.filters[filterId]; - var def = this.deleteFilter(filter.serverSideId).then(function () { - const groupOfFavorites = self.groups[filter.groupId]; - const isActive = groupOfFavorites.activeFilterIds.some(isEqualTo([filterId])); - if (isActive) { - self.toggleFilter(filterId); - } - delete self.filters[filterId]; - }); - return def; - }, - /** - * Return the state of the control panel (the filters, groups and the - * current query). This state can then be used in an other control panel - * model (with same key modelName) via the importState method. - * - * @returns {Object} - */ - exportState: function () { - return { - filters: this.filters, - groups: this.groups, - query: this.query, - }; - }, - /** - * @override - * - * @returns {Object} - */ - get: function () { - var self = this; - // we maintain a unique source activeFilterIds that contain information - // on active filters. But the renderer can have more information since - // it does not modifies filters activity. - // We thus give a different structure to renderer that may contain duplicated - // information. - // Note that filters are filters of filter type only, groupbys are groupbys,...! - var filterFields = []; - var filters = []; - var groupBys = []; - var timeRanges = []; - var favorites = []; - Object.keys(this.filters).forEach(function (filterId) { - var filter = _.extend({}, self.filters[filterId]); - if (filter.invisible) { - return; - } - var group = self.groups[filter.groupId]; - filter.isActive = group.activeFilterIds.some(id => id[0] === filterId); - if (filter.type === 'field') { - filterFields.push(filter); - } - if (filter.type === 'filter') { - filters.push(filter); - } - if (filter.type === 'groupBy') { - groupBys.push(filter); - } - if (filter.type === 'favorite') { - favorites.push(filter); - } - if (filter.type === 'timeRange') { - timeRanges.push(filter); - } - }); - var facets = this._getFacets(); - favorites = _.sortBy(favorites, 'groupNumber'); - return { - facets: facets, - filterFields: filterFields, - filters: filters, - groupBys: groupBys, - timeRanges: timeRanges, - favorites: favorites, - groups: this.groups, - query: _.extend([], this.query), - fields: this.fields, - }; - }, - /** - * @returns {Object} An object called search query with keys domain, groupBy, - * context, orderedBy. - */ - getQuery: function () { - var userContext = session.user_context; - var context = _.extend( - pyUtils.eval('contexts', this._getQueryContext(), userContext), - this._getTimeRangeMenuData(true) - ); - var domain = Domain.prototype.stringToArray(this._getDomain(), userContext); - // this must be done because pyUtils.eval does not know that it needs to evaluate domains within contexts - if (context.timeRangeMenuData) { - if (typeof context.timeRangeMenuData.timeRange === 'string') { - context.timeRangeMenuData.timeRange = pyUtils.eval('domain', context.timeRangeMenuData.timeRange); - } - if (typeof context.timeRangeMenuData.comparisonTimeRange === 'string') { - context.timeRangeMenuData.comparisonTimeRange = pyUtils.eval('domain', context.timeRangeMenuData.comparisonTimeRange); - } - } - var action_context = this.actionContext; - var results = pyUtils.eval_domains_and_contexts({ - domains: [this.actionDomain].concat([domain] || []), - contexts: [action_context].concat(context || []), - eval_context: session.user_context, - }); - if (results.error) { - throw new Error(_.str.sprintf(_t("Failed to evaluate search criterions")+": \n%s", - JSON.stringify(results.error))); - } - - var groupBys = this._getGroupBy(); - var groupBy = groupBys.length ? - groupBys : - (this.actionContext.group_by || []); - groupBy = (typeof groupBy === 'string') ? [groupBy] : groupBy; - - context = _.omit(results.context, 'time_ranges'); - - return { - context: context, - domain: results.domain, - groupBy: groupBy, - orderedBy: this._getOrderedBy(), - }; - }, - /** - * Set filters, groups, and query keys according to the given state. - * - * @param {Object} state - */ - importState: function (state) { - this.filters = state.filters; - this.groups = state.groups; - this.query = state.query; - }, - /** - * The load method is the place where the favorites are also loaded and - * the model state is first established. - * The computation of the active filters at initialization is accomplished - * here. - * - * @param {Object} params - * @param {boolean} [params.activateDefaultFavorite=false] - * @param {Array[]} [params.groups=[]] - * @param {Object} [params.initialState] - * @param {string[]} [params.searchMenuTypes=[]] - * @param {Object} [params.timeRanges] - * @param {boolean} [params.withSearchBar] - * @returns {Promise} - */ - load: function (params) { - var self = this; - this.searchMenuTypes = params.searchMenuTypes || []; - this.activateDefaultFavorite = params.activateDefaultFavorite; - - if (!params.withSearchBar && params.searchMenuTypes.length === 0) { - // The model state will remain as set in init method - // and the info comming from arch put in params.groups (if any) - // will be lost. This is not a problem because the state - // won't be use elsewhere. - return Promise.resolve(); - } - if (params.initialState) { - this.importState(params.initialState); - return Promise.resolve(); - } else { - var groups = params.groups || []; - groups.forEach(function (group) { - self._createGroupOfFilters(group); - }); - if (this._getGroupIdOfType('groupBy') === undefined) { - this._createEmptyGroup('groupBy'); - } - this._createGroupOfTimeRanges(); - return Promise.all(self._loadSearchDefaults()).then(function () { - return self._loadFavorites().then(function () { - if (self.query.length === 0) { - self._activateDefaultFilters(); - self._activateDefaultTimeRanges(params.timeRanges); - } - }); - }); - } - }, - /** - * Toggle a filter with given id in a way appropriate to its type. - * - * @param {Object} params - * @param {string} params.filterId - * @param {Object} params.autoCompleteValues - */ - toggleAutoCompletionFilter: function (params) { - var filter = this.filters[params.filterId]; - if (filter.type === 'field') { - filter.autoCompleteValues = params.autoCompleteValues; - // the autocompletion filter is dynamic - filter.domain = this._getAutoCompletionFilterDomain(filter); - // active the filter - var group = this.groups[filter.groupId]; - if (!group.activeFilterIds.some(isEqualTo([filter.id]))) { - group.activeFilterIds.push([filter.id]); - this.query.push(group.id); - } - } else { - if (filter.hasOptions) { - this.toggleFilterWithOptions(filter.id); - } else { - this.toggleFilter(filter.id); - } - } - }, - /** - * Toggle a filter throught the modification of this.groups and potentially - * of this.query and this.filters. - * - * @param {string} filterId - */ - toggleFilter: function (filterId) { - var self = this; - var filter = this.filters[filterId]; - var group = this.groups[filter.groupId]; - var index = group.activeFilterIds.findIndex(isEqualTo([filterId])); - var initiaLength = group.activeFilterIds.length; - if (index === -1) { - // we need to deactivate all groups when activating a favorite - if (filter.type === 'favorite') { - this.query.forEach(function (groupId) { - const group = self.groups[groupId]; - group.activeFilterIds.forEach(id => { - const filter = self.filters[id[0]]; - if (filter.autoCompleteValues) { - filter.autoCompleteValues = []; - } - if (filter.currentOptionIds) { - filter.currentOptionIds.clear(); - } - }) - group.activeFilterIds = []; - }); - this.query = []; - } - group.activeFilterIds.push([filterId]); - // if initiaLength is 0, the group was not active. - if (filter.type === 'favorite' || initiaLength === 0) { - this.query.push(group.id); - } - } else { - if (filter.type === 'field' && filter.autoCompleteValues) { - filter.autoCompleteValues = []; - } - group.activeFilterIds.splice(index, 1); - // if initiaLength is 1, the group is now inactive. - if (initiaLength === 1) { - this.query.splice(this.query.indexOf(group.id), 1); - } - } - }, - /** - * Used to toggle a given filter(Id) that has options with a given option(Id). - * - * @param {string} filterId - * @param {string} [optionId] - */ - toggleFilterWithOptions: function (filterId, optionId) { - const filter = this.filters[filterId]; - optionId = optionId || filter.defaultOptionId; - const group = this.groups[filter.groupId]; - - const selectedYears = () => YEAR_OPTIONS.reduce( - (acc, y) => { - if (filter.currentOptionIds.has(y.optionId)) { - acc.push(y.optionId); - } - return acc; - }, - [] - ); - const defaultYearId = (optionId) => { - const year = filter.options.find(o => o.optionId === optionId).defaultYear; - return filter.options.find(o => o.setParam.year === year).optionId; - }; - - if (filter.type === 'filter') { - const alreadyActive = group.activeFilterIds.some(isEqualTo([filterId])); - if (alreadyActive) { - if (filter.currentOptionIds.has(optionId)) { - filter.currentOptionIds.delete(optionId); - if (!selectedYears().length) { - // 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. - filter.currentOptionIds.clear(); - } - if (!filter.currentOptionIds.size) { - // Here no option is selected so that the filter becomes inactive. - this.toggleFilter(filterId); - } - } else { - filter.currentOptionIds.add(optionId); - } - } else { - this.toggleFilter(filterId); - filter.currentOptionIds.add(optionId); - if (!selectedYears().length) { - // Here we add 'this_year' as options if no option of type year is already selected. - filter.currentOptionIds.add(defaultYearId(optionId)); - } - } - } else if (filter.type === 'groupBy') { - const combinationId = [filterId, optionId]; - const initiaLength = group.activeFilterIds.length; - const index = group.activeFilterIds.findIndex(isEqualTo(combinationId)); - - if (index === -1) { - group.activeFilterIds = this._insert(group.activeFilterIds, combinationId); - filter.currentOptionIds.add(optionId); - if (initiaLength === 0) { - this.query.push(group.id); - } - } else { - group.activeFilterIds.splice(index, 1); - filter.currentOptionIds.delete(optionId); - if (initiaLength === 1) { - this.query.splice(this.query.indexOf(group.id), 1); - } - } - } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Activate all filters (but favorites) with key isDefault=true - * - * @private - */ - _activateDefaultFilters: function () { - Object.values(this.filters) - .filter(f => f.isDefault && f.type !== 'favorite') - .sort((f1, f2) => (f1.defaultRank || 100) - (f2.defaultRank || 100)) - .forEach(f => { - if (f.hasOptions) { - this.toggleFilterWithOptions(f.id); - } else { - this.toggleFilter(f.id); - } - }); - }, - /** - * If defaultTimeRanges param is provided, activate the filter of type - * 'timeRange' it determines with the correct options. - * - * @private - * @param {Object} [defaultTimeRanges] - * @param {string} [defaultTimeRanges.field], field of type 'date' or 'datetime' - * @param {string} [defaultTimeRanges.range], to choose among the following: - * 'last_7_days', 'last_30_days', 'last_365_days', 'today', 'this_week', - * 'this_month', 'this_quarter', 'this_year', 'yesterday', 'last_week', - * 'last_month', 'last_quarter', 'last_year' - * @param {'previous_period'|'previous_year'} [defaultTimeRanges.comparisonRange] - */ - _activateDefaultTimeRanges: function (defaultTimeRanges) { - if (defaultTimeRanges) { - var filter = _.find(this.filters, function (filter) { - return filter.type === 'timeRange' && filter.fieldName === defaultTimeRanges.field; - }); - if (filter) { - this.activateTimeRange( - filter.id, - defaultTimeRanges.range, - defaultTimeRanges.comparisonRange - ); - } - } - }, - /** - * Create a new filter of type 'favorite' and toggle it. - * It belongs to the unique group of favorites. - * - * @private - * @param {Object} favorite - */ - _addNewFavorite: function (favorite) { - var id = _.uniqueId('__filter__'); - favorite.id = id; - favorite.groupId = this._getGroupIdOfType('favorite'); - this.filters[id] = favorite; - this.toggleFilter(favorite.id); - }, - /** - * Computes the string representation of the current domain associated to a date filter - * starting from its currentOptionIds. - * - * @param {Object} filter - * @returns {string} - */ - _computeDateFilterDomain: function (filter) { - const domains = []; - const p = _.partition([...filter.currentOptionIds], optionId => - OPTION_GENERATORS.find(o => o.optionId === optionId).groupId === 1); - const yearIds = p[1]; - const otherOptionIds = p[0]; - // the following case corresponds to years selected only - if (otherOptionIds.length === 0) { - yearIds.forEach(yearId => { - const d = filter.basicDomains[yearId]; - domains.push(d.domain); - }); - } else { - otherOptionIds.forEach(optionId => { - yearIds.forEach(yearId => { - const d = filter.basicDomains[yearId + '__' + optionId]; - domains.push(d.domain); - }); - }); - } - return pyUtils.assembleDomains(domains, 'OR'); - }, - /** - * Add a new empty group to this.groups of a specified type. - * - * @private - * @param {string} type - */ - _createEmptyGroup: function (type) { - var id = _.uniqueId('__group__'); - this.groups[id] = { - id: id, - type: type, - activeFilterIds: [], - }; - }, - /** - * Using a list of 'prefilters', create a new group in this.groups and a new - * filter for each prefilter. The new filters are put in the new group. - * - * @private - * @param {Object[]} group, list of 'prefilters' - */ - _createGroupOfFilters: function (group) { - var self= this; - var type; - var groupId = _.uniqueId('__group__'); - group.forEach(function (filter) { - var id = _.uniqueId('__filter__'); - filter.id = id; - filter.groupId = groupId; - type = filter.type; - self.filters[id] = filter; - }); - this.groups[groupId] = { - id: groupId, - type: type, - activeFilterIds: [], - }; - }, - /** - * Add a group of type 'timeRange' in this.groups and generate a filter - * of the same type for each suitable field in this.fields. The new filters - * are put in the new group. - * - * @private - */ - _createGroupOfTimeRanges: function () { - var self = this; - var timeRanges = []; - Object.keys(this.fields).forEach(function (fieldName) { - var field = self.fields[fieldName]; - var fieldType = field.type; - if (_.contains(['date', 'datetime'], fieldType) && field.sortable) { - timeRanges.push({ - type: 'timeRange', - description: field.string, - fieldName : fieldName, - fieldType: fieldType, - timeRangeId: false, - comparisonTimeRangeId: false, - defaultTimeRangeId: DEFAULT_TIMERANGE, - timeRangeOptions: TIME_RANGE_OPTIONS, - comparisonTimeRangeOptions: COMPARISON_TIME_RANGE_OPTIONS - }); - } - }); - if (timeRanges.length) { - this._createGroupOfFilters(timeRanges); - } else { - // create empty timeRange group - this._createEmptyGroup('timeRange'); - } - }, - /** - * Create a new groupNumber not already used elsewhere. - * Group numbers are used to separate graphically groups of items in the - * search menus (filter menu, groupBy menu,...). - * - * @private - * @returns {number} groupNumber - */ - _generateNewGroupNumber: function () { - var self = this; - var groupNumber = 1 + Object.keys(this.filters).reduce( - function (max, filterId) { - var filter = self.filters[filterId]; - if (filter.groupNumber) { - max = Math.max(filter.groupNumber, max); - } - return max; - }, - 1 - ); - return groupNumber; - }, - /** - * @private - * @param {Object} filter - * @returns {string} domain - */ - _getAutoCompletionFilterDomain: function (filter) { - var domain = ""; - var field = this.fields[filter.attrs.name]; - // TODO: should not do that, the domain logic should be put somewhere else - var Obj = searchBarAutocompleteRegistry.getAny([filter.attrs.widget, field.type]); - if (Obj) { - var obj = new (Obj) (this, filter, field, this.actionContext); - domain = obj.getDomain(filter.autoCompleteValues); - } - return domain; - }, - /** - * Return the string representation of a domain created by combining - * appropriately (with an 'AND') the domains coming from the active groups. - * - * @private - * @returns {string} the string representation of a domain - */ - _getDomain: function () { - var self = this; - var domains = this.query.map(function (groupId) { - var group = self.groups[groupId]; - return self._getGroupDomain(group); - }); - return pyUtils.assembleDomains(domains, 'AND'); - }, - /** - * Return an array containing 'facets' used to create the content of the search bar. - * - * @returns {Object} - */ - _getFacets: function () { - var self = this; - // resolve active filters for facets - return this.query.map(groupId => { - var group = self.groups[groupId]; - var facet = _.extend({}, group); - if (group.type === 'groupBy') { - facet.filters = group.activeFilterIds.map(id => { - let filter = _.extend({}, self.filters[id[0]]); - if (filter.hasOptions) { - filter.optionId = id[1]; - } - return filter; - }); - } else { - facet.filters = _.compact(group.activeFilterIds.map(id => self.filters[id[0]])); - } - return facet; - }); - }, - /** - * Return the context of the provided filter. - * - * @private - * @param {Object} filter - * @returns {Object} context - */ - _getFilterContext: function (filter) { - var 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.attrs.context) { - context = pyUtils.eval('context', filter.attrs.context, { - self: _.map(filter.autoCompleteValues, function (autoCompleteValue) { - return autoCompleteValue.value; - }), - }); - } - // the following code aims to restore 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) { - if (this.fields[filter.attrs.name].type === 'many2one') { - var value = filter.defaultValue; - // the following if required to make the main_flow_tour pass (see - // https://github.com/odoo/odoo/blob/12.0/addons/web/static/src/js/views/search/search_inputs.js#L461) - if (_.isArray(filter.defaultValue)) { - value = filter.defaultValue[0]; - } - context['default_' + filter.attrs.name] = value; - } - } - return context; - }, - /** - * Compute (if possible) the domain of the provided filter. - * - * @private - * @param {Object} filter - * @returns {string|undefined} domain, string representation of a domain - */ - _getFilterDomain: function (filter) { - let domain; - if (filter.type === 'filter') { - domain = filter.domain; - if (filter.hasOptions) { - domain = this._computeDateFilterDomain(filter); - } - } else if (filter.type === 'favorite') { - domain = filter.domain; - } else if (filter.type === 'field') { - domain = filter.domain; - } - return domain; - }, - /** - * Compute the groupBys (if possible) of the provided filter. - * - * @private - * @param {Array} filterId - * @returns {string[]|undefined} groupBys - */ - _getFilterGroupBys: function (filterId) { - var groupBys; - var filter = this.filters[filterId[0]]; - if (filter.type === 'groupBy') { - var optionId = filterId[1]; - var groupBy = filter.fieldName; - if (optionId) { - groupBy = groupBy + ':' + optionId; - } - groupBys = [groupBy]; - } - if (filter.type === 'favorite') { - groupBys = filter.groupBys; - } - return groupBys; - }, - /** - * Return the concatenation of groupBys comming from the active filters. - * The array this.query encoding the order in which the groups have been - * activated, the results respect 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'. - * - * @private - * @returns {string[]} groupBys - */ - _getGroupBy: function () { - var self = this; - var groupBys = this.query.reduce( - function (acc, groupId) { - var group = self.groups[groupId]; - return acc.concat(self._getGroupGroupBys(group)); - }, - [] - ); - return groupBys; - }, - /** - * Return the list of the contexts of the filters acitve in the given - * group. - * - * @private - * @param {Object} group - * @returns {Object[]} - */ - _getGroupContexts: function (group) { - var self = this; - var contexts = group.activeFilterIds.map(function (filterId) { - var filter = self.filters[filterId[0]]; - return self._getFilterContext(filter); - }); - return _.compact(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: function (group) { - var self = this; - var domains = group.activeFilterIds.map(function (filterId) { - var filter = self.filters[filterId[0]]; - return self._getFilterDomain(filter); - }); - return pyUtils.assembleDomains(_.compact(domains), 'OR'); - }, - /** - * Return the groupBys coming form the filters active in the given group. - * - * @private - * @param {Object} group - * @returns {string[]} - */ - _getGroupGroupBys: function (group) { - var self = this; - var groupBys = group.activeFilterIds.reduce( - function (acc, filterId) { - acc = acc.concat(self._getFilterGroupBys(filterId)); - return acc; - }, - [] - ); - return _.compact(groupBys); - }, - /** - * Return the id of the group with the provided type. - * - * @private - * @param {'groupBy'|'favorite'|'timeRange'} type - * @returns {string} - */ - _getGroupIdOfType: function (type) { - var self = this; - return _.find(Object.keys(this.groups), function (groupId) { - var group = self.groups[groupId]; - return group.type === type; - }); - }, - /** - * Used to get the key orderedBy of a favorite. - * - * @private - * @returns {Object[]|undefined} orderedBy - */ - _getOrderedBy: function () { - var orderedBy; - var id = this._getGroupIdOfType('favorite'); - if (this.query.indexOf(id) !== -1) { - // if we are here, this means that the group of favorite is - // active and activeFilterIds is a list of length one. - var group = this.groups[id]; - var activeFavoriteId = group.activeFilterIds[0][0]; - var favorite = this.filters[activeFavoriteId]; - if (favorite.orderedBy && favorite.orderedBy.length) { - orderedBy = favorite.orderedBy; - } - } - return orderedBy; - }, - /** - * Return the list of the contexts of active filters. - * - * @private - * @returns {Object[]} - */ - _getQueryContext: function () { - var self = this; - var contexts = this.query.reduce( - function (acc, groupId) { - var group = self.groups[groupId]; - acc = acc.concat(self._getGroupContexts(group)); - return acc; - }, - [] - ); - return _.compact(contexts); - }, - /** - * Return an empty object or an object with a key timeRangeMenuData - * containing info on time ranges and their descriptions if a filter of type - * 'timeRange' is activated (only one can be). - * The key timeRange and comparisonTimeRange will be string or array - * representation of domains according to the value of evaluation: - * array if evaluation is true, string if false. - * - * @private - * @param {boolean} [evaluation=false] - * @returns {Object} - */ - _getTimeRangeMenuData: function (evaluation) { - var context = {}; - // groupOfTimeRanges can be undefined in case with withSearchBar is false - var groupOfTimeRanges = this.groups[this._getGroupIdOfType('timeRange')]; - if (groupOfTimeRanges && groupOfTimeRanges.activeFilterIds.length) { - var filter = this.filters[groupOfTimeRanges.activeFilterIds[0][0]]; - - var comparisonTimeRange = "[]"; - var comparisonTimeRangeDescription; - - var timeRange = Domain.prototype.constructDomain( - filter.fieldName, - filter.timeRangeId, - filter.fieldType - ); - var timeRangeDescription = _.find(filter.timeRangeOptions, function (option) { - return option.optionId === filter.timeRangeId; - }).description.toString(); - if (filter.comparisonTimeRangeId) { - comparisonTimeRange = Domain.prototype.constructDomain( - filter.fieldName, - filter.timeRangeId, - filter.fieldType, - filter.comparisonTimeRangeId - ); - comparisonTimeRangeDescription = _.find(filter.comparisonTimeRangeOptions, function (comparisonOption) { - return comparisonOption.optionId === filter.comparisonTimeRangeId; - }).description.toString(); - } - if (evaluation) { - timeRange = Domain.prototype.stringToArray(timeRange); - comparisonTimeRange = Domain.prototype.stringToArray(comparisonTimeRange); - } - context = { - timeRangeMenuData: { - comparisonField: filter.fieldName, - timeRange: timeRange, - timeRangeDescription: timeRangeDescription, - comparisonTimeRange: comparisonTimeRange, - comparisonTimeRangeDescription: comparisonTimeRangeDescription, - } - }; - } - return context; - }, - /** - * Insert a combination of type [filterId, optionId] in activeFilterIds for - * a corresponding filter of type 'groupBy' with options. - * The array activeFilterIds is by construction of the form - * - * A1 'concat' A2 'concat' A3 - * - * where filterId does not occur in the arrays A1 and A3 (possibly empty) and - * A2 = [[filterId, optionId_1], ..., [filterId, optionId_n]] (possibly empty). - * - * The idea of the function below is to insert [filterId, optionId] at the end - * of activeFilterIds if A2 is empty and inside A2 otherwise while keeping A2 ordered - * for the natural order on option ids: 'year' < 'quarter' < ... < 'day'. - * - * @private - * @param {Array[]} activeFilterIds - * @param {Array} combinationId - * @returns {Array[]} - */ - _insert(activeFilterIds, combinationId) { - const filterId = combinationId[0]; - let firstIndex = -1; - let lastIndex = -1; - activeFilterIds.forEach((cId, i) => { - if (cId[0] === filterId) { - firstIndex = firstIndex === -1 ? i : firstIndex; - lastIndex = i; - } - }); - if (firstIndex === -1) { // case A2 empty - activeFilterIds.push(combinationId); - } else { // case A2 non empty - const a1 = activeFilterIds.slice(0, firstIndex); - const a2 = activeFilterIds.slice(firstIndex, lastIndex + 1); - const a3 = activeFilterIds.slice(lastIndex + 1); - - a2.push(combinationId); - a2.sort((c1, c2) => rank(c1[1]) - rank(c2[1])); - - activeFilterIds = [].concat(a1, a2, a3); - } - return activeFilterIds; - }, - /** - * Load custom filters in db, then create a group of type 'favorite' and a - * filter of type 'favorite' for each loaded custom filters. - * The new filters are put in the new group. - * Finally, if there exists (a necessarily unique) default favorite, it is activated - * if this.activateDefaultFavorite is true. - * - * @private - * @returns {Promise} - */ - _loadFavorites: function () { - var self = this; - var def = this.loadFilters(this.modelName,this.actionId).then(function (favorites) { - if (favorites.length) { - favorites = favorites.map(function (favorite) { - var userId = favorite.user_id ? favorite.user_id[0] : false; - var groupNumber = userId ? 1 : 2; - var context = pyUtils.eval('context', favorite.context, session.user_context); - var groupBys = []; - if (context.group_by) { - groupBys = context.group_by; - delete context.group_by; - } - var sort = JSON.parse(favorite.sort); - var orderedBy = sort.map(function (order) { - var fieldName; - var asc; - var 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 { - name: fieldName, - asc: asc, - }; - }); - return { - type: 'favorite', - description: favorite.name, - isRemovable: true, - groupNumber: groupNumber, - isDefault: favorite.is_default, - domain: favorite.domain, - groupBys: groupBys, - // we want to keep strings as long as possible - context: favorite.context, - orderedBy: orderedBy, - userId: userId, - serverSideId: favorite.id, - }; - }); - self._createGroupOfFilters(favorites); - if (self.activateDefaultFavorite) { - var defaultFavorite = _.find(self.filters, function (filter) { - return filter.type === 'favorite' && filter.isDefault; - }); - if (defaultFavorite) { - self.toggleFilter(defaultFavorite.id); - } - } - } else { - self._createEmptyGroup('favorite'); - } - }); - return def; - }, - /** - * Load search defaults and set the `domain` key on filter (of type `field`). - * Some search defaults need to fetch data (like m2o for example) so this - * is asynchronous. - * - * @private - * @returns {Promise[]} - */ - _loadSearchDefaults: function () { - var self = this; - var defs = []; - _.each(this.filters, function (filter) { - if (filter.type === 'field' && filter.isDefault) { - var def; - var field = self.fields[filter.attrs.name]; - var value = filter.defaultValue; - if (field.type === 'many2one') { - if (value instanceof Array) { - // M2O search fields do not currently handle multiple default values - // there are many cases of {search_default_$m2ofield: [id]}, need - // to handle this as if it were a single value. - value = value[0]; - } - def = self._rpc({ - model: field.relation, - method: 'name_get', - args: [value], - context: self.actionContext, - }).then(function (result) { - var autocompleteValue = { - label: result[0][1], - value: value, - }; - filter.autoCompleteValues.push(autocompleteValue); - filter.domain = self._getAutoCompletionFilterDomain(filter); - }); - } else { - var autocompleteValue; - if (field.type === 'selection') { - var match = _.find(field.selection, function (sel) { - return sel[0] === value; - }); - autocompleteValue = { - label: match[1], - value: match[0], - }; - } else { - autocompleteValue = { - label: String(value), - value: value, - }; - } - filter.autoCompleteValues.push(autocompleteValue); - filter.domain = self._getAutoCompletionFilterDomain(filter); - } - if (def) { - defs.push(def); - } - } - }); - return defs; - }, - /** - * 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 operatio is successful, a new filter of type 'favorite' is - * created and activated. - * - * @private - * @param {Object} favorite - * @returns {Promise} - */ - _saveQuery: function (favorite) { - var self = this; - var userContext = session.user_context; - var controllerQueryParams; - this.trigger_up('get_controller_query_params', { - callback: function (state) { - controllerQueryParams = state; - }, - }); - var queryContext = this._getQueryContext(); - var timeRangeMenuInfo = this._getTimeRangeMenuData(false); - var context = pyUtils.eval( - 'contexts', - [userContext, controllerQueryParams.context, timeRangeMenuInfo].concat(queryContext) - ); - context = _.omit(context, Object.keys(userContext)); - var groupBys = this._getGroupBy(); - if (groupBys.length) { - context.group_by = groupBys; - } - var domain = this._getDomain(); - var userId = favorite.isShared ? false : session.uid; - var orderedBy = this._getOrderedBy() || []; - if (controllerQueryParams.orderedBy) { - orderedBy = controllerQueryParams.orderedBy; - } - var sort = orderedBy.map(function (order) { - return order.name + ((order.asc === false) ? " desc" : ""); - }); - - var irFilter = { - name: favorite.description, - context: context, - domain: domain, - is_default: favorite.isDefault, - user_id: userId, - model_id: this.modelName, - action_id: this.actionId, - sort: JSON.stringify(sort), - }; - return this.createFilter(irFilter).then(function (serverSideId) { - // we don't want the groupBys to be located in the context in control panel model - delete context.group_by; - favorite.isRemovable = true; - favorite.groupNumber = userId ? 1 : 2; - favorite.context = context; - favorite.groupBys = groupBys; - favorite.domain = domain; - favorite.orderedBy = orderedBy; - // not sure keys are usefull - favorite.userId = userId; - favorite.serverSideId = serverSideId; - self._addNewFavorite(favorite); - }); - }, -}); - -return ControlPanelModel; - -}); diff --git a/addons/web/static/src/js/views/control_panel/control_panel_renderer.js b/addons/web/static/src/js/views/control_panel/control_panel_renderer.js deleted file mode 100644 index 12d120b53ab..00000000000 --- a/addons/web/static/src/js/views/control_panel/control_panel_renderer.js +++ /dev/null @@ -1,387 +0,0 @@ -odoo.define('web.ControlPanelRenderer', function (require) { -"use strict"; - -var config = require('web.config'); -var data = require('web.data'); -var FavoriteMenu = require('web.FavoriteMenu'); -var FilterMenu = require('web.FilterMenu'); -var GroupByMenu = require('web.GroupByMenu'); -var mvc = require('web.mvc'); -var SearchBar = require('web.SearchBar'); -var TimeRangeMenu = require('web.TimeRangeMenu'); - -var Renderer = mvc.Renderer; - -var ControlPanelRenderer = Renderer.extend({ - template: 'ControlPanel', - custom_events: { - get_action_info: '_onGetActionInfo', - }, - events: _.extend({}, Renderer.prototype.events, { - 'click .o_searchview_more': '_onMore', - }), - - /** - * @override - * @param {Object} [params.action] current action if any - * @param {Object} [params.context] - * @param {Object[]} [params.breadcrumbs=[]] list of breadcrumbs elements - * @param {boolean} [params.withBreadcrumbs=false] if false, breadcrumbs - * won't be rendered - * @param {boolean} [params.withSearchBar=false] if false, no search bar - * is rendered - * @param {string[]} [params.searchMenuTypes=[]] determines the search menus - * that are displayed. - * @param {String} [params.template] the QWeb template to render the - * ControlPanel. By default, the template 'ControlPanel' will be used. - * @param {string} [params.title=''] the title visible in control panel - */ - init: function (parent, state, params) { - this._super.apply(this, arguments); - this._breadcrumbs = params.breadcrumbs || []; - this._title = params.title || ''; - this.withBreadcrumbs = params.withBreadcrumbs; - this.withSearchBar = params.withSearchBar; - if (params.template) { - this.template = params.template; - } - this.context = params.context; - - this.$subMenus = null; - this.action = params.action; - this.displaySearchMenu = true; - this.isMobile = config.device.isMobile; - this.menusSetup = false; - this.searchMenuTypes = params.searchMenuTypes || []; - this.subMenus = {}; - }, - /** - * Render the control panel and create a dictionnary of its exposed elements. - * - * @override - */ - start: function () { - var self = this; - - // exposed jQuery nodesets - this.nodes = { - $buttons: this.$('.o_cp_buttons'), - $pager: this.$('.o_cp_pager'), - $sidebar: this.$('.o_cp_sidebar'), - $switch_buttons: this.$('.o_cp_switch_buttons'), - }; - - // if we don't use the default search bar and buttons, we expose the - // corresponding areas for custom content - if (!this.withSearchBar) { - this.nodes.$searchview = this.$('.o_cp_searchview'); - } - if (this.searchMenuTypes.length === 0) { - this.nodes.$searchview_buttons = this.$('.o_search_options'); - } - - if (this.withBreadcrumbs) { - this._renderBreadcrumbs(); - } - - var superDef = this._super.apply(this, arguments); - var searchDef = this._renderSearch(); - return Promise.all([superDef, searchDef]).then(function () { - self._setSearchMenusVisibility(); - }); - }, - /** - * @override - */ - on_attach_callback: function () { - this._focusSearchInput(); - }, - /** - * @override - */ - on_detach_callback: function () { - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @returns {Object|undefined} - */ - getLastFacet: function () { - return this.state.facets.slice(-1)[0]; - }, - /** - * This function is called when actions call 'updateControlPanel' with - * custom contents to insert in the exposed areas. - * - * @param {Object} status - * @param {Object} [status.cp_content] dictionnary containing the jQuery - * elements to insert in the exposed areas - * @param {string} [status.breadcrumbs] the breadcrumbs to display before - * the current controller - * @param {string} [status.title] the title of the current controller, to - * display at the end of the breadcrumbs - * @param {Object} [options] - * @param {Boolean} [options.clear=true] set to false to keep control panel - * elements that are not in status.cp_content (useful for partial updates) - */ - updateContents: function (status, options) { - var new_cp_content = status.cp_content || {}; - var clear = 'clear' in (options || {}) ? options.clear : true; - - if (this.withBreadcrumbs) { - this._breadcrumbs = status.breadcrumbs || this._breadcrumbs; - this._title = status.title || this._title; - this._renderBreadcrumbs(); - } - - if (clear) { - this._detachContent(this.nodes); - } else { - this._detachContent(_.pick(this.nodes, _.keys(new_cp_content))); - } - this._attachContent(new_cp_content); - }, - /** - * Update the state of the renderer state. It retriggers a full rerendering. - * - * @param {Object} state - * @returns {Promise} - */ - updateState: function (state) { - this.state = state; - return this._renderSearch(); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - * @param {Object} content dictionnary of jQuery elements to attach, whose - * keys are jQuery nodes identifiers in this.nodes - */ - _attachContent: function (content) { - for (var $element in content) { - var $nodeset = content[$element]; - if ($nodeset && this.nodes[$element]) { - this.nodes[$element].append($nodeset); - } - } - }, - /** - * @private - * @param {Object} content subset of this.nodes to detach - */ - _detachContent: function (content) { - for (var $element in content) { - content[$element].contents().detach(); - } - }, - /** - * @private - */ - _focusSearchInput: function () { - if (this.withSearchBar && !config.device.isMobileDevice) { - // in mobile mode, we would rather not focus manually the - // input, because it opens up the integrated keyboard, which is - // not what you expect when you just selected a filter. - this.searchBar.focus(); - } - }, - /** - * @private - * @param {string} menuType - * @returns {Objects[]} menuItems - */ - _getMenuItems: function (menuType) { - var menuItems; - if (menuType === 'filter') { - menuItems = this.state.filters; - } - if (menuType === 'groupBy') { - menuItems = this.state.groupBys; - } - if (menuType === 'timeRange') { - menuItems = this.state.timeRanges; - } - if (menuType === 'favorite') { - menuItems = this.state.favorites; - } - return menuItems; - }, - /** - * @private - * @returns {jQueryElement} - */ - _getSubMenusPlace: function () { - return $('<div>').appendTo(this.$('.o_search_options')); - }, - /** - * @private - */ - _renderBreadcrumbs: function () { - var self = this; - var breadcrumbsDescriptors = this._breadcrumbs.concat({title: this._title}); - var breadcrumbs = breadcrumbsDescriptors.map(function (bc, index) { - return self._renderBreadcrumbsItem(bc, index, breadcrumbsDescriptors.length); - }); - this.$('.breadcrumb').html(breadcrumbs); - }, - /** - * Render a breadcrumbs' li jQuery element. - * - * @private - * @param {Object} bc - * @param {string} bc.title - * @param {string} bc.controllerID - * @param {integer} index - * @param {integer} length - * @returns {jQueryElement} $bc - */ - _renderBreadcrumbsItem: function (bc, index, length) { - var self = this; - var is_last = (index === length-1); - var li_content = bc.title && _.escape(bc.title.trim()) || data.noDisplayContent; - var $bc = $('<li>', {class: 'breadcrumb-item'}) - .append(is_last ? li_content : $('<a>', {href: '#'}).html(li_content)) - .toggleClass('active', is_last); - if (!is_last) { - $bc.click(function (ev) { - ev.preventDefault(); - self.trigger_up('breadcrumb_clicked', {controllerID: bc.controllerID}); - }); - } - - var secondLast = index === length - 2; - if (secondLast) { - $bc.attr('accessKey', 'b'); - } - - return $bc; - }, - /** - * Renderer the search bar and the search menus - * - * @private - * @returns {Promise} - */ - _renderSearch: function () { - var defs = []; - if (this.menusSetup) { - this._updateMenus(); - } else { - this.menusSetup = true; - defs = defs.concat(this._setupMenus()); - } - if (this.withSearchBar) { - defs.push(this._renderSearchBar()); - } - return Promise.all(defs).then(this._focusSearchInput.bind(this)); - }, - /** - * @private - * @returns {Promise} - */ - _renderSearchBar: function () { - // TODO: might need a reload instead of a destroy/instantiate - var oldSearchBar = this.searchBar; - this.searchBar = new SearchBar(this, { - context: this.context, - facets: this.state.facets, - fields: this.state.fields, - filterFields: this.state.filterFields, - }); - return this.searchBar.appendTo(this.$('.o_searchview')).then(function () { - if (oldSearchBar) { - oldSearchBar.destroy(); - } - }); - }, - /** - * Hide or show the search menus according to this.displaySearchMenu. - * - * @private - */ - _setSearchMenusVisibility: function () { - this.$('.o_searchview_more') - .toggleClass('fa-search-plus', !this.displaySearchMenu) - .toggleClass('fa-search-minus', this.displaySearchMenu); - this.$('.o_search_options') - .toggleClass('o_hidden', !this.displaySearchMenu); - }, - /** - * Create a new menu of the given type and append it to this.$subMenus. - * This menu is also added to this.subMenus. - * - * @private - * @param {string} menuType - * @returns {Promise} - */ - _setupMenu: function (menuType) { - var Menu; - var menu; - if (menuType === 'filter') { - Menu = FilterMenu; - } - if (menuType === 'groupBy') { - Menu = GroupByMenu; - } - if (menuType === 'timeRange') { - Menu = TimeRangeMenu; - } - if (menuType === 'favorite') { - Menu = FavoriteMenu; - } - if (_.contains(['filter', 'groupBy', 'timeRange'], menuType)) { - menu = new Menu(this, this._getMenuItems(menuType), this.state.fields); - } - if (menuType === 'favorite') { - menu = new Menu(this, this._getMenuItems(menuType), this.action); - } - this.subMenus[menuType] = menu; - return menu.appendTo(this.$subMenus); - }, - /** - * Instantiate the search menu determined by this.searchMenuTypes. - * - * @private - * @returns {Promise[]} - */ - _setupMenus: function () { - this.$subMenus = this._getSubMenusPlace(); - return this.searchMenuTypes.map(this._setupMenu.bind(this)); - }, - /** - * Update the search menus. - * - * @private - */ - _updateMenus: function () { - var self = this; - this.searchMenuTypes.forEach(function (menuType) { - self.subMenus[menuType].update(self._getMenuItems(menuType)); - }); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Toggle the search menus visibility. - * - * @private - */ - _onMore: function () { - this.displaySearchMenu = !this.displaySearchMenu; - this._setSearchMenusVisibility(); - }, -}); - -return ControlPanelRenderer; - -}); diff --git a/addons/web/static/src/js/views/control_panel/control_panel_view.js b/addons/web/static/src/js/views/control_panel/control_panel_view.js deleted file mode 100644 index c2d4e3ebf33..00000000000 --- a/addons/web/static/src/js/views/control_panel/control_panel_view.js +++ /dev/null @@ -1,343 +0,0 @@ -odoo.define('web.ControlPanelView', function (require) { -"use strict"; - -var ControlPanelController = require('web.ControlPanelController'); -var ControlPanelModel = require('web.ControlPanelModel'); -var ControlPanelRenderer = require('web.ControlPanelRenderer'); -var controlPanelViewParameters = require('web.controlPanelViewParameters'); -var mvc = require('web.mvc'); -var pyUtils = require('web.py_utils'); -var viewUtils = require('web.viewUtils'); -var Domain = require('web.Domain'); - -var DEFAULT_INTERVAL = controlPanelViewParameters.DEFAULT_INTERVAL; -var DEFAULT_PERIOD = controlPanelViewParameters.DEFAULT_PERIOD; -var INTERVAL_OPTIONS = controlPanelViewParameters.INTERVAL_OPTIONS; -const OPTION_GENERATORS = controlPanelViewParameters.OPTION_GENERATORS; - -var Factory = mvc.Factory; - -var ControlPanelView = Factory.extend({ - config: _.extend({}, Factory.prototype.config, { - Controller: ControlPanelController, - Model: ControlPanelModel, - Renderer: ControlPanelRenderer, - }), - - /** - * @override - * @param {Object} [params={}] - * @param {Object} [params.action={}] - * @param {Object} [params.context={}] - * @param {string} [params.domain=[]] - * @param {string} [params.modelName] - * @param {string[]} [params.searchMenuTypes=[]] - * determines search menus displayed. - * @param {Object} [params.state] used to determine the control panel model - * essential content at load. For instance, state can be the state of an - * other control panel model that we want to use. - * @param {string} [params.title] the name of the action, to display in the - * breadcrumb - * @param {string} [params.template] the QWeb template to render - * @param {Object} [params.viewInfo={arch: '<search/>', fields: {}}] a - * search fieldsview - * @param {string} [params.viewInfo.arch] - * @param {boolean} [params.withBreadcrumbs=true] if set to false, - * breadcrumbs won't be rendered - * @param {boolean} [params.withSearchBar=true] if set to false, no default - * search bar will be rendered - * @param {Object[]} [params.dynamicFilters=[]] filters to add to the - * search (in addition to those described in the arch), each filter being - * an object with keys 'description' (what is displayed in the searchbar) - * and 'domain' - */ - init: function (params) { - var self = this; - this._super(); - params = params || {}; - var viewInfo = params.viewInfo || {arch: '<search/>', fields: {}}; - var context = _.extend({}, params.context); - var domain = params.domain || []; - var action = params.action || {}; - - this.searchDefaults = {}; - Object.keys(context).forEach(function (key) { - var match = /^search_default_(.*)$/.exec(key); - if (match) { - self.searchDefaults[match[1]] = context[key]; - delete context[key]; - } - }); - - this.arch = viewUtils.parseArch(viewInfo.arch); - this.fields = viewInfo.fields; - - this.referenceMoment = moment(); - - const setDescriptions = (options) => { - return 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 _.extend(oClone, {description: 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); - - this.controllerParams.modelName = params.modelName; - - this.modelParams.context = context; - this.modelParams.domain = domain; - this.modelParams.modelName = params.modelName; - this.modelParams.actionId = action.id; - this.modelParams.fields = this.fields; - - this.rendererParams.action = action; - this.rendererParams.breadcrumbs = params.breadcrumbs; - this.rendererParams.context = context; - this.rendererParams.searchMenuTypes = params.searchMenuTypes || []; - this.rendererParams.template = params.template; - this.rendererParams.title = params.title; - this.rendererParams.withBreadcrumbs = params.withBreadcrumbs !== false; - this.rendererParams.withSearchBar = 'withSearchBar' in params ? params.withSearchBar : true; - - this.loadParams.withSearchBar = 'withSearchBar' in params ? params.withSearchBar : true; - this.loadParams.searchMenuTypes = params.searchMenuTypes || []; - this.loadParams.activateDefaultFavorite = params.activateDefaultFavorite; - if (this.loadParams.withSearchBar) { - if (params.state) { - this.loadParams.initialState = params.state; - } else { - // groups are determined in _parseSearchArch - this.loadParams.groups = []; - this.loadParams.timeRanges = context.time_ranges; - this._parseSearchArch(this.arch); - } - } - - // add a filter group with the dynamic filters, if any - if (params.dynamicFilters && params.dynamicFilters.length) { - var dynamicFiltersGroup = params.dynamicFilters.map(function (filter) { - return { - description: filter.description, - domain: JSON.stringify(filter.domain), - isDefault: true, - type: 'filter', - }; - }); - this.loadParams.groups.unshift(dynamicFiltersGroup); - } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - * @param {Object} child parsed arch node - * @returns {Object} - */ - _evalArchChild: function (child) { - if (child.attrs.context) { - try { - var context = pyUtils.eval('context', child.attrs.context); - if (context.group_by) { - // let us extract basic data since we just evaluated context - // and use a correct tag! - child.tag = 'groupBy'; - child.attrs.fieldName = context.group_by.split(':')[0]; - child.attrs.defaultInterval = context.group_by.split(':')[1]; - } - } catch (e) {} - } - return child; - }, - /** - * @private - * @param {Object} filter - * @param {Object} attrs - */ - _extractAttributes: function (filter, attrs) { - filter.isDefault = this.searchDefaults[attrs.name] ? true : false; - filter.description = attrs.string || - attrs.help || - attrs.name || - attrs.domain || - 'Ω'; - if (attrs.invisible) { - filter.invisible = true; - } - if (filter.type === 'filter') { - if (filter.isDefault) { - filter.defaultRank = -5; - } - filter.domain = attrs.domain; - filter.context = pyUtils.eval('context', attrs.context); - if (attrs.date) { - filter.fieldName = attrs.date; - filter.fieldType = this.fields[attrs.date].type; - filter.hasOptions = true; - filter.options = this.optionGenerators; - filter.defaultOptionId = attrs.default_period || - DEFAULT_PERIOD; - filter.currentOptionIds = new Set(); - filter.basicDomains = this._getDateFilterBasicDomains(filter); - } - } else if (filter.type === 'groupBy') { - if (filter.isDefault) { - const val = this.searchDefaults[attrs.name]; - filter.defaultRank = typeof val === 'number' ? val : 100; - } - filter.fieldName = attrs.fieldName; - filter.fieldType = this.fields[attrs.fieldName].type; - if (_.contains(['date', 'datetime'], filter.fieldType)) { - filter.hasOptions = true; - filter.options = this.intervalOptions; - filter.defaultOptionId = attrs.defaultInterval || - DEFAULT_INTERVAL; - filter.currentOptionIds = new Set(); - } - } else if (filter.type === 'field') { - if (filter.isDefault) { - filter.defaultRank = -10; - } - var field = this.fields[attrs.name]; - filter.attrs = attrs; - filter.autoCompleteValues = []; - if (filter.isDefault) { - // on field, default can be used with a value - filter.defaultValue = this.searchDefaults[filter.attrs.name]; - } - if (!attrs.string) { - attrs.string = field.string; - } - } - }, - /** - * Constructs an object containing various domains based on this.referenceMoment and - * the field associated with the provided date filter. - * - * @private - * @param {Object} filter - * @returns {Object} - */ - _getDateFilterBasicDomains: function (filter) { - 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 (filter.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([ - '&', - [filter.fieldName, ">=", leftBound], - [filter.fieldName, "<=", rightBound] - ]); - - let description; - if (o) { - description = o.description + " " + y.description; - } else { - description = y.description; - } - - return { domain, description }; - }; - - const domains = {}; - this.optionGenerators.filter(y => y.groupId === 2).forEach(y => { - domains[y.optionId] = _constructBasicDomain(y); - this.optionGenerators.filter(y => y.groupId === 1).forEach(o => { - domains[y.optionId + "__" + o.optionId] = _constructBasicDomain(y, o); - }); - }); - return domains; - }, - /** - * Parse the arch of a 'search' view. - * - * @private - * @param {Object} arch arch with root node <search> - */ - _parseSearchArch: function (arch) { - var self = this; - // a searchview arch may contain a 'searchpanel' node, but this isn't - // the concern of the ControlPanelView (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. - var children = arch.children.filter(function (child) { - return child.tag !== 'searchpanel'; - }); - var preFilters = _.flatten(children.map(function (child) { - return child.tag !== 'group' ? - self._evalArchChild(child) : - child.children.map(self._evalArchChild); - })); - preFilters.push({tag: 'separator'}); - - var filter; - var currentTag; - var currentGroup = []; - var groupOfGroupBys = []; - var groupNumber = 1; - - _.each(preFilters, function (preFilter) { - if (preFilter.tag !== currentTag || _.contains(['separator', 'field'], preFilter.tag)) { - if (currentGroup.length) { - if (currentTag === 'groupBy') { - groupOfGroupBys = groupOfGroupBys.concat(currentGroup); - } else { - self.loadParams.groups.push(currentGroup); - } - } - currentTag = preFilter.tag; - currentGroup = []; - groupNumber++; - } - if (preFilter.tag !== 'separator') { - 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; - } - self._extractAttributes(filter, preFilter.attrs); - currentGroup.push(filter); - } - }); - if (groupOfGroupBys.length) { - this.loadParams.groups.push(groupOfGroupBys); - } - }, -}); - -return ControlPanelView; - -}); diff --git a/addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js b/addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js deleted file mode 100644 index 6897b596471..00000000000 --- a/addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js +++ /dev/null @@ -1,177 +0,0 @@ -odoo.define('web.AddNewFavoriteMenu', function (require) { -"use strict"; - -var core = require('web.core'); -var favoritesSubmenusRegistry = require('web.favorites_submenus_registry'); -var Widget = require('web.Widget'); - -var _t = core._t; - -var AddNewFavoriteMenu = Widget.extend({ - template: 'AddNewFavoriteMenu', - events: _.extend({}, Widget.prototype.events, { - 'click .o_save_favorite': '_onSaveFavoriteClick', - 'click .o_add_favorite.o_menu_header': '_onMenuHeaderClick', - 'click input[type="checkbox"]': '_onCheckboxClick', - 'keyup .o_save_name input': '_onKeyUp', - }), - /** - * @override - * @param {Object} params - * @param {Object} params.action - * @param {Object} params.favorites - */ - init: function (parent, params) { - this._super(parent); - this.favorites = params.favorites; - this.action = params.action; - this.isOpen = false; - }, - /** - * @override - */ - start: function () { - this._render(); - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @param {Object} params - */ - update: function (params) { - this.favorites = params.favorites; - }, - /** - * Close the menu and re-render the widget. - */ - closeMenu: function () { - this.isOpen = false; - this._render(); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - */ - _render: function () { - this.renderElement(); - if (this.isOpen) { - var $input = this.$('.o_favorite_name input.o_input'); - $input.val(this.action.name); - $input.focus(); - } - }, - /** - * @private - */ - _saveFavorite: function () { - var self = this; - var $inputs = this.$('input'); - var description = $inputs[0].value; - var isDefault = $inputs[1].checked; - var isShared = $inputs[2].checked; - - if (!description.length){ - this.do_warn(_t("Error"), _t("A name for your favorite is required.")); - $inputs[0].focus(); - return; - } - var descriptionAlreadyExists = !!this.favorites.find(function (favorite) { - return favorite.description === description; - }); - if (descriptionAlreadyExists) { - this.do_warn(_t("Error"), _t("Filter with same name already exists.")); - $inputs[0].focus(); - return; - } - this.trigger_up('new_favorite', { - type: 'favorite', - description: description, - isDefault: isDefault, - isShared: isShared, - on_success: function () {self.generatorMenuIsOpen = false;}, - }); - this.closeMenu(); - }, - /** - * Hide and display the submenu which allows adding custom filters. - * - * @private - */ - _toggleMenu: function () { - this.isOpen = !this.isOpen; - this._render(); - this.trigger_up('favorite_submenu_toggled'); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Additional behavior when a checkbox is clicked upon - * Namely, some checkbox are mutually exclusive - * this function allows this - * - * @private - * @param {jQueryEvent} ev - */ - _onCheckboxClick: function (ev) { - function mutuallyExclusive (elem) { - if (!elem.id) return false; - return ['use_by_default', 'share_all_users'].some(function (str) { - return elem.id.indexOf(str); - }); - } - var $checkboxes = this.$('input[type="checkbox"]'); - var currentCheckBox = ev.target; - - if (mutuallyExclusive(currentCheckBox)) { - for (var i=0; i < $checkboxes.length; i++) { - var checkbox = $checkboxes[i]; - if (checkbox !== currentCheckBox && mutuallyExclusive(checkbox)) { - checkbox.checked = false; - } - } - } - }, - /** - * @private - * @param {jQueryEvent} ev - */ - _onKeyUp: function (ev) { - if (ev.which === $.ui.keyCode.ENTER) { - this._saveFavorite(); - } - }, - /** - * @private - * @param {jQueryEvent} ev - */ - _onMenuHeaderClick: function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - this._toggleMenu(); - }, - /** - * @private - * @param {jQueryEvent} ev - */ - _onSaveFavoriteClick: function (ev) { - ev.stopPropagation(); - this._saveFavorite(); - }, -}); - -favoritesSubmenusRegistry.add('add_new_favorite_menu', AddNewFavoriteMenu, 0); - -return AddNewFavoriteMenu; - -}); diff --git a/addons/web/static/src/js/views/control_panel/search/control_panel_view_parameters.js b/addons/web/static/src/js/views/control_panel/search/control_panel_view_parameters.js deleted file mode 100644 index ca493935156..00000000000 --- a/addons/web/static/src/js/views/control_panel/search/control_panel_view_parameters.js +++ /dev/null @@ -1,80 +0,0 @@ -odoo.define('web.controlPanelViewParameters', function (require) { -"use strict"; - -var core = require('web.core'); - -var _lt = core._lt; - -// for FilterMenu -const DEFAULT_PERIOD = 'this_month'; -const PERIOD_OPTIONS = [ - { description: _lt('Last 7 Days'), optionId: 'last_7_days', groupId: 1 }, - { description: _lt('Last 30 Days'), optionId: 'last_30_days', groupId: 1 }, - { description: _lt('Last 365 Days'), optionId: 'last_365_days', groupId: 1 }, - { description: _lt('Last 5 Years'), optionId: 'last_5_years', groupId: 1 }, - { description: _lt('Today'), optionId: 'today', groupId: 2 }, - { description: _lt('This Week'), optionId: 'this_week', groupId: 2 }, - { description: _lt('This Month'), optionId: 'this_month', groupId: 2 }, - { description: _lt('This Quarter'), optionId: 'this_quarter', groupId: 2 }, - { description: _lt('This Year'), optionId: 'this_year', groupId: 2 }, - { description: _lt('Yesterday'), optionId: 'yesterday', groupId: 3 }, - { description: _lt('Last Week'), optionId: 'last_week', groupId: 3 }, - { description: _lt('Last Month'), optionId: 'last_month', groupId: 3 }, - { description: _lt('Last Quarter'), optionId: 'last_quarter', groupId: 3 }, - { description: _lt('Last Year'), optionId: 'last_year', groupId: 3 }, -]; -const MONTH_OPTIONS = [ - { optionId: 'this_month', groupId: 1, format: 'MMMM', addParam: {}, setParam: {}, granularity: 'month' }, - { optionId: 'last_month', groupId: 1, format: 'MMMM', addParam: { months: -1 }, setParam: {}, granularity: 'month' }, - { optionId: 'antepenultimate_month', groupId: 1, format: 'MMMM', addParam: { months: -2 }, setParam: {}, granularity: 'month' } -] -const QUARTER_OPTIONS = [ - { optionId: 'fourth_quarter', groupId: 1, description: _lt("Q4"), addParam: {}, setParam: { quarter: 4 }, granularity: 'quarter' }, - { optionId: 'third_quarter', groupId: 1, description: _lt("Q3"), addParam: {}, setParam: { quarter: 3 }, granularity: 'quarter' }, - { optionId: 'second_quarter', groupId: 1, description: _lt("Q2"), addParam: {}, setParam: { quarter: 2 }, granularity: 'quarter' }, - { optionId: 'first_quarter', groupId: 1, description: _lt("Q1"), addParam: {}, setParam: { quarter: 1 }, granularity: 'quarter' } -] -const YEAR_OPTIONS = [ - { optionId: 'this_year', groupId: 2, format: 'YYYY', addParam: {}, setParam: {}, granularity: 'year' }, - { optionId: 'last_year', groupId: 2, format: 'YYYY', addParam: { years: -1 }, setParam: {}, granularity: 'year' }, - { optionId: 'antepenultimate_year', groupId: 2, format: 'YYYY', addParam: { years: -2 }, setParam: {}, granularity: 'year' }, -]; -const OPTION_GENERATORS = [...MONTH_OPTIONS, ...QUARTER_OPTIONS, ...YEAR_OPTIONS]; - -// for GroupBy menu -const GROUPABLE_TYPES = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime', 'integer']; -const DEFAULT_INTERVAL = 'month'; -const INTERVAL_OPTIONS = [ - { description: _lt('Year'), optionId: 'year', groupId: 1 }, - { description: _lt('Quarter'), optionId: 'quarter', groupId: 1 }, - { description: _lt('Month'), optionId: 'month', groupId: 1 }, - { description: _lt('Week'), optionId: 'week', groupId: 1 }, - { description: _lt('Day'), optionId: 'day', groupId: 1 }, -]; -function rank(oId) { - return INTERVAL_OPTIONS.findIndex(o => o.optionId === oId); -} - -// for TimeRangeMenu -const DEFAULT_TIMERANGE = DEFAULT_PERIOD; -const TIME_RANGE_OPTIONS = PERIOD_OPTIONS; -const COMPARISON_TIME_RANGE_OPTIONS = [ - { description: _lt('Previous Period'), optionId: 'previous_period' }, - { description: _lt('Previous Year'), optionId: 'previous_year' } -]; - -return { - COMPARISON_TIME_RANGE_OPTIONS: COMPARISON_TIME_RANGE_OPTIONS, - DEFAULT_INTERVAL: DEFAULT_INTERVAL, - DEFAULT_PERIOD: DEFAULT_PERIOD, - DEFAULT_TIMERANGE: DEFAULT_TIMERANGE, - GROUPABLE_TYPES: GROUPABLE_TYPES, - INTERVAL_OPTIONS: INTERVAL_OPTIONS, - OPTION_GENERATORS: OPTION_GENERATORS, - PERIOD_OPTIONS: PERIOD_OPTIONS, - TIME_RANGE_OPTIONS: TIME_RANGE_OPTIONS, - YEAR_OPTIONS: YEAR_OPTIONS, - rank: rank, -}; - -});
\ No newline at end of file diff --git a/addons/web/static/src/js/views/control_panel/search/favorite_menu.js b/addons/web/static/src/js/views/control_panel/search/favorite_menu.js deleted file mode 100644 index b2dc25d9327..00000000000 --- a/addons/web/static/src/js/views/control_panel/search/favorite_menu.js +++ /dev/null @@ -1,134 +0,0 @@ -odoo.define('web.FavoriteMenu', function (require) { -"use strict"; - -var config = require('web.config'); -var core = require('web.core'); -var Dialog = require('web.Dialog'); -var DropdownMenu = require('web.DropdownMenu'); -var favorites_submenus_registry = require('web.favorites_submenus_registry'); - -var _t = core._t; - -var FavoriteMenu = DropdownMenu.extend({ - custom_events: _.extend({}, DropdownMenu.prototype.custom_events, { - favorite_submenu_toggled: '_onSubMenuToggled', - }), - /** - * @override - * @param {Object} action - */ - init: function (parent, favorites, action) { - this._super(parent, favorites); - this.action = action; - this.isMobile = config.device.isMobile; - this.dropdownCategory = 'favorite'; - this.dropdownTitle = _t('Favorites'); - this.dropdownIcon = 'fa fa-star'; - this.dropdownSymbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : false; - this.dropdownStyle.mainButton.class = 'o_favorites_menu_button ' + - this.dropdownStyle.mainButton.class; - - }, - /** - * Render the template used to register a new favorite and append it - * to the basic dropdown menu. - * - * @override - */ - start: function () { - var self = this; - var params = { - favorites: this.items, - action: this.action, - }; - var superProm = this._super.apply(this, arguments); - this.$menu.addClass('o_favorites_menu'); - this.subMenus = []; - favorites_submenus_registry.values().forEach(function (SubMenu) { - var subMenu = new SubMenu(self, params); - subMenu.appendTo(self.$menu); - self.subMenus.push(subMenu); - }); - return superProm; - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - update: function (items) { - this._super.apply(this, arguments); - _.invoke(this.subMenus, 'update', { favorites: this.items }); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - */ - _closeSubMenus: function () { - _.invoke(this.subMenus, 'closeMenu'); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @override - * @private - */ - _onBootstrapClose: function () { - this._super.apply(this, arguments); - this._closeSubMenus(); - }, - /** - * Reacts to a submenu being toggled - * - * When a submenu is toggled, it has changed the position - * and size of the Favorite's dropdown. This method - * repositions the current dropdown - * - * @private - * @param {OdooEvent} ev - * - */ - _onSubMenuToggled: function (ev) { - ev.stopPropagation(); - this.$dropdownReference.dropdown('update'); - }, - /** - * @override - * @private - * @param {MouseEvent} event - */ - _onTrashButtonClick: function (event) { - event.preventDefault(); - event.stopPropagation(); - var self = this; - var id = $(event.currentTarget).data('id'); - var favorite = this.items.find(function (favorite) { - return favorite.id === id; - }); - var globalWarning = _t("This filter is global and will be removed for everybody if you continue."); - var warning = _t("Are you sure that you want to remove this filter?"); - var message = favorite.userId ? warning : globalWarning; - - Dialog.confirm(self, message, { - title: _t("Warning"), - confirm_callback: function () { - self.trigger_up('item_trashed', {id: id}); - }, - }); - - }, -}); - -return FavoriteMenu; - -});
\ No newline at end of file diff --git a/addons/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js b/addons/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js deleted file mode 100644 index b05f8dfe268..00000000000 --- a/addons/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js +++ /dev/null @@ -1,8 +0,0 @@ -odoo.define('web.favorites_submenus_registry', function (require) { -"use strict"; - -var Registry = require('web.Registry'); - -return new Registry(); - -}); diff --git a/addons/web/static/src/js/views/control_panel/search/filter_menu.js b/addons/web/static/src/js/views/control_panel/search/filter_menu.js deleted file mode 100644 index 1c81701a8e0..00000000000 --- a/addons/web/static/src/js/views/control_panel/search/filter_menu.js +++ /dev/null @@ -1,186 +0,0 @@ -odoo.define('web.FilterMenu', function (require) { -"use strict"; - -var config = require('web.config'); -var core = require('web.core'); -var Domain = require('web.Domain'); -var DropdownMenu = require('web.DropdownMenu'); -var search_filters = require('web.search_filters'); - -var _t = core._t; -var QWeb = core.qweb; - -var FilterMenu = DropdownMenu.extend({ - custom_events: { - confirm_proposition: '_onConfirmProposition', - remove_proposition: '_onRemoveProposition', - }, - events: _.extend({}, DropdownMenu.prototype.events, { - 'click .o_add_custom_filter': '_onAddCustomFilterClick', - 'click .o_add_condition': '_onAddCondition', - 'click .o_apply_filter': '_onApplyClick', - }), - /** - * @override - * @param {Object} fields - */ - init: function (parent, filters, fields) { - this._super(parent, filters); - - // determines where the filter menu is displayed and its style - this.isMobile = config.device.isMobile; - // determines when the 'Add custom filter' submenu is open - this.generatorMenuIsOpen = false; - this.propositions = []; - this.fields = _.pick(fields, function (field, name) { - return field.selectable !== false && name !== 'id'; - }); - this.fields.id = {string: 'ID', type: 'id', searchable: true}; - this.dropdownCategory = 'filter'; - this.dropdownTitle = _t('Filters'); - this.dropdownIcon = 'fa fa-filter'; - this.dropdownSymbol = this.isMobile ? - 'fa fa-chevron-right float-right mt4' : - false; - this.dropdownStyle.mainButton.class = 'o_filters_menu_button ' + - this.dropdownStyle.mainButton.class; - }, - /** - * Render the template used to add a new custom filter and append it - * to the basic dropdown menu. - * - * @override - */ - start: function () { - var superProm = this._super.apply(this, arguments); - this.$menu.addClass('o_filters_menu'); - this._renderGeneratorMenu(); - return superProm; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Add a proposition inside the custom filter edition menu. - * - * @private - * @returns {Promise} - */ - _appendProposition: function () { - // make modern sear_filters code!!! It works but... - var prop = new search_filters.ExtendedSearchProposition(this, this.fields); - this.propositions.push(prop); - this.$('.o_apply_filter').prop('disabled', false); - return prop.insertBefore(this.$addFilterMenu); - }, - /** - * Confirm a filter proposition, creates it and add it to the menu. - * - * @private - */ - _commitSearch: function () { - var filters = _.invoke(this.propositions, 'get_filter').map(function (preFilter) { - return { - type: 'filter', - description: preFilter.attrs.string, - domain: Domain.prototype.arrayToString(preFilter.attrs.domain), - }; - }); - this.trigger_up('new_filters', {filters: filters}); - _.invoke(this.propositions, 'destroy'); - this.propositions = []; - this._toggleCustomFilterMenu(); - }, - /** - * @private - */ - _renderGeneratorMenu: function () { - this.$el.find('.o_generator_menu').remove(); - if (!this.generatorMenuIsOpen) { - _.invoke(this.propositions, 'destroy'); - this.propositions = []; - } - var $generatorMenu = QWeb.render('FilterMenuGenerator', {widget: this}); - this.$menu.append($generatorMenu); - this.$addFilterMenu = this.$menu.find('.o_add_filter_menu'); - if (this.generatorMenuIsOpen && !this.propositions.length) { - this._appendProposition(); - } - this.$dropdownReference.dropdown('update'); - }, - /** - * Hide and display the submenu which allows adding custom filters. - * - * @private - */ - _toggleCustomFilterMenu: function () { - this.generatorMenuIsOpen = !this.generatorMenuIsOpen; - this._renderGeneratorMenu(); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {MouseEvent} ev - */ - _onAddCondition: function (ev) { - ev.stopPropagation(); - this._appendProposition(); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onAddCustomFilterClick: function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - this._toggleCustomFilterMenu(); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onApplyClick: function (ev) { - ev.stopPropagation(); - this._commitSearch(); - }, - /** - * @override - * @private - * @param {jQueryEvent} ev - */ - _onBootstrapClose: function () { - this._super.apply(this, arguments); - this.generatorMenuIsOpen = false; - this._renderGeneratorMenu(); - }, - /** - * @private - * @param {OdooEvent} ev - */ - _onConfirmProposition: function (ev) { - ev.stopPropagation(); - this._commitSearch(); - }, - /** - * @private - * @param {OdooEvent} ev - */ - _onRemoveProposition: function (ev) { - ev.stopPropagation(); - this.propositions = _.without(this.propositions, ev.target); - if (!this.propositions.length) { - this.$('.o_apply_filter').prop('disabled', true); - } - ev.target.destroy(); - }, -}); - -return FilterMenu; - -}); diff --git a/addons/web/static/src/js/views/control_panel/search/groupby_menu.js b/addons/web/static/src/js/views/control_panel/search/groupby_menu.js deleted file mode 100644 index 17c158dde87..00000000000 --- a/addons/web/static/src/js/views/control_panel/search/groupby_menu.js +++ /dev/null @@ -1,206 +0,0 @@ -odoo.define('web.GroupByMenu', function (require) { -"use strict"; - -var config = require('web.config'); -var controlPanelViewParameters = require('web.controlPanelViewParameters'); -var core = require('web.core'); -var DropdownMenu = require('web.DropdownMenu'); - -var _t = core._t; -var QWeb = core.qweb; - -var DEFAULT_INTERVAL = controlPanelViewParameters.DEFAULT_INTERVAL; -var GROUPABLE_TYPES = controlPanelViewParameters.GROUPABLE_TYPES; -var INTERVAL_OPTIONS = controlPanelViewParameters.INTERVAL_OPTIONS; - -var GroupByMenu = DropdownMenu.extend({ - events: _.extend({}, DropdownMenu.prototype.events, { - 'click .o_add_custom_group': '_onAddCustomGroupClick', - 'click button.o_apply_group': '_onButtonApplyClick', - 'click .o_group_selector': '_onGroupSelectorClick', - }), - /** - * @override - * @param {Widget} parent - * @param {Object[]} groupBys list of groupBys - * } - * @param {Object} fields 'field_get' of a model: mapping from field name - * to an object with its properties - * @param {Object} options - * @param {string} options.headerStyle conditions the style of the main button - */ - init: function (parent, groupBys, fields, options) { - var self = this; - options = options || {}; - this._super(parent, groupBys); - this.fields = fields; - // determines when the 'Add custom groupby' submenu is open - this.generatorMenuIsOpen = false; - // determines list of options used by groupBys of type 'date' - this.groupableFields = []; - _.each(fields, function (field, name) { - if (field.sortable && name !== "id" && _.contains(GROUPABLE_TYPES, field.type)) { - self.groupableFields.push(_.extend({}, field, { - name: name, - })); - } - }); - this.groupableFields = _.sortBy(this.groupableFields, 'string'); - // determines the list of field names that can be added to the menu - // via the 'Add Custom Groupby' menu - this.presentedFields = this._setPresentedFields(groupBys); - - // determines where the filter menu is displayed and partly its style - this.isMobile = config.device.isMobile; - - this.dropdownCategory = 'groupby'; - this.dropdownTitle = _t('Group By'); - this.dropdownIcon = 'fa fa-bars'; - this.dropdownSymbol = this.isMobile && !options.noSymbol ? 'fa fa-chevron-right float-right mt4' : false; - // the default style of the groupby menu can be changed here using the options key 'headerStyle' - if (options.headerStyle === 'primary') { - this.dropdownStyle = { - el: {class: 'btn-group o_group_by_menu o_dropdown', attrs: {'role': 'group'}}, - mainButton: {class: 'btn btn-primary dropdown-toggle'}, - }; - } - INTERVAL_OPTIONS = INTERVAL_OPTIONS.map(function (option) { - return _.extend(option, {description: option.description.toString()}); - }); - }, - - /** - * Render the template used to add a new custom groupby and append it - * to the basic dropdown menu. - * - * @override - */ - start: function () { - var superProm = this._super.apply(this, arguments); - this.$menu.addClass('o_group_by_menu'); - this._renderGeneratorMenu(); - return superProm; - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - * @param {Object[]} groupBys - */ - update: function (groupBys) { - this._super.apply(this, arguments); - this.presentedFields = this._setPresentedFields(groupBys); - this._renderGeneratorMenu(); - }, - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Method called via the 'Add Custom Groupby' menu to create a new groupby - * and add it (activated) to the menu. In case the field is of type date, it - * is activated using the option determined by the parameter - * 'DEFAULT_INTERVAL'. - * - * @private - * @param {string} fieldName - */ - _addGroupby: function (fieldName) { - var field = this.presentedFields.find(function (field) { - return field.name === fieldName; - }); - var groupBy = { - type: 'groupBy', - description: field.string, - fieldName: fieldName, - fieldType: field.type, - }; - if (_.contains(['date', 'datetime'], field.type)) { - groupBy.hasOptions = true; - groupBy.options = INTERVAL_OPTIONS; - groupBy.defaultOptionId = DEFAULT_INTERVAL; - groupBy.currentOptionIds = new Set([]); - } - this.trigger_up('new_groupBy', groupBy); - }, - /** - * @private - */ - _renderGeneratorMenu: function () { - this.$el.find('.o_generator_menu').remove(); - var $generatorMenu = QWeb.render('GroupByMenuGenerator', {widget: this}); - this.$menu.append($generatorMenu); - this.$addCustomGroup = this.$menu.find('.o_add_custom_group'); - this.$groupSelector = this.$menu.find('.o_group_selector'); - this.$dropdownReference.dropdown('update'); - }, - /** - * @private - */ - _setPresentedFields: function (groupBys) { - return this.groupableFields.filter(function (field) { - var groupByFields = _.pluck(groupBys, 'fieldName'); - return !_.contains(groupByFields, field.name); - }); - }, - /** - * @private - */ - _toggleCustomGroupMenu: function () { - this.generatorMenuIsOpen = !this.generatorMenuIsOpen; - this._renderGeneratorMenu(); - if (this.generatorMenuIsOpen) { - this.$groupSelector.focus(); - } - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Toggle the 'Add Custom Group'. - * - * @private - * @param {MouseEvent} ev - */ - _onAddCustomGroupClick: function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - this._toggleCustomGroupMenu(); - }, - /* - * @override - * @private - * @param {jQueryEvent} ev - */ - _onBootstrapClose: function () { - this._super.apply(this, arguments); - this.generatorMenuIsOpen = false; - this._renderGeneratorMenu(); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onButtonApplyClick: function (ev) { - ev.stopPropagation(); - var fieldName = this.$groupSelector.val(); - this.generatorMenuIsOpen = false; - this._addGroupby(fieldName); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onGroupSelectorClick: function (ev) { - ev.stopPropagation(); - }, -}); - -return GroupByMenu; - -}); diff --git a/addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js b/addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js deleted file mode 100644 index a51386d20aa..00000000000 --- a/addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js +++ /dev/null @@ -1,147 +0,0 @@ -odoo.define('web.GroupByMenuMixin', function (require) { -"use strict"; - -var GroupByMenu = require('web.GroupByMenu'); -var controlPanelViewParameters = require('web.controlPanelViewParameters'); - -/** - * The aim of this mixin is to facilitate the interaction between - * a view controller and a dropdown menu with its control panel - * TO DO: the pivot subview has two types of groupbys so that it will not - * understand the current implementation of this mixin - * - * @mixin - * @name GroupByMenuMixin - */ -var GroupByMenuMixin = { - init: function () { - this.custom_events = _.extend({}, this.custom_events, { - menu_item_clicked: '_onMenuItemClicked', - item_option_clicked: '_onItemOptionClicked', - }); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Instantiate a widget GroupByMenu and incorporate it to the control panel - * of an embedded view. This is used for instance in the dashboard view in - * enterprise where there is no GroupBy menu in the search view because it - * would not make sense to have one at the global level. - * This function is called by renderButtons when the parameter - * 'this.isEmbedded' is set to true. - * - * private - * @param {jQuery} $node - * @param {Object} groupableFields - * @param {Promise} - */ - _addGroupByMenu: function ($node, groupableFields) { - this.sortedFieldNames = Object.keys(groupableFields).sort(); - this.groupableFields = groupableFields; - const groupBys = this._getGroupBys(this.model.get().groupBy); - this.groupByMenu = new GroupByMenu(this, groupBys, this.groupableFields, {noSymbol: true}); - return this.groupByMenu.insertAfter($node.find('div:first')); - }, - /** - * This method puts the active groupBys in a convenient form. - * - * @private - * @param {string[]} activeGroupBys - * returns {Object[]} groupBysNormalized - */ - _normalizeActiveGroupBys: function (activeGroupBys) { - return activeGroupBys.map(gb => { - const fieldName = gb.split(':')[0]; - const field = this.groupableFields[fieldName]; - const ngb = {fieldName: fieldName}; - if (_.contains(['date', 'datetime'], field.type)) { - ngb.interval = gb.split(':')[1] || controlPanelViewParameters.DEFAULT_INTERVAL; - } - return ngb; - }); - }, - /** - * This method has to be implemented by the view controller that needs to - * interpret the click in an appropriate manner. - * - * @private - * @param {string[]} groupBys - */ - _setGroupby: function (groupBys) {}, - /** - * Return the list of groupBys in a form suitable for the groupByMenu. We do - * this each time because we want to be synchronized with the view model. - * - * @private - * @param {string[]} activeGroupBys - * returns {Object[]} groupBys - */ - _getGroupBys: function (activeGroupBys) { - const groupBysNormalized = this._normalizeActiveGroupBys(activeGroupBys); - return this.sortedFieldNames.map(fieldName => { - const field = this.groupableFields[fieldName]; - const groupByActivity = groupBysNormalized.filter(gb => (gb.fieldName === fieldName)); - const groupBy = { - id: fieldName, - isActive: groupByActivity.length ? true : false, - description: field.string, - }; - if (_.contains(['date', 'datetime'], field.type)) { - groupBy.hasOptions = true; - groupBy.options = controlPanelViewParameters.INTERVAL_OPTIONS; - groupBy.currentOptionIds = groupByActivity.length ? - new Set(groupByActivity.map(gb => gb.interval)) : - new Set([]); - } - return groupBy; - }); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {OdooEvent} ev - */ - _onItemOptionClicked: function (ev) { - const fieldName = ev.data.id; - const optionId = ev.data.optionId; - const activeGroupBys = this.model.get().groupBy; - const groupBysNormalized = this._normalizeActiveGroupBys(activeGroupBys); - const index = groupBysNormalized.findIndex(ngb => - ngb.fieldName === fieldName && ngb.interval === optionId); - if (index === -1) { - activeGroupBys.push(fieldName + ':' + optionId); - } else { - activeGroupBys.splice(index, 1); - } - this._setGroupby(activeGroupBys); - this.groupByMenu.update(this._getGroupBys(activeGroupBys)); - }, - /** - * @private - * @param {OdooEvent} ev - */ - _onMenuItemClicked: function (ev) { - const fieldName = ev.data.id; - const activeGroupBys = this.model.get().groupBy; - const groupByFieldNames = activeGroupBys.map(gb => gb.split(':')[0]); - const indexOfGroupby = groupByFieldNames.indexOf(fieldName); - if (indexOfGroupby === -1) { - activeGroupBys.push(fieldName); - } else { - activeGroupBys.splice(indexOfGroupby, 1); - } - this._setGroupby(activeGroupBys); - this.groupByMenu.update(this._getGroupBys(activeGroupBys)); - }, -}; - -return GroupByMenuMixin; - -}); diff --git a/addons/web/static/src/js/views/control_panel/search/search_bar.js b/addons/web/static/src/js/views/control_panel/search/search_bar.js deleted file mode 100644 index 42b156beed7..00000000000 --- a/addons/web/static/src/js/views/control_panel/search/search_bar.js +++ /dev/null @@ -1,260 +0,0 @@ -odoo.define('web.SearchBar', function (require) { -"use strict"; - -var AutoComplete = require('web.AutoComplete'); -var searchBarAutocompleteRegistry = require('web.search_bar_autocomplete_sources_registry'); -var SearchFacet = require('web.SearchFacet'); -var Widget = require('web.Widget'); - -var SearchBar = Widget.extend({ - template: 'SearchView.SearchBar', - events: _.extend({}, Widget.prototype.events, { - 'compositionend .o_searchview_input': '_onCompositionendInput', - 'compositionstart .o_searchview_input': '_onCompositionstartInput', - 'keydown': '_onKeydown', - }), - /** - * @override - * @param {Object} [params] - * @param {Object} [params.context] - * @param {Object[]} [params.facets] - * @param {Object} [params.fields] - * @param {Object[]} [params.filterFields] - * @param {Object[]} [params.filters] - * @param {Object[]} [params.groupBys] - */ - init: function (parent, params) { - this._super.apply(this, arguments); - - this.context = params.context; - - this.facets = params.facets; - this.fields = params.fields; - this.filterFields = params.filterFields; - - this.autoCompleteSources = []; - this.searchFacets = []; - this._isInputComposing = false; - }, - /** - * @override - */ - start: function () { - this.$input = this.$('input'); - var self = this; - var defs = [this._super.apply(this, arguments)]; - _.each(this.facets, function (facet) { - defs.push(self._renderFacet(facet)); - }); - defs.push(this._setupAutoCompletion()); - return Promise.all(defs); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Focus the searchbar. - */ - focus: function () { - this.$input.focus(); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - */ - _focusFollowing: function () { - var focusedIndex = this._getFocusedFacetIndex(); - var $toFocus; - if (focusedIndex === this.searchFacets.length - 1) { - $toFocus = this.$input; - } else { - $toFocus = this.searchFacets.length && this.searchFacets[focusedIndex + 1].$el; - } - - if ($toFocus.length) { - $toFocus.focus(); - } - }, - /** - * @private - */ - _focusPreceding: function () { - var focusedIndex = this._getFocusedFacetIndex(); - var $toFocus; - if (focusedIndex === -1) { - $toFocus = this.searchFacets.length && _.last(this.searchFacets).$el; - } else if (focusedIndex === 0) { - $toFocus = this.$input; - } else { - $toFocus = this.searchFacets.length && this.searchFacets[focusedIndex - 1].$el; - } - - if ($toFocus.length) { - $toFocus.focus(); - } - }, - /** - * @private - * @returns {integer} - */ - _getFocusedFacetIndex: function () { - return _.findIndex(this.searchFacets, function (searchFacet) { - return searchFacet.$el[0] === document.activeElement; - }); - }, - /** - * Provide auto-completion result for req.term. - * - * @private - * @param {Object} req request to complete - * @param {String} req.term searched term to complete - * @param {Function} callback - */ - _getAutoCompleteSources: function (req, callback) { - var defs = this.autoCompleteSources.map(function (source) { - return source.getAutocompletionValues(req.term); - }); - Promise.all(defs).then(function (result) { - var resultCleaned = _(result).chain() - .compact() - .flatten(true) - .value(); - callback(resultCleaned); - }); - }, - /** - * @private - * @param {Object} facet - * @returns {Promise} - */ - _renderFacet: function (facet) { - var searchFacet = new SearchFacet(this, facet); - this.searchFacets.push(searchFacet); - return searchFacet.insertBefore(this.$('input')); - }, - /** - * @private - * @returns {Promise} - */ - _setupAutoCompletion: function () { - var self = this; - this._setupAutoCompletionWidgets(); - this.autoComplete = new AutoComplete(this, { - $input: this.$('input'), - source: this._getAutoCompleteSources.bind(this), - select: this._onAutoCompleteSelected.bind(this), - get_search_string: function () { - return self.$input.val().trim(); - }, - }); - return this.autoComplete.appendTo(this.$el); - }, - /** - * @private - */ - _setupAutoCompletionWidgets: function () { - var self = this; - var registry = searchBarAutocompleteRegistry; - _.each(this.filterFields, function (filter) { - var field = self.fields[filter.attrs.name]; - var Obj = registry.getAny([filter.attrs.widget, field.type]); - if (Obj) { - self.autoCompleteSources.push(new (Obj) (self, filter, field, self.context)); - } - }); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {Event} e - * @param {Object} ui - * @param {Object} ui.item selected completion item - */ - _onAutoCompleteSelected: function (e, ui) { - e.preventDefault(); - var facet = ui.item.facet; - if (!facet) { - // this happens when selecting "(no result)" item - this.trigger_up('reset'); - return; - } - var filter = facet.filter; - if (filter.type === 'field') { - var values = filter.autoCompleteValues; - values.push(facet.values[0]); - this.trigger_up('autocompletion_filter', { - filterId: filter.id, - autoCompleteValues: values, - }); - } else { - this.trigger_up('autocompletion_filter', { - filterId: filter.id, - }); - } - }, - /** - * @rivate - * @param {CompositionEvent} ev - */ - _onCompositionendInput: function () { - this._isInputComposing = false; - }, - /** - * @rivate - * @param {CompositionEvent} ev - */ - _onCompositionstartInput: function () { - this._isInputComposing = true; - }, - /** - * @private - * @param {KeyEvent} e - */ - _onKeydown: function (e) { - if (this._isInputComposing) { - return; - } - switch(e.which) { - case $.ui.keyCode.LEFT: - this._focusPreceding(); - e.preventDefault(); - break; - case $.ui.keyCode.RIGHT: - this._focusFollowing(); - e.preventDefault(); - break; - case $.ui.keyCode.DOWN: - // if the searchbar dropdown is closed, try to focus the renderer - const $dropdown = this.$('.o_searchview_autocomplete:visible'); - if (!$dropdown.length) { - this.trigger_up('navigation_move', { direction: 'down' }); - e.preventDefault(); - } - break; - case $.ui.keyCode.BACKSPACE: - if (this.$input.val() === '') { - this.trigger_up('facet_removed'); - } - break; - case $.ui.keyCode.ENTER: - if (this.$input.val() === '') { - this.trigger_up('reload'); - } - break; - } - }, -}); - -return SearchBar; - -}); diff --git a/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js b/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js deleted file mode 100644 index 8adc385a789..00000000000 --- a/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js +++ /dev/null @@ -1,485 +0,0 @@ -odoo.define('web.SearchBarAutoCompleteSources', function (require) { -"use strict"; - -var Class = require('web.Class'); -var core = require('web.core'); -var Domain = require('web.Domain'); -var field_utils = require('web.field_utils'); -var mixins = require('web.mixins'); -var pyUtils = require('web.py_utils'); -var ServicesMixin = require('web.ServicesMixin'); -var time = require('web.time'); - -var _t = core._t; -var _lt = core._lt; - -var FilterInterface = Class.extend(mixins.EventDispatcherMixin, { - completion_label: _lt("%s"), - /** - * @override - * @param {Object} filter - */ - init: function (parent, filter) { - mixins.EventDispatcherMixin.init.call(this); - this.setParent(parent); - - this.filter = filter; - }, - /** - * Fetch auto-completion values for the widget. - * - * The completion values should be an array of objects with keys facet and - * label. They will be used by search_bar in @_onAutoCompleteSelected. - * - * @param {string} value value to getAutocompletionValues - * @returns {Promise<null|Array>} - */ - getAutocompletionValues: function (value) { - var result; - value = value.toLowerCase(); - if (fuzzy.test(value, this.filter.description)) { - result = [{ - label: _.str.sprintf(this.completion_label.toString(), - _.escape(this.filter.description)), - facet: { - filter: this.filter, - }, - }]; - } - return Promise.resolve(result); - }, -}); - -var Filter = FilterInterface.extend({ - completion_label: _lt("Filter on: %s"), -}); - -var GroupBy = FilterInterface.extend({ - completion_label: _lt("Group by: %s"), -}); - -var Field = FilterInterface.extend(ServicesMixin, { - default_operator: '=', - /** - * @override - * @param {Object} field - * @param {Object} filter - * @param {Object} context needed for extra rpc (i.e. m2o) - */ - init: function (parent, filter, field, context) { - this._super.apply(this, arguments); - - this.field = field; - this.filter = filter; - this.attrs = _.extend({}, field, filter.attrs); - this.context = context; - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - getAutocompletionValues: function (value) { - return Promise.resolve([{ - label: this._getAutocompletionLabel(value), - facet: this._getFacetValue(value), - }]); - }, - /** - * TODO: the domain logic (getDomain, operator, etc.) should not be put here - * (this has nothing to do with the autocomplete sources). - * - * This method is used by the ControlPanelModel when setting the - * `filter_domain`. - * - * @param {Object[]} values - * @returns {string} - */ - getDomain: function (values) { - if (!values.length) { return; } - - var valueToDomain; - var self = this; - var domain = this.attrs.filter_domain; - if (domain) { - valueToDomain = function (facetValue) { - return Domain.prototype.stringToArray( - domain, - { - // these are the values that can be used in search view - // fields `filter_domain` attribute - self: self._valueFrom(facetValue), - raw_value: facetValue.value, - } - ); - }; - } else { - valueToDomain = function (facetValue) { - return self._makeDomain( - self.attrs.name, - self.attrs.operator || self.default_operator, - facetValue - ); - }; - } - var domains = values.map(valueToDomain); - - domains = domains.map(Domain.prototype.arrayToString); - return pyUtils.assembleDomains(domains, 'OR'); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - * @param {any} value - * @returns {string} - */ - _getAutocompletionLabel: function (value) { - return _.str.sprintf(_.str.escapeHTML( - _t("Search %(field)s for: %(value)s")), { - field: '<em>' + _.escape(this.attrs.string) + '</em>', - value: '<strong>' + _.escape(value) + '</strong>'}); - }, - /** - * @private - * @param {any} value - * @returns {Object} - */ - _getFacetValue: function (value) { - return { - filter: this.filter, - values: [{label: value, value: value}], - }; - }, - /** - * Function creating the returned domain for the field, override this - * methods in children if you only need to customize the field's domain - * without more complex alterations or tests (and without the need to - * change override the handling of filter_domain) - * - * @private - * @param {String} name the field's name - * @param {String} operator the field's operator (either attribute-specified or default operator for the field - * @param {Number|String} facet parsed value for the field - * @returns {Array<Array>} domain to include in the resulting search - */ - _makeDomain: function (name, operator, facet) { - return [[name, operator, this._valueFrom(facet)]]; - }, - /** - * @private - * @param {Object} facetValue - * @param {any} facetValue.value - * @returns {any} - */ - _valueFrom: function (facetValue) { - return facetValue.value; - }, -}); - -var CharField = Field.extend({ - default_operator: 'ilike', - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - getAutocompletionValues: function (value) { - if (_.isEmpty(value)) { return Promise.resolve(null); } - return this._super.apply(this, arguments); - }, -}); - -var NumberField = Field.extend({ - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - getAutocompletionValues: function (value) { - var val = this.parse(value); - if (isNaN(val)) { return Promise.resolve(); } - return this._super.apply(this, arguments); - }, -}); - -var IntegerField = NumberField.extend({ - error_message: _t("not a valid integer"), - parse: function (value) { - try { - return field_utils.parse.integer(value); - } catch (e) { - return NaN; - } - }, -}); - -var FloatField = NumberField.extend({ - error_message: _t("not a valid number"), - parse: function (value) { - try { - return field_utils.parse.float(value); - } catch (e) { - return NaN; - } - }, -}); - -var SelectionField = Field.extend({ - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - getAutocompletionValues: function (value) { - var self = this; - var results = _(this.attrs.selection).chain() - .filter(function (sel) { - var selValue = sel[0], label = sel[1]; - if (selValue === undefined || !label) { return false; } - return label.toLowerCase().indexOf(value.toLowerCase()) !== -1; - }) - .map(function (sel) { - return { - label: _.escape(sel[1]), - indent: true, - facet: self._getFacetValue(sel) - }; - }).value(); - if (_.isEmpty(results)) { return Promise.resolve(null); } - return Promise.resolve([{ - label: _.escape(this.attrs.string) - }].concat(results)); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - * @param {any} value - * @returns {Object} - */ - _getFacetValue: function (value) { - return { - filter: this.filter, - values: [{label: value[1], value: value[0]}], - }; - }, -}); - -var BooleanField = SelectionField.extend({ - /** - * @override - */ - init: function () { - this._super.apply(this, arguments); - - this.attrs.selection = [ - [true, _t("Yes")], - [false, _t("No")] - ]; - }, -}); - -var DateField = Field.extend({ - /** - * @override - */ - getAutocompletionValues: function (value) { - // Make sure the value has a correct format before the creation of the moment object. See - // issue https://github.com/moment/moment/issues/1407 - var t, v; - try { - t = (this.attrs && this.attrs.type === 'datetime') ? 'datetime' : 'date'; - v = field_utils.parse[t](value, {type: t}, {timezone: true}); - } catch (e) { - return Promise.resolve(null); - } - - var m = moment(v, t === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD'); - if (!m.isValid()) { return Promise.resolve(null); } - var dateString = field_utils.format[t](m, {type: t}); - var label = this._getAutocompletionLabel(dateString); - var facet = this._getFacetValue(dateString, m.toDate()); - return Promise.resolve([{ - label: label, - facet: facet, - }]); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @override - */ - _getAutocompletionLabel: function (value) { - return _.str.sprintf(_.str.escapeHTML( - _t("Search %(field)s at: %(value)s")), { - field: '<em>' + _.escape(this.attrs.string) + '</em>', - value: '<strong>' + value + '</strong>'}); - }, - /** - * @override - * @param {any} rawValue - */ - _getFacetValue: function (value, rawValue) { - var facet = this._super.apply(this, arguments); - facet.values[0].value = rawValue; - return facet; - }, - /** - * @override - */ - _valueFrom: function (facetValue) { - return time.date_to_str(facetValue.value); - }, -}); - -var DateTimeField = DateField.extend({ - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @override - */ - _valueFrom: function (facetValue) { - return time.datetime_to_str(facetValue.value); - }, -}); - -var ManyToOneField = CharField.extend({ - default_operator: {}, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - getAutocompletionValues: function (value) { - if (_.isEmpty(value)) { return Promise.resolve(null); } - var label = this._getAutocompletionLabel(value); - return Promise.resolve([{ - label: label, - facet: this._getFacetValue(value), - expand: this._expand.bind(this), - }]); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @override - */ - _getExpandedFacetValue: function (value) { - return { - filter: this.filter, - values: [{label: value[1], value: value[0]}], - }; - }, - /** - * @override - */ - _getFacetValue: function (value) { - return { - filter: this.filter, - values: [{label: value, value: value, operator: 'ilike'}], - }; - }, - /** - * @override - */ - _expand: function (value) { - var self = this; - var args = this.attrs.domain; - if (typeof args === 'string') { - try { - args = Domain.prototype.stringToArray(args); - } catch(e) { - args = []; - } - } - return this._rpc({ - model: this.attrs.relation, - method: 'name_search', - kwargs: { - name: value, - args: args, - limit: 8, - context: this.context, - }, - }) - .then(function (results) { - if (_.isEmpty(results)) { return null; } - return _(results).map(function (result) { - return { - label: _.escape(result[1]), - facet: self._getExpandedFacetValue(result) - }; - }); - }); - }, - /** - * @override - */ - _makeDomain: function (name, operator, facetValue) { - operator = facetValue.operator || operator; - - switch(operator){ - case this.default_operator: - return [[name, '=', facetValue.value]]; - case 'ilike': - return [[name, 'ilike', facetValue.value]]; - case 'child_of': - return [[name, 'child_of', facetValue.value]]; - } - return this._super(name, operator, facetValue); - }, - /** - * @override - */ - _valueFrom: function (facetValue) { - return facetValue.label; - }, -}); - -return { - BooleanField: BooleanField, - CharField: CharField, - DateField: DateField, - DateTimeField: DateTimeField, - Field: Field, - Filter: Filter, - FloatField: FloatField, - GroupBy: GroupBy, - IntegerField: IntegerField, - ManyToOneField: ManyToOneField, - NumberField: NumberField, - SelectionField: SelectionField, -}; - -}); diff --git a/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js b/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js deleted file mode 100644 index fd5d1052b0b..00000000000 --- a/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js +++ /dev/null @@ -1,37 +0,0 @@ -odoo.define('web.search_bar_autocomplete_sources_registry', function (require) { -"use strict"; - -var Registry = require('web.Registry'); - -return new Registry(); - -}); - -odoo.define('web._search_bar_autocomplete_sources_registry', function(require) { -"use strict"; - -var registry = require('web.search_bar_autocomplete_sources_registry'); -var SearchBarAutoCompleteSources = require('web.SearchBarAutoCompleteSources'); - -// fields -registry - .add('char', SearchBarAutoCompleteSources.CharField) - .add('text', SearchBarAutoCompleteSources.CharField) - .add('html', SearchBarAutoCompleteSources.CharField) - .add('boolean', SearchBarAutoCompleteSources.BooleanField) - .add('integer', SearchBarAutoCompleteSources.IntegerField) - .add('id', SearchBarAutoCompleteSources.IntegerField) - .add('float', SearchBarAutoCompleteSources.FloatField) - .add('monetary', SearchBarAutoCompleteSources.FloatField) - .add('selection', SearchBarAutoCompleteSources.SelectionField) - .add('datetime', SearchBarAutoCompleteSources.DateTimeField) - .add('date', SearchBarAutoCompleteSources.DateField) - .add('many2one', SearchBarAutoCompleteSources.ManyToOneField) - .add('many2many', SearchBarAutoCompleteSources.CharField) - .add('one2many', SearchBarAutoCompleteSources.CharField); - -// others -registry.add('filter', SearchBarAutoCompleteSources.Filter); -registry.add('groupby', SearchBarAutoCompleteSources.GroupBy); - -}); diff --git a/addons/web/static/src/js/views/control_panel/search/search_facet.js b/addons/web/static/src/js/views/control_panel/search/search_facet.js deleted file mode 100644 index 22d957c030b..00000000000 --- a/addons/web/static/src/js/views/control_panel/search/search_facet.js +++ /dev/null @@ -1,172 +0,0 @@ -odoo.define('web.SearchFacet', function (require) { -"use strict"; - -var core = require('web.core'); -var Widget = require('web.Widget'); - -var _t = core._t; - -var SearchFacet = Widget.extend({ - template: 'SearchView.SearchFacet', - events: _.extend({}, Widget.prototype.events, { - 'click .o_facet_remove': '_onFacetRemove', - 'compositionend': '_onCompositionend', - 'compositionstart': '_onCompositionstart', - 'keydown': '_onKeydown', - }), - /** - * @override - * @param {Object} facet - */ - init: function (parent, facet) { - this._super.apply(this, arguments); - - var self = this; - this.facet = facet; - this.facetValues = _.map(this.facet.filters, function (filter) { - return self._getFilterDescription(filter); - }); - this.separator = this._getSeparator(); - this.icon = this._getIcon(); - this._isComposing = false; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Get the correct description according to filter. - * - * @private - * @returns {string} - */ - _getFilterDescription: function (filter) { - if (filter.type === 'field') { - var values = _.pluck(filter.autoCompleteValues, 'label'); - return values.join(_t(' or ')); - } - var description = filter.description; - if (filter.hasOptions) { - if (filter.type === 'filter') { - const optionDescriptions = []; - const sortFunction = (o1, o2) => - filter.options.findIndex(o => o.optionId === o1) - filter.options.findIndex(o => o.optionId === o2); - const p = _.partition([...filter.currentOptionIds], optionId => - filter.options.find(o => o.optionId === optionId).groupId === 1); - const yearIds = p[1].sort(sortFunction); - const otherOptionIds = p[0].sort(sortFunction); - // the following case corresponds to years selected only - if (otherOptionIds.length === 0) { - yearIds.forEach(yearId => { - const d = filter.basicDomains[yearId]; - optionDescriptions.push(d.description); - }); - } else { - otherOptionIds.forEach(optionId => { - yearIds.forEach(yearId => { - const d = filter.basicDomains[yearId + '__' + optionId]; - optionDescriptions.push(d.description); - }); - }); - } - description += ': ' + optionDescriptions.join('/'); - } else { - description = description += ': ' + - filter.options.find(o => o.optionId === filter.optionId).description; - } - } - if (filter.type === 'timeRange') { - var timeRangeValue =_.findWhere(filter.timeRangeOptions, { - optionId: filter.timeRangeId, - }); - description += ': ' + timeRangeValue.description; - if (filter.comparisonTimeRangeId) { - var comparisonTimeRangeValue =_.findWhere(filter.comparisonTimeRangeOptions, { - optionId: filter.comparisonTimeRangeId, - }); - description += ' / ' + comparisonTimeRangeValue.description; - } - } - return description; - }, - /** - * Get the correct icon according to facet type. - * - * @private - * @returns {string} - */ - _getIcon: function () { - var icon; - if (this.facet.type === 'filter') { - icon = 'fa-filter'; - } else if (this.facet.type === 'groupBy') { - icon = 'fa-bars'; - } else if (this.facet.type === 'favorite') { - icon = 'fa-star'; - } else if (this.facet.type === 'timeRange') { - icon = 'fa-calendar'; - } - return icon; - }, - /** - * Get the correct separator according to facet type. - * - * @private - * @returns {string} - */ - _getSeparator: function () { - var separator; - if (this.facet.type === 'filter') { - separator = _t('or'); - } else if (this.facet.type === 'field') { - separator = _t('or'); - } else if (this.facet.type === 'groupBy') { - separator = '>'; - } - return separator; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {CompositionEvent} ev - */ - _onCompositionend: function (ev) { - this._isComposing = false; - }, - /** - * @private - * @param {CompositionEvent} ev - */ - _onCompositionstart: function (ev) { - this._isComposing = true; - }, - /** - * @private - */ - _onFacetRemove: function () { - this.trigger_up('facet_removed', {group: this.facet}); - }, - /** - * @private - * @param {KeyboardEvent} ev - */ - _onKeydown: function (ev) { - if (this._isComposing) { - return; - } - switch (ev.which) { - case $.ui.keyCode.BACKSPACE: - this.trigger_up('facet_removed', {group: this.facet}); - break; - } - }, -}); - -return SearchFacet; - -}); diff --git a/addons/web/static/src/js/views/control_panel/search/search_filters.js b/addons/web/static/src/js/views/control_panel/search/search_filters.js deleted file mode 100644 index f6b4bc13f6b..00000000000 --- a/addons/web/static/src/js/views/control_panel/search/search_filters.js +++ /dev/null @@ -1,418 +0,0 @@ -odoo.define('web.search_filters', function (require) { -"use strict"; - -var core = require('web.core'); -var datepicker = require('web.datepicker'); -var field_utils = require('web.field_utils'); -var search_filters_registry = require('web.search_filters_registry'); -var Widget = require('web.Widget'); - -var _t = core._t; -var _lt = core._lt; - -var ExtendedSearchProposition = Widget.extend({ - template: 'SearchView.extended_search.proposition', - events: { - 'change .o_searchview_extended_prop_field': 'changed', - 'change .o_searchview_extended_prop_op': 'operator_changed', - 'click .o_searchview_extended_delete_prop': function (e) { - e.stopPropagation(); - this.trigger_up('remove_proposition'); - }, - 'keyup .o_searchview_extended_prop_value': function (ev) { - if (ev.which === $.ui.keyCode.ENTER) { - this.trigger_up('confirm_proposition'); - } - }, - }, - /** - * @override - * @param fields - */ - init: function (parent, fields) { - this._super(parent); - this.fields = _(fields).chain() - .map(function (val, key) { return _.extend({}, val, {'name': key}); }) - .filter(function (field) { return !field.deprecated && field.searchable; }) - .sortBy(function (field) {return field.string;}) - .value(); - this.attrs = {_: _, fields: this.fields, selected: null}; - this.value = null; - }, - start: function () { - var parent = this._super(); - parent.then(this.proxy('changed')); - return parent; - }, - changed: function () { - var nval = this.$(".o_searchview_extended_prop_field").val(); - if(this.attrs.selected === null || this.attrs.selected === undefined || nval !== this.attrs.selected.name) { - this.select_field(_.detect(this.fields, function (x) {return x.name === nval;})); - } - }, - operator_changed: function (e) { - this.value.show_inputs($(e.target)); - }, - /** - * Selects the provided field object - * - * @param field a field descriptor object (as returned by fields_get, augmented by the field name) - */ - select_field: function (field) { - var self = this; - if(this.attrs.selected !== null && this.attrs.selected !== undefined) { - this.value.destroy(); - this.value = null; - this.$('.o_searchview_extended_prop_op').html(''); - } - this.attrs.selected = field; - if(field === null || field === undefined) { - return; - } - - var type = field.type; - var Field = search_filters_registry.getAny([type, "char"]); - - this.value = new Field(this, field); - _.each(this.value.operators, function (operator) { - $('<option>', {value: operator.value}) - .text(String(operator.text)) - .appendTo(self.$('.o_searchview_extended_prop_op')); - }); - var $value_loc = this.$('.o_searchview_extended_prop_value').show().empty(); - this.value.appendTo($value_loc); - - }, - get_filter: function () { - if (this.attrs.selected === null || this.attrs.selected === undefined) { - return null; - } - var field = this.attrs.selected, - op_select = this.$('.o_searchview_extended_prop_op')[0], - operator = op_select.options[op_select.selectedIndex]; - - return { - attrs: { - domain: this.value.get_domain(field, operator), - string: this.value.get_label(field, operator), - }, - children: [], - tag: 'filter', - }; - }, -}); - -var Field = Widget.extend({ - init: function (parent, field) { - this._super(parent); - this.field = field; - }, - get_label: function (field, operator) { - var format; - switch (operator.value) { - case '∃': case '∄': format = _t('%(field)s %(operator)s'); break; - default: format = _t('%(field)s %(operator)s "%(value)s"'); break; - } - return this.format_label(format, field, operator); - }, - format_label: function (format, field, operator) { - return _.str.sprintf(format, { - field: field.string, - // According to spec, HTMLOptionElement#label should return - // HTMLOptionElement#text when not defined/empty, but it does - // not in older Webkit (between Safari 5.1.5 and Chrome 17) and - // Gecko (pre Firefox 7) browsers, so we need a manual fallback - // for those - operator: operator.label || operator.text, - value: this - }); - }, - get_domain: function (field, operator) { - switch (operator.value) { - case '∃': return [[field.name, '!=', false]]; - case '∄': return [[field.name, '=', false]]; - default: return [[field.name, operator.value, this.get_value()]]; - } - }, - show_inputs: function ($operator) { - var $value = this.$el.parent(); - switch ($operator.val()) { - case '∃': - case '∄': - $value.hide(); - break; - default: - $value.show(); - } - }, - /** - * Returns a human-readable version of the value, in case the "logical" - * and the "semantic" values of a field differ (as for selection fields, - * for instance). - * - * The default implementation simply returns the value itself. - * - * @return {String} human-readable version of the value - */ - toString: function () { - return this.get_value(); - } -}); - -var Char = Field.extend({ - tagName: 'input', - className: 'o_input', - attributes: { - type: 'text' - }, - operators: [ - {value: "ilike", text: _lt("contains")}, - {value: "not ilike", text: _lt("doesn't contain")}, - {value: "=", text: _lt("is equal to")}, - {value: "!=", text: _lt("is not equal to")}, - {value: "∃", text: _lt("is set")}, - {value: "∄", text: _lt("is not set")} - ], - get_value: function () { - return this.$el.val(); - } -}); - -var DateTime = Field.extend({ - tagName: 'span', - serverFormat: 'YYYY-MM-DD HH:mm:ss', - attributes: { - type: 'datetime' - }, - operators: [ - {value: "between", text: _lt("is between")}, - {value: "=", text: _lt("is equal to")}, - {value: "!=", text: _lt("is not equal to")}, - {value: ">", text: _lt("is after")}, - {value: "<", text: _lt("is before")}, - {value: ">=", text: _lt("is after or equal to")}, - {value: "<=", text: _lt("is before or equal to")}, - {value: "∃", text: _lt("is set")}, - {value: "∄", text: _lt("is not set")} - ], - /** - * Gets the value of the datepicker - * - * @public - * @param {Integer} [index] The datepicker's index. - * 0 for the lower boundary (default) - * 1 for the higher boundary - * - * @return {Moment} The value in UTC - */ - get_value: function (index) { - // retrieve the datepicker value - var value = this["datewidget_" + (index || 0)].getValue().clone(); - // convert to utc - return value.add(-this.getSession().getTZOffset(value), 'minutes'); - }, - get_domain: function (field, operator) { - switch (operator.value) { - case '∃': - return [[field.name, '!=', false]]; - case '∄': - return [[field.name, '=', false]]; - case 'between': - return [ - [field.name, '>=', this._formatMomentToServer(this.get_value(0))], - [field.name, '<=', this._formatMomentToServer(this.get_value(1))] - ]; - default: - return [[field.name, operator.value, this._formatMomentToServer(this.get_value())]]; - } - }, - show_inputs: function ($operator) { - this._super.apply(this, arguments); - - if ($operator.val() === "between") { - this.datewidget_1.do_show(); - } else { - this.datewidget_1.do_hide(); - } - }, - toString: function () { - var str = field_utils.format[this.attributes.type](this.get_value(), {type: this.attributes.type}); - // the second datewidget might have been hidden because the operator has changed - var date_1_value = this.datewidget_1 && !this.datewidget_1.$el.hasClass('o_hidden') && this.get_value(1); - if (date_1_value) { - str += _lt(" and ") + field_utils.format[this.attributes.type](date_1_value, {type: this.attributes.type}); - } - return str; - }, - start: function () { - return Promise.all([ - this._super.apply(this, arguments), - this._create_new_widget("datewidget_0", '00:00:00', 'hh:mm:ss'), - this._create_new_widget("datewidget_1", '23:59:59', 'hh:mm:ss'), - ]).then(() => { - if (this.operators[0].value !== "between") { - this.datewidget_1.do_hide(); - } - }); - }, - _create_new_widget: function (name, ...time) { - this[name] = new (this._get_widget_class())(this); - return this[name].appendTo(this.$el).then((function () { - this[name].setValue(moment(...time)); - }).bind(this)); - }, - _get_widget_class: function () { - return datepicker.DateTimeWidget; - }, - /** - * Transform a Moment in a server acceptable format - * - * @private - * @param {Moment} momentValue The moment to get the string for - * - * @return {String} Represents the value in UTC - */ - _formatMomentToServer: function (momentValue) { - return momentValue.locale('en').format(this.serverFormat); - }, -}); - -var Date = DateTime.extend({ - serverFormat: 'YYYY-MM-DD', - attributes: { - type: 'date' - }, - operators: [ - {value: "=", text: _lt("is equal to")}, - {value: "!=", text: _lt("is not equal to")}, - {value: ">", text: _lt("is after")}, - {value: "<", text: _lt("is before")}, - {value: ">=", text: _lt("is after or equal to")}, - {value: "<=", text: _lt("is before or equal to")}, - {value: "between", text: _lt("is between")}, - {value: "∃", text: _lt("is set")}, - {value: "∄", text: _lt("is not set")} - ], - /** - * @override - */ - get_value: function (index) { - // retrieve the datepicker value - var value = this["datewidget_" + (index || 0)].getValue(); - return value && value.clone(); - }, - _get_widget_class: function () { - return datepicker.DateWidget; - }, -}); - -var Integer = Field.extend({ - tagName: 'input', - className: 'o_input', - attributes: { - type: 'number', - value: '0', - }, - operators: [ - {value: "=", text: _lt("is equal to")}, - {value: "!=", text: _lt("is not equal to")}, - {value: ">", text: _lt("greater than")}, - {value: "<", text: _lt("less than")}, - {value: ">=", text: _lt("greater than or equal to")}, - {value: "<=", text: _lt("less than or equal to")}, - {value: "∃", text: _lt("is set")}, - {value: "∄", text: _lt("is not set")} - ], - toString: function () { - return this.$el.val(); - }, - get_value: function () { - try { - var val =this.$el.val(); - return field_utils.parse.integer(val === "" ? 0 : val); - } catch (e) { - return ""; - } - } -}); - -var Id = Integer.extend({ - operators: [{value: "=", text: _lt("is")}] -}); - -var Float = Field.extend({ - template: 'SearchView.extended_search.proposition.float', - operators: [ - {value: "=", text: _lt("is equal to")}, - {value: "!=", text: _lt("is not equal to")}, - {value: ">", text: _lt("greater than")}, - {value: "<", text: _lt("less than")}, - {value: ">=", text: _lt("greater than or equal to")}, - {value: "<=", text: _lt("less than or equal to")}, - {value: "∃", text: _lt("is set")}, - {value: "∄", text: _lt("is not set")} - ], - init: function (parent) { - this._super(parent); - this.decimal_point = _t.database.parameters.decimal_point; - }, - toString: function () { - return this.$el.val(); - }, - get_value: function () { - try { - var val =this.$el.val(); - return field_utils.parse.float(val === "" ? 0.0 : val); - } catch (e) { - return ""; - } - } -}); - -var Selection = Field.extend({ - template: 'SearchView.extended_search.proposition.selection', - operators: [ - {value: "=", text: _lt("is")}, - {value: "!=", text: _lt("is not")}, - {value: "∃", text: _lt("is set")}, - {value: "∄", text: _lt("is not set")} - ], - toString: function () { - var select = this.$el[0]; - var option = select.options[select.selectedIndex]; - return option.label || option.text; - }, - get_value: function () { - return this.$el.val(); - } -}); - -var Boolean = Field.extend({ - tagName: 'span', - operators: [ - {value: "=", text: _lt("is true")}, - {value: "!=", text: _lt("is false")} - ], - get_label: function (field, operator) { - return this.format_label( - _t('%(field)s %(operator)s'), field, operator); - }, - get_value: function () { - return true; - } -}); - -return { - Boolean: Boolean, - Char: Char, - Date: Date, - DateTime: DateTime, - ExtendedSearchProposition: ExtendedSearchProposition, - Field: Field, - Float: Float, - Id: Id, - Integer: Integer, - Selection: Selection, -}; - -}); diff --git a/addons/web/static/src/js/views/control_panel/search/search_filters_registry.js b/addons/web/static/src/js/views/control_panel/search/search_filters_registry.js deleted file mode 100644 index d6cbb9be2d0..00000000000 --- a/addons/web/static/src/js/views/control_panel/search/search_filters_registry.js +++ /dev/null @@ -1,30 +0,0 @@ -odoo.define('web.search_filters_registry', function (require) { -"use strict"; - -var Registry = require('web.Registry'); - -return new Registry(); - -}); - -odoo.define('web._search_filters_registry', function(require) { -"use strict"; - -var registry = require('web.search_filters_registry'); -var search_filters = require('web.search_filters'); - -registry - .add('boolean', search_filters.Boolean) - .add('char', search_filters.Char) - .add('date', search_filters.Date) - .add('datetime', search_filters.DateTime) - .add('float', search_filters.Float) - .add('id', search_filters.Id) - .add('integer', search_filters.Integer) - .add('many2many', search_filters.Char) - .add('many2one', search_filters.Char) - .add('monetary', search_filters.Float) - .add('one2many', search_filters.Char) - .add('text', search_filters.Char) - .add('selection', search_filters.Selection); -}); diff --git a/addons/web/static/src/js/views/control_panel/search/time_range_menu.js b/addons/web/static/src/js/views/control_panel/search/time_range_menu.js deleted file mode 100644 index 8b6c6552c47..00000000000 --- a/addons/web/static/src/js/views/control_panel/search/time_range_menu.js +++ /dev/null @@ -1,106 +0,0 @@ -odoo.define('web.TimeRangeMenu', function (require) { -"use strict"; - -var config = require('web.config'); -var controlPanelViewParameters = require('web.controlPanelViewParameters'); -var Widget = require('web.Widget'); - -var COMPARISON_TIME_RANGE_OPTIONS = controlPanelViewParameters.COMPARISON_TIME_RANGE_OPTIONS; -var PERIOD_OPTIONS = controlPanelViewParameters.PERIOD_OPTIONS; - -var TimeRangeMenu = Widget.extend({ - template: 'web.TimeRangeMenu', - events: { - 'click .o_apply_range': '_onApplyButtonClick', - 'click .o_comparison_checkbox': '_onCheckBoxClick', - }, - /** - * @override - * @param {Widget} parent - * @param {Object[]} timeRanges - * - */ - init: function (parent, timeRanges) { - this._super.apply(this, arguments); - // determine header style - this.isMobile = config.device.isMobile; - this.symbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : 'caret'; - // fixed parameters - this.periodOptions = PERIOD_OPTIONS; - this.comparisonTimeRangeOptions = COMPARISON_TIME_RANGE_OPTIONS; - this.periodGroups = _.uniq(PERIOD_OPTIONS.map(function (option) { - return option.groupId; - })); - // variable parameters - this.timeRanges = timeRanges; - this.configuration = { - comparisonIsSelected: false, - comparisonTimeRangeId: false, - id: false, - timeRangeId: false, - }; - this._configure(); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @param {Object[]} timeRanges - */ - update: function (timeRanges) { - this.timeRanges = timeRanges; - this._configure(); - this.renderElement(); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _configure: function () { - this.configuration = this.timeRanges.find(function (timeRange) { - return timeRange.isActive; - }) || this.configuration; - this.configuration.comparisonIsSelected = !!this.configuration.comparisonTimeRangeId; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onApplyButtonClick: function () { - var id = this.$('.o_date_field_selector').val(); - var timeRangeId = this.$('.o_time_range_selector').val(); - var comparisonTimeRangeId = false; - if (this.configuration.comparisonIsSelected) { - comparisonTimeRangeId = this.$('.o_comparison_time_range_selector').val(); - } - this.trigger_up('activate_time_range', { - id: id, - timeRangeId: timeRangeId, - comparisonTimeRangeId: comparisonTimeRangeId - }); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onCheckBoxClick: function (ev) { - ev.stopPropagation(); - this.configuration.comparisonIsSelected = this.$('.o_comparison_checkbox').prop('checked'); - this.$('.o_comparison_time_range_selector').toggleClass('o_hidden'); - this.$el.addClass('open'); - } -}); - -return TimeRangeMenu; - -}); diff --git a/addons/web/static/src/js/widgets/auto_complete.js b/addons/web/static/src/js/widgets/auto_complete.js deleted file mode 100644 index fef24527e46..00000000000 --- a/addons/web/static/src/js/widgets/auto_complete.js +++ /dev/null @@ -1,268 +0,0 @@ -odoo.define('web.AutoComplete', function (require) { -"use strict"; - -var Widget = require('web.Widget'); - -return Widget.extend({ - template: "SearchView.autocomplete", - - // Parameters for autocomplete constructor: - // - // parent: this is used to detect keyboard events - // - // options.source: function ({term:query}, callback). This function will be called to - // obtain the search results corresponding to the query string. It is assumed that - // options.source will call callback with the results. - // options.select: function (ev, {item: {facet:facet}}). Autocomplete widget will call - // that function when a selection is made by the user - // options.get_search_string: function (). This function will be called by autocomplete - // to obtain the current search string. - init: function (parent, options) { - this._super(parent); - this.$input = options.$input; - this.source = options.source; - this.select = options.select; - this.get_search_string = options.get_search_string; - - this.current_result = null; - - this.searching = true; - this.search_string = ''; - this.current_search = null; - this._isInputComposing = false; - }, - start: function () { - var self = this; - this.$input.on('compositionend', function (ev) { - self._isInputComposing = false; - }); - this.$input.on('compositionstart', function (ev) { - self._isInputComposing = true; - }); - this.$input.on('keyup', function (ev) { - if (ev.which === $.ui.keyCode.RIGHT && !self._isInputComposing) { - self.searching = true; - ev.preventDefault(); - return; - } - if (ev.which === $.ui.keyCode.ENTER && !self._isInputComposing) { - if (self.search_string.length) { - self.select_item(ev); - } - return; - } - self._updateSearch(); - }); - this.$input.on('input', function (ev) { - if (ev.originalEvent.inputType === 'insertCompositionText') { - // click inside keyboard IME suggestions menu - self._updateSearch(); - } - }); - this.$input.on('keypress', function (ev) { - if (ev.which > 31 && ev.which !== 127) { - // we filter control character out of the search string - self.search_string = self.search_string + String.fromCharCode(ev.which); - } - if (self.search_string.length) { - self.searching = true; - var search_string = self.search_string; - self.initiate_search(search_string); - } else { - self.close(); - } - }); - this.$input.on('keydown', function (ev) { - if (self._isInputComposing) { - return; - } - switch (ev.which) { - case $.ui.keyCode.ENTER: - - // TAB and direction keys are handled at KeyDown because KeyUp - // is not guaranteed to fire. - // See e.g. https://github.com/aef-/jquery.masterblaster/issues/13 - case $.ui.keyCode.TAB: - if (self.search_string.length) { - self.select_item(ev); - } - break; - case $.ui.keyCode.DOWN: - self.move('down'); - self.searching = false; - ev.preventDefault(); - break; - case $.ui.keyCode.UP: - self.move('up'); - self.searching = false; - ev.preventDefault(); - break; - case $.ui.keyCode.RIGHT: - if(self.$input[0].selectionStart < self.search_string.length) { - ev.stopPropagation(); - return; - } - self.searching = false; - var current = self.current_result; - if (current && current.expand && !current.expanded) { - self.expand(); - self.searching = true; - ev.stopPropagation(); - } - ev.preventDefault(); - break; - case $.ui.keyCode.LEFT: - if(self.$input[0].selectionStart > 0) { - ev.stopPropagation(); - } - break; - case $.ui.keyCode.ESCAPE: - self.close(); - self.searching = false; - break; - } - }); - }, - initiate_search: function (query) { - if (query === this.search_string && query !== this.current_search) { - this.search(query); - } - }, - search: function (query) { - var self = this; - this.current_search = query; - this.source({term:query}, function (results) { - if (results.length) { - self.render_search_results(results); - self.focus_element(self.$('li:first-child')); - } else { - self.close(); - } - }); - }, - render_search_results: function (results) { - var self = this; - var $list = this.$el; - $list.empty(); - results.forEach(function (result) { - var $item = self.make_list_item(result).appendTo($list); - result.$el = $item; - }); - // IE9 doesn't support addEventListener with option { once: true } - this.el.onmousemove = function (ev) { - self.$('li').each(function (index, li) { - li.onmouseenter = self.focus_element.bind(self, $(li)); - }); - var targetFocus = ev.target.tagName === 'LI' ? - ev.target : - ev.target.closest('li'); - self.focus_element($(targetFocus)); - self.el.onmousemove = null; - }; - this.show(); - }, - make_list_item: function (result) { - var self = this; - var $li = $('<li>') - .mousedown(function (ev) { - if (ev.button === 0) { // left button - self.select(ev, {item: {facet: result.facet}}); - self.close(); - } - }) - .data('result', result); - if (result.expand) { - var $expand = $('<a class="o-expand" href="#">').appendTo($li); - $expand.mousedown(function (ev) { - ev.stopPropagation(); - ev.preventDefault(); // to prevent dropdown from closing - if (result.expanded) { - self.fold(); - } else { - self.expand(); - } - }); - $expand.click(function(ev) { - ev.preventDefault(); // to prevent url from changing due to href="#" - }); - result.expanded = false; - } - if (result.indent) $li.addClass('o-indent'); - $li.append($('<a href="#">').html(result.label)); - return $li; - }, - expand: function () { - var self = this; - var current_result = this.current_result; - current_result.expand(this.get_search_string()).then(function (results) { - (results || [{label: '(no result)'}]).reverse().forEach(function (result) { - result.indent = true; - var $li = self.make_list_item(result); - current_result.$el.after($li); - }); - self.current_result.expanded = true; - self.current_result.$el.find('a.o-expand').removeClass('o-expand').addClass('o-expanded'); - }); - }, - fold: function () { - var $next = this.current_result.$el.next(); - while ($next.hasClass('o-indent')) { - $next.remove(); - $next = this.current_result.$el.next(); - } - this.current_result.expanded = false; - this.current_result.$el.find('a.o-expanded').removeClass('o-expanded').addClass('o-expand'); - }, - focus_element: function ($li) { - this.$('li').removeClass('o-selection-focus'); - $li.addClass('o-selection-focus'); - this.current_result = $li.data('result'); - }, - select_item: function (ev) { - this.select(ev, {item: {facet: this.current_result.facet}}); - this.close(); - }, - show: function () { - this.$el.show(); - }, - close: function () { - this.current_search = null; - this.search_string = ''; - this.searching = true; - this.$el.hide(); - }, - move: function (direction) { - var $next; - if (direction === 'down') { - $next = this.$('li.o-selection-focus').next(); - if (!$next.length) $next = this.$('li').first(); - } else { - $next = this.$('li.o-selection-focus').prev(); - if (!$next.length) $next = this.$('li').last(); - } - this.focus_element($next); - }, - is_expandable: function () { - return !!this.$('.o-selection-focus .o-expand').length; - }, - is_expanded: function() { - return this.$el[0].style.display === "block"; - }, - /** - * Update search dropdown menu based on new input content. - * - * @private - */ - _updateSearch: function () { - var search_string = this.get_search_string(); - if (this.search_string !== search_string) { - if (search_string.length) { - this.search_string = search_string; - this.initiate_search(search_string); - } else { - this.close(); - } - } - }, -}); -}); diff --git a/addons/web/static/src/js/widgets/dropdown_menu.js b/addons/web/static/src/js/widgets/dropdown_menu.js deleted file mode 100644 index d8663731c67..00000000000 --- a/addons/web/static/src/js/widgets/dropdown_menu.js +++ /dev/null @@ -1,145 +0,0 @@ -odoo.define('web.DropdownMenu', function (require) { -"use strict"; - -var core = require('web.core'); -var Widget = require('web.Widget'); - -var QWeb = core.qweb; - -var _t = core._t; - -var DropdownMenu = Widget.extend({ - template: 'web.DropdownMenu', - - events: { - 'click .o_menu_item': '_onItemClick', - 'click .o_item_option': '_onOptionClick', - 'click span.o_trash_button': '_onTrashButtonClick', - 'hidden.bs.dropdown': '_onBootstrapClose', - 'click .dropdown-item-text': '_onDropDownItemTextClick', - }, - - init: function (parent, items) { - this._super(parent); - // should be specified - this.dropdownCategory = null; - this.dropdownTitle = null; - this.dropdownIcon = null; - this.dropdownSymbol = false; - // this parameter fixes the menu style. By default, - // the style used is the one used in the search view - this.dropdownStyle = { - el: {class: 'btn-group o_dropdown', attrs: {}}, - mainButton: { - class: 'o_dropdown_toggler_btn btn btn-secondary ' + - 'dropdown-toggle ' + - (this.dropdownSymbol ? 'o-no-caret' : '') - }, - }; - this.items = items; - this.openItems = {}; - }, - /** - * @override - */ - start: function () { - this.$menu = this.$('.o_dropdown_menu'); - this.$dropdownReference = this.$('.o_dropdown_toggler_btn'); - - if (_t.database.parameters.direction === 'rtl') { - this.$menu.addClass('dropdown-menu-right'); - } - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @param {Object} items - */ - update: function (items) { - this.items = items; - this._renderMenuItems(); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - */ - _renderMenuItems: function () { - var newMenuItems = QWeb.render('DropdownMenu.MenuItems', {widget: this}); - this.$el.find('.o_menu_item, .dropdown-divider[data-removable="1"]').remove(); - this.$('.o_dropdown_menu').prepend(newMenuItems); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * This method is called when Bootstrap trigger a 'close' event (for example - * when the user clicks outside the dropdown menu). - * - * @private - * @param {jQueryEvent} event - */ - _onBootstrapClose: function () { - this.openItems = {}; - this._renderMenuItems(); - }, - /** - * Reacts to click on bootstrap's dropdown-item-text - * Protects against Bootstrap dropdown close from inside click - * - * @private - */ - _onDropDownItemTextClick: function (ev) { - ev.stopPropagation(); - }, - /** - * @private - * @param {MouseEvent} event - */ - _onItemClick: function (event) { - event.preventDefault(); - event.stopPropagation(); - var id = $(event.currentTarget).data('id'); - var item = this.items.find(function (item) { - return item.id === id; - }); - if (item.hasOptions) { - this.openItems[id] = !this.openItems[id]; - this._renderMenuItems(); - } else { - this.trigger_up('menu_item_clicked', {id: id}); - } - }, - /** - * @private - * @param {MouseEvent} event - */ - _onOptionClick: function (event) { - event.preventDefault(); - event.stopPropagation(); - var optionId = $(event.currentTarget).data('option_id'); - var id = $(event.currentTarget).data('item_id'); - this.trigger_up('item_option_clicked', {id: id, optionId: optionId}); - }, - /** - * @private - * @param {MouseEvent} event - * - * To implement in child - */ - _onTrashButtonClick: function (event) { - }, -}); - -return DropdownMenu; - -});
\ No newline at end of file diff --git a/addons/web/static/src/scss/control_panel.scss b/addons/web/static/src/scss/control_panel.scss index 490b6a1d8ac..77def271d55 100644 --- a/addons/web/static/src/scss/control_panel.scss +++ b/addons/web/static/src/scss/control_panel.scss @@ -45,7 +45,7 @@ display: flex; justify-content: space-between; - > .o_cp_sidebar { + > .o_cp_action_menus { padding-right: 10px; .o_hidden_input_file { @@ -83,14 +83,14 @@ text-align: center; user-select: none; - > div { + .o_pager { display: flex; align-items: center; - } - .o_pager_counter { - margin-right: 5px; - min-width: $o-statbutton-height; + .o_pager_counter { + margin-right: 5px; + min-width: $o-statbutton-height; + } } } @@ -123,12 +123,17 @@ } } -// Pager input (to edit pager values) -.o_pager_value > input { +.o_pager_value { display: inline-block; - width: 60px; - text-align: right; +} +span.o_pager_value { + border-bottom: 1px solid transparent; +} + +input.o_pager_value { + text-align: right; + width: 60px; &:focus { outline: none; } diff --git a/addons/web/static/src/scss/dropdown_menu.scss b/addons/web/static/src/scss/dropdown_menu.scss index 7ce7412b6d0..d2da44a3125 100644 --- a/addons/web/static/src/scss/dropdown_menu.scss +++ b/addons/web/static/src/scss/dropdown_menu.scss @@ -17,9 +17,6 @@ color: darken($o-main-text-color, 10%); } } - .dropdown-divider:first-child { - display: none; - } } } diff --git a/addons/web/static/src/scss/search_view.scss b/addons/web/static/src/scss/search_view.scss index 3a4aaae28dc..a2a8e80669a 100644 --- a/addons/web/static/src/scss/search_view.scss +++ b/addons/web/static/src/scss/search_view.scss @@ -1,93 +1,88 @@ -@mixin o-search-options-dropdown-custom-item { - width: auto; - max-width: 250px; -} - .o_searchview { align-items: flex-end; - position: relative; padding: 0 20px 1px 0; + position: relative; .o_searchview_more { - font-size: 16px; cursor: pointer; + font-size: 16px; } .o_searchview_input_container { - position: relative; display: flex; flex-flow: row wrap; + position: relative; .o_searchview_facet { - $o-searchview-facet-remove-width: 18px; + display: flex; flex: 0 0 auto; + margin: 1px 3px 0 0; max-width: 100%; - display: flex; position: relative; - margin: 1px 3px 0 0; + $o-searchview-facet-remove-width: 18px; .o_searchview_facet_label { + align-items: center; + color: white; flex: 0 0 auto; - @include o-text-overflow; padding: 0 3px; - color: white; - - display: flex; - align-items: center; + @include o-text-overflow($display: flex); } .o_facet_values { direction: ltr#{"/*rtl:ignore*/"}; padding: 0 $o-searchview-facet-remove-width 0 5px; + .o_facet_values_sep { font-style: italic; + margin: 0 0.3rem; } } + .o_facet_remove { - flex: 0 0 auto; + align-items: center; + cursor: pointer; display: flex; + flex: 0 0 auto; justify-content: center; - align-items: center; - @include o-position-absolute(0, 0, 0); width: $o-searchview-facet-remove-width; - cursor: pointer; + @include o-position-absolute(0, 0, 0); } } + .o_searchview_input { - width: 100px; flex: 1 0 auto; + width: 100px; } .o_searchview_more { - font-size: 16px; cursor: pointer; + font-size: 16px; } .o_searchview_autocomplete { - @include o-position-absolute(100%, $left: auto); width: 100%; + @include o-position-absolute(100%, $left: auto); - li { + .o_menu_item { + align-items: center; + display: flex; padding-left: 25px; - position: relative; - &.o-indent { + + &.o_indent { padding-left: 50px; } + a { - display: inline-block; - padding-left: 0px; - padding-right: 0px; &:hover { background-color: inherit; } - &.o-expand, &.o-expanded { // caret - @include o-position-absolute($left: 6px); - } - &.o-expand:before { - @include o-caret-right; - } - &.o-expanded:before { - @include o-caret-down; + + &.o_expand { + display: flex; + justify-content: center; + width: 25px; + @include o-position-absolute($left: 0); } } } @@ -95,127 +90,131 @@ } } -// Searchview and Sidebar dropdowns -// FIXME: move me out of here +.o_search_options .o_dropdown_toggler_btn:first-child { + // apply the padding to the first element so that when the `favorites` menu goes under + // the `filters` menu, they're still aligned + margin-right: 10px; +} + +// Dropdowns .o_dropdown { + color: $o-main-text-color; + cursor: pointer; + display: inline-block; + user-select: none; // Prevent text selection when toggling the dropdowns white-space: nowrap; // prevent the arrow from going under the dropdown when too little space - .dropdown-toggle:first-child { - // apply the padding to the first element so that when the `favorites` menu goes under - // the `filters` menu, they're still aligned - padding-right: 10px; + + .o_dropdown_title { + padding: 0 0.3rem; } - display: inline-block; - cursor: pointer; - user-select: none; // Prevent text selection when toggling the dropdowns - > a { // dropdown togglers - color: $o-main-text-color; - &:hover { - text-decoration: none; - font-weight: bold; + .o_dropdown_caret { + font-size: 12px; + width: 1rem; + } + + &:hover, + &.show { + + .o_dropdown_toggler_btn { color: darken($o-main-text-color, 10%); } } - &.show > a { - font-weight: bold; - color: darken($o-main-text-color, 10%); - } -} + .o_dropdown_menu { + + ul.o_embed_menu { + padding: 0; + } + + a.dropdown-item { + align-items: center; + display: flex; + position: relative; + .o_item_description { + flex: 1; + } + + .o_icon_right { + @include o-position-absolute($right: $dropdown-item-padding-x * 0.5); + } + + .o_icon_right + .o_icon_right { + @include o-position-absolute($right: $dropdown-item-padding-x * 1.25); + } + } + + .o_menu_item_options { + list-style: none; + margin-bottom: 1rem; + padding: 0; + } + } +} // Open and closed carets in dropdowns .o_closed_menu { position: relative; + &:before { + margin-top: (-$caret-width); @include o-caret-right; @include o-position-absolute($top: 50%, $left: 10px); - margin-top: (-$caret-width); } } .o_open_menu { position: relative; + &:before { + margin-top: (-$caret-width); @include o-caret-down; @include o-position-absolute($top: 50%, $left: 10px); - margin-top: (-$caret-width); } } -.o_filters_menu, .o_group_by_menu, .o_time_range_menu { - max-height: calc(100vh - 125px); - overflow: auto; -} +// Filter menu +.o_filter_menu { -.o_filters_menu, .o_group_by_menu { - .o_submenu_switcher { - @include o-position-absolute($right: $dropdown-item-padding-x * 0.5); - } -} - -// Filters -.o_filters_menu { .o_filter_condition { - @include o-search-options-dropdown-custom-item; margin-bottom: 8px; + position: relative; + .o_or_filter { // or between conditions - display: none; // hidden for the first condition @include o-position-absolute($top: 3px, $left: 4px); } - } - .o_filter_condition + .o_filter_condition { - .o_or_filter { - display: block; // displayed from second condition + + .o_generator_menu_operator { + margin: 3px 0px; // vertical gap between selects } - } - .o_searchview_extended_prop_op { - margin: 3px 0px; // vertical gap between selects - } - .o_searchview_extended_prop_value { // date fields: datepicker position - .datepickerbutton { - @include o-position-absolute(3px, -20px); + + .o_generator_menu_value { // date fields: datepicker position + .datepickerbutton { + cursor: pointer; + @include o-position-absolute(3px, -20px); + } + } + + .o_generator_menu_delete { // delete condition button position cursor: pointer; + @include o-position-absolute($top: 6px, $right: 4px); } } - .o_searchview_extended_delete_prop { // delete condition button position - @include o-position-absolute($top: 6px, $right: 4px); - cursor: pointer; - } + .o_add_filter_menu { // apply and add buttons - @include o-search-options-dropdown-custom-item; + display: flex; + justify-content: space-between; } } -// Favorites -.o_favorites_menu { - max-height: 75vh; - overflow-y: auto; - - max-height: calc(100vh - 125px); - overflow: auto; +// Time range menu +.o_time_range_editor { - .o-searchview-custom-private, .o-searchview-custom-public { - .o-remove-filter { - @include o-position-absolute(50%, 12px); - margin-top: -6px; - cursor: pointer; - } - } - .o-searchview-custom-public a:after { - font-family: FontAwesome; - content: "\f0c0"; - margin-left: 5px; - font-weight: normal; - } - .o_save_name { // save button - display: none; // hidden by default - @include o-search-options-dropdown-custom-item; + label { + font-weight: bold; } -} -// Time Ranges -.o_time_range_menu { - .dropdown-item-text > label, .custom-control-label { - font-weight: bold; + .o_time_range_section:not(:first-child) { + padding-top: 0.5rem; } } diff --git a/addons/web/static/src/scss/search_view_extra.scss b/addons/web/static/src/scss/search_view_extra.scss index a0ff4db908a..11107f2ad02 100644 --- a/addons/web/static/src/scss/search_view_extra.scss +++ b/addons/web/static/src/scss/search_view_extra.scss @@ -27,7 +27,7 @@ .o_searchview_more { @include o-position-absolute($top: 6px, $right: 5px); } - .o_searchview_autocomplete li.o-selection-focus { + .o_searchview_autocomplete li.o_selection_focus { background-color: $o-brand-odoo; > a { color: white; diff --git a/addons/web/static/src/scss/webclient_layout.scss b/addons/web/static/src/scss/webclient_layout.scss index a030ac88535..4d2a7b44374 100644 --- a/addons/web/static/src/scss/webclient_layout.scss +++ b/addons/web/static/src/scss/webclient_layout.scss @@ -19,7 +19,7 @@ html { height: 100%; display: flex; flex-flow: column nowrap; - > .o_cp_controller { + > .o_control_panel { flex: 0 0 auto; } > .o_content { diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 07919bcfa84..9e799e44e38 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -3,7 +3,97 @@ <!-- Owl Templates --> -<t t-name="CustomFileInput" owl="1"> +<t t-name="web.ControlPanel" owl="1"> + <div class="o_control_panel"> + <div class="o_cp_top"> + <ol t-if="props.withBreadcrumbs" class="breadcrumb" role="navigation"> + <li t-foreach="props.breadcrumbs" t-as="bc" t-key="bc.controllerID" + class="breadcrumb-item" + t-att-class="{ o_back_button: bc_index === props.breadcrumbs.length - 1 }" + t-att-accesskey="bc_last and 'b'" + t-on-click.prevent="trigger('breadcrumb-clicked', { controllerID: bc.controllerID })" + > + <a t-if="bc.title" href="#" t-esc="bc.title"/> + <em t-else="" class="text-warning">Unnamed</em> + </li> + <li class="breadcrumb-item active"> + <t t-if="props.title" t-esc="props.title"/> + <em t-else="" class="text-warning">Unnamed</em> + </li> + </ol> + <div class="o_cp_searchview" + role="search" + t-ref="searchView" + > + <div t-if="props.withSearchBar" class="o_searchview" role="search" aria-autocomplete="list" > + <i t-if="props.searchMenuTypes.length" + t-attf-class="o_searchview_more fa fa-search-{{ state.displayDropdowns ? 'minus' : 'plus' }}" + title="Advanced Search..." + role="img" + aria-label="Advanced Search..." + t-on-click.prevent="state.displayDropdowns = !state.displayDropdowns" + /> + <SearchBar t-if="props.withSearchBar" fields="fields"/> + </div> + </div> + </div> + <div class="o_cp_bottom"> + <div class="o_cp_left"> + <div class="o_cp_buttons" role="toolbar" aria-label="Control panel buttons" t-ref="buttons"> + <t t-slot="buttons"/> + </div> + <ActionMenus t-if="props.actionMenus and props.actionMenus.items" + t-props="props.actionMenus" + /> + </div> + <div class="o_cp_right"> + <div class="btn-group o_search_options position-static" + role="search" + t-ref="searchViewButtons" + > + <t t-if="props.withSearchBar and state.displayDropdowns"> + <FilterMenu t-if="props.searchMenuTypes.includes('filter')" + class="o_filter_menu" + fields="fields" + /> + <GroupByMenu t-if="props.searchMenuTypes.includes('groupBy')" + class="o_group_by_menu" + fields="fields" + /> + <TimeRangeMenu t-if="props.searchMenuTypes.includes('timeRange')" + class="o_time_range_menu" + fields="fields" + /> + <FavoriteMenu t-if="props.searchMenuTypes.includes('favorite')" + class="o_favorite_menu" + /> + </t> + </div> + <div class="o_cp_pager" role="search" t-ref="pager"> + <Pager t-if="props.pager and props.pager.limit" t-props="props.pager"/> + </div> + <nav t-if="props.views.length gt 1" class="btn-group o_cp_switch_buttons" role="toolbar" aria-label="View switcher"> + <t t-foreach="props.views" t-as="view" t-key="view.type"> + <t t-call="web.ViewSwitcherButton"/> + </t> + </nav> + </div> + </div> + </div> +</t> + +<t t-name="web.ControlPanelX2Many" owl="1"> + <div class="o_x2m_control_panel"> + <div class="o_cp_buttons" role="toolbar" aria-label="Control panel buttons" t-ref="buttons"> + <t t-slot="buttons"/> + </div> + <div class="o_cp_pager" role="search" t-ref="pager"> + <Pager t-if="_shouldShowPager()" t-props="props.pager"/> + </div> + </div> +</t> + +<t t-name="web.CustomFileInput" owl="1"> <span class="o_file_input" aria-atomic="true"> <span class="o_file_input_trigger" t-on-click.prevent="_onTriggerClicked"> <t t-slot="default"> @@ -18,15 +108,350 @@ </span> </t> -<t t-name="DialogButton.tooltip" owl="1"> +<t t-name="web.DatePicker" owl="1"> + <div class="o_datepicker" aria-atomic="true" t-att-id="datePickerId" data-target-input="nearest"> + <input type="text" class="o_datepicker_input o_input datetimepicker-input" autofocus="" + t-att-name="props.name" + t-att-placeholder="props.placeholder" + t-attf-data-target="#{{ datePickerId }}" + t-att-readonly="props.readonly" + t-ref="input" + t-on-change="_onInputChange" + t-on-click="_onInputClick" + /> + <span t-if="props.warn_future and state.warning" class="fa fa-exclamation-triangle o_tz_warning o_datepicker_warning"> + <t>This date is on the future. Make sure it is what you expected.</t> + </span> + <span class="o_datepicker_button"/> + </div> +</t> + +<t t-name="web.DialogButton.tooltip" owl="1"> <div class="oe_tooltip_string" role="tooltip"> <div class="tooltip-inner"> - Hit ENTER to <t t-esc="title"/> + <t>Hit ENTER to <t t-esc="title"/></t> </div> </div> </t> -<t t-name="OwlDialog" owl="1"> +<t t-name="web.DropdownMenu" owl="1"> + <div class="btn-group o_dropdown" t-att-class="{ show: state.open }"> + <button type="button" + class="o_dropdown_toggler_btn btn btn-secondary" + t-att-aria-expanded="state.open ? 'true' : 'false'" + tabindex="-1" + t-on-click="state.open = !state.open" + t-on-keydown="_onButtonKeydown" + > + <i t-if="props.icon" t-att-class="props.icon"/> + <span class="o_dropdown_title" t-esc="title"/> + <i t-if="displayCaret" t-attf-class="o_dropdown_caret fa fa-caret-{{ state.open ? 'down' : 'right' }}"/> + <i t-else="" t-attf-class="fa fa-chevron-{{ state.open ? 'down' : 'right' }} float-right mt4"/> + </button> + <ul t-if="state.open" class="o_dropdown_menu dropdown-menu show" role="menu" + t-on-item-selected="_onItemSelected" + > + <t t-set="currentGroup" t-value="null"/> + <t t-foreach="items" t-as="item" t-key="item.key || item.id || ('item-' + item_index)"> + <li t-if="currentGroup !== null and currentGroup !== item.groupNumber" class="dropdown-divider" role="separator"/> + <t t-if="item.Component" t-component="item.Component" t-props="item.props"/> + <DropdownMenuItem t-else="" t-props="item"/> + <t t-set="currentGroup" t-value="item.groupNumber"/> + </t> + </ul> + </div> +</t> + +<t t-name="web.FavoriteMenu" t-inherit="web.DropdownMenu" t-inherit-mode="primary" owl="1"> + <xpath expr="//DropdownMenuItem" position="attributes"> + <attribute name="t-on-remove-item.stop">_onItemRemoved</attribute> + </xpath> + <xpath expr="//div[1]" position="inside"> + <Dialog t-if="state.deletedFavorite" + title="'Warning'" + size="'medium'" + t-on-dialog-closed="state.deletedFavorite = false" + > + <t t-if="state.deletedFavorite.userId">Are you sure that you want to remove this filter?</t> + <t t-else="">This filter is global and will be removed for everybody if you continue.</t> + <t t-set="buttons"> + <button type="button" class="btn btn-primary" t-on-click="_onRemoveFavorite"> + <t>Ok</t> + </button> + <button type="button" class="btn btn-secondary" t-on-click.stop="state.deletedFavorite = false"> + <t>Cancel</t> + </button> + </t> + </Dialog> + </xpath> +</t> + +<t t-name="web.FilterMenu" t-inherit="web.DropdownMenu" t-inherit-mode="primary" owl="1"> + <xpath expr="//ul[@role='menu']" position="inside"> + <li t-if="items.length" class="dropdown-divider" role="separator"/> + <CustomFilterItem fields="props.fields"/> + </xpath> +</t> + +<t t-name="web.GroupByMenu" t-inherit="web.DropdownMenu" t-inherit-mode="primary" owl="1"> + <xpath expr="//ul[@role='menu']" position="inside"> + <t t-if="fields.length"> + <li t-if="items.length" class="dropdown-divider" role="separator"/> + <CustomGroupByItem fields="fields"/> + </t> + </xpath> +</t> + +<t t-name="web.TimeRangeMenu" t-inherit="web.DropdownMenu" t-inherit-mode="primary" owl="1"> + <xpath expr="//ul[@role='menu']/t[@t-foreach]" position="replace"> + <li class="o_menu_item dropdown-item-text" role="menuitem"> + <t t-if="fields.length" t-call="web.TimeRangeEditor"/> + <t t-else="">No date field available.</t> + </li> + <li class="o_menu_item dropdown-item-text" role="menuitem"> + <button type="button" class="btn btn-primary" t-on-click="_onApply">Apply</button> + </li> + </xpath> +</t> + +<t t-name="web.DropdownMenuItem" owl="1"> + <li class="o_menu_item" role="menuitem" t-on-keydown="_onKeydown"> + <t t-if="canBeOpened"> + <a class="o_menu_item_parent dropdown-item" + t-att-class="{ selected: props.isActive }" + role="menuitem" + href="#" + t-ref="fallback-focus" + t-on-click.prevent="state.open = !state.open" + > + <t t-esc="props.description"/> + <i t-attf-class="o_icon_right fa fa-caret-{{ state.open ? 'down' : 'right' }}"/> + </a> + <ul t-if="state.open" class="o_menu_item_options"> + <t t-set="currentGroup" t-value="null"/> + <t t-foreach="props.options" t-as="option" t-key="option_index"> + <li t-if="currentGroup !== null and currentGroup !== option.groupNumber" + class="dropdown-divider" + role="separator" + /> + <li class="o_item_option" role="menuitem"> + <a class="dropdown-item pl-5" + t-att-class="{ selected: option.isActive }" + role="menuitem" + t-att-href="option.url or '#'" + t-esc="option.description" + t-on-click.prevent="trigger('item-selected', { item: props, option: option })" + /> + </li> + <t t-set="currentGroup" t-value="option.groupNumber"/> + </t> + </ul> + </t> + <a t-else="" class="dropdown-item" + t-att-class="{ selected: props.isActive, [props.className]: props.className }" + role="menuitem" + t-att-href="props.url or '#'" + t-on-click.prevent="trigger('item-selected', { item: props })" + > + <t t-esc="props.description"/> + <i t-if="props.removable" + class="o_icon_right fa fa-trash-o" + title="Delete item" + t-on-click.stop.prevent="trigger('remove-item', { item: props })" + /> + </a> + </li> +</t> + +<t t-name="web.CustomFavoriteItem" owl="1"> + <li class="o_menu_item o_add_favorite" role="menuitem"> + <button type="button" + class="dropdown-item" + t-ref="fallback-focus" + t-on-click="state.open = !state.open" + > + <t>Save Current Search</t> + </button> + <div t-if="state.open" class="dropdown-item-text"> + <div role="menuitem" class="dropdown-item-text"> + <input type="text" + class="o_input" + autofocus="" + t-ref="description" + t-model.trim="state.description" + t-on-keydown="_onInputKeydown" + /> + </div> + <div class="custom-control custom-checkbox"> + <input type="checkbox" + t-att-id="useByDefaultId" + class="custom-control-input" + t-att-checked="state.isDefault" + t-on-change="_onCheckboxChange" + /> + <label class="custom-control-label" t-att-for="useByDefaultId"> + <t>Use by default</t> + </label> + </div> + <div class="custom-control custom-checkbox"> + <input type="checkbox" + t-att-id="shareAllUsersId" + class="custom-control-input" + t-att-checked="state.isShared" + t-on-change="_onCheckboxChange" + /> + <label class="custom-control-label" t-att-for="shareAllUsersId"> + <t>Share with all users</t> + <i class="fa fa-users" role="img" aria-label="Users" title="Users"/> + </label> + </div> + <div class="dropdown-item-text"> + <button type="button" class="o_save_favorite btn btn-primary" t-on-click="_saveFavorite">Save</button> + </div> + </div> + </li> +</t> + +<t t-name="web.CustomFilterItem" owl="1"> + <div class="o_generator_menu"> + <button type="button" + class="o_add_custom_filter dropdown-item" + aria-expanded="false" + t-ref="fallback-focus" + t-on-click="state.open = !state.open" + t-on-keydown="_onKeydown" + > + <t>Add Custom Filter</t> + </button> + <t t-if="state.open"> + <div t-foreach="state.conditions" t-as="condition" t-key="condition_index" + class=" o_filter_condition dropdown-item-text" + > + <t t-set="fieldType" t-value="fields[condition.field].type"/> + <t t-set="selectedOperator" t-value="OPERATORS[FIELD_TYPES[fieldType]][condition.operator]"/> + <span t-if="!condition_first" class="o_or_filter">or</span> + <select class="o_input o_generator_menu_field" + t-on-change="_onFieldSelect(condition)" + > + <option t-foreach="fields" t-as="field" t-key="field_index" + t-att-value="field.name" + t-att-selected="field_index === condition.field" + t-esc="field.string" + /> + </select> + <select class="o_input o_generator_menu_operator" + t-on-change="_onOperatorSelect(condition)" + > + <option t-foreach="OPERATORS[FIELD_TYPES[fieldType]]" t-as="operator" t-key="operator_index" + t-att-value="operator.symbol" + t-att-selected="operator_index === condition.operator" + t-esc="operator.description" + /> + </select> + <span t-if="!('value' in selectedOperator)" class="o_generator_menu_value"> + <t t-if="fieldType === 'date'"> + <DatePicker + date="condition.value[0]" + t-on-datetime-changed="_onDateChanged(condition, 0)" + /> + <DatePicker t-if="selectedOperator.symbol === 'between'" + date="condition.value[1]" + t-on-datetime-changed="_onDateChanged(condition, 1)" + /> + </t> + <t t-elif="fieldType === 'datetime'"> + <DateTimePicker + date="condition.value[0]" + t-on-datetime-changed="_onDateChanged(condition, 0)" + /> + <DateTimePicker t-if="selectedOperator.symbol === 'between'" + date="condition.value[1]" + t-on-datetime-changed="_onDateChanged(condition, 1)" + /> + </t> + <select t-elif="fieldType === 'selection'" class="o_input" + t-on-change="_onValueInput(condition)" + > + <option t-foreach="fields[condition.field].selection" t-as="option" t-key="option_index" + t-att-value="option[0]" + t-esc="option[1]" + /> + </select> + <input t-elif="fieldType === 'float'" + class="o_input" + step="0.01" + t-att-type="DECIMAL_POINT === '.' ? 'number' : 'text'" + t-attf-title="Number using {{ DECIMAL_POINT }} as decimal separator." + t-attf-pattern="[0-9]+([\\{{ DECIMAL_POINT }}][0-9]+)?" + t-att-value="condition.value or (0 + DECIMAL_POINT + 0)" + t-on-input="_onValueInput(condition)" + /> + <input t-elif="['integer', 'id'].includes(fieldType)" + class="o_input" + step="1" + type="number" + t-att-value="condition.value or 0" + t-on-input="_onValueInput(condition)" + /> + <input t-else="" + type="text" + class="o_input" + t-att-value="condition.value or ''" + t-on-input="_onValueInput(condition)" + /> + </span> + <i t-if="state.conditions.length gt 1" + class="fa fa-trash-o o_generator_menu_delete" + role="image" + aria-label="Delete" + title="Delete" + t-on-click="_onRemoveCondition(condition_index)" + /> + </div> + <div class="o_add_filter_menu dropdown-item-text"> + <button type="button" class="btn btn-primary o_apply_filter" t-on-click="_onApply"> + <t>Apply</t> + </button> + <button type="button" + class="btn btn-secondary o_add_condition" + t-on-click="_addDefaultCondition" + > + <i class="fa fa-plus-circle"/> + <t>Add a condition</t> + </button> + </div> + </t> + </div> +</t> + +<t t-name="web.CustomGroupByItem" owl="1"> + <div class="o_generator_menu"> + <button type="button" + class="o_add_custom_group_by dropdown-item" + aria-expanded="false" + t-ref="fallback-focus" + t-on-click="state.open = !state.open" + t-on-keydown="_onKeydown" + > + <t>Add Custom Group</t> + </button> + <div t-if="state.open" class="dropdown-item-text"> + <select class="w-auto o_input o_group_by_selector" t-model="state.fieldName"> + <option t-foreach="props.fields" t-as="field" t-key="field.name" + t-att-value="field.name" + t-esc="field.string" + /> + </select> + </div> + <div t-if="state.open" class="o_add_group_by_menu dropdown-item-text"> + <button type="button" class="btn btn-primary o_apply_group_by" t-on-click="_onApply"> + <t>Apply</t> + </button> + </div> + </div> +</t> + +<t t-name="web.OwlDialog" owl="1"> <Portal target="'body'"> <div class="o_dialog" t-on-focus="_onFocus" t-on-click="_onClick"> <div t-if="props.backdrop" class="modal-backdrop show" t-on-click="_onBackdropClick"/> @@ -51,7 +476,7 @@ <footer t-if="props.renderFooter" class="modal-footer" t-ref="modal-footer"> <t t-slot="buttons"> <button class="btn btn-primary" t-on-click.prevent="_close"> - Ok + <t>Ok</t> </button> </t> </footer> @@ -62,6 +487,182 @@ </Portal> </t> +<t t-name="web.Pager" owl="1"> + <nav class="o_pager" aria-label="Pager"> + <span class="o_pager_counter" t-on-click.stop=""> + <input t-if="state.editing" type="text" + class="o_pager_value o_input" + autofocus="" + t-att-value="value" + t-on-blur="state.editing = false" + t-on-change="_onValueChange" + t-on-keydown.stop="_onValueKeydown" + /> + <span t-else="" + class="o_pager_value" + t-esc="value" + t-on-click="_onEdit" + /> + <span> / </span> + <span class="o_pager_limit" t-esc="props.size"/> + </span> + <span class="btn-group" aria-atomic="true"> + <!-- accesskeys not wanted in X2Many widgets --> + <button type="button" + class="fa fa-chevron-left btn btn-secondary o_pager_previous" + t-att-disabled="state.disabled || singlePage" + t-att-accesskey="props.withAccessKey ? 'p' : false" + aria-label="Previous" + title="Previous" + tabindex="-1" + t-on-click="_changeSelection(-1)" + /> + <button type="button" + class="fa fa-chevron-right btn btn-secondary o_pager_next" + t-att-disabled="state.disabled || singlePage" + t-att-accesskey="props.withAccessKey ? 'n' : false" + aria-label="Next" + title="Next" + tabindex="-1" + t-on-click="_changeSelection(1)" + /> + </span> + </nav> +</t> + +<t t-name="web.SearchBar" owl="1"> + <div class="o_searchview_input_container"> + <div t-foreach="model.getFacets()" t-as="facet" t-key="facet_index" + tabindex="0" + class="o_searchview_facet" + role="img" + aria-label="search" + t-on-keydown="_onFacetKeydown(facet, facet_index)" + > + <span t-if="facet.title" class="o_searchview_facet_label" t-esc="facet.title"/> + <span t-else="" t-attf-class="o_searchview_facet_label fa {{ facet.icon }}"/> + <div class="o_facet_values"> + <t t-foreach="facet.values" t-as="facetValue" t-key="facetValue_index"> + <span t-if="!facetValue_first" class="o_facet_values_sep" t-esc="facet.separator"/> + <span class="o_facet_value" t-esc="facetValue"/> + </t> + </div> + <i class="fa fa-sm fa-remove o_facet_remove" + role="img" + aria-label="Remove" + title="Remove" + t-on-click="_onFacetRemove(facet)" + /> + </div> + <input type="text" + class="o_searchview_input" + autofocus="" + accesskey="Q" + placeholder="Search..." + role="searchbox" + t-ref="search-input" + t-on-keydown="_onSearchKeydown" + t-on-input="_onSearchInput" + /> + <ul t-if="state.sources.length" class="o_dropdown_menu o_searchview_autocomplete dropdown-menu show" role="menu"> + <li t-foreach="state.sources" t-as="src" t-key="src.id" + t-att-id="src.id" + class="o_menu_item" + t-att-class="{ o_indent: src.parent, o_selection_focus: src_index === state.focusedItem }" + t-on-click="_selectSource(src)" + t-on-mousemove="_onSourceMousemove(src_index)" + > + <a t-if="src.expand" + href="#" + class="o_expand" + t-on-click.stop.prevent="_expandSource(src, !src.expanded)" + > + <i t-attf-class="fa fa-caret-{{ src.expanded ? 'down' : 'right' }}"/> + </a> + <a href="#" t-on-click.prevent=""> + <t t-if="src.label" t-esc="src.label"/> + <t t-elif="['date', 'datetime'].includes(props.fields[src.fieldName].type)"> + <t>Search <em t-esc="src.description"/> at: <strong t-esc="state.inputValue"/></t> + </t> + <t t-else="">Search <em t-esc="src.description"/> for: <strong t-esc="state.inputValue"/></t> + </a> + </li> + </ul> + </div> +</t> + +<t t-name="web.ActionMenus" owl="1"> + <div class="o_cp_action_menus" t-on-item-selected.stop="_onItemSelected"> + <DropdownMenu t-if="printItems.length" + title="env._t('Print')" + items="printItems" + /> + <DropdownMenu t-if="actionItems.length" + title="env._t('Action')" + items="actionItems" + /> + </div> +</t> + +<t t-name="web.TimeRangeEditor" owl="1"> + <div class="o_time_range_editor"> + <div class="o_time_range_section"> + <label t-attf-for="field_select_{{ domId }}">Based On</label> + <select t-attf-id="field_select_{{ domId }}" class="o_input" t-model="state.fieldName"> + <option t-foreach="fields" t-as="field" t-key="field.name" + t-att-selected="state.fieldName === field.name" + t-att-value="field.name" + t-esc="field.description" + /> + </select> + </div> + <div class="o_time_range_section"> + <label t-attf-for="range_select_{{ domId }}">Range</label> + <select t-attf-id="range_select_{{ domId }}" class="o_input" t-model="state.rangeId"> + <optgroup t-foreach="periodGroups" t-as="group" t-key="group" label=""> + <t t-foreach="Object.values(periodOptions)" t-as="option" t-key="option_index"> + <option t-if="option.groupNumber === group" + t-att-selected="state.rangeId === option.id" + t-att-value="option.id" + t-esc="option.description" + /> + </t> + </optgroup> + </select> + </div> + <div class="o_time_range_section"> + <div class="custom-control custom-checkbox"> + <input type="checkbox" t-attf-id="checkbox_select_{{ domId }}" + class="custom-control-input" + t-att-checked="state.comparisonRangeId" + t-on-click="_onCheckboxClick" + /> + <label t-attf-for="checkbox_select_{{ domId }}" class="custom-control-label">Compare To</label> + </div> + <select t-if="state.comparisonRangeId" class="o_input" t-model="state.comparisonRangeId"> + <option t-foreach="Object.values(comparisonTimeRangeOptions)" t-as="option" t-key="option_index" + t-att-selected="state.comparisonRangeId === option.id" + t-att-value="option.id" + t-esc="option.description" + /> + </select> + </div> + </div> +</t> + +<t t-name="web.ViewSwitcherButton" owl="1"> + <button type="button" + t-att-accesskey="view.accessKey" + t-attf-class="btn btn-secondary fa fa-lg o_switch_view o_{{ view.type }} {{ view.icon }}" + t-att-class="{ active: env.view.type === view.type }" + t-attf-aria-label="View {{ view.type }}" + t-attf-title="View {{ view.type }}" + data-toggle="tooltip" + tabindex="-1" + t-on-click="trigger('switch-view', { view_type: view.type })" + /> +</t> + <!-- Legacy Templates --> <div t-name="EmptyComponent"/> @@ -205,112 +806,9 @@ </form> <div t-name="Action" class="o_action"> - <!-- CP --> <div class="o_content"/> </div> -<t t-name="ControlPanel"> - <div class="o_control_panel"> - <div> - <ol class="breadcrumb" role="navigation"/> - <div class="o_cp_searchview" role="search"> - <t t-if="widget.withSearchBar" t-call="SearchView"/> - </div> - </div> - <div> - <div class="o_cp_left"> - <div class="o_cp_buttons" role="toolbar" aria-label="Control panel toolbar"/> - <aside class="o_cp_sidebar"/> - </div> - <div class="o_cp_right"> - <div class="btn-group o_search_options position-static" role="search"/> - <nav class="o_cp_pager" role="search" aria-label="Pager"/> - <nav class="btn-group o_cp_switch_buttons" role="toolbar" aria-label="View switcher"/> - </div> - </div> - </div> -</t> -<t t-name="X2ManyControlPanel"> - <div class="o_x2m_control_panel"> - <nav class="o_cp_buttons" aria-label="Control panel toolbar" role="toolbar"/> - <nav class="o_cp_pager" aria-label="Pager" role="toolbar"/> - </div> -</t> - -<t t-name="ControlPanel.SwitchButton"> - <button type="button" t-att-accesskey="view.accessKey" - t-attf-class="btn btn-secondary fa fa-lg #{view.icon} o_cp_switch_#{view.type} #{viewType === view.type ? ' active' : ''}" - t-attf-aria-label="View #{view.type}" - t-att-data-view-type="view.type" t-attf-title="View #{view.type}" tabindex="-1"/> -</t> - -<t t-name="ControlPanel.SwitchButtons.Mobile"> - <button class="btn btn-link btn-sm" data-toggle="dropdown" aria-expanded="false"> - <span class="o_switch_view_button_icon"/> - </button> - <ul class="dropdown-menu dropdown-menu-right list-inline"> - <li t-foreach="views" t-as="view"> - <t t-call="ControlPanel.SwitchButton"/> - </li> - </ul> -</t> - -<t t-name="ControlPanel.SwitchButtons"> - <t t-foreach="views" t-as="view"> - <t t-call="ControlPanel.SwitchButton"/> - </t> -</t> - -<t t-name="Sidebar"> - <t t-foreach="widget.sections" t-as="section"> - <div class="btn-group o_dropdown"> - <button t-if="section.name != 'buttons'" class="o_dropdown_toggler_btn btn btn-secondary dropdown-toggle" data-toggle="dropdown" aria-expanded="false"> - <t t-if="section.name == 'files'" t-raw="widget.items[section.name].length || ''"/> - <t t-esc="section.label"/> - </button> - <t t-if="section.name == 'buttons'" t-foreach="widget.items[section.name]" t-as="item" t-att-class="item.classname"> - <a t-att-title="item.title or None" t-att-data-section="section.name" t-att-data-index="item_index" t-att-href="item.url or '#'" target="_blank"> - <t t-raw="item.label"/> - </a> - </t> - <div class="dropdown-menu o_dropdown_menu" role="menu"> - <t t-foreach="widget.items[section.name]" t-as="item"> - <t t-if="section.name == 'files'"> - <t t-set="item.title"> - <b>Attachment : </b><br/> - <t t-raw="item.name"/> - </t> - <t t-if="item.create_uid and item.create_uid[0]" t-set="item.title"> - <t t-raw="item.title"/><br/> - <b>Created by : </b><br/> - <t t-raw="item.create_uid[1]"/> <t t-esc="item.create_date_string" /> - </t> - <t t-if="item.create_uid and item.write_uid and item.create_uid[0] != item.write_uid[0]" t-set="item.title"> - <t t-raw="item.title"/><br/> - <b>Modified by : </b><br/> - <t t-raw="item.write_uid[1]"/> <t t-esc="item.write_date_string"/> - </t> - </t> - <a t-attf-class="dropdown-item #{item.classname}" role="menuitem" t-att-title="item.title or None" t-att-data-section="section.name" t-att-data-index="item_index" t-att-href="item.url or '#'"> - <t t-raw="item.label"/> - <span t-if="section.name == 'files' and widget.options.editable and !item.callback" class="fa fa-trash-o o_sidebar_delete_attachment" t-att-data-id="item.id" title="Delete this attachment" role="img" aria-label="Delete this attachment"/> - </a> - </t> - <div role="menuitem" t-if="section.name == 'files' and widget.options.editable" class="dropdown-item o_sidebar_add_attachment"> - <t t-call="HiddenInputFile"> - <t t-set="fileupload_id" t-value="widget.fileuploadId"/> - <t t-set="fileupload_action" t-translation="off">/web/binary/upload_attachment</t> - <t t-set="multi_upload" t-value="true"/> - <input type="hidden" name="model" t-att-value="widget.env and widget.env.model"/> - <input type="hidden" name="id" t-att-value="widget.env.activeIds[0]"/> - <span>Add...</span> - </t> - </div> - </div> - </div> - </t> -</t> - <div t-name="web.NoContentHelper" class="o_view_nocontent" role="alert" owl="1"> <div class="o_nocontent_help"> <p class="o_view_nocontent_empty_folder"> @@ -489,18 +987,7 @@ </table> </form> <t t-name="GraphView.buttons"> - <div class="btn-group" role="toolbar" aria-label="Main actions"> - <button class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-expanded="false"> - Measures - </button> - <div class="dropdown-menu o_graph_measures_list" role="menu"> - <t t-foreach="measures" t-as="measure"> - <a role="menuitem" href="#" class="dropdown-item" t-att-data-field="measure[0]"><t t-esc="measure[1].string"/><t t-if="measure[1].type === 'many2one'"> (count)</t></a> - </t> - <div role="separator" class="dropdown-divider"/> - <a role="menuitem" href="#" class="dropdown-item" data-field="__count__">Count</a> - </div> - </div> + <div class="btn-group" role="toolbar" aria-label="Main actions"/> <div class="btn-group" role="toolbar" aria-label="Change graph"> <button class="btn btn-secondary fa fa-bar-chart-o o_graph_button" title="Bar Chart" aria-label="Bar Chart" data-mode="bar"/> <button class="btn btn-secondary fa fa-area-chart o_graph_button" title="Line Chart" aria-label="Line Chart" data-mode="line"/> @@ -579,6 +1066,7 @@ </div> <div t-else="">Select a model to add a filter.</div> </t> + <t t-name="DomainNode.ControlPanel"> <div t-if="!widget.readonly && !widget.noControlPanel" class="o_domain_node_control_panel" role="toolbar" aria-label="Domain node"> <button class="btn o_domain_delete_node_button" title="Delete node" aria-label="Delete node"><i class="fa fa-times"/></button> @@ -753,6 +1241,7 @@ </li> </t> </ul> + <t t-name="web.datepicker"> <t t-set="placeholder" t-value="widget.getParent().node and widget.getParent().node.attrs.placeholder"/> <t t-set="datepickerID" t-value="_.uniqueId('datepicker')"/> @@ -1098,255 +1587,6 @@ </t> </div> -<t t-name="DropdownMenu.MenuItems"> - <t t-if="widget.items.length !== 0"> - <t t-set="group" t-value="0"/> - <t t-foreach="widget.items" t-as="item"> - <div t-if="group !== item.groupNumber" role="separator" class="dropdown-divider" data-removable="1"/> - <div t-attf-class="o_menu_item" - t-att-data-id="item.id"> - <a role="menuitem" href="#" t-attf-class="dropdown-item#{item.isActive ? ' selected ' : ''}"> - <t t-esc="item.description"/> - <span t-if="item.isRemovable" t-att-data-id="item.id" class="o_trash_button" > <span class="fa fa-trash-o"></span> </span> - <span t-if="item.hasOptions" t-att-data-id="item.id" class="o_submenu_switcher"> - <span t-att-class="widget.openItems[item.id] ? 'fa fa-caret-down' : 'fa fa-caret-right'"></span> - </span> - </a> - <ul t-if="item.hasOptions && widget.openItems[item.id]" class="list-unstyled"> - <t t-set="subgroup" t-value="0"/> - <t t-foreach="item.options" t-as="option"> - <li t-if="subgroup !== option.groupId" role="separator" class="dropdown-divider" data-removable="1"/> - <li class="o_item_option" - t-att-data-item_id="item.id" t-att-data-option_id="option.optionId"> - <a role="menuitem" href="#" t-attf-class="dropdown-item#{item.currentOptionIds.has(option.optionId) ? ' selected': ''} pl-5"><t t-esc="option.description"/></a> - </li> - <t t-set="subgroup" t-value="option.groupId"/> - </t> - </ul> - </div> - <t t-set="group" t-value="item.groupNumber"/> - </t> - </t> -</t> - -<div t-name="web.DropdownMenu" t-att-class="widget.dropdownStyle.el.class" t-att="widget.dropdownStyle.el.attrs"> - <button t-att-class="widget.dropdownStyle.mainButton.class" data-toggle="dropdown" aria-expanded="false" tabindex="-1" data-flip="false" data-boundary="viewport"> - <span t-att-class="widget.dropdownIcon"/> <t t-esc="widget.dropdownTitle"/> <span t-if="widget.dropdownSymbol" t-att-class="widget.dropdownSymbol"/> - </button> - <div class="dropdown-menu o_dropdown_menu" role="menu"> - <t t-call="DropdownMenu.MenuItems"/> - </div> -</div> - -<t t-name="GroupByMenuGenerator"> - <div t-if="widget.presentedFields.length !== 0 && widget.items.length !== 0" role="separator" class="dropdown-divider o_generator_menu"/> - <button t-if="widget.presentedFields.length !== 0" type="button" class="dropdown-item o_generator_menu o_add_custom_group" aria-expanded="false">Add Custom Group</button> - <div t-if="widget.generatorMenuIsOpen" class="dropdown-item-text o_generator_menu"> - <select class="w-auto o_input o_group_selector o_add_group"> - <t t-foreach="widget.presentedFields" t-as="field"> - <option t-att-value="field.name"><t t-esc="field.string"/></option> - </t> - </select> - </div> - <div t-if="widget.generatorMenuIsOpen" class="dropdown-item-text o_generator_menu"> - <button type="button" class="btn btn-primary o_apply_group o_add_group">Apply</button> - </div> -</t> - -<t t-name="FilterMenuGenerator"> - <div t-if="Object.keys(widget.fields).length !== 0 && widget.items.length !== 0" role="separator" class="dropdown-divider o_generator_menu"/> - <button t-if="Object.keys(widget.fields).length !== 0" type="button" class="dropdown-item o_generator_menu o_add_custom_filter" aria-expanded="false">Add Custom Filter</button> - <div t-if="widget.generatorMenuIsOpen" class="dropdown-item-text o_generator_menu o_add_filter_menu"> - <button class="btn btn-primary o_apply_filter" type="button">Apply</button> - <button class="btn btn-secondary o_add_condition" type="button"> - <span class="fa fa-plus-circle"/> - Add a condition - </button> - </div> -</t> - -<div t-name="web.TimeRangeMenu" class="btn-group o_dropdown"> - <button t-att-class="'o_dropdown_toggler_btn btn btn-secondary dropdown-toggle o_time_range_menu_button' + (widget.isMobile ? ' o-no-caret' : '')" data-toggle="dropdown" - aria-expanded="false" tabindex="-1"> - <span class="fa fa-calendar"/> Time Ranges <span t-att-class="widget.symbol"/> - </button> - <div class="dropdown-menu o_dropdown_menu o_time_range_menu" role="menu"> - <div class="dropdown-item-text"> - <label for="date_field_selector">Based On</label> - <select id="date_field_selector" class="o_input o_date_field_selector"> - <t t-foreach="widget.timeRanges" t-as="timeRange"> - <t t-if="widget.configuration.id === timeRange.id"> - <option t-att-value="timeRange.id" selected="selected"> - <t t-esc="timeRange.description"/> - </option> - </t> - <t t-else=""> - <option t-att-value="timeRange.id"> - <t t-esc="timeRange.description"/> - </option> - </t> - </t> - </select> - </div> - <div class="dropdown-item-text"> - <label for="time_range_selector">Range</label> - <select id="time_range_selector" class="o_input o_time_range_selector"> - <t t-foreach="widget.periodGroups" t-as="group"> - <optgroup t-if="group !== 1"/> - <t t-foreach="widget.periodOptions" t-as="option"> - <t t-if="option.groupId === group"> - <t t-if="widget.configuration.timeRangeId === option.optionId"> - <option t-att-value="option.optionId" selected="selected"> - <t t-esc="option.description"/> - </option> - </t> - <t t-else=""> - <option t-att-value="option.optionId"> - <t t-esc="option.description"/> - </option> - </t> - </t> - </t> - </t> - </select> - </div> - <div class="dropdown-item-text"> - <t t-set="isSelected" t-value="widget.configuration.comparisonIsSelected"/> - <div class="custom-control custom-checkbox"> - <input type="checkbox" id="comparison_checkbox" class="custom-control-input o_comparison_checkbox" t-att-checked="isSelected ? 'checked' : undefined"/> - <label for="comparison_checkbox" class="custom-control-label">Compare To</label> - </div> - <select t-att-class="'o_input o_comparison_time_range_selector' + (widget.configuration.comparisonIsSelected ? '' : ' o_hidden')"> - <t t-foreach="widget.comparisonTimeRangeOptions" t-as="option"> - <t t-if="widget.configuration.comparisonTimeRangeId === option.optionId"> - <option t-att-value="option.optionId" selected="selected"> - <t t-esc="option.description"/> - </option> - </t> - <t t-else=""> - <option t-att-value="option.optionId"> - <t t-esc="option.description"/> - </option> - </t> - </t> - </select> - </div> - <div class="dropdown-item-text"> - <button class="btn btn-primary o_apply_range" type="button">Apply</button> - </div> - </div> -</div> - -<div t-name="SearchView" class="o_searchview" role="search" aria-autocomplete="list"> - <t t-if="widget.isMobile"> - <t t-call="MobileSearchView"/> - <button class="o_toggle_searchview_full o_hidden btn fa fa-filter"/> - <button class="o_enable_searchview btn btn-link fa fa-search"/> - </t> - <t t-else=""> - <span class="o_searchview_more fa" title="Advanced Search..." role="img" aria-label="Advanced Search..."/> - </t> -</div> - -<div t-name="SearchView.SearchBar" class="o_searchview_input_container"> - <input type="text" class="o_searchview_input" accesskey="Q" placeholder="Search..." role="searchbox" aria-haspopup="true"/> -</div> - -<div t-name="SearchView.SearchFacet" tabindex="0" class="o_searchview_facet" role="img" aria-label="search"> - <span t-if="widget.facet.type === 'field'" class="o_searchview_facet_label"> - <t t-esc="widget.facet.filters[0].attrs.string"/> - </span> - <span t-else="1" t-att-class="'fa ' + widget.icon + ' o_searchview_facet_label'"/> - <div class="o_facet_values"> - <t t-foreach="widget.facetValues" t-as="facetValue"> - <span t-if="facetValue_index > 0" class="o_facet_values_sep" t-esc="widget.separator"/> <span t-esc="facetValue"/> - </t> - </div> - <div class="fa fa-sm fa-remove o_facet_remove" role="img" aria-label="Remove" title="Remove"/> -</div> - -<t t-name="SearchView.autocomplete"> - <div class="dropdown-menu o_searchview_autocomplete" role="menu"/> -</t> - -<t t-name="SearchView.filters"> - <t t-foreach="widget.filters" t-as="filter"> - <a t-if="!filter.visible || filter.visible()" - href="#" - class="dropdown-item" - t-att-title="filter.attrs.string ? filter.attrs.help : undefined" - t-att-data-index="filter_index" - role="menuitem"> - <t t-esc="filter.attrs.string or filter.attrs.help or filter.attrs.name or 'Ω'"/> - </a> - </t> -</t> -<t t-name="SearchView.extended_search.proposition"> - <div role="menuitem" class="dropdown-item-text o_filter_condition"> - <span class="o_or_filter">or</span> - <span> - <select class="o_input o_searchview_extended_prop_field"> - <t t-foreach="widget.attrs.fields" t-as="field"> - <option t-att="{'selected': field === widget.attrs.selected ? 'selected' : null}" - t-att-value="field.name"> - <t t-esc="field.string"/> - </option> - </t> - </select> - <span class="o_searchview_extended_delete_prop fa fa-trash-o" role="img" aria-label="Delete" title="Delete"/> - </span> - <select class="o_input o_searchview_extended_prop_op"/> - <span class="o_searchview_extended_prop_value"/> - </div> -</t> -<t t-name="SearchView.extended_search.proposition.float"> - <input class="o_input" - t-att-type="widget.decimal_point === '.' ? 'number' : 'text'" - t-attf-title="Number using #{widget.decimal_point || '.' } as decimal separator." - t-attf-pattern="[0-9]+([\\#{widget.decimal_point || '.' }][0-9]+)?" - t-attf-value="0#{widget.decimal_point || '.' }0" step="0.01"/> -</t> -<t t-name="SearchView.extended_search.proposition.selection"> - <select class="o_input"> - <option t-foreach="widget.field.selection" t-as="element" t-att-value="element[0]"> - <t t-esc="element[1]"/> - </option> - </select> -</t> - -<div t-name="MobileSearchView" class="o_mobile_search o_hidden"> - <div class="o_mobile_search_header"> - <span class="o_mobile_search_close float-left mt16 mb16 mr8 ml16"> - <i class="fa fa-arrow-left"/> - <strong class="float-right ml8">FILTER</strong> - </span> - <span class="float-right o_mobile_search_clear_facets mt16 mr16">CLEAR</span> - </div> - <div class="o_mobile_search_filter o_search_options mb8 mt8 ml16 mr16"/> - <div class="btn btn-primary o_mobile_search_show_result fixed-bottom">SEE RESULT</div> -</div> - -<div t-name="AddNewFavoriteMenu"> - <button type="button" class="dropdown-item o_generator_menu o_add_favorite o_menu_header">Save Current Search</button> - <div t-if="widget.isOpen" class="dropdown-item-text o_generator_menu"> - <div role="menuitem" class="dropdown-item-text o_favorite_name"> - <input type="text" class="o_input"></input> - </div> - <t t-set="favId" t-value="_.uniqueId('_checkbox_')" /> - <div class="custom-control custom-checkbox"> - <input type="checkbox" t-attf-id="o_favorite_use_by_default#{favId}" class="custom-control-input"/> - <label t-attf-for="o_favorite_use_by_default#{favId}" class="custom-control-label">Use by default</label> - </div> - <div class="custom-control custom-checkbox"> - <input type="checkbox" t-attf-id="o_favorite_share_all_users#{favId}" class="custom-control-input"/> - <label t-attf-for="o_favorite_share_all_users#{favId}" class="custom-control-label">Share with all users <span class="fa fa-users" role="img" aria-label="Users" title="Users"/></label> - </div> - <div class="dropdown-item-text o_save_favorite"> - <button type="button" class="btn btn-primary">Save</button> - </div> - </div> -</div> - <div t-name="ExportDialog" class="o_export"> <div class="row o-export-panel no-gutters"> <div class="col-6 d-flex o_left_panel"> @@ -1459,21 +1699,6 @@ </button> </t> -<div t-name="Pager" class="o_pager"> - <span class="o_pager_counter"> - <span class="o_pager_value"></span> / <span class="o_pager_limit"></span> - </span> - <span class="btn-group" aria-atomic="true"> - <!-- accesskeys not wanted in X2Many widgets --> - <t t-if="widget.options.withAccessKey"> - <t t-set="att_prev" t-value="{'accesskey': 'p'}" /> - <t t-set="att_next" t-value="{'accesskey': 'n'}" /> - </t> - <button type="button" class="fa fa-chevron-left btn btn-secondary o_pager_previous" t-att="att_prev" aria-label="Previous" title="Previous" tabindex="-1"/> - <button type="button" class="fa fa-chevron-right btn btn-secondary o_pager_next" t-att="att_next" aria-label="Next" title="Next" tabindex="-1"/> - </span> -</div> - <t t-name="AceEditor"> <div class="oe_form_field o_ace_view_editor oe_ace_open"> <div class="ace-view-editor"/> |
