summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/web/static/src/js/chrome/pager.js276
-rw-r--r--addons/web/static/src/js/chrome/sidebar.js219
-rw-r--r--addons/web/static/src/js/components/action_menus.js171
-rw-r--r--addons/web/static/src/js/components/datepicker.js244
-rw-r--r--addons/web/static/src/js/components/dropdown_menu.js133
-rw-r--r--addons/web/static/src/js/components/dropdown_menu_item.js98
-rw-r--r--addons/web/static/src/js/components/pager.js221
-rw-r--r--addons/web/static/src/js/control_panel/control_panel.js225
-rw-r--r--addons/web/static/src/js/control_panel/control_panel_model.js1721
-rw-r--r--addons/web/static/src/js/control_panel/control_panel_x2many.js40
-rw-r--r--addons/web/static/src/js/control_panel/custom_favorite_item.js153
-rw-r--r--addons/web/static/src/js/control_panel/custom_filter_item.js258
-rw-r--r--addons/web/static/src/js/control_panel/custom_group_by_item.js46
-rw-r--r--addons/web/static/src/js/control_panel/favorite_menu.js101
-rw-r--r--addons/web/static/src/js/control_panel/filter_menu.js74
-rw-r--r--addons/web/static/src/js/control_panel/groupby_menu.js94
-rw-r--r--addons/web/static/src/js/control_panel/search_bar.js480
-rw-r--r--addons/web/static/src/js/control_panel/search_utils.js161
-rw-r--r--addons/web/static/src/js/control_panel/time_range_menu.js109
-rw-r--r--addons/web/static/src/js/model.js157
-rw-r--r--addons/web/static/src/js/views/control_panel/control_panel_controller.js269
-rw-r--r--addons/web/static/src/js/views/control_panel/control_panel_model.js1269
-rw-r--r--addons/web/static/src/js/views/control_panel/control_panel_renderer.js387
-rw-r--r--addons/web/static/src/js/views/control_panel/control_panel_view.js343
-rw-r--r--addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js177
-rw-r--r--addons/web/static/src/js/views/control_panel/search/control_panel_view_parameters.js80
-rw-r--r--addons/web/static/src/js/views/control_panel/search/favorite_menu.js134
-rw-r--r--addons/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js8
-rw-r--r--addons/web/static/src/js/views/control_panel/search/filter_menu.js186
-rw-r--r--addons/web/static/src/js/views/control_panel/search/groupby_menu.js206
-rw-r--r--addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js147
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_bar.js260
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js485
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js37
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_facet.js172
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_filters.js418
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_filters_registry.js30
-rw-r--r--addons/web/static/src/js/views/control_panel/search/time_range_menu.js106
-rw-r--r--addons/web/static/src/js/widgets/auto_complete.js268
-rw-r--r--addons/web/static/src/js/widgets/dropdown_menu.js145
-rw-r--r--addons/web/static/src/scss/control_panel.scss25
-rw-r--r--addons/web/static/src/scss/dropdown_menu.scss3
-rw-r--r--addons/web/static/src/scss/search_view.scss231
-rw-r--r--addons/web/static/src/scss/search_view_extra.scss2
-rw-r--r--addons/web/static/src/scss/webclient_layout.scss2
-rw-r--r--addons/web/static/src/xml/base.xml993
-rw-r--r--addons/web/views/webclient_templates.xml40
47 files changed, 5245 insertions, 6159 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 &amp;&amp; !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 &amp;&amp; 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 &amp;&amp; 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 &amp;&amp; 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"/>
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index 6111f0e5732..8928ab3b210 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -260,7 +260,6 @@
<script type="text/javascript" src="/web/static/src/js/services/data_manager.js"></script>
<script type="text/javascript" src="/web/static/src/js/services/report_service.js"></script>
<script type="text/javascript" src="/web/static/src/js/services/session.js"></script>
- <script type="text/javascript" src="/web/static/src/js/widgets/auto_complete.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/change_password.js"></script>
<script type="text/javascript" src="/web/static/src/js/tools/test_menus_loader.js"/>
<script type="text/javascript" src="/web/static/src/js/tools/debug_manager_backend.js"></script>
@@ -273,19 +272,21 @@
<script type="text/javascript" src="/web/static/src/js/widgets/iframe_widget.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/loading.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/model_field_selector.js"></script>
- <script type="text/javascript" src="/web/static/src/js/chrome/sidebar.js"></script>
- <script type="text/javascript" src="/web/static/src/js/chrome/pager.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/systray_menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/switch_company_menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/user_menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/apps_menu.js"></script>
- <script type="text/javascript" src="/web/static/src/js/widgets/dropdown_menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/pie_chart.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/ribbon.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/signature.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/components/action_menus.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/components/dropdown_menu.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/components/dropdown_menu_item.js"></script>
<script type="text/javascript" src="/web/static/src/js/components/custom_file_input.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/components/datepicker.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/components/pager.js"></script>
<script type="text/javascript" src="/web/static/src/js/apps.js"></script>
<script type="text/javascript" src="/web/static/src/js/fields/abstract_field.js"></script>
@@ -303,30 +304,25 @@
<script type="text/javascript" src="/web/static/src/js/views/abstract_renderer.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/abstract_renderer_owl.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/abstract_model.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/model.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/abstract_controller.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/renderer_wrapper.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/basic/basic_model.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/basic/basic_view.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/basic/basic_controller.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/basic/basic_renderer.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/control_panel_controller.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/control_panel_model.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/control_panel_renderer.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/control_panel_view.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_facet.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_bar.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/filter_menu.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/groupby_menu.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/time_range_menu.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/favorite_menu.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/control_panel_view_parameters.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_filters.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_filters_registry.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/control_panel/control_panel.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/control_panel/control_panel_model.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/control_panel/control_panel_x2many.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/control_panel/custom_favorite_item.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/control_panel/favorite_menu.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/control_panel/custom_filter_item.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/control_panel/filter_menu.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/control_panel/groupby_menu.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/control_panel/custom_group_by_item.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/control_panel/search_bar.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/control_panel/search_utils.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/control_panel/time_range_menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/search_panel.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/field_manager_mixin.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/file_upload_mixin.js"></script>