From 48a50b4bc011fc6dfc82a673f921fad941dc79cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Thu, 6 Dec 2018 12:25:06 +0000 Subject: [PATCH 1/8] [IMP] web: add MVC Factory This rev. adds a factory class to generate MVC components, to properly standardize the way MVC components should behave (in the odoo way). The most popular MVC of the webclient concerns views (Model, Controller, Renderer, for each view type). However, we wanted to generalize the notion of MVC (and extract it from the views), as we will use it in a future commit for the ControlPanel. Part of task 1893568 Co-authored-by: Aaron Bohy --- addons/web/static/src/js/core/mvc.js | 242 +++++++++++++++++++++++ addons/web/views/webclient_templates.xml | 1 + 2 files changed, 243 insertions(+) create mode 100644 addons/web/static/src/js/core/mvc.js diff --git a/addons/web/static/src/js/core/mvc.js b/addons/web/static/src/js/core/mvc.js new file mode 100644 index 00000000000..cdef88c4211 --- /dev/null +++ b/addons/web/static/src/js/core/mvc.js @@ -0,0 +1,242 @@ +odoo.define('web.mvc', function (require) { +"use strict"; + +/** + * This file contains a 'formalization' of a MVC pattern, applied to Odoo + * idioms. + * + * For a simple widget/component, this is definitely overkill. However, when + * working on complex systems, such as Odoo views (or the control panel, or some + * client actions), it is useful to clearly separate the code in concerns. + * + * We define here 4 classes: Factory, Model, Renderer, Controller. Note that + * for various historical reasons, we use the term Renderer instead of View. The + * main issue is that the term 'View' is used way too much in Odoo land, and + * adding it would increase the confusion. + * + * In short, here are the responsabilities of the four classes: + * - Model: this is where the main state of the system lives. This is the part + * that will talk to the server, process the results and is the owner of the + * state + * - Renderer: this is the UI code: it should only be concerned with the look + * and feel of the system: rendering, binding handlers, ... + * - Controller: coordinates the model with the renderer and the parents widgets. + * This is more a 'plumbing' widget. + * - Factory: setting up the MRC components is a complex task, because each of + * them needs the proper arguments/options, it needs to be extensible, they + * needs to be created in the proper order, ... The job of the factory is + * to process all the various arguments, and make sure each component is as + * simple as possible. + */ + +var ajax = require('web.ajax'); +var Class = require('web.Class'); +var mixins = require('web.mixins'); +var ServicesMixin = require('web.ServicesMixin'); +var Widget = require('web.Widget'); + + +/** + * Owner of the state, this component is tasked with fetching data, processing + * it, updating it, ... + * + * Note that this is not a widget: it is a class which has not UI representation. + * + * @class Model + */ +var Model = Class.extend(mixins.EventDispatcherMixin, ServicesMixin, { + /** + * @param {Widget} parent + * @param {Object} params + */ + init: function (parent, params) { + mixins.EventDispatcherMixin.init.call(this); + this.setParent(parent); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This method should return the complete state necessary for the renderer + * to display the current data. + * + * @returns {*} + */ + get: function () { + }, + /** + * The load method is called once in a model, when we load the data for the + * first time. The method returns (a promise that resolves to) some kind + * of token/handle. The handle can then be used with the get method to + * access a representation of the data. + * + * @param {Object} params + * @returns {Promise} The promise resolves to some kind of handle + */ + load: function () { + return $.when(); + }, +}); + +/** + * Only responsability of this component is to display the user interface, and + * react to user changes. + * + * @class Renderer + */ +var Renderer = Widget.extend({ + /** + * @override + * @param {any} state + * @param {Object} params + */ + init: function (parent, state, params) { + this._super(parent); + this.state = state; + }, +}); + +/** + * The controller has to coordinate between parent, renderer and model. + * + * @class Controller + */ +var Controller = Widget.extend({ + /** + * @override + * @param {Model} model + * @param {Renderer} renderer + * @param {Object} params + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.model = model; + this.renderer = renderer; + }, + /** + * @returns {Promise} + */ + start: function () { + return $.when( + this._super.apply(this, arguments), + this._startRenderer() + ); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Appends the renderer in the $el. To override to insert it elsewhere. + * + * @private + */ + _startRenderer: function () { + return this.renderer.appendTo(this.$el); + }, +}); + +var Factory = Class.extend({ + config: { + Model: Model, + Renderer: Renderer, + Controller: Controller, + }, + /** + * @override + */ + init: function () { + this.rendererParams = {}; + this.controllerParams = {}; + this.modelParams = {}; + this.loadParams = {}; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Main method of the Factory class. Create a controller, and make sure that + * data and libraries are loaded. + * + * There is a unusual thing going in this method with parents: we create + * renderer/model with parent as parent, then we have to reassign them at + * the end to make sure that we have the proper relationships. This is + * necessary to solve the problem that the controller needs the model and + * the renderer to be instantiated, but the model need a parent to be able + * to load itself, and the renderer needs the data in its constructor. + * + * @param {Widget} parent the parent of the resulting Controller (most + * likely an action manager) + * @returns {Promise} + */ + getController: function (parent) { + var self = this; + var model = this.getModel(parent); + return $.when(this._loadData(model), ajax.loadLibs(this)).then(function (state) { + var renderer = self.getRenderer(parent, state); + var Controller = self.Controller || self.config.Controller; + var controllerParams = _.extend({ + initialState: state, + }, self.controllerParams); + var controller = new Controller(parent, model, renderer, controllerParams); + model.setParent(controller); + renderer.setParent(controller); + return controller; + }); + }, + /** + * Returns a new model instance + * + * @param {Widget} parent the parent of the model + * @returns {Model} instance of the model + */ + getModel: function (parent) { + var Model = this.config.Model; + return new Model(parent, this.modelParams); + }, + /** + * Returns a new renderer instance + * + * @param {Widget} parent the parent of the renderer + * @param {Object} state the information related to the rendered data + * @returns {Renderer} instance of the renderer + */ + getRenderer: function (parent, state) { + var Renderer = this.config.Renderer; + return new Renderer(parent, state, this.rendererParams); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Loads initial data from the model + * + * @private + * @param {Model} model a Model instance + * @returns {Promise<*>} a promise that resolves to the value returned by + * the get method from the model + * @todo: get rid of loadParams (use modelParams instead) + */ + _loadData: function (model) { + return model.load(this.loadParams).then(function () { + return model.get.apply(model, arguments); + }); + }, +}); + + +return { + Factory: Factory, + Model: Model, + Renderer: Renderer, + Controller: Controller, +}; + +}); diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index a01f0244180..b4c1c5f5d3e 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -198,6 +198,7 @@ + From 40dd121938552d3f88289d2e5fcd1d57a0dd01bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Thu, 6 Dec 2018 12:39:06 +0000 Subject: [PATCH 2/8] [REF] web: move ControlPanel inside controllers/actions This branch introduces a large-scale reorganization of the component tree generated by the web client. The short version is that now, the control panel is a child of the view controller and no longer a sibling. Graphically (and simplified), we go from this: webClient / | \ ... ... actionManager / \ controlPanel viewController / \ to this: webClient / | \ ... ... actionManager | viewController / | \ ControlPanelController / \ CPRenderer CPModel The motivation is that this work moves the code where it should be. Before this commit, it was kind of weird to have code in the controllers to render buttons outside of their root node (in renderButtons). Also, the action manager had to take care of coordinating search view states between view transitions. So, this work simplifies the code. It also makes it easier to extend. We see day after day that Odoo needs to take care of more complex UI needs, and in many cases, these needs were quite difficult to implement (we prefer spaghettis in our plates, not in our code). The changes in this branch should open the way to implement these features. For example, - it will now be easy to add the possibility of views (for example, the search view or a new ControlPanel view) to add custom buttons (of type action or object) in the control panel. - Another need will be to serialize/ restore the state of the search view across action boundaries (needed by the dashboard). - Another example is the possibility for views to customize easily the presence/absence of sub menus (filters/groupbys/favorites/ time range/...) - Another need is an easier way for views/client action to customize their control panel. This commit also contains a large rewrite of the search view (so it is more inline with our architecture and easier to maintain). Part of task 1893568 Co-authored-by: Aaron Bohy Co-authored-by: Mathieu Duckerts-Antoine Co-authored-by: Martin Geubelle --- .../static/src/js/chrome/abstract_action.js | 160 ++- .../src/js/chrome/abstract_web_client.js | 17 +- .../static/src/js/chrome/action_manager.js | 167 +-- .../js/chrome/action_manager_act_window.js | 484 ++----- .../web/static/src/js/chrome/action_mixin.js | 182 +++ .../web/static/src/js/chrome/control_panel.js | 286 ---- addons/web/static/src/js/core/registry.js | 51 +- .../web/static/src/js/core/service_mixins.js | 27 +- .../static/src/js/fields/relational_fields.js | 15 +- .../web/static/src/js/report/client_action.js | 12 +- addons/web/static/src/js/services/core.js | 2 - .../static/src/js/services/data_manager.js | 23 +- .../web/static/src/js/tools/debug_manager.js | 2 +- .../src/js/views/abstract_controller.js | 205 +-- .../web/static/src/js/views/abstract_model.js | 37 +- .../static/src/js/views/abstract_renderer.js | 12 +- .../web/static/src/js/views/abstract_view.js | 336 +++-- .../src/js/views/basic/basic_controller.js | 29 +- .../static/src/js/views/basic/basic_model.js | 6 +- .../src/js/views/basic/basic_renderer.js | 29 - .../static/src/js/views/basic/basic_view.js | 19 +- .../js/views/calendar/calendar_controller.js | 6 +- .../src/js/views/calendar/calendar_view.js | 2 +- .../control_panel/control_panel_controller.js | 237 ++++ .../control_panel/control_panel_model.js | 1093 +++++++++++++++ .../control_panel/control_panel_renderer.js | 387 +++++ .../views/control_panel/control_panel_view.js | 238 ++++ .../mobile_control_panel_controller.js | 34 + .../mobile_control_panel_renderer.js} | 71 +- .../search/add_new_favorite_menu.js | 142 ++ .../search/control_panel_view_parameters.js} | 35 +- .../control_panel/search/favorite_menu.js | 103 ++ .../search/favorites_submenus_registry.js | 8 + .../views/control_panel/search/filter_menu.js | 219 +++ .../control_panel/search/groupby_menu.js | 214 +++ .../search/groupby_menu_mixin.js | 160 +++ .../views/control_panel/search/search_bar.js | 225 +++ .../search/search_bar_autocomplete_sources.js | 485 +++++++ ...earch_bar_autocomplete_sources_registry.js | 37 + .../control_panel/search/search_facet.js | 129 ++ .../search/search_filters.js | 52 +- .../search/search_filters_registry.js | 30 + .../control_panel/search/time_range_menu.js | 106 ++ .../src/js/views/form/form_controller.js | 11 +- .../static/src/js/views/form/form_renderer.js | 7 +- .../web/static/src/js/views/form/form_view.js | 30 +- .../src/js/views/graph/graph_controller.js | 47 +- .../src/js/views/graph/graph_renderer.js | 2 +- .../static/src/js/views/graph/graph_view.js | 3 +- .../src/js/views/kanban/kanban_controller.js | 4 +- .../static/src/js/views/kanban/kanban_view.js | 7 +- .../js/views/kanban/quick_create_form_view.js | 19 +- .../src/js/views/list/list_controller.js | 8 +- .../static/src/js/views/list/list_renderer.js | 3 +- .../web/static/src/js/views/list/list_view.js | 10 +- .../src/js/views/pivot/pivot_controller.js | 19 +- .../static/src/js/views/pivot/pivot_view.js | 8 +- .../src/js/views/search/favorites_menu.js | 335 ----- .../src/js/views/search/filters_menu.js | 253 ---- .../src/js/views/search/groupby_menu.js | 232 --- .../search/groupby_menu_interface_mixin.js | 117 -- .../src/js/views/search/search_inputs.js | 840 ----------- .../static/src/js/views/search/search_view.js | 1244 ----------------- .../src/js/views/search/time_range_menu.js | 198 --- .../web/static/src/js/views/view_dialogs.js | 169 +-- addons/web/static/src/js/views/view_utils.js | 46 +- .../static/src/js/widgets/dropdown_menu.js | 192 +-- .../src/js/widgets/old/dropdown_menu.js | 236 ++++ addons/web/static/src/js/widgets/pie_chart.js | 8 +- addons/web/static/src/scss/control_panel.scss | 4 - addons/web/static/src/scss/dropdown_menu.scss | 4 + addons/web/static/src/scss/graph_view.scss | 4 +- addons/web/static/src/scss/search_view.scss | 3 +- .../static/src/scss/search_view_mobile.scss | 4 + .../web/static/src/scss/webclient_layout.scss | 24 +- addons/web/static/src/xml/base.xml | 238 ++-- .../chrome/action_manager_mobile_tests.js | 8 +- .../tests/chrome/action_manager_tests.js | 107 +- .../web/static/tests/chrome/search_tests.js | 172 --- .../web/static/tests/core/registry_tests.js | 50 +- .../tests/fields/basic_fields_mobile_tests.js | 2 +- .../static/tests/fields/basic_fields_tests.js | 85 +- .../field_many2many_tests.js | 85 +- .../relational_fields/field_many2one_tests.js | 44 +- .../relational_fields/field_one2many_tests.js | 175 ++- .../tests/fields/relational_fields_tests.js | 24 +- .../web/static/tests/fields/special_fields.js | 2 +- addons/web/static/tests/helpers/test_utils.js | 4 +- .../static/tests/helpers/test_utils_create.js | 147 +- .../static/tests/tools/debug_manager_tests.js | 3 +- .../static/tests/views/abstract_view_tests.js | 38 +- .../web/static/tests/views/calendar_tests.js | 23 +- .../static/tests/views/control_panel_tests.js | 334 +++++ addons/web/static/tests/views/form_tests.js | 295 ++-- addons/web/static/tests/views/graph_tests.js | 53 +- addons/web/static/tests/views/kanban_tests.js | 105 +- addons/web/static/tests/views/list_tests.js | 98 +- .../mobile_control_panel_tests.js} | 0 addons/web/static/tests/views/pivot_tests.js | 48 +- .../tests/views/search_filter_menu_tests.js | 167 +++ .../tests/views/search_filters_menu_tests.js | 93 +- .../tests/views/search_groupby_menu_tests.js | 174 ++- .../static/tests/views/search_view_tests.js | 665 +++++---- .../static/tests/views/view_dialogs_tests.js | 31 +- .../tests/widgets/dropdown_menu_tests.js | 196 ++- addons/web/views/webclient_templates.xml | 42 +- .../static/tests/web_editor_tests.js | 4 +- 107 files changed, 7307 insertions(+), 6410 deletions(-) create mode 100644 addons/web/static/src/js/chrome/action_mixin.js delete mode 100644 addons/web/static/src/js/chrome/control_panel.js create mode 100644 addons/web/static/src/js/views/control_panel/control_panel_controller.js create mode 100644 addons/web/static/src/js/views/control_panel/control_panel_model.js create mode 100644 addons/web/static/src/js/views/control_panel/control_panel_renderer.js create mode 100644 addons/web/static/src/js/views/control_panel/control_panel_view.js create mode 100644 addons/web/static/src/js/views/control_panel/mobile_control_panel_controller.js rename addons/web/static/src/js/views/{search/search_view_mobile.js => control_panel/mobile_control_panel_renderer.js} (59%) create mode 100644 addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js rename addons/web/static/src/js/views/{search/time_range_menu_options.js => control_panel/search/control_panel_view_parameters.js} (51%) create mode 100644 addons/web/static/src/js/views/control_panel/search/favorite_menu.js create mode 100644 addons/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js create mode 100644 addons/web/static/src/js/views/control_panel/search/filter_menu.js create mode 100644 addons/web/static/src/js/views/control_panel/search/groupby_menu.js create mode 100644 addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js create mode 100644 addons/web/static/src/js/views/control_panel/search/search_bar.js create mode 100644 addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js create mode 100644 addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js create mode 100644 addons/web/static/src/js/views/control_panel/search/search_facet.js rename addons/web/static/src/js/views/{ => control_panel}/search/search_filters.js (92%) create mode 100644 addons/web/static/src/js/views/control_panel/search/search_filters_registry.js create mode 100644 addons/web/static/src/js/views/control_panel/search/time_range_menu.js delete mode 100644 addons/web/static/src/js/views/search/favorites_menu.js delete mode 100644 addons/web/static/src/js/views/search/filters_menu.js delete mode 100644 addons/web/static/src/js/views/search/groupby_menu.js delete mode 100644 addons/web/static/src/js/views/search/groupby_menu_interface_mixin.js delete mode 100644 addons/web/static/src/js/views/search/search_inputs.js delete mode 100644 addons/web/static/src/js/views/search/search_view.js delete mode 100644 addons/web/static/src/js/views/search/time_range_menu.js create mode 100644 addons/web/static/src/js/widgets/old/dropdown_menu.js delete mode 100644 addons/web/static/tests/chrome/search_tests.js create mode 100644 addons/web/static/tests/views/control_panel_tests.js rename addons/web/static/tests/{chrome/search_view_mobile_tests.js => views/mobile_control_panel_tests.js} (100%) create mode 100644 addons/web/static/tests/views/search_filter_menu_tests.js diff --git a/addons/web/static/src/js/chrome/abstract_action.js b/addons/web/static/src/js/chrome/abstract_action.js index c4755338bf0..226802ba76b 100644 --- a/addons/web/static/src/js/chrome/abstract_action.js +++ b/addons/web/static/src/js/chrome/abstract_action.js @@ -2,82 +2,126 @@ odoo.define('web.AbstractAction', function (require) { "use strict"; /** - * We define here the generic notion of Action (from the point of view of the - * web client). In short, an action is a widget which controls the main part - * of the screen (everything below the control panel). - * - * More precisely, the action manager is the component that coordinates a stack - * of actions. Whenever the user navigates in the interface, switches views, - * open different menus, the action manager creates/updates/destroys special - * widgets which are actions. These actions need to answer to a standardised - * API, which is the reason for this AbstractAction class. - * - * In practice, most actions are view controllers (coming from a - * ir.action.act_window). However, some actions are 'client actions'. They - * also need to be an AbstractAction for a better cooperation with the action - * manager. + * We define here the AbstractAction widget, which implements the ActionMixin. + * All client actions must extend this widget. * * @module web.AbstractAction */ +var ActionMixin = require('web.ActionMixin'); +var ControlPanelView = require('web.ControlPanelView'); var Widget = require('web.Widget'); -var AbstractAction = Widget.extend({ +var AbstractAction = Widget.extend(ActionMixin, { + config: { + ControlPanelView: ControlPanelView, + }, /** - * Called each time the action is attached into the DOM. - */ - on_attach_callback: function () {}, - /** - * Called each time the action is detached from the DOM. - */ - on_detach_callback: function () {}, - /** - * Called by the action manager when action is restored (typically, when the - * user clicks on the action in the breadcrumb) + * If this flag is set to true, the client action will create a control + * panel whenever it is created. * - * @returns {Deferred|undefined} + * @type boolean */ - willRestore: function () {}, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- + hasControlPanel: false, /** - * In some situations, we need confirmation from the controller that the - * current state can be destroyed without prejudice to the user. For - * example, if the user has edited a form, maybe we should ask him if we - * can discard all his changes when we switch to another action. In that - * case, the action manager will call this method. If the returned - * deferred is succesfully resolved, then we can destroy the current action, - * otherwise, we need to stop. + * If true, this flag indicates that the client action should automatically + * fetch the of a search view (or control panel view). Note that + * to do that, it also needs a specific modelName. * - * @returns {Deferred} resolved if the action can be removed, rejected otherwise + * For example, the Discuss application adds the following line in its + * constructor: + * + * this.controlPanelParams.modelName = 'mail.message'; + * + * @type boolean */ - canBeRemoved: function () { + loadControlPanel: false, + + /** + * A client action might want to use a search bar in its control panel, or + * it could choose not to use it. + * + * Note that it only makes sense if hasControlPanel is set to true. + * + * @type boolean + */ + withSearchBar: false, + + /** + * This parameter can be set to customize the available sub menus in the + * controlpanel (Filters/Group By/Favorites). This is basically a list of + * the sub menus that we want to use. + * + * Note that it only makes sense if hasControlPanel is set to true. + * + * For example, set ['filter', 'favorite'] to enable the Filters and + * Favorites menus. + * + * @type string[] + */ + searchMenuTypes: [], + + /** + * @override + * + * @param {Widget} parent + * @param {Object} action + * @param {Object} [options] + */ + init: function (parent, action, options) { + this._super(parent); + this._title = action.display_name || action.name; + this.controlPanelParams = { + actionId: action.id, + context: action.context, + breadcrumbs: options && options.breadcrumbs || [], + title: this.getTitle(), + viewId: action.search_view_id && action.search_view_id[0], + withSearchBar: this.withSearchBar, + searchMenuTypes: this.searchMenuTypes, + }; + }, + /** + * The willStart method is actually quite complicated if the client action + * has a controlPanel, because it needs to prepare it. + * + * @override + */ + willStart: function () { + var self = this; + if (this.hasControlPanel) { + var params = this.controlPanelParams; + var def; + if (this.loadControlPanel) { + def = this + .loadFieldView(params.modelName, params.context || {}, params.viewId, 'search') + .then(function (fieldsView) { + params.viewInfo = { + arch: fieldsView.arch, + fields: fieldsView.fields, + }; + }); + } + return $.when(def).then(function () { + var controlPanelView = new self.config.ControlPanelView(params); + return controlPanelView.getController(self).then(function (controlPanel) { + self._controlPanel = controlPanel; + return self._controlPanel.appendTo(document.createDocumentFragment()); + }); + }); + } return $.when(); }, /** - * This function is called when the current context (~state) of the action - * should be known. For instance, if the action is a view controller, - * this may be useful to reinstantiate the view in the same state. + * @override */ - getContext: function () { - }, - /** - * Gives the focus to the action - */ - giveFocus: function () { - }, - /** - * Renders the buttons to append, in most cases, to the control panel (in - * the bottom left corner). When the action is rendered in a dialog, those - * buttons might be moved to the dialog's footer. - * - * @param {jQuery Node} $node - */ - renderButtons: function ($node) { + start: function () { + if (this._controlPanel) { + this._controlPanel.$el.prependTo(this.$el); + } + return this._super.apply(this, arguments); }, }); diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js index 80711fd24ed..ea8fdc24a18 100644 --- a/addons/web/static/src/js/chrome/abstract_web_client.js +++ b/addons/web/static/src/js/chrome/abstract_web_client.js @@ -60,10 +60,11 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi }, load_filters: function (event) { return data_manager - .load_filters(event.data.dataset, event.data.action_id) + .load_filters(event.data) .then(event.data.on_success); }, create_filter: '_onCreateFilter', + delete_filter: '_onDeleteFilter', push_state: '_onPushState', show_effect: '_onShowEffect', // session @@ -218,7 +219,7 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi this.action_manager = new ActionManager(this, session.user_context); var fragment = document.createDocumentFragment(); return this.action_manager.appendTo(fragment).then(function () { - dom.append(self.$('.o_main_content'), fragment, { + dom.append(self.$el, fragment, { in_DOM: true, callbacks: [{widget: self.action_manager}], }); @@ -367,6 +368,18 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi .create_filter(e.data.filter) .then(e.data.on_success); }, + /** + * @private + * @param {OdooEvent} e + * @param {Object} e.data.filter the filter description + * @param {function} e.data.on_success called when the RPC succeeds with its + * returned value as argument + */ + _onDeleteFilter: function (e) { + data_manager + .delete_filter(e.data.filterId) + .then(e.data.on_success); + }, /** * Displays a warning in a dialog or with the notification service * diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js index 18bbb6a3681..c798189c0a0 100644 --- a/addons/web/static/src/js/chrome/action_manager.js +++ b/addons/web/static/src/js/chrome/action_manager.js @@ -10,10 +10,8 @@ odoo.define('web.ActionManager', function (require) { */ var AbstractAction = require('web.AbstractAction'); -var Bus = require('web.Bus'); var concurrency = require('web.concurrency'); var Context = require('web.Context'); -var ControlPanel = require('web.ControlPanel'); var config = require('web.config'); var core = require('web.core'); var Dialog = require('web.Dialog'); @@ -24,7 +22,7 @@ var Widget = require('web.Widget'); var _t = core._t; var ActionManager = Widget.extend({ - className: 'o_content', + className: 'o_action_manager', custom_events: { breadcrumb_clicked: '_onBreadcrumbClicked', history_back: '_onHistoryBack', @@ -63,17 +61,6 @@ var ActionManager = Widget.extend({ // dialog (i.e. coming from an action with target='new') this.currentDialogController = null; }, - /** - * @override - */ - start: function () { - // AAB: temporarily instantiate a unique main ControlPanel used by - // controllers in the controllerStack - this.controlPanel = new ControlPanel(this); - var def = this.controlPanel.insertBefore(this.$el); - - return $.when(def, this._super.apply(this, arguments)); - }, /** * Called each time the action manager is attached into the DOM. */ @@ -266,14 +253,6 @@ var ActionManager = Widget.extend({ if (controller.scrollPosition) { this.trigger_up('scrollTo', controller.scrollPosition); } - - if (!controller.widget.need_control_panel) { - this.controlPanel.do_hide(); - } else { - this.controlPanel.update({ - breadcrumbs: this._getBreadcrumbs(), - }, {clear: false}); - } }, /** * Closes the current dialog, if any. Because we listen to the 'closed' @@ -330,19 +309,12 @@ var ActionManager = Widget.extend({ return this._executeActionInDialog(action, options); } + var controller = self.controllers[action.controllerID]; return this.clearUncommittedChanges() .then(function () { - var controller = self.controllers[action.controllerID]; - var widget = controller.widget; - // AAB: this will be moved to the Controller - if (widget.need_control_panel) { - // set the ControlPanel bus on the controller to allow it to - // communicate its status - widget.set_cp_bus(self.controlPanel.get_bus()); - } return self.dp.add(self._startController(controller)); }) - .then(function (controller) { + .then(function () { if (self.currentDialogController) { self._closeDialog({ silent: true }); } @@ -358,7 +330,7 @@ var ActionManager = Widget.extend({ } // update the internal state and the DOM - self._pushController(controller, options); + self._pushController(controller); // toggle the fullscreen mode for actions in target='fullscreen' self._toggleFullscreen(); @@ -387,12 +359,6 @@ var ActionManager = Widget.extend({ var self = this; var controller = this.controllers[action.controllerID]; var widget = controller.widget; - // AAB: this will be moved to the Controller - if (widget.need_control_panel) { - // set the ControlPanel bus on the controller to allow it to - // communicate its status - widget.set_cp_bus(new Bus()); - } return this._startController(controller).then(function (controller) { var prevDialogOnClose; @@ -472,20 +438,18 @@ var ActionManager = Widget.extend({ } var controllerID = _.uniqueId('controller_'); + + var index = this._getControllerStackIndex(options); + options.breadcrumbs = this._getBreadcrumbs(this.controllerStack.slice(0, index)); options.controllerID = controllerID; + var widget = new ClientAction(this, action, options); var controller = { actionID: action.jsID, + index: index, jsID: controllerID, - widget: new ClientAction(this, action, options), + title: widget.getTitle(), + widget: widget, }; - // AAB: TODO: simplify this with AbstractAction (implement a getTitle - // function that returns action.name by default, and that can be - // overriden in client actions and view controllers) - Object.defineProperty(controller, 'title', { - get: function () { - return controller.widget.get('title') || action.display_name || action.name; - }, - }); this.controllers[controllerID] = controller; action.controllerID = controllerID; return this._executeAction(action, options).done(function () { @@ -586,33 +550,51 @@ var ActionManager = Widget.extend({ return $.when(); }, /** - * Returns a description of the current stack of controllers, used to render - * the breadcrumbs. It is an array of Objects with keys 'title' (what to - * display in the breadcrumbs) and 'controllerID' (the ID of the - * corresponding controller, used to restore it when this part of the + * Returns a description of the controllers in the given controller stack. + * It is used to render the breadcrumbs. It is an array of Objects with keys + * 'title' (what to display in the breadcrumbs) and 'controllerID' (the ID + * of the corresponding controller, used to restore it when this part of the * breadcrumbs is clicked). - * Ignores the content of the stack of controllers if the action of the - * last controller of the stack is flagged with 'no_breadcrumbs', indicating - * that the breadcrumbs should not be displayed for that action. * * @private + * @param {string[]} controllerStack * @returns {Object[]} */ - _getBreadcrumbs: function () { + _getBreadcrumbs: function (controllerStack) { var self = this; - var currentController = this.getCurrentController(); - var noBreadcrumbs = !currentController || - this.actions[currentController.actionID].context.no_breadcrumbs; - if (noBreadcrumbs) { - return []; - } - return _.map(this.controllerStack, function (controllerID) { + return _.map(controllerStack, function (controllerID) { return { - title: self.controllers[controllerID].title, controllerID: controllerID, + title: self.controllers[controllerID].title, }; }); }, + /** + * Returns the index where a controller should be inserted in the controller + * stack according to the given options. By default, a controller is pushed + * on the top of the stack. + * + * @private + * @param {options} [options.clear_breadcrumbs=false] if true, insert at + * index 0 and remove all other controllers + * @param {options} [options.index=null] if given, that index is returned + * @param {options} [options.replace_last_action=false] if true, replace the + * last controller of the stack + * @returns {integer} index + */ + _getControllerStackIndex: function (options) { + var index; + if ('index' in options) { + index = options.index; + } else if (options.clear_breadcrumbs) { + index = 0; + } else if (options.replace_last_action) { + index = this.controllerStack.length - 1; + } else { + index = this.controllerStack.length; + } + return index; + }, /** * Returns an object containing information about the given controller, like * its title, its action's id, the active_id and active_ids of the action... @@ -625,7 +607,7 @@ var ActionManager = Widget.extend({ var controller = this.controllers[controllerID]; var action = this.actions[controller.actionID]; var state = { - title: controller.title, + title: controller.widget.getTitle(), }; if (action.id) { state.action = action.id; @@ -706,41 +688,28 @@ var ActionManager = Widget.extend({ * @param {Object} controller * @param {string} controller.jsID * @param {Widget} controller.widget - * @param {Object} [options] - * @param {Object} [options.clear_breadcrumbs=false] if true, destroys all - * controllers from the controller stack before adding the given one - * @param {Object} [options.replace_last_action=false] if true, replaces the - * last controller of the controller stack by the given one - * @param {integer} [options.index] if given, pushes the controller at that - * position in the controller stack, and destroys the controllers with an - * higher index + * @param {integer} controller.index the controller is pushed at that + * position in the controller stack and controllers with an higher index + * are destroyed */ - _pushController: function (controller, options) { - options = options || {}; + _pushController: function (controller) { var self = this; // detach the current controller this._detachCurrentController(); - // empty the controller stack or replace the last controller as requested, - // destroy the removed controllers and push the new controller to the stack - var toDestroy; - if (options.clear_breadcrumbs) { - toDestroy = this.controllerStack; - this.controllerStack = []; - } else if (options.replace_last_action && this.controllerStack.length > 0) { - toDestroy = [this.controllerStack.pop()]; - } else if (options.index !== undefined) { - toDestroy = this.controllerStack.splice(options.index); - // reject from the list of controllers to destroy the one that we are - // currently pushing, or those linked to the same action as the one - // linked to the controller that we are pushing - toDestroy = _.reject(toDestroy, function (controllerID) { - return controllerID === controller.jsID || - self.controllers[controllerID].actionID === controller.actionID; - }); - } + // push the new controller to the stack at the given position, and + // destroy controllers with an higher index + var toDestroy = this.controllerStack.slice(controller.index); + // reject from the list of controllers to destroy the one that we are + // currently pushing, or those linked to the same action as the one + // linked to the controller that we are pushing + toDestroy = _.reject(toDestroy, function (controllerID) { + return controllerID === controller.jsID || + self.controllers[controllerID].actionID === controller.actionID; + }); this._removeControllers(toDestroy); + this.controllerStack = this.controllerStack.slice(0, controller.index); this.controllerStack.push(controller.jsID); // append the new controller to the DOM @@ -861,7 +830,7 @@ var ActionManager = Widget.extend({ return $.when(def).then(function () { return $.when(controller.widget.do_show()).then(function () { var index = _.indexOf(self.controllerStack, controllerID); - self._pushController(controller, {index: index}); + self._pushController(controller, index); }); }); }, @@ -878,19 +847,7 @@ var ActionManager = Widget.extend({ * @returns {Deferred} resolved with the controller when it is ready */ _startController: function (controller) { - var self = this; var fragment = document.createDocumentFragment(); - // AAB: change this logic to stop using the properties mixin - controller.widget.on("change:title", this, function () { - if (self.getCurrentController() !== controller) { - return; - } - var action = self.actions[controller.actionID]; - if (!action.flags || !action.flags.headless) { - var breadcrumbs = self._getBreadcrumbs(); - self.controlPanel.update({breadcrumbs: breadcrumbs}, {clear: false}); - } - }); return controller.widget.appendTo(fragment).then(function () { return controller; }); diff --git a/addons/web/static/src/js/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js index e66ae801bc1..1a688ffa6b2 100644 --- a/addons/web/static/src/js/chrome/action_manager_act_window.js +++ b/addons/web/static/src/js/chrome/action_manager_act_window.js @@ -10,21 +10,12 @@ var ActionManager = require('web.ActionManager'); var config = require('web.config'); var Context = require('web.Context'); var core = require('web.core'); -var data = require('web.data'); // this will be removed at some point var pyUtils = require('web.py_utils'); -var SearchView = require('web.SearchView'); var view_registry = require('web.view_registry'); -var _t = core._t; - ActionManager.include({ custom_events: _.extend({}, ActionManager.prototype.custom_events, { - env_updated: '_onEnvUpdated', execute_action: '_onExecuteAction', - get_controller_context: '_onGetControllerContext', - navigation_move: '_onNavigationMove', - update_filters: '_onUpdateFilters', - search: '_onSearch', switch_view: '_onSwitchView', }), @@ -32,6 +23,26 @@ ActionManager.include({ // Public //-------------------------------------------------------------------------- + /** + * Override to handle the case of lazy-loaded controllers, which may be the + * last controller in the stack, but which should not be considered as + * current controller as they don't have an alive widget. + * + * Note: this function assumes that there can be at most one lazy loaded + * controller in the stack + * + * @override + */ + getCurrentController: function () { + var currentController = this._super.apply(this, arguments); + var action = currentController && this.actions[currentController.actionID]; + if (action && action.type === 'ir.actions.act_window' && !currentController.widget) { + var lastControllerID = this.controllerStack.pop(); + currentController = this._super.apply(this, arguments); + this.controllerStack.push(lastControllerID); + } + return currentController; + }, /** * Overrides to handle the case where an 'ir.actions.act_window' has to be * loaded. @@ -59,9 +70,9 @@ ActionManager.include({ currentAction.type === 'ir.actions.act_window') { // the action to load is already the current one, so update it this._closeDialog(true); // there may be a currently opened dialog, close it - currentAction.env.currentId = state.id; + var viewOptions = {currentId: state.id}; var viewType = state.view_type || currentController.viewType; - return this._switchController(currentAction, viewType); + return this._switchController(currentAction, viewType, viewOptions); } else if (!core.action_registry.contains(state.action)) { // the action to load isn't the current one, so execute it var context = {}; @@ -113,59 +124,6 @@ ActionManager.include({ // Private //-------------------------------------------------------------------------- - /** - * Instantiates a search view for a given action, and starts it so that it - * is ready to be appended to the DOM. - * - * @private - * @param {Object} action - * @returns {Deferred} resolved with the search view when it is ready - */ - _createSearchView: function (action) { - // if requested, keep the searchview of the current action instead of - // creating a new one - if (action._keepSearchView) { - var currentAction = this.getCurrentAction(); - if (currentAction) { - action.searchView = currentAction.searchView; - action.env = currentAction.env; // make those actions share the same env - return $.when(currentAction.searchView); - } else { - // there is not searchview to keep, so reset the flag to false - // to ensure that the one that will be created will be correctly - // destroyed - action._keepSearchView = false; - } - } - - // AAB: temporarily create a dataset, until the SearchView is refactored - // and stops using it - var dataset = new data.DataSetSearch(this, action.res_model, action.context, action.domain); - if (action.res_id) { - dataset.ids.push(action.res_id); - dataset.index = 0; - } - - // find 'search_default_*' keys in actions's context - var searchDefaults = {}; - _.each(action.context, function (value, key) { - var match = /^search_default_(.*)$/.exec(key); - if (match) { - searchDefaults[match[1]] = value; - } - }); - var searchView = new SearchView(this, dataset, action.searchFieldsView, { - $buttons: $('
'), - action: action, - disable_custom_filters: action.flags.disableCustomFilters, - search_defaults: searchDefaults, - }); - - return searchView.appendTo(document.createDocumentFragment()).then(function () { - action.searchView = searchView; - return searchView; - }); - }, /** * Instantiates the controller for a given action and view type, and adds it * to the list of controllers in the action. @@ -181,6 +139,9 @@ ActionManager.include({ * @param {Object} [viewOptions] dict of options passed to the initialization * of the controller's widget * @param {Object} [options] + * @param {string} [options.controllerID=false] when the controller has + * previously been lazy-loaded, we want to keep its jsID when loading it + * @param {integer} [options.index=0] the controller's index in the stack * @param {boolean} [options.lazy=false] set to true to differ the * initialization of the controller's widget * @returns {Deferred} resolved with the created controller @@ -195,31 +156,36 @@ ActionManager.include({ return $.Deferred().reject(); } - var controllerID = _.uniqueId('controller_'); + options = options || {}; + var index = options.index || 0; + var controllerID = options.controllerID || _.uniqueId('controller_'); var controller = { actionID: action.jsID, className: 'o_act_window', // used to remove the padding in dialogs + index: index, jsID: controllerID, viewType: viewType, }; Object.defineProperty(controller, 'title', { get: function () { // handle the case where the widget is lazy loaded - return controller.widget ? controller.widget.getTitle() : (action.display_name || action.name); + return controller.widget ? + controller.widget.getTitle() : + (action.display_name || action.name); }, }); this.controllers[controllerID] = controller; - if (!options || !options.lazy) { + if (!options.lazy) { // build the view options from different sources - viewOptions = _.extend({ + var flags = action.flags || {}; + viewOptions = _.extend({}, flags, flags[viewType], viewOptions, { action: action, - limit: action.limit, - }, action.flags, action.flags[viewType], viewOptions, action.env); - // pass the controllerID to the views as an hook for further - // communication with trigger_up (e.g. for 'env_updated' event) - viewOptions = _.extend(viewOptions, { controllerID: controllerID }); - + breadcrumbs: this._getBreadcrumbs(this.controllerStack.slice(0, index)), + // pass the controllerID to the views as an hook for further + // communication with trigger_up + controllerID: controllerID, + }); var view = new viewDescr.Widget(viewDescr.fieldsView, viewOptions); var def = $.Deferred(); action.controllers[viewType] = def; @@ -263,9 +229,6 @@ ActionManager.include({ // its reference is removed controllerDef.reject(); }); - if (action.searchView && !action._keepSearchView) { - action.searchView.destroy(); - } }, /** * Executes actions of type 'ir.actions.act_window'. @@ -280,75 +243,64 @@ ActionManager.include({ */ _executeWindowAction: function (action, options) { var self = this; - - if (action.context.active_id || action.context.active_ids) { - // we assume that when 'active_id' or 'active_ids' is used in the - // context, we are in a 'related' action, so we disable the - // searchview's default custom filters - action.context.search_disable_custom_filters = true; - } - action.flags = this._generateActionFlags(action); - return this.dp.add(this._loadViews(action)).then(function (fieldsViews) { var views = self._generateActionViews(action, fieldsViews); action._views = action.views; // save the initial attribute action.views = views; - if (fieldsViews.search) { - action.searchFieldsView = fieldsViews.search; - } - action.env = self._generateActionEnv(action, options); + action.controlPanelFieldsView = fieldsViews.search; action.controllers = {}; - // select the first view to display, and optionally the main view - // which will be lazyloaded - var firstView = options.viewType && _.findWhere(views, {type: options.viewType}); - var mainView; - if (firstView) { - if (!firstView.multiRecord && views[0].multiRecord) { - mainView = views[0]; + // select the current view to display, and optionally the main view + // of the action which will be lazyloaded + var curView = options.viewType && _.findWhere(views, {type: options.viewType}); + var lazyView; + if (curView) { + if (!curView.multiRecord && views[0].multiRecord) { + lazyView = views[0]; } } else { - firstView = views[0]; + curView = views[0]; } // use mobile-friendly view by default in mobile, if possible if (config.device.isMobile) { - if (!firstView.isMobileFriendly) { - firstView = self._findMobileView(views, firstView.multiRecord) || firstView; + if (!curView.isMobileFriendly) { + curView = self._findMobileView(views, curView.multiRecord) || curView; } - if (mainView && !mainView.isMobileFriendly) { - mainView = self._findMobileView(views, mainView.multiRecord) || mainView; + if (lazyView && !lazyView.isMobileFriendly) { + lazyView = self._findMobileView(views, lazyView.multiRecord) || lazyView; } } - var def; - if (action.flags.hasSearchView) { - def = self._createSearchView(action).then(function (searchView) { - // udpate domain, context and groupby in the env - var searchData = searchView.build_search_data(); - _.extend(action.env, self._processSearchData(action, searchData)); - }); + var lazyViewDef; + if (lazyView) { + // if the main view is lazy-loaded, its (lazy-loaded) controller is inserted + // into the controller stack (so that breadcrumbs can be correctly computed), + // so we force clear_breadcrumbs to false so that it won't be removed when the + // current controller will be inserted afterwards + options.clear_breadcrumbs = false; + // this controller being lazy-loaded, this call is actually sync + lazyViewDef = self._createViewController(action, lazyView.type, {}, {lazy: true}) + .then(function (lazyLoadedController) { + self.controllerStack.push(lazyLoadedController.jsID); + }); } - return $.when(def).then(function () { - var defs = []; - defs.push(self._createViewController(action, firstView.type)); - if (mainView) { - defs.push(self._createViewController(action, mainView.type, {}, {lazy: true})); - } - return self.dp.add($.when.apply($, defs)); - }).then(function (controller, lazyLoadedController) { - action.controllerID = controller.jsID; - return self._executeAction(action, options).done(function () { - if (lazyLoadedController) { - // controller should be placed just before the current one - var index = self.controllerStack.length - 1; - self.controllerStack.splice(index, 0, lazyLoadedController.jsID); - self.controlPanel.update({ - breadcrumbs: self._getBreadcrumbs(), - }, {clear: false}); - } - }); - }).fail(self._destroyWindowAction.bind(self, action)); + return self.dp.add($.when(lazyViewDef)) + .then(function () { + var viewOptions = { + controllerState: options.controllerState, + currentId: options.resID, + }; + var curViewDef = self._createViewController(action, curView.type, viewOptions, { + index: self._getControllerStackIndex(options), + }); + return self.dp.add(curViewDef); + }) + .then(function (controller) { + action.controllerID = controller.jsID; + return self._executeAction(action, options); + }) + .fail(self._destroyWindowAction.bind(self, action)); }); }, /** @@ -367,49 +319,6 @@ ActionManager.include({ multiRecord: multiRecord, }); }, - /** - * Generates the initial environment of a given action, which is a dict - * containing information like the model name, the domain, the context... - * That information is shared between the controllers of the corresponding - * action. - * - * @private - * @param {Object} action - * @param {Object} options - * @param {integer} [options.resID] - * @returns {Object} - */ - _generateActionEnv: function (action, options) { - var resID = options.resID || action.res_id; - return { - modelName: action.res_model, - ids: resID ? [resID] : undefined, - currentId: resID || undefined, - domain: [], - context: action.context || {}, - groupBy: [], - }; - }, - /** - * Generates the flags of a given action. - * - * @private - * @param {Object} action - * @returns {Object} - */ - _generateActionFlags: function (action) { - var popup = action.target === 'new'; - var inline = action.target === 'inline'; - var form = action.views[0][1] === 'form'; - return _.defaults({}, action.flags, { - disableCustomFilters: action.context && action.context.search_disable_custom_filters, - footerToButtons: popup, - hasSearchView: !(popup && form) && !inline, - hasSidebar: !popup && !inline, - headless: (popup || inline) && form, - mode: (popup || inline || action.target === 'fullscreen') && 'edit', - }); - }, /** * Generate the description of the views of a given action. For each view, * it generates a dict with information like the fieldsView, the view type, @@ -483,72 +392,20 @@ ActionManager.include({ * @returns {Deferred} */ _loadViews: function (action) { + var inDialog = action.target === 'new'; + var inline = action.target === 'inline'; var options = { action_id: action.id, - toolbar: action.flags.hasSidebar, + toolbar: inDialog && inline, }; var views = action.views.slice(); - if (action.flags.hasSearchView) { + if (!inline && !(inDialog && action.views[0][1] === 'form')) { options.load_filters = true; var searchviewID = action.search_view_id && action.search_view_id[0]; views.push([searchviewID || false, 'search']); } return this.loadViews(action.res_model, action.context, views, options); }, - /** - * Overrides to handle the 'keepSearchView' option. If set to true, the - * search view of the current action will be re-used in the new action, i.e. - * the environment (domain, context, groupby) will be shared between both - * actions. - * - * @override - */ - _preprocessAction: function (action, options) { - this._super.apply(this, arguments); - if (action.type === 'ir.actions.act_window' && options.keepSearchView) { - action._keepSearchView = true; - } - }, - /** - * Processes the search data sent by the search view. - * - * @private - * @param {Object} action - * @param {Object} searchData - * @param {Object} [searchData.contexts=[]] - * @param {Object} [searchData.domains=[]] - * @param {Object} [searchData.groupbys=[]] - * @returns {Object} an object with keys 'context', 'domain', 'groupBy' - */ - _processSearchData: function (action, searchData) { - var contexts = searchData.contexts; - var domains = searchData.domains; - var groupbys = searchData.groupbys; - var action_context = action.context || {}; - var results = pyUtils.eval_domains_and_contexts({ - domains: [action.domain || []].concat(domains || []), - contexts: [action_context].concat(contexts || []), - group_by_seq: groupbys || [], - eval_context: this.userContext, - }); - var groupBy = results.group_by.length ? - results.group_by : - (action.context.group_by || []); - groupBy = (typeof groupBy === 'string') ? [groupBy] : groupBy; - - if (results.error) { - throw new Error(_.str.sprintf(_t("Failed to evaluate search criterions")+": \n%s", - JSON.stringify(results.error))); - } - - var context = _.omit(results.context, 'time_ranges'); - - return { - context: context, - domain: results.domain, - groupBy: groupBy, - }; - }, /** * Overrides to handle the case of 'ir.actions.act_window' actions, i.e. * destroys all controllers associated to the given action, and its search @@ -598,8 +455,8 @@ ActionManager.include({ return this._super.apply(this, arguments); }, /** - * Handles the switch from a controller to another inside the same window - * action. + * Handles the switch from a controller to another (either inside the same + * window action, or from a window action to another using the breadcrumbs). * * @private * @param {Object} controller the controller to switch to @@ -608,18 +465,55 @@ ActionManager.include({ */ _switchController: function (action, viewType, viewOptions) { var self = this; + var view = _.findWhere(action.views, {type: viewType}); + if (!view) { + // can't switch to an unknown view + return $.Deferred().reject(); + } + var currentController = this.getCurrentController(); + var index; + if (currentController.actionID !== action.jsID) { + // the requested controller is from another action, so we went back + // to a previous action using the breadcrumbs + var controller = _.findWhere(this.controllers, { + actionID: action.jsID, + viewType: viewType, + }); + index = _.indexOf(this.controllerStack, controller.jsID); + } else { + // the requested controller is from the same action as the current + // one, so we either + // 1) go one step back from a mono record view to a multi record + // one using the breadcrumbs + // 2) or we switched from a view to another using the view + // switcher + // 3) or we opened a record from a multi record view + if (view.multiRecord) { + // cases 1) and 2) (with multi record views): replace the first + // controller linked to the same action in the stack + index = _.findIndex(this.controllerStack, function (controllerID) { + return self.controllers[controllerID].actionID === action.jsID; + }); + } else if (!_.findWhere(action.views, {type: currentController.viewType}).multiRecord) { + // case 2) (with mono record views): replace the last + // controller by the new one if they are from the same action + // and if they both are mono record + index = this.controllerStack.length - 1; + } else { + // case 3): insert the controller on the top of the controller + // stack + index = this.controllerStack.length; + } + } - var newController = function () { + var newController = function (controllerID) { + var options = { + controllerID: controllerID, + index: index, + }; return self - ._createViewController(action, viewType, viewOptions) + ._createViewController(action, viewType, viewOptions, options) .then(function (controller) { - // AAB: this will be moved to the Controller - var widget = controller.widget; - if (widget.need_control_panel) { - // set the ControlPanel bus on the controller to allow it to - // communicate its status - widget.set_cp_bus(self.controlPanel.get_bus()); - } return self._startController(controller); }); }; @@ -637,15 +531,9 @@ ActionManager.include({ } else { controllerDef = controllerDef.then(function (controller) { if (!controller.widget) { - // lazy loaded -> load it now - return newController().done(function (newController) { - // replace the old controller (without widget) by the new one - var index = self.controllerStack.indexOf(controller.jsID); - self.controllerStack[index] = newController.jsID; - delete self.controllers[controller.jsID]; - }); + // lazy loaded -> load it now (with same jsID) + return newController(controller.jsID); } else { - viewOptions = _.extend(viewOptions || {}, action.env); return $.when(controller.widget.willRestore()).then(function () { return controller.widget.reload(viewOptions).then(function () { return controller; @@ -656,22 +544,7 @@ ActionManager.include({ } return this.dp.add(controllerDef).then(function (controller) { - var view = _.findWhere(action.views, {type: viewType}); - var currentController = self.getCurrentController(); - var index; - if (currentController.actionID !== action.jsID) { - index = _.indexOf(self.controllerStack, controller.jsID); - } else if (view.multiRecord) { - // remove other controllers linked to the same action from the stack - index = _.findIndex(self.controllerStack, function (controllerID) { - return self.controllers[controllerID].actionID === action.jsID; - }); - } else if (!_.findWhere(action.views, {type: currentController.viewType}).multiRecord) { - // replace the last controller by the new one if they are from the - // same action and if they both are mono record - index = self.controllerStack.length - 1; - } - return self._pushController(controller, {index: index}); + return self._pushController(controller); }); }, @@ -679,18 +552,6 @@ ActionManager.include({ // Handlers //-------------------------------------------------------------------------- - /** - * @private - * @param {OdooEvent} ev - * @param {string} ev.data.controllerID - * @param {Object} ev.data.env - */ - _onEnvUpdated: function (ev) { - ev.stopPropagation(); - var controller = this.controllers[ev.data.controllerID]; - var action = this.actions[controller.actionID]; - _.extend(action.env, ev.data.env); - }, /** * Handler for event 'execute_action', which is typically called when a * button is clicked. The button may be of type 'object' (call a given @@ -802,67 +663,6 @@ ActionManager.include({ return self.doAction(action, options).then(ev.data.on_success, ev.data.on_fail); }); }, - /** - * Handles a context request: provides to the caller the context of the - * current controller. - * - * @private - * @param {OdooEvent} ev - * @param {function} ev.data.callback used to send the requested context - */ - _onGetControllerContext: function (ev) { - ev.stopPropagation(); - var currentController = this.getCurrentController(); - var context = currentController && currentController.widget.getContext(); - ev.data.callback(context || {}); - }, - /** - * Called mainly from the control panel when the focus should be given to a - * controller - * - * @private - * @param {OdooEvent} ev - */ - _onNavigationMove : function (ev) { - switch(ev.data.direction) { - case 'down' : - var currentController = this.getCurrentController().widget; - currentController.giveFocus(); - ev.stopPropagation(); - break; - } - }, - /** - * Handles a request to add/remove search view filters. - * - * @param {OdooEvent} ev - * @param {string} ev.data.controllerID - * @param {Array[Object]} [ev.data.newFilters] - * @param {Array[Object]} [ev.data.filtersToRemove] - * @param {function} ev.data.callback called with the added filters as arg - */ - _onUpdateFilters: function (ev) { - var controller = this.controllers[ev.data.controllerID]; - var action = this.actions[controller.actionID]; - var data = ev.data; - var addedFilters = action.searchView.updateFilters(data.newFilters, data.filtersToRemove); - data.callback(addedFilters); - }, - /** - * Called when there is a change in the search view, so the current action's - * environment needs to be updated with the new domain, context and groupby. - * - * @private - * @param {OdooEvent} ev - */ - _onSearch: function (ev) { - ev.stopPropagation(); - // AAB: the id of the correct controller should be given in data - var currentController = this.getCurrentController(); - var action = this.actions[currentController.actionID]; - _.extend(action.env, this._processSearchData(action, ev.data)); - currentController.widget.reload(_.extend({offset: 0}, action.env)); - }, /** * @private * @param {OdooEvent} ev @@ -882,13 +682,11 @@ ActionManager.include({ // only switch to the requested view if the controller that // triggered the request is the current controller var action = this.actions[currentController.actionID]; - if ('res_id' in ev.data) { - action.env.currentId = ev.data.res_id; - } - var options = {}; - if (viewType === 'form' && !action.env.currentId) { - options.mode = 'edit'; - } else if (ev.data.mode) { + var options = { + controllerState: currentController.widget.exportState(), + currentId: ev.data.res_id, + }; + if (ev.data.mode) { options.mode = ev.data.mode; } this._switchController(action, viewType, options); diff --git a/addons/web/static/src/js/chrome/action_mixin.js b/addons/web/static/src/js/chrome/action_mixin.js new file mode 100644 index 00000000000..099244592ad --- /dev/null +++ b/addons/web/static/src/js/chrome/action_mixin.js @@ -0,0 +1,182 @@ +odoo.define('web.ActionMixin', function (require) { +"use strict"; + +/** + * We define here the ActionMixin, the generic notion of action (from the point + * of view of the web client). In short, an action is a widget which controls + * the main part of the screen (everything below the navbar). + * + * More precisely, the action manager is the component that coordinates a stack + * of actions. Whenever the user navigates in the interface, switches views, + * open different menus, the action manager creates/updates/destroys special + * widgets which implements the ActionMixin. These actions need to answer to a + * standardised API, which is the reason for this mixin. + * + * In practice, most actions are view controllers (coming from an + * ir.action.act_window). However, some actions are 'client actions'. They + * also need to implement the ActionMixin for a better cooperation with the + * action manager. + * + * @module web.ActionMixin + */ + +var core = require('web.core'); + +var ActionMixin = { + template: 'Action', + + /** + * The action mixin assumes that it is rendered with the 'Action' template. + * This template has a special zone ('.o_content') where the content should + * be added. Actions that want to automatically render a template there + * should define the contentTemplate key. In short, client actions should + * probably define a contentTemplate key, and not a template key. + */ + contentTemplate: null, + + /** + * If an action wants to use a control panel, it will be created and + * registered in this _controlPanel key (the widget). The way this control + * panel is created is up to the implementation (so, view controllers or + * client actions may have different needs). + * + * Note that most of the time, this key should be set by the framework, not + * by the code of the client action. + */ + _controlPanel: null, + + /** + * String containing the title of the client action (which may be needed to + * display in the breadcrumbs zone of the control panel). + * + * @see _setTitle + */ + _title: '', + + /** + * @override + */ + renderElement: function () { + this._super.apply(this, arguments); + if (this.contentTemplate) { + var content = core.qweb.render(this.contentTemplate, {widget: this}); + this.$('.o_content').append(content); + } + }, + /** + * Called each time the action is attached into the DOM. + */ + on_attach_callback: function () {}, + /** + * Called each time the action is detached from the DOM. + */ + on_detach_callback: function () {}, + /** + * Called by the action manager when action is restored (typically, when the + * user clicks on the action in the breadcrumb) + * + * @returns {Deferred|undefined} + */ + willRestore: function () {}, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * In some situations, we need confirmation from the controller that the + * current state can be destroyed without prejudice to the user. For + * example, if the user has edited a form, maybe we should ask him if we + * can discard all his changes when we switch to another action. In that + * case, the action manager will call this method. If the returned + * deferred is succesfully resolved, then we can destroy the current action, + * otherwise, we need to stop. + * + * @returns {Deferred} resolved if the action can be removed, rejected + * otherwise + */ + canBeRemoved: function () { + return $.when(); + }, + /** + * This function is called when the current state of the action + * should be known. For instance, if the action is a view controller, + * this may be useful to reinstantiate a view in the same state. + * Typically the state can (and should) be encoded in a query object of + * the form + * { + * context: {...}, + * groupBy: [...], + * domain = [...], + * orderedBy = [...], + * } + * where the context key can contain many information. + * This method is mainly called during the creation of a custom filter. + * + * @returns {Object} + */ + getOwnedQueryParams: function () { + return {}; + }, + /** + * Returns a title that may be displayed in the breadcrumb area. For + * example, the name of the record (for a form view). This is actually + * important for the action manager: this is the way it is able to give + * the proper titles for other actions. + * + * @returns {string} + */ + getTitle: function () { + return this._title; + }, + /** + * Gives the focus to the action + */ + giveFocus: function () { + }, + /** + * Renders the buttons to append, in most cases, to the control panel (in + * the bottom left corner). When the action is rendered in a dialog, those + * buttons might be moved to the dialog's footer. + * + * @param {jQuery Node} $node + */ + renderButtons: function ($node) { + }, + /** + * This is the main method to customize the controlpanel content. + * + * @see updateContents method in ControlPanelRenderer for more info + * + * @param {Object} [status] + * @param {string} [status.title] + * @param {Object} [options] + * @param {boolean} [options.clear] + */ + updateControlPanel: function (status, options) { + if (this._controlPanel) { + status = status || {}; + status.title = status.title || this.getTitle(); + this._controlPanel.updateContents(status, options || {}); + } + }, + // TODO: add hooks methods: + // - onRestoreHook (on_reverse_breadcrumbs) + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {string} title + */ + _setTitle: function (title) { + this._title = title; + this.updateControlPanel({title: this._title}, {clear: false}); + }, +}; + +return ActionMixin; + +}); diff --git a/addons/web/static/src/js/chrome/control_panel.js b/addons/web/static/src/js/chrome/control_panel.js deleted file mode 100644 index 5d0601820d0..00000000000 --- a/addons/web/static/src/js/chrome/control_panel.js +++ /dev/null @@ -1,286 +0,0 @@ -odoo.define('web.ControlPanelMixin', function (require) { -"use strict"; - -/** - * Mixin allowing widgets to communicate with the ControlPanel. Widgets needing a - * ControlPanel should use this mixin and call update_control_panel(cp_status) where - * cp_status contains information for the ControlPanel to update itself. - * - * Note that the API is slightly awkward. Hopefully we will improve this when - * we get the time to refactor the control panel. - * - * For example, here is what a typical client action would need to do to add - * support for a control panel with some buttons:: - * - * var ControlPanelMixin = require('web.ControlPanelMixin'); - * - * var SomeClientAction = Widget.extend(ControlPanelMixin, { - * ... - * start: function () { - * this._renderButtons(); - * this._updateControlPanel(); - * ... - * }, - * do_show: function () { - * ... - * this._updateControlPanel(); - * }, - * _renderButtons: function () { - * this.$buttons = $(QWeb.render('SomeTemplate.Buttons')); - * this.$buttons.on('click', ...); - * }, - * _updateControlPanel: function () { - * this.update_control_panel({ - * cp_content: { - * $buttons: this.$buttons, - * }, - * }); - */ -var ControlPanelMixin = { - need_control_panel: true, - /** - * @param {web.Bus} [cp_bus] Bus to communicate with the ControlPanel - */ - set_cp_bus: function(cp_bus) { - this.cp_bus = cp_bus; - }, - /** - * Triggers 'update' on the cp_bus to update the ControlPanel according to cp_status - * @param {Object} [cp_status] see web.ControlPanel.update() for a description - * @param {Object} [options] see web.ControlPanel.update() for a description - */ - update_control_panel: function(cp_status, options) { - if (this.cp_bus) { - this.cp_bus.trigger("update", cp_status || {}, options || {}); - } - }, -}; - -return ControlPanelMixin; - -}); - -odoo.define('web.ControlPanel', function (require) { -"use strict"; - -var Bus = require('web.Bus'); -var data = require('web.data'); -var Widget = require('web.Widget'); - -var ControlPanel = Widget.extend({ - template: 'ControlPanel', - /** - * @param {String} [template] the QWeb template to render the ControlPanel. - * By default, the template 'ControlPanel' will be used - */ - init: function(parent, template) { - this._super(parent); - if (template) { - this.template = template; - } - - this.bus = new Bus(this); - // the updateIndex is used to prevent concurrent updates of the control - // panel depending on asynchronous code to be executed in the wrong order - this.bus.updateIndex = 0; - this.bus.on("update", this, this.update); - }, - /** - * Renders the control panel and creates a dictionnary of its exposed elements - * @return {jQuery.Deferred} - */ - start: function() { - // Exposed jQuery nodesets - this.nodes = { - $buttons: this.$('.o_cp_buttons'), - $pager: this.$('.o_cp_pager'), - $searchview: this.$('.o_cp_searchview'), - $searchview_buttons: this.$('.o_search_options'), - $sidebar: this.$('.o_cp_sidebar'), - $switch_buttons: this.$('.o_cp_switch_buttons'), - }; - - // Prevent the search dropdowns to close when clicking inside them - this.$el.on('click.bs.dropdown', '.o_search_options .dropdown-menu', function (e) { - e.stopPropagation(); - }); - - // By default, hide the ControlPanel and remove its contents from the DOM - this._toggle_visibility(false); - - return this._super(); - }, - /** - * @return {Object} the Bus the ControlPanel is listening on - */ - get_bus: function() { - return this.bus; - }, - /** - * Updates the content and displays the ControlPanel - * @param {Object} [status.active_view] the current active view - * @param {Array} [status.breadcrumbs] the breadcrumbs to display (see _render_breadcrumbs() for - * precise description) - * @param {Object} [status.cp_content] dictionnary containing the new ControlPanel jQuery elements - * @param {Boolean} [status.hidden] true if the ControlPanel should be hidden - * @param {openerp.web.SearchView} [status.searchview] the searchview widget - * @param {Boolean} [status.search_view_hidden] true if the searchview is hidden, false otherwise - * @param {Boolean} [options.clear] set to true to clear from control panel - * elements that are not in status.cp_content - */ - update: function(status, options) { - this.bus.updateIndex++; - - this._toggle_visibility(!status.hidden); - - // Don't update the ControlPanel in headless mode as the views have - // inserted themselves the buttons where they want, so inserting them - // again in the ControlPanel will remove them from where they should be - if (!status.hidden) { - options = _.defaults({}, options, { - clear: true, // clear control panel by default - }); - var new_cp_content = status.cp_content || {}; - - // Render the breadcrumbs - if (status.breadcrumbs) { - this.$('.breadcrumb').html(this._render_breadcrumbs(status.breadcrumbs)); - } - - // Detach control_panel old content and attach new elements - var toDetach = this.nodes; - if (status.searchview && this.searchview === status.searchview) { - // If the searchview is the same as before, don't detach it s.t. - // we don't loose any floating content, nor the focus - toDetach = _.omit(toDetach, '$searchview'); - new_cp_content = _.omit(new_cp_content, '$searchview'); - } - if (options.clear) { - this._detach_content(toDetach); - // Show the searchview buttons area, which might have been hidden by - // the searchview, as client actions may insert elements into it - this.nodes.$searchview_buttons.show(); - } else { - this._detach_content(_.pick(toDetach, _.keys(new_cp_content))); - } - this._attach_content(new_cp_content); - if (options.clear || status.searchview) { - this.searchview = status.searchview; - } - - // Update the searchview and switch buttons - if (status.searchview || options.clear) { - this._update_search_view(status.searchview, status.search_view_hidden, status.groupable, status.enableTimeRangeMenu); - } - if (status.active_view_selector) { - this._update_switch_buttons(status.active_view_selector); - } - } - }, - /** - * Private function that hides (or shows) the ControlPanel in headless (resp. non-headless) mode - * Also detaches or attaches its contents to clean the DOM - * @param {Boolean} [visible] true to show the control panel, false to hide it - */ - _toggle_visibility: function(visible) { - this.do_toggle(visible); - if (!visible && !this.$content) { - this.$content = this.$el.contents().detach(); - } else if (this.$content) { - this.$content.appendTo(this.$el); - this.$content = null; - } - }, - /** - * Private function that detaches the content of the ControlPanel - * @param {Object} [elements_to_detach] subset of this.nodes to detach - */ - _detach_content: function(elements_to_detach) { - _.each(elements_to_detach, function($nodeset) { - $nodeset.contents().detach(); - }); - }, - /** - * Private function that attaches content to the ControlPanel - * @param {Object} [content] dictionnary of jQuery elements to attach, whose keys - * are jQuery nodes identifiers in this.nodes - */ - _attach_content: function(content) { - var self = this; - _.each(content, function($nodeset, $element) { - if ($nodeset && self.nodes[$element]) { - self.nodes[$element].append($nodeset); - } - }); - }, - /** - * Private function that removes active class on all switch-buttons and adds - * it to the one of the active view - * @param {Object} [active_view_selector] the selector of the div to activate - */ - _update_switch_buttons: function(active_view_selector) { - _.each(this.nodes.$switch_buttons.find('button'), function(button) { - $(button).removeClass('active'); - }); - this.$(active_view_selector).addClass('active'); - }, - /** - * Private function that renders the breadcrumbs - * @param {Array} [breadcrumbs] list of objects containing the following keys: - * - action: the action to execute when clicking on this part of the breadcrumbs - * - index: the index in the breadcrumbs (starting at 0) - * - title: what to display in the breadcrumbs - * @return {Array} list of breadcrumbs' li jQuery elements - */ - _render_breadcrumbs: function (breadcrumbs) { - var self = this; - return breadcrumbs.map(function (bc, index) { - return self._render_breadcrumbs_li(bc, index, breadcrumbs.length); - }); - }, - /** - * Private function that renders a breadcrumbs' li Jquery element - */ - _render_breadcrumbs_li: 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 = $('
  • ', {class: 'breadcrumb-item'}) - .append(is_last ? li_content : $('', {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}); - }); - } - return $bc; - }, - /** - * Private function that updates the SearchView's visibility and extend the - * breadcrumbs area if the SearchView is not visible - * - * @private - * @param {SearchView} [searchview] the searchview Widget - * @param {boolean} [isHidden] visibility of the searchview - * @param {boolean} [groupable] visibility of the groupable menu (only - * relevant if searchview is visible) - */ - _update_search_view: function (searchview, isHidden, groupable, enableTimeRangeMenu) { - if (searchview) { - searchview.toggle_visibility(!isHidden); - if (groupable !== undefined){ - searchview.groupby_menu.do_toggle(groupable); - } - if (enableTimeRangeMenu !== undefined){ - searchview.displayTimeRangeMenu(enableTimeRangeMenu); - } - } - this.nodes.$searchview.toggle(!isHidden); - this.$el.toggleClass('o_breadcrumb_full', !!isHidden); - }, -}); - -return ControlPanel; - -}); diff --git a/addons/web/static/src/js/core/registry.js b/addons/web/static/src/js/core/registry.js index f925b0ea30a..c6d35229639 100644 --- a/addons/web/static/src/js/core/registry.js +++ b/addons/web/static/src/js/core/registry.js @@ -25,6 +25,8 @@ var Registry = Class.extend({ */ init: function (mapping) { this.map = Object.create(mapping || null); + this._scoreMapping = Object.create(null); + this._sortedKeys = null; this.listeners = []; // listening callbacks on newly added items. }, @@ -39,9 +41,12 @@ var Registry = Class.extend({ * * @param {string} key * @param {any} value + * @param {[number]} score if given, this value will be used to order keys * @returns {Registry} can be used to chain add calls. */ - add: function (key, value) { + add: function (key, value, score) { + this._scoreMapping[key] = score === undefined ? key : score; + this._sortedKeys = null; this.map[key] = value; _.each(this.listeners, function (callback) { callback(key, value); @@ -49,7 +54,7 @@ var Registry = Class.extend({ return this; }, /** - * Check if the registry contains the value + * Check if the registry contains the key * * @param {string} key * @returns {boolean} @@ -58,18 +63,12 @@ var Registry = Class.extend({ return (key in this.map); }, /** - * Creates and returns a copy of the current mapping, with the provided - * mapping argument added in (replacing existing keys if needed) + * Returns the content of the registry (an object mapping keys to values) * - * Parent and child remain linked, a new key in the parent (which is not - * overwritten by the child) will appear in the child. - * - * @param {Object} [mapping={}] a mapping of keys to object-paths + * @returns {Object} */ - extend: function (mapping) { - var child = new Registry(this.map); - _.extend(child.map, mapping); - return child; + entries: function () { + return Object.create(this.map); }, /** * Returns the value associated to the given key. @@ -95,6 +94,23 @@ var Registry = Class.extend({ } return null; }, + /** + * Return the list of keys in map object. + * + * The registry guarantees that the keys have a consistent order, defined by + * the 'score' value when the item has been added. + * + * @returns {string[]} + */ + keys: function () { + var self = this; + if (!this._sortedKeys) { + this._sortedKeys = _.sortBy(Object.keys(this.map), function (key) { + return self._scoreMapping[key] || 0; + }); + } + return this._sortedKeys; + }, /** * Register a callback to execute when items are added to the registry. * @@ -103,6 +119,17 @@ var Registry = Class.extend({ onAdd: function (callback) { this.listeners.push(callback); }, + /** + * Return the list of values in map object + * + * @returns {string[]} + */ + values: function () { + var self = this; + return this.keys().map(function (key) { + return self.map[key]; + }); + }, }); return Registry; diff --git a/addons/web/static/src/js/core/service_mixins.js b/addons/web/static/src/js/core/service_mixins.js index 9c127a1bc8c..e8088d45cb5 100644 --- a/addons/web/static/src/js/core/service_mixins.js +++ b/addons/web/static/src/js/core/service_mixins.js @@ -139,8 +139,8 @@ var ServicesMixin = { promise.abort = abort.bind(def); return promise; }, - loadFieldView: function (dataset, view_id, view_type, options) { - return this.loadViews(dataset.model, dataset.get_context().eval(), [[view_id, view_type]], options).then(function (result) { + loadFieldView: function (modelName, context, view_id, view_type, options) { + return this.loadViews(modelName, context, [[view_id, view_type]], options).then(function (result) { return result[view_type]; }); }, @@ -155,11 +155,28 @@ var ServicesMixin = { }); return def; }, - loadFilters: function (dataset, action_id) { + loadFilters: function (modelName, actionId, context) { var def = $.Deferred(); this.trigger_up('load_filters', { - dataset: dataset, - action_id: action_id, + modelName: modelName, + actionId: actionId, + context: context, + on_success: def.resolve.bind(def), + }); + return def; + }, + createFilter: function (filter) { + var def = $.Deferred(); + this.trigger_up('create_filter', { + filter: filter, + on_success: def.resolve.bind(def), + }); + return def; + }, + deleteFilter: function (filterId) { + var def = $.Deferred(); + this.trigger_up('delete_filter', { + filterId: filterId, on_success: def.resolve.bind(def), }); return def; diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 60b0275c8c8..6d53d283f8a 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -16,7 +16,7 @@ odoo.define('web.relational_fields', function (require) { var AbstractField = require('web.AbstractField'); var basicFields = require('web.basic_fields'); var concurrency = require('web.concurrency'); -var ControlPanel = require('web.ControlPanel'); +var ControlPanelView = require('web.ControlPanelView'); var dialogs = require('web.view_dialogs'); var core = require('web.core'); var data = require('web.data'); @@ -1030,7 +1030,14 @@ var FieldX2Many = AbstractField.extend({ } var self = this; var defs = []; - this.control_panel = new ControlPanel(this, "X2ManyControlPanel"); + var controlPanelView = new ControlPanelView({ + template: 'X2ManyControlPanel', + withSearchBar: false, + }); + var cpDef = controlPanelView.getController(this).then(function (controlPanel) { + self._controlPanel = controlPanel; + return self._controlPanel.prependTo(self.$el); + }); this.pager = new Pager(this, this.value.count, this.value.offset + 1, this.value.limit, { single_page_hidden: true, withAccessKey: false, @@ -1053,9 +1060,9 @@ var FieldX2Many = AbstractField.extend({ }); this._renderButtons(); defs.push(this.pager.appendTo($('
    '))); // start the pager - defs.push(this.control_panel.prependTo(this.$el)); + defs.push(cpDef); return $.when.apply($, defs).then(function () { - self.control_panel.update({ + self._controlPanel.updateContents({ cp_content: { $buttons: self.$buttons, $pager: self.pager.$el, diff --git a/addons/web/static/src/js/report/client_action.js b/addons/web/static/src/js/report/client_action.js index 7041913c286..3a520e3d6b5 100644 --- a/addons/web/static/src/js/report/client_action.js +++ b/addons/web/static/src/js/report/client_action.js @@ -4,7 +4,6 @@ odoo.define('report.client_action', function (require) { var AbstractAction = require('web.AbstractAction'); var config = require('web.config'); var core = require('web.core'); -var ControlPanelMixin = require('web.ControlPanelMixin'); var session = require('web.session'); var utils = require('report.utils'); @@ -17,9 +16,9 @@ var AUTHORIZED_MESSAGES = [ 'report:do_action', ]; -var ReportAction = AbstractAction.extend(ControlPanelMixin, { - - template: 'report.client_action', +var ReportAction = AbstractAction.extend({ + hasControlPanel: true, + contentTemplate: 'report.client_action', init: function (parent, action, options) { this._super.apply(this, arguments); @@ -27,7 +26,7 @@ var ReportAction = AbstractAction.extend(ControlPanelMixin, { options = options || {}; this.action_manager = parent; - this.title = options.display_name || options.name; + this._title = options.display_name || options.name; this.edit_mode_available = false; this.in_edit_mode = false; @@ -42,7 +41,6 @@ var ReportAction = AbstractAction.extend(ControlPanelMixin, { start: function () { var self = this; - this.set('title', this.title); this.iframe = this.$('iframe')[0]; return $.when(this._super.apply(this, arguments), session.is_bound).then(function () { var web_base_url = session['web.base.url']; @@ -93,7 +91,7 @@ var ReportAction = AbstractAction.extend(ControlPanelMixin, { }, _update_control_panel: function () { - this.update_control_panel({ + this.updateControlPanel({ cp_content: { $buttons: this.$buttons, }, diff --git a/addons/web/static/src/js/services/core.js b/addons/web/static/src/js/services/core.js index 522d6b07302..3d6cfc4e684 100644 --- a/addons/web/static/src/js/services/core.js +++ b/addons/web/static/src/js/services/core.js @@ -39,8 +39,6 @@ return { // registries action_registry : new Registry(), crash_registry: new Registry(), - search_filters_registry: new Registry(), - search_widgets_registry: new Registry(), serviceRegistry: new Registry(), /** * @type {String} diff --git a/addons/web/static/src/js/services/data_manager.js b/addons/web/static/src/js/services/data_manager.js index bb7f25d206b..afedaa1148f 100644 --- a/addons/web/static/src/js/services/data_manager.js +++ b/addons/web/static/src/js/services/data_manager.js @@ -128,15 +128,20 @@ return core.Class.extend({ * * @param {Object} [dataset] the dataset for which the filters are loaded * @param {int} [action_id] the id of the action (optional) + * @param {Object} params + * @param {string} params.modelName + * @param {Object} params.context + * @param {integer} params.actionId * @return {Deferred} resolved with the requested filters */ - load_filters: function (dataset, action_id) { - var key = this._gen_key(dataset.model, action_id); + load_filters: function (params) { + var key = this._gen_key(params.modelName, params.actionId); if (!this._cache.filters[key]) { this._cache.filters[key] = rpc.query({ - args: [dataset.model, action_id], + args: [params.modelName, params.actionId], kwargs: { - context: dataset.get_context(), + context: params.context || {}, + // get_context() de dataset }, model: 'ir.filters', method: 'get_filters', @@ -158,26 +163,26 @@ return core.Class.extend({ model: 'ir.filters', method: 'create_or_replace', }) - .then(function (filter_id) { + .then(function (filterId) { var key = [ filter.model_id, filter.action_id || false, ].join(','); self._invalidate(self._cache.filters, key); - return filter_id; + return filterId; }); }, /** * Calls 'unlink' on 'ir_filters'. * - * @param {Object} [filter] the description of the filter to remove + * @param {integer} filterId, Id of the filter to remove * @return {Deferred} */ - delete_filter: function (filter) { + delete_filter: function (filterId) { var self = this; return rpc.query({ - args: [filter.id], + args: [filterId], model: 'ir.filters', method: 'unlink', }) diff --git a/addons/web/static/src/js/tools/debug_manager.js b/addons/web/static/src/js/tools/debug_manager.js index 391d8613aac..a348f54acd9 100644 --- a/addons/web/static/src/js/tools/debug_manager.js +++ b/addons/web/static/src/js/tools/debug_manager.js @@ -345,8 +345,8 @@ DebugManager.include({ action: this._action, can_edit: this._can_edit_views, controller: this._controller, + controlPanelView: this._controller && this._controller._controlPanel, manager: this, - searchview: this._controller && this._controller.searchView, view: this._controller && _.findWhere(this._action.views, { type: this._controller.viewType, }), diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index d2d22a626d1..77584a272d6 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -12,18 +12,21 @@ odoo.define('web.AbstractController', function (require) { * reading localstorage, ...) has to go through the controller. */ -var AbstractAction = require('web.AbstractAction'); +var ActionMixin = require('web.ActionMixin'); var ajax = require('web.ajax'); var concurrency = require('web.concurrency'); var config = require('web.config'); -var ControlPanelMixin = require('web.ControlPanelMixin'); var core = require('web.core'); +var mvc = require('web.mvc'); var QWeb = core.qweb; -var AbstractController = AbstractAction.extend(ControlPanelMixin, { +var AbstractController = mvc.Controller.extend(ActionMixin, { custom_events: { + get_controller_query_params: '_onGetControllerQueryParams', + navigation_move: '_onNavigationMove', open_record: '_onOpenRecord', + search: '_onSearch', switch_view: '_onSwitchView', }, events: { @@ -31,50 +34,30 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { }, /** - * @constructor - * @param {Widget} parent - * @param {AbstractModel} model - * @param {AbstractRenderer} renderer - * @param {object} params + * @override * @param {string} params.modelName * @param {string} [params.controllerID] an id to ease the communication * with upstream components + * @param {ControlPanelController} [params.controlPanel] * @param {any} [params.handle] a handle that will be given to the model (some id) - * @param {any} params.initialState the initialState * @param {boolean} params.isMultiRecord * @param {Object[]} params.actionViews * @param {string} params.viewType - * @param {boolean} params.withControlPanel set to false to hide the - * ControlPanel */ init: function (parent, model, renderer, params) { this._super.apply(this, arguments); - this.model = model; - this.renderer = renderer; + this._controlPanel = params.controlPanel; + this._title = params.displayName; this.modelName = params.modelName; - this.handle = params.handle; this.activeActions = params.activeActions; this.controllerID = params.controllerID; this.initialState = params.initialState; this.bannerRoute = params.bannerRoute; - // use a DropPrevious to correctly handle concurrent updates - this.dp = new concurrency.DropPrevious(); - // those arguments are temporary, they won't be necessary as soon as the - // ControlPanel will be handled by the View - this.displayName = params.displayName; this.isMultiRecord = params.isMultiRecord; - this.searchable = params.searchable; - this.searchView = params.searchView; - this.searchViewHidden = params.searchViewHidden; - this.groupable = params.groupable; - this.enableTimeRangeMenu = params.enableTimeRangeMenu; this.actionViews = params.actionViews; this.viewType = params.viewType; - this.withControlPanel = params.withControlPanel !== false; - // override this.need_control_panel so that the ActionManager doesn't - // update the control panel when it isn't visible (this is a temporary - // hack that can be removed as soon as the CP'll be handled by the view) - this.need_control_panel = this.withControlPanel; + // use a DropPrevious to correctly handle concurrent updates + this.dp = new concurrency.DropPrevious(); }, /** * Simply renders and updates the url. @@ -86,13 +69,13 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { this.$el.addClass('o_view_controller'); - // render the ControlPanel elements (buttons, pager, sidebar...) - this.controlPanelElements = this._renderControlPanelElements(); + return this._super.apply(this, arguments).then(function () { + if (self._controlPanel) { + // render the ControlPanel elements (buttons, pager, sidebar...) + self.controlPanelElements = self._renderControlPanelElements(); + self._controlPanel.$el.prependTo(self.$el); + } - return $.when( - this._super.apply(this, arguments), - this.renderer.appendTo(this.$el) - ).then(function () { return self._update(self.initialState); }); }, @@ -106,14 +89,14 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { if (this.controlPanelElements && this.controlPanelElements.$switch_buttons) { this.controlPanelElements.$switch_buttons.off(); } - return this._super.apply(this, arguments); + this._super.apply(this, arguments); }, /** * Called each time the controller is attached into the DOM. */ on_attach_callback: function () { - if (this.searchView) { - this.searchView.on_attach_callback(); + if (this._controlPanel) { + this._controlPanel.on_attach_callback(); } this.renderer.on_attach_callback(); }, @@ -153,26 +136,18 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { return $.when(); }, /** - * Returns any special keys that may be useful when reloading the view to - * get the same effect. This is necessary for saving the current view in - * the favorites. For example, a graph view might want to add a key to - * save the current graph type. + * Export the state of the controller containing information that is shared + * between different controllers of a same action (like the current + * searchQuery of the controlPanel). * * @returns {Object} */ - getContext: function () { - return {}; - }, - /** - * Returns a title that may be displayed in the breadcrumb area. For - * example, the name of the record. - * - * note: this will be moved to AbstractAction - * - * @returns {string} - */ - getTitle: function () { - return this.displayName; + exportState: function () { + var state = {}; + if (this._controlPanel) { + state.cpState = this._controlPanel.exportState(); + } + return state; }, /** * Gives the focus to the renderer @@ -199,7 +174,16 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { * @returns {Deferred} */ reload: function (params) { - return this.update(params || {}); + params = params || {}; + var def; + var controllerState = params.controllerState || {}; + var cpState = controllerState.cpState; + if (this._controlPanel && cpState) { + def = this._controlPanel.importState(cpState).then(function (searchQuery) { + params = _.extend({}, params, searchQuery); + }); + } + return $.when(def).then(this.update.bind(this, params, {})); }, /** * For views that require a pager, this method will be called to allow the @@ -246,16 +230,16 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { // and this controller should no longer update the control panel. // note that this won't be necessary as soon as each controller will have // its own control panel - var cpUpdateIndex = this.cp_bus && this.cp_bus.updateIndex; + var cpUpdateIndex = this._controlPanel && this._controlPanel.updateIndex; return this.dp.add(def).then(function (handle) { - if (self.cp_bus && cpUpdateIndex !== self.cp_bus.updateIndex) { + if (self._controlPanel && cpUpdateIndex !== self._controlPanel.updateIndex) { return; } self.handle = handle || self.handle; // update handle if we reloaded var state = self.model.get(self.handle); var localState = self.renderer.getLocalState(); return self.dp.add(self.renderer.updateState(state, params)).then(function () { - if (self.cp_bus && cpUpdateIndex !== self.cp_bus.updateIndex) { + if (self._controlPanel && cpUpdateIndex !== self._controlPanel.updateIndex) { return; } self.renderer.setLocalState(localState); @@ -327,7 +311,7 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { js.remove(); }); return $.when.apply($, defs).then(function () { - $banner.prependTo(self.$el); + $banner.prependTo(self.$('> .o_content')); self._$banner = $banner; }); }); @@ -342,24 +326,20 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { * @returns {Object} an object containing the control panel jQuery elements */ _renderControlPanelElements: function () { - var elements = {}; + var elements = { + $buttons: $('
    '), + $sidebar: $('
    '), + $pager: $('
    '), + }; - if (this.withControlPanel) { - elements = { - $buttons: $('
    '), - $sidebar: $('
    '), - $pager: $('
    '), - }; - - this.renderButtons(elements.$buttons); - this.renderSidebar(elements.$sidebar); - this.renderPager(elements.$pager); - // remove the unnecessary outer div - elements = _.mapObject(elements, function($node) { - return $node && $node.contents(); - }); - elements.$switch_buttons = this._renderSwitchButtons(); - } + this.renderButtons(elements.$buttons); + this.renderSidebar(elements.$sidebar); + this.renderPager(elements.$pager); + // remove the unnecessary outer div + elements = _.mapObject(elements, function($node) { + return $node && $node.contents(); + }); + elements.$switch_buttons = this._renderSwitchButtons(); return elements; }, @@ -379,6 +359,7 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { var template = config.device.isMobile ? 'ControlPanel.SwitchButtons.Mobile' : 'ControlPanel.SwitchButtons'; var $switchButtons = $(QWeb.render(template, { + viewType: this.viewType, views: views, })); // create bootstrap tooltips @@ -392,14 +373,21 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { self.trigger_up('switch_view', {view_type: viewType}); }, 200, true)); + // set active view's icon as view switcher button's icon in mobile if (config.device.isMobile) { - // set active view's icon as view switcher button's icon var activeView = _.findWhere(views, {type: this.viewType}); $switchButtons.find('.o_switch_view_button_icon').addClass('fa fa-lg ' + activeView.icon); } return $switchButtons; }, + /** + * @override + * @private + */ + _startRenderer: function () { + return this.renderer.appendTo(this.$('.o_content')); + }, /** * This method is called after each update or when the start method is * completed. @@ -413,23 +401,12 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { * @param {Object} state the state given by the model * @returns {Deferred} */ - _update: function (state) { + _update: function () { // AAB: update the control panel -> this will be moved elsewhere at some point var cpContent = _.extend({}, this.controlPanelElements); - if (this.searchView) { - _.extend(cpContent, { - $searchview: this.searchView.$el, - $searchview_buttons: this.searchView.$buttons, - }); - } - this.update_control_panel({ - active_view_selector: '.o_cp_switch_' + this.viewType, + this.updateControlPanel({ cp_content: cpContent, - hidden: !this.withControlPanel, - searchview: this.searchView, - search_view_hidden: !this.searchable || this.searchviewHidden, - groupable: this.groupable, - enableTimeRangeMenu: this.enableTimeRangeMenu, + title: this.getTitle(), }); this._pushState(); @@ -478,6 +455,36 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { this.do_action($target.attr('name')); } }, + /** + * FIXME: this logic should be rethought + * + * Handles a context request: provides to the caller the state of the + * current controller. + * + * @private + * @param {OdooEvent} ev + * @param {function} ev.data.callback used to send the requested state + */ + _onGetControllerQueryParams: function (ev) { + ev.stopPropagation(); + var state = this.getOwnedQueryParams(); + ev.data.callback(state || {}); + }, + /** + * Called mainly from the control panel when the focus should be given to + * the controller + * + * @private + * @param {OdooEvent} ev + */ + _onNavigationMove : function (ev) { + switch(ev.data.direction) { + case 'down' : + ev.stopPropagation(); + this.giveFocus(); + break; + } + }, /** * When an Odoo event arrives requesting a record to be opened, this method * gets the res_id, and request a switch view in the appropriate mode @@ -502,6 +509,20 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { model: this.modelName, }); }, + /** + * Called when there is a change in the search view, so the current action's + * environment needs to be updated with the new domain, context and groupby. + * + * @private + * @param {OdooEvent} ev + * @param {Array[]} ev.data.domain + * @param {Object} ev.data.context + * @param {string[]} ev.data.groupby + */ + _onSearch: function (ev) { + ev.stopPropagation(); + this.reload(_.extend({offset: 0}, ev.data)); + }, /** * Intercepts the 'switch_view' event to add the controllerID into the data, * and lets the event bubble up. diff --git a/addons/web/static/src/js/views/abstract_model.js b/addons/web/static/src/js/views/abstract_model.js index 4ea6b217e65..da73ac664a3 100644 --- a/addons/web/static/src/js/views/abstract_model.js +++ b/addons/web/static/src/js/views/abstract_model.js @@ -14,52 +14,21 @@ odoo.define('web.AbstractModel', function (require) { * in order to be able to notify its parent by bubbling events up. */ -var Class = require('web.Class'); var fieldUtils = require('web.field_utils'); -var mixins = require('web.mixins'); -var ServicesMixin = require('web.ServicesMixin'); - -var AbstractModel = Class.extend(mixins.EventDispatcherMixin, ServicesMixin, { - /** - * @param {Widget} parent - */ - init: function (parent) { - mixins.EventDispatcherMixin.init.call(this); - this.setParent(parent); - }, +var mvc = require('web.mvc'); +var AbstractModel = mvc.Model.extend({ //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- - /** - * This method should return the complete state necessary for the renderer - * to display the currently viewed data. - * - * @returns {*} - */ - get: function () { - }, - /** - * The load method is called once in a model, when we load the data for the - * first time. The method returns (a deferred that resolves to) some kind - * of token/handle. The handle can then be used with the get method to - * access a representation of the data. - * - * @param {Object} params - * @param {string} params.modelName the name of the model - * @returns {Deferred} The deferred resolves to some kind of handle - */ - load: function (params) { - return $.when(); - }, /** * When something changes, the data may need to be refetched. This is the * job for this method: reloading (only if necessary) all the data and * making sure that they are ready to be redisplayed. * * @param {Object} params - * @returns {Deferred} + * @returns {Promise} */ reload: function (params) { return $.when(); diff --git a/addons/web/static/src/js/views/abstract_renderer.js b/addons/web/static/src/js/views/abstract_renderer.js index 059380f87bd..0f18263c9ee 100644 --- a/addons/web/static/src/js/views/abstract_renderer.js +++ b/addons/web/static/src/js/views/abstract_renderer.js @@ -7,22 +7,18 @@ odoo.define('web.AbstractRenderer', function (require) { * */ -var Widget = require('web.Widget'); +var mvc = require('web.mvc'); /** * @class AbstractRenderer */ -return Widget.extend({ +return mvc.Renderer.extend({ /** - * @constructor - * @param {Widget} parent - * @param {any} state - * @param {Object} params + * @override * @param {string} [params.noContentHelp] */ init: function (parent, state, params) { - this._super(parent); - this.state = state; + this._super.apply(this, arguments); this.arch = params.arch; this.noContentHelp = params.noContentHelp; }, diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js index 024216b5619..75de2be9446 100644 --- a/addons/web/static/src/js/views/abstract_view.js +++ b/addons/web/static/src/js/views/abstract_view.js @@ -23,14 +23,16 @@ odoo.define('web.AbstractView', function (require) { * in most case discarded. */ -var ajax = require('web.ajax'); -var Class = require('web.Class'); var AbstractModel = require('web.AbstractModel'); var AbstractRenderer = require('web.AbstractRenderer'); var AbstractController = require('web.AbstractController'); -var utils = require('web.utils'); +var ControlPanelView = require('web.ControlPanelView'); +var mvc = require('web.mvc'); +var viewUtils = require('web.viewUtils'); -var AbstractView = Class.extend({ +var Factory = mvc.Factory; + +var AbstractView = Factory.extend({ // name displayed in view switchers display_name: '', // indicates whether or not the view is mobile-friendly @@ -40,22 +42,20 @@ var AbstractView = Class.extend({ // multi_record is used to distinguish views displaying a single record // (e.g. FormView) from those that display several records (e.g. ListView) multi_record: true, - // determine if a search view should be displayed in the control panel and - // allowed to interact with the view. Currently, the only not searchable - // views are the form view and the diagram view. - searchable: true, // viewType is the type of the view, like 'form', 'kanban', 'list'... viewType: undefined, - // if searchable, this flag determines if the search view will display a - // groupby menu or not. This is useful for the views which do not support - // grouping data. - groupable: true, - enableTimeRangeMenu: false, - config: { + // determines if a search bar is available + withSearchBar: true, + // determines the search menus available and their orders + searchMenuTypes: ['filter', 'groupBy', 'favorite'], + // determines if a control panel should be instantiated + withControlPanel: true, + // determines the MVC components to use + config: _.extend({}, Factory.prototype.config, { Model: AbstractModel, Renderer: AbstractRenderer, Controller: AbstractController, - }, + }), /** * The constructor function is supposed to set 3 variables: rendererParams, @@ -65,33 +65,32 @@ var AbstractView = Class.extend({ * @constructs AbstractView * * @param {Object} viewInfo - * @param {Object} viewInfo.arch + * @param {Object|string} viewInfo.arch * @param {Object} viewInfo.fields * @param {Object} viewInfo.fieldsInfo * @param {Object} params - * @param {string} params.modelName The actual model name - * @param {Object} params.context - * @param {number} [params.count] + * @param {string} [params.modelName] + * @param {Object} [params.action={}] + * @param {Object} [params.context={}] * @param {string} [params.controllerID] - * @param {string[]} params.domain - * @param {string[][]} params.timeRange - * @param {string[][]} params.comparisonTimeRange - * @param {string} params.timeRangeDescription - * @param {string} params.comparisonTimeRangeDescription - * @param {boolean} params.compare - * @param {string[]} params.groupBy + * @param {number} [params.count] * @param {number} [params.currentId] - * @param {boolean} params.isEmbedded + * @param {string} [params.controllerState] + * @param {string} [params.displayName] + * @param {Array[]} [params.domain=[]] * @param {number[]} [params.ids] - * @param {boolean} [params.withControlPanel] - * @param {boolean} [params.action.flags.headless] - * @param {string} [params.action.display_name] - * @param {string} [params.action.name] - * @param {string} [params.action.help] - * @param {string} [params.action.jsID] - * @param {boolean} [params.action.views] + * @param {boolean} [params.isEmbedded=false] + * @param {Object} [params.searchQuery={}] + * @param {Object} [params.searchQuery.context={}] + * @param {Array[]} [params.searchQuery.domain=[]] + * @param {string[]} [params.searchQuery.groupBy=[]] + * @param {Object} [params.userContext={}] + * @param {boolean} [params.withControlPanel=true] */ init: function (viewInfo, params) { + var action = params.action || {}; + params = _.defaults(params, this._extractParamsFromAction(action)); + // in general, the fieldsView has to be processed by the View (e.g. the // arch is a string that needs to be parsed) ; the only exception is for // inline form views inside form views, as they are processed alongside @@ -103,89 +102,51 @@ var AbstractView = Class.extend({ } else { this.fieldsView = viewInfo; } - this.fields = this.fieldsView.viewFields; this.arch = this.fieldsView.arch; - // the boolean parameter 'isEmbedded' determines if the view should be considered - // as a subview. For now this is only used by the graph controller that appends a - // 'Group By' button beside the 'Measures' button when the graph view is embedded. + this.fields = this.fieldsView.viewFields; + this.userContext = params.userContext || {}; + this.withControlPanel = params.withControlPanel; + + // the boolean parameter 'isEmbedded' determines if the view should be + // considered as a subview. For now this is only used by the graph + // controller that appends a 'Group By' button beside the 'Measures' + // button when the graph view is embedded. var isEmbedded = params.isEmbedded || false; this.rendererParams = { arch: this.arch, isEmbedded: isEmbedded, - noContentHelp: params.action && params.action.help, + noContentHelp: params.noContentHelp, }; - var timeRangeMenuData = params.context.timeRangeMenuData; - var timeRange = []; - var comparisonTimeRange = []; - var compare = false; - var timeRangeDescription = ""; - var comparisonTimeRangeDescription = ""; - if (this.enableTimeRangeMenu && timeRangeMenuData) { - timeRange = timeRangeMenuData.timeRange; - comparisonTimeRange = timeRangeMenuData.comparisonTimeRange; - compare = comparisonTimeRange.length > 0; - timeRangeDescription = timeRangeMenuData.timeRangeDescription; - comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription; - this.rendererParams.timeRangeDescription = timeRangeDescription; - this.rendererParams.comparisonTimeRangeDescription = comparisonTimeRangeDescription; - } - this.controllerParams = { - modelName: params.modelName, + actionViews: params.actionViews, activeActions: { edit: this.arch.attrs.edit ? JSON.parse(this.arch.attrs.edit) : true, create: this.arch.attrs.create ? JSON.parse(this.arch.attrs.create) : true, delete: this.arch.attrs.delete ? JSON.parse(this.arch.attrs.delete) : true, duplicate: this.arch.attrs.duplicate ? JSON.parse(this.arch.attrs.duplicate) : true, }, - groupable: this.groupable, - enableTimeRangeMenu: this.enableTimeRangeMenu, - isEmbedded: isEmbedded, - controllerID: params.controllerID, bannerRoute: this.arch.attrs.banner_route, + controllerID: params.controllerID, + displayName: params.displayName, + isEmbedded: isEmbedded, + isMultiRecord: this.multi_record, + modelName: params.modelName, + viewType: this.viewType, }; - // AAB: these params won't be necessary as soon as the ControlPanel will - // be instantiated by the View - this.controllerParams.displayName = params.action && (params.action.display_name || params.action.name); - this.controllerParams.isMultiRecord = this.multi_record; - this.controllerParams.searchable = this.searchable; - this.controllerParams.searchView = params.action && params.action.searchView; - this.controllerParams.searchViewHidden = this.searchview_hidden; // AAB: use searchable instead where it is used? - this.controllerParams.actionViews = params.action ? params.action.views : []; - this.controllerParams.viewType = this.viewType; - this.controllerParams.withControlPanel = true; - if (params.action && params.action.flags) { - this.controllerParams.withControlPanel = !params.action.flags.headless; - } else if ('withControlPanel' in params) { - this.controllerParams.withControlPanel = params.withControlPanel; - } - - var groupBy = params.groupBy; - if (typeof groupBy === 'string') { - groupBy = [groupBy]; - } + var controllerState = params.controllerState || {}; + var currentId = controllerState.currentId || params.currentId; this.loadParams = { context: params.context, count: params.count || ((this.controllerParams.ids !== undefined) && this.controllerParams.ids.length) || 0, domain: params.domain, - timeRange: timeRange, - timeRangeDescription: timeRangeDescription, - comparisonTimeRange: comparisonTimeRange, - comparisonTimeRangeDescription: comparisonTimeRangeDescription, - compare: compare, - groupedBy: groupBy, modelName: params.modelName, - res_id: params.currentId, - res_ids: params.ids, - orderedBy: params.context ? params.context.orderedBy : [], + res_id: currentId, + res_ids: controllerState.resIds || params.ids || (currentId ? [currentId] : undefined), }; - if (params.modelName) { - this.loadParams.modelName = params.modelName; - } // default_order is like: // 'name,id desc' // but we need it like: @@ -197,8 +158,23 @@ var AbstractView = Class.extend({ return {name: order[0], asc: order[1] !== 'desc'}; }); } + if (params.searchQuery) { + this._updateMVCParams(params.searchQuery); + } - this.userContext = params.userContext; + this.controlPanelParams = { + action: action, + activateDefaultFavorite: params.activateDefaultFavorite, + breadcrumbs: params.breadcrumbs, + context: this.loadParams.context, + domain: this.loadParams.domain, + modelName: params.modelName, + searchMenuTypes: params.searchMenuTypes, + state: controllerState.cpState, + viewInfo: params.controlPanelFieldsView, + withBreadcrumbs: params.withBreadcrumbs, + withSearchBar: params.withSearchBar, + }; }, //-------------------------------------------------------------------------- @@ -206,69 +182,48 @@ var AbstractView = Class.extend({ //-------------------------------------------------------------------------- /** - * Main method of the view class. Create a controller, and make sure that - * data and libraries are loaded. - * - * There is a unusual thing going in this method with parents: we create - * renderer/model with parent as parent, then we have to reassign them at - * the end to make sure that we have the proper relationships. This is - * necessary to solve the problem that the controller need the model and the - * renderer to be instantiated, but the model need a parent to be able to - * load itself, and the renderer needs the data in its constructor. - * - * @param {Widget} parent The parent of the resulting Controller (most - * likely an action manager) - * @returns {Deferred} The deferred resolves to a controller + * @override */ getController: function (parent) { var self = this; - // check if a model already exists, as if not, one will be created and - // we'll have to set the controller as its parent - var alreadyHasModel = !!this.model; - return $.when(this._loadData(parent), ajax.loadLibs(this)).then(function () { - var state = self.model.get(arguments[0]); - var renderer = self.getRenderer(parent, state); - var Controller = self.Controller || self.config.Controller; - var controllerParams = _.extend({ - initialState: state, - }, self.controllerParams); - var controller = new Controller(parent, self.model, renderer, controllerParams); - renderer.setParent(controller); - - if (!alreadyHasModel) { - // if we have a model, it already has a parent. Otherwise, we - // set the controller, so the rpcs from the model actually work - self.model.setParent(controller); + var def; + if (this.withControlPanel) { + def = this._createControlPanel(parent); + } + var _super = this._super.bind(this); + return $.when(def).then(function (controlPanel) { + if (controlPanel) { + var searchQuery = controlPanel.getSearchQuery(); + self._updateMVCParams(searchQuery); } - return controller; + // get the parent of the model if it already exists, as _super will + // set the new controller as parent, which we don't want + var modelParent = self.model && self.model.getParent(); + return _super(parent).done(function (controller) { + if (controlPanel) { + controlPanel.setParent(controller); + } + if (modelParent) { + // if we already add a model, restore its parent + self.model.setParent(modelParent); + } + }); + }); }, /** - * Returns the view model or create an instance of it if none + * Ensures that only one instance of AbstractModel is created * - * @param {Widget} parent the parent of the model, if it has to be created - * @return {Object} instance of the view model + * @override */ - getModel: function (parent) { + getModel: function () { if (!this.model) { - var Model = this.config.Model; - this.model = new Model(parent); + this.model = this._super.apply(this, arguments); } return this.model; }, /** - * Returns the a new view renderer instance - * - * @param {Widget} parent the parent of the model, if it has to be created - * @param {Object} state the information related to the rendered view - * @return {Object} instance of the view renderer - */ - getRenderer: function (parent, state) { - var Renderer = this.config.Renderer; - return new Renderer(parent, state, this.rendererParams); - }, - /** - * this is useful to customize the actual class to use before calling + * This is useful to customize the actual class to use before calling * createView. * * @param {Controller} Controller @@ -282,16 +237,61 @@ var AbstractView = Class.extend({ //-------------------------------------------------------------------------- /** - * Load initial data from the model + * Instantiates and starts a ControlPanelController. * * @private - * @param {Widget} parent the parent of the model - * @returns {Deferred<*>} a deferred that resolves to whatever the model - * decide to return + * @param {Widget} parent + * @returns {ControlPanelController} */ - _loadData: function (parent) { - var model = this.getModel(parent); - return model.load(this.loadParams); + _createControlPanel: function (parent) { + var self = this; + var controlPanelView = new ControlPanelView(this.controlPanelParams); + return controlPanelView.getController(parent).then(function (controlPanel) { + self.controllerParams.controlPanel = controlPanel; + return controlPanel.appendTo(document.createDocumentFragment()).then(function () { + return controlPanel; + }); + }); + }, + /** + * @private + * @param {Object} [action] + * @param {Object} [action.context || {}] + * @param {boolean} [action.context.no_breadcrumbs=false] + * @param {integer} [action.context.active_id] + * @param {integer[]} [action.context.active_ids] + * @param {Object} [action.controlPanelFieldsView] + * @param {string} [action.display_name] + * @param {Array[]} [action.domain=[]] + * @param {string} [action.help] + * @param {integer} [action.id] + * @param {integer} [action.limit] + * @param {string} [action.name] + * @param {string} [action.res_model] + * @param {string} [action.target] + * @returns {Object} + */ + _extractParamsFromAction: function (action) { + action = action || {}; + var context = action.context || {}; + var inline = action.target === 'inline'; + return { + actionId: action.id || false, + actionViews: action.views || [], + activateDefaultFavorite: !context.active_id && !context.active_ids, + context: action.context || {}, + controlPanelFieldsView: action.controlPanelFieldsView, + currentId: action.res_id ? action.res_id : undefined, // load returns 0 + displayName: action.display_name || action.name, + domain: action.domain || [], + limit: action.limit, + modelName: action.res_model, + noContentHelp: action.help, + searchMenuTypes: inline ? [] : this.searchMenuTypes, + withBreadcrumbs: 'no_breadcrumbs' in context ? !context.no_breadcrumbs : true, + withControlPanel: this.withControlPanel, + withSearchBar: inline ? false : this.withSearchBar, + }; }, /** * Processes a fieldsView. In particular, parses its arch. @@ -303,12 +303,44 @@ var AbstractView = Class.extend({ */ _processFieldsView: function (fieldsView) { var fv = _.extend({}, fieldsView); - var doc = $.parseXML(fv.arch).documentElement; - var stripWhitespaces = doc.nodeName.toLowerCase() !== 'kanban'; - fv.arch = utils.xml_to_json(doc, stripWhitespaces); + fv.arch = viewUtils.parseArch(fv.arch); fv.viewFields = _.defaults({}, fv.viewFields, fv.fields); return fv; }, + /** + * Hook to update the renderer, controller and load params with the result + * of a search (i.e. a context, a domain and a groupBy). + * + * @private + * @param {Object} searchQuery + * @param {Object} searchQuery.context + * @param {Object} [searchQuery.context.timeRangeMenuData={}] + * @param {Array[]} [searchQuery.context.timeRangeMenuData.comparisonTimeRange=[]] + * @param {string} [searchQuery.context.timeRangeMenuData.comparisonTimeRangeDescription=''] + * @param {string} [searchQuery.context.timeRangeMenuData.timeRangeDescription=''] + * @param {Array[]} [searchQuery.context.timeRangeMenuData.timeRange=[]] + * @param {Array[]} searchQuery.domain + * @param {string[]} searchQuery.groupBy + */ + _updateMVCParams: function (searchQuery) { + var timeRangeMenuData = searchQuery.context.timeRangeMenuData || {}; + var comparisonTimeRange = timeRangeMenuData.comparisonTimeRange || []; + var comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription || ''; + var timeRangeDescription = timeRangeMenuData.timeRangeDescription || ''; + this.loadParams = _.extend(this.loadParams, { + compare: comparisonTimeRange.length > 0, + comparisonTimeRange: comparisonTimeRange, + comparisonTimeRangeDescription: comparisonTimeRangeDescription, + context: searchQuery.context, + domain: searchQuery.domain, + groupedBy: searchQuery.groupBy, + timeRange: timeRangeMenuData.timeRange || [], + timeRangeDescription: timeRangeMenuData.timeRangeDescription || '', + }); + this.loadParams.orderedBy = searchQuery.orderedBy ? searchQuery.orderedBy : this.loadParams.orderedBy; + this.rendererParams.timeRangeDescription = timeRangeDescription; + this.rendererParams.comparisonTimeRangeDescription = comparisonTimeRangeDescription; + }, }); return AbstractView; diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index 183885b9fb8..7f578da077d 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -36,8 +36,8 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { this.confirmOnDelete = params.confirmOnDelete; this.hasButtons = params.hasButtons; FieldManagerMixin.init.call(this, this.model); - this.handle = params.initialState.id; this.mode = params.mode || 'readonly'; + this.handle = this.initialState.id; }, /** * @override @@ -201,6 +201,18 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { self._updatePager(); }); }, + /** + * @override + */ + reload: function (params) { + if (params && params.controllerState) { + if (params.controllerState.currentId) { + params.currentId = params.controllerState.currentId; + } + params.ids = params.controllerState.resIds; + } + return this._super.apply(this, arguments); + }, //-------------------------------------------------------------------------- // Private @@ -368,6 +380,20 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { this.$buttons.find('button').removeAttr('disabled'); } }, + /** + * Override to add the current record ID (currentId) and the list of ids + * (resIds) in the current dataPoint to the exported state. + * + * @override + */ + exportState: function () { + var state = this._super.apply(this, arguments); + var env = this.model.get(this.handle, {env: true}); + return _.extend(state, { + currentId: env.currentId, + resIds: env.ids, + }); + }, /** * Returns the new sidebar env * @@ -487,7 +513,6 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { var sidebarEnv = this._getSidebarEnv(); this.sidebar.updateEnv(sidebarEnv); } - this.trigger_up('env_updated', {controllerID: this.controllerID, env: env}); }, /** * Helper method, to make sure the information displayed by the pager is up diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index 598f88637d8..8266b1dee42 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -2053,14 +2053,12 @@ var BasicModel = AbstractModel.extend({ // We don't do it now because it's not an important case. // However, we can for sure increment by 1 if we are on the last page. - var handleFieldValue = computedList.data[index].data[handleField]; if (position === 'top') { handleFieldValue--; } else if (list.count <= list.offset + list.limit - (list.tempLimitIncrement || 0)) { handleFieldValue++; } - return { field: handleField, value: handleFieldValue, @@ -4383,7 +4381,9 @@ var BasicModel = AbstractModel.extend({ if (options.context !== undefined) { element.context = options.context; - element.orderedBy = options.context.orderedBy || element.orderedBy; + } + if (options.orderedBy !== undefined) { + element.orderedBy = options.orderedBy || element.orderedBy; } if (options.domain !== undefined) { element.domain = options.domain; diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js index 8db200183ef..27821081f96 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -517,35 +517,6 @@ var BasicRenderer = AbstractRenderer.extend({ }); }); }, - /** - * Renders a button according to a given node element. - * - * @private - * @param {Object} node - * @param {Object} [options] - * @param {string} [options.extraClass] - * @param {boolean} [options.textAsTitle=false] - * @returns {jQuery} - */ - _renderButtonFromNode: function (node, options) { - var btnOptions = { - attrs: _.omit(node.attrs, 'icon', 'string', 'type', 'attrs', 'modifiers', 'options'), - icon: node.attrs.icon, - }; - if (options && options.extraClass) { - var classes = btnOptions.attrs.class ? btnOptions.attrs.class.split(' ') : []; - btnOptions.attrs.class = _.uniq(classes.concat(options.extraClass.split(' '))).join(' '); - } - var str = (node.attrs.string || '').replace(/_/g, ''); - if (str) { - if (options && options.textAsTitle) { - btnOptions.attrs.title = str; - } else { - btnOptions.text = str; - } - } - return dom.renderButton(btnOptions); - }, /** * Instantiates the appropriate AbstractField specialization for the given * node and prepares its rendering and addition to the DOM. Indeed, the diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js index eb3efe7001d..d5d6cfa18d5 100644 --- a/addons/web/static/src/js/views/basic/basic_view.js +++ b/addons/web/static/src/js/views/basic/basic_view.js @@ -51,7 +51,6 @@ var BasicView = AbstractView.extend({ this.loadParams.fieldsInfo = this.fieldsInfo; this.loadParams.fields = this.fields; - this.loadParams.context = params.context || {}; this.loadParams.limit = parseInt(this.arch.attrs.limit, 10) || params.limit; this.loadParams.viewType = this.viewType; this.loadParams.parentID = params.parentID; @@ -96,19 +95,17 @@ var BasicView = AbstractView.extend({ * @private * @returns {Deferred} */ - _loadData: function () { + _loadData: function (model) { if (this.recordID) { - var self = this; - // Add the fieldsInfo of the current view to the given recordID, // as it will be shared between two views, and it must be able to // handle changes on fields that are only on this view. - this.model.addFieldsInfo(this.recordID, { + model.addFieldsInfo(this.recordID, { fields: this.fields, fieldsInfo: this.fieldsInfo, }); - var record = this.model.get(this.recordID); + var record = model.get(this.recordID); var viewType = this.viewType; var viewFields = Object.keys(record.fieldsInfo[viewType]); var fieldNames = _.difference(viewFields, Object.keys(record.data)); @@ -168,14 +165,14 @@ var BasicView = AbstractView.extend({ // onchange RPCs), that we haven't been able to process earlier // (because those fields were unknow at that time). So we ask // the model to process them. - def = this.model.applyRawChanges(record.id, viewType).then(function () { - if (self.model.isNew(record.id)) { - return self.model.applyDefaultValues(record.id, {}, { + def = model.applyRawChanges(record.id, viewType).then(function () { + if (model.isNew(record.id)) { + return model.applyDefaultValues(record.id, {}, { fieldNames: fieldNames, viewType: viewType, }); } else { - return self.model.reload(record.id, { + return model.reload(record.id, { fieldNames: fieldNames, keepChanges: true, viewType: viewType, @@ -184,7 +181,7 @@ var BasicView = AbstractView.extend({ }); } return $.when(def).then(function () { - return record.id; + return model.get(record.id); }); } return this._super.apply(this, arguments); diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js index b699879472a..0934ae462b3 100644 --- a/addons/web/static/src/js/views/calendar/calendar_controller.js +++ b/addons/web/static/src/js/views/calendar/calendar_controller.js @@ -75,7 +75,7 @@ var CalendarController = AbstractController.extend({ * @returns {string} */ getTitle: function () { - return this.get('title'); + return this._title; }, /** * Render the buttons according to the CalendarView.buttons template and @@ -156,7 +156,7 @@ var CalendarController = AbstractController.extend({ * @returns {Deferred} */ _updateRecord: function (record) { - return this.model.updateRecord(record).then(this.reload.bind(this)); + return this.model.updateRecord(record).then(this.reload.bind(this, {})); }, //-------------------------------------------------------------------------- @@ -430,7 +430,7 @@ var CalendarController = AbstractController.extend({ this.$buttons.find('.active').removeClass('active'); this.$buttons.find('.o_calendar_button_' + this.mode).addClass('active'); } - this.set({title: this.displayName + ' (' + event.data.title + ')'}); + this._setTitle(this.displayName + ' (' + event.data.title + ')'); }, }); diff --git a/addons/web/static/src/js/views/calendar/calendar_view.js b/addons/web/static/src/js/views/calendar/calendar_view.js index 20d358c517c..90db648ffd3 100644 --- a/addons/web/static/src/js/views/calendar/calendar_view.js +++ b/addons/web/static/src/js/views/calendar/calendar_view.js @@ -29,7 +29,7 @@ var CalendarView = AbstractView.extend({ Renderer: CalendarRenderer, }, viewType: 'calendar', - groupable: false, + searchMenuTypes: ['filter', 'favorite'], /** * @override 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 new file mode 100644 index 00000000000..991cee949e8 --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/control_panel_controller.js @@ -0,0 +1,237 @@ +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', + }, + + /** + * @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(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @see ControlPanelModel (exportState) + * @returns {Object} + */ + exportState: function () { + return this.model.exportState(); + }, + /** + * 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 {Deferred} 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 $.when(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 {Deferred} + */ + _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; + if (!group) { + group = this.renderer.getLastFacet(); + } + 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(); + }, +}); + +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 new file mode 100644 index 00000000000..9353022eacf --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/control_panel_model.js @@ -0,0 +1,1093 @@ +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; + +var DEFAULT_TIMERANGE = controlPanelViewParameters.DEFAULT_TIMERANGE; +var TIME_RANGE_OPTIONS = controlPanelViewParameters.TIME_RANGE_OPTIONS; +var COMPARISON_TIME_RANGE_OPTIONS = controlPanelViewParameters.COMPARISON_TIME_RANGE_OPTIONS; + +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 (_.contains(group.activeFilterIds, 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, function (filterId) { + var filter = self.filters[filterId]; + // TODO: put this logic in toggleFilter 'field' type + if (filter.autoCompleteValues) { + filter.autoCompleteValues = []; + } + }); + // 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 () { + var activeFavoriteId = self.groups[filter.groupId].activeFilterIds[0]; + var isActive = activeFavoriteId === 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]); + var group = self.groups[filter.groupId]; + filter.isActive = group.activeFilterIds.indexOf(filterId) !== -1; + 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 = []; + // resolve active filters for facets + this.query.forEach(function (groupID) { + var group = self.groups[groupID]; + var facet = _.extend({}, group); + facet.filters = facet.activeFilterIds.map(function (filterID) { + return self.filters[filterID]; + }); + facets.push(facet); + }); + 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 {Deferred} + */ + 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 $.when(); + } + if (params.initialState) { + this.importState(params.initialState); + return $.when(); + } else { + var groups = params.groups || []; + groups.forEach(function (group) { + self._createGroupOfFilters(group); + }); + if (this._getGroupIdOfType('groupBy') === undefined) { + this._createEmptyGroup('groupBy'); + } + this._createGroupOfTimeRanges(); + return $.when.apply($, 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.includes(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.indexOf(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) { + self.groups[groupId].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) { + var filter = this.filters[filterId]; + var group = this.groups[filter.groupId]; + var alreadyActive = group.activeFilterIds.indexOf(filterId) !== -1; + if (alreadyActive) { + if (filter.currentOptionId === optionId) { + this.toggleFilter(filterId); + filter.currentOptionId = false; + } else { + filter.currentOptionId = optionId || filter.defaultOptionId; + } + } else { + this.toggleFilter(filterId); + filter.currentOptionId = optionId || filter.defaultOptionId; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Activate all filters (but favorites) with key isDefault=true + * + * @private + */ + _activateDefaultFilters: function () { + var self = this; + Object.keys(this.filters).forEach(function (filterId) { + var filter = self.filters[filterId]; + // if we are here, this means there is no favorite with isDefault set to true + if (filter.isDefault && filter.type !== 'favorite') { + if (filter.hasOptions) { + self.toggleFilterWithOptions(filter.id); + } else { + self.toggleFilter(filter.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) { + var self = this; + if (defaultTimeRanges) { + var filterId = Object.keys(this.filters).find(function (filterId) { + var filter = self.filters[filterId]; + return filter.type === 'timeRange' && filter.fieldName === defaultTimeRanges.field; + }); + if (filterId) { + this.activateTimeRange( + filterId, + 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); + }, + /** + * 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 the context of the provided filter. + * + * @private + * @param {Object} filter + * @returns {Object} context + */ + _getFilterContext: function (filter) { + var context = {}; + if (filter.type === 'favorite') { + _.extend(context, filter.context); + } + // the following code aims to restore this: + // https://github.com/odoo/odoo/blob/master/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/master/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) { + var domain; + if (filter.type === 'filter') { + domain = filter.domain; + if (filter.domain === undefined) { + domain = Domain.prototype.constructDomain( + filter.fieldName, + filter.currentOptionId, + filter.fieldType + ); + } + } + if (filter.type === 'favorite') { + domain = filter.domain; + } + if (filter.type === 'field') { + domain = filter.domain; + } + return domain; + }, + /** + * Compute the groupBys (if possible) of the provided filter. + * + * @private + * @param {Object} filter + * @returns {string[]|undefined} groupBys + */ + _getFilterGroupBys: function (filter) { + var groupBys; + if (filter.type === 'groupBy') { + var groupBy = filter.fieldName; + if (filter.currentOptionId) { + groupBy = groupBy + ':' + filter.currentOptionId; + } + 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]; + 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]; + return self._getFilterDomain(filter); + }); + return pyUtils.assembleDomains(_.compact(domains), 'OR'); + }, + /** + * Return the groupBys coming form the filtes 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) { + var filter = self.filters[filterId]; + acc = acc.concat(self._getFilterGroupBys(filter)); + 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 Object.keys(this.groups).find(function (groupId) { + var group = self.groups[groupId]; + return group.type === type; + }); + }, + /** + * Used to get the key orderedBy of a favorite. + * + * @private + * @returns {Object[]} 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]; + var favorite = this.filters[activeFavoriteId]; + 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]]; + + var comparisonTimeRange = "[]"; + var comparisonTimeRangeDescription; + + var timeRange = Domain.prototype.constructDomain( + filter.fieldName, + filter.timeRangeId, + filter.fieldType + ); + var timeRangeDescription = filter.timeRangeOptions.find(function (option) { + return option.optionId === filter.timeRangeId; + }).description.toString(); + if (filter.comparisonTimeRangeId) { + comparisonTimeRange = Domain.prototype.constructDomain( + filter.fieldName, + filter.timeRangeId, + filter.fieldType, + null, + filter.comparisonTimeRangeId + ); + comparisonTimeRangeDescription = filter.comparisonTimeRangeOptions.find(function (comparisonOption) { + return comparisonOption.optionId === filter.comparisonTimeRangeId; + }).description.toString(); + } + if (evaluation) { + timeRange = Domain.prototype.stringToArray(timeRange); + comparisonTimeRange = Domain.prototype.stringToArray(comparisonTimeRange); + } + context = { + timeRangeMenuData: { + timeRange: timeRange, + timeRangeDescription: timeRangeDescription, + comparisonTimeRange: comparisonTimeRange, + comparisonTimeRangeDescription: comparisonTimeRangeDescription, + } + }; + } + return context; + }, + /** + * 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 {Deferred} + */ + _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 orderTerms = order.split(' '); + return { + name: orderTerms[0], + asc: orderTerms.length === 2 && orderTerms[1] === '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 defaultFavoriteId = Object.keys(self.filters).find(function (filterId) { + var filter = self.filters[filterId]; + return filter.type === 'favorite' && filter.isDefault; + }); + if (defaultFavoriteId) { + self.toggleFilter(defaultFavoriteId); + } + } + } 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 {Deferred[]} + */ + _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 {Deferred} + */ + _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 new file mode 100644 index 00000000000..eea7fdae430 --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/control_panel_renderer.js @@ -0,0 +1,387 @@ +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.bs.dropdown .o_search_options .dropdown-menu': '_onDropdownClicked', + '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.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 $.when(superDef, searchDef).then(function () { + self._setSearchMenusVisibility(); + }); + }, + /** + * @override + */ + on_attach_callback: function () { + this._focusSearchInput(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {Object} + */ + 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 {Deferred} + */ + 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.isMobile) { + // 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 $('
    ').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 = $('
  • ', {class: 'breadcrumb-item'}) + .append(is_last ? li_content : $('', {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}); + }); + } + return $bc; + }, + /** + * Renderer the search bar and the search menus + * + * @private + * @returns {Deferred} + */ + _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 $.when(this, defs).then(this._focusSearchInput.bind(this)); + }, + /** + * @private + * @returns {Deferred} + */ + _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, + filters: this.state.filters, + filterFields: this.state.filterFields, + groupBys: this.state.groupBys, + }); + 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 {Deferred} + */ + _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 {Deferred[]} + */ + _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 + //-------------------------------------------------------------------------- + + /** + * Prevent the search dropdowns from closing when clicking inside them. + * + * @private + * @param {MouseEvent} ev + */ + _onDropdownClicked: function (ev) { + ev.stopPropagation(); + }, + /** + * 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 new file mode 100644 index 00000000000..3ba7afb2a60 --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/control_panel_view.js @@ -0,0 +1,238 @@ +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 DEFAULT_INTERVAL = controlPanelViewParameters.DEFAULT_INTERVAL; +var DEFAULT_PERIOD = controlPanelViewParameters.DEFAULT_PERIOD; +var INTERVAL_OPTIONS = controlPanelViewParameters.INTERVAL_OPTIONS; +var PERIOD_OPTIONS = controlPanelViewParameters.PERIOD_OPTIONS; + +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.template] the QWeb template to render + * @param {Object} [params.viewInfo={arch: '', 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 {boolean} [activateDefaultFavorite=false] determine if the default + * custom filters can be activated + */ + init: function (params) { + var self = this; + this._super(); + params = params || {}; + var viewInfo = params.viewInfo || {arch: '', 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.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.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); + } + } + + PERIOD_OPTIONS = PERIOD_OPTIONS.map(function (option) { + return _.extend(option, {description: option.description.toString()}); + }); + INTERVAL_OPTIONS = INTERVAL_OPTIONS.map(function (option) { + return _.extend(option, {description: option.description.toString()}); + }); + }, + + //-------------------------------------------------------------------------- + // 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 (filter.type === 'filter') { + filter.domain = attrs.domain; + if (attrs.date) { + filter.fieldName = attrs.date; + filter.fieldType = this.fields[attrs.date].type; + // we should be able to declare list of options per date filter + // (request of POs) (same remark for groupbys) + filter.hasOptions = true; + filter.options = PERIOD_OPTIONS; + filter.defaultOptionId = attrs.default_period || + DEFAULT_PERIOD; + filter.currentOptionId = false; + } + } else if (filter.type === 'groupBy') { + filter.fieldName = attrs.fieldName; + filter.fieldType = this.fields[attrs.fieldName].type; + if (_.contains(['date', 'datetime'], filter.fieldType)) { + filter.hasOptions = true; + filter.options = INTERVAL_OPTIONS; + filter.defaultOptionId = attrs.defaultInterval || + DEFAULT_INTERVAL; + filter.currentOptionId = false; + } + } else if (filter.type === 'field') { + 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; + } + } + }, + /** + * Parse the arch of a 'search' view. + * + * @private + * @param {Object} arch arch with root node + */ + _parseSearchArch: function (arch) { + var self = this; + var preFilters = _.flatten(arch.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/mobile_control_panel_controller.js b/addons/web/static/src/js/views/control_panel/mobile_control_panel_controller.js new file mode 100644 index 00000000000..92537c4d8e0 --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/mobile_control_panel_controller.js @@ -0,0 +1,34 @@ + odoo.define('web.MobileControlPanelController', function (require) { +"use strict"; + +var config = require('web.config'); +var ControlPanelController = require('web.ControlPanelController'); + +if (!config.device.isMobile) { + return; +} + +ControlPanelController.include({ + custom_events:_.extend({}, ControlPanelController.prototype.custom_events, { + 'search_bar_cleared': '_onSearchBarCleared', + }), + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * There is a 'Clear' button in the mobile control panel view. + * + * @private + */ + _onSearchBarCleared: function () { + var self = this; + this.model.get().query.forEach(function (groupId) { + self.model.deactivateGroup(groupId); + }); + this._reportNewQueryAndRender(); + }, +}); + +}); diff --git a/addons/web/static/src/js/views/search/search_view_mobile.js b/addons/web/static/src/js/views/control_panel/mobile_control_panel_renderer.js similarity index 59% rename from addons/web/static/src/js/views/search/search_view_mobile.js rename to addons/web/static/src/js/views/control_panel/mobile_control_panel_renderer.js index ea7fe33ad81..23852abcf53 100644 --- a/addons/web/static/src/js/views/search/search_view_mobile.js +++ b/addons/web/static/src/js/views/control_panel/mobile_control_panel_renderer.js @@ -1,16 +1,16 @@ - odoo.define('web.SearchViewMobile', function (require) { + odoo.define('web.MobileControlPanelRenderer', function (require) { "use strict"; var config = require('web.config'); -var SearchView = require('web.SearchView'); +var ControlPanelRenderer = require('web.ControlPanelRenderer'); if (!config.device.isMobile) { return; } -SearchView.include({ - template:'SearchViewMobile', - events:_.extend({}, SearchView.prototype.events, { +ControlPanelRenderer.include({ + template:'MobileControlPanel', + events:_.extend({}, ControlPanelRenderer.prototype.events, { 'click .o_mobile_search_close, .o_mobile_search_show_result, .o_enable_searchview': '_toggleMobileSearchView', 'click': '_onOpenMobileSearchView', 'click .o_mobile_search_clear_facets': '_onEmptyAll', @@ -18,43 +18,47 @@ SearchView.include({ 'hide.bs.dropdown .o_mobile_search_filter .o_dropdown': '_onDropdownToggle', }), - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - renderFacets: function () { - this._super.apply(this, arguments); - this.$('.o_mobile_search_clear_facets') - .toggleClass('o_hidden', !this.query.length); - }, - /** - * @override - */ - toggle_visibility: function (is_visible) { - // Do not do anything, toggling visibility of searchview is handled - // explicitly for mobile - }, - //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** + * Overridden to correctly place submenu inside the mobile section. + * + * @private * @override */ - _getButtonsElement: function () { + _getSubMenusPlace: function () { return this.$('.o_mobile_search_filter'); }, /** - * Toggle mobile search view screen + * @private + * @override + */ + _render: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self.$('.o_mobile_search_clear_facets') + .toggleClass('o_hidden', !self.state.query.length); + }); + }, + /** + * @private + * @override + */ + _renderSearchBar: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self.searchBar.$el.detach().insertAfter(self.$('.o_mobile_search_header')); + }); + }, + /** + * Toggles mobile search view screen. * * @private */ _toggleMobileSearchView: function () { - this.$('.o_enable_searchview').toggleClass('btn-secondary', !!this.query.length); + this.$('.o_enable_searchview').toggleClass('btn-secondary', !!this.state.query.length); this.$('.o_mobile_search').toggleClass('o_hidden'); }, @@ -63,7 +67,7 @@ SearchView.include({ //-------------------------------------------------------------------------- /** - * Toggle the little arrow in main buttons + * Toggles the little arrow in main buttons. * * @private * @param {BootstrapEvent} ev @@ -72,16 +76,15 @@ SearchView.include({ $(ev.currentTarget).find('.fa-chevron-right').toggleClass('fa-chevron-down'); }, /** - * Clears all filters from the search view + * Clears all filters from the search view. * * @private - * @param {MouseEvent} event */ _onEmptyAll: function () { - this.query.reset(); + this.trigger_up('search_bar_cleared'); }, /** - * Open the mobile search view screen + * Opens the mobile search view screen. * * @private * @param {MouseEvent} ev @@ -93,4 +96,4 @@ SearchView.include({ }, }); -}); \ No newline at end of file +}); 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 new file mode 100644 index 00000000000..0a2498c69df --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js @@ -0,0 +1,142 @@ +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', + '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 + //-------------------------------------------------------------------------- + + /** + * 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(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @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/search/time_range_menu_options.js b/addons/web/static/src/js/views/control_panel/search/control_panel_view_parameters.js similarity index 51% rename from addons/web/static/src/js/views/search/time_range_menu_options.js rename to addons/web/static/src/js/views/control_panel/search/control_panel_view_parameters.js index 9c042935d0e..171737b66fb 100644 --- a/addons/web/static/src/js/views/search/time_range_menu_options.js +++ b/addons/web/static/src/js/views/control_panel/search/control_panel_view_parameters.js @@ -1,10 +1,13 @@ -odoo.define('web.TimeRangeMenuOptions', function (require) { +odoo.define('web.controlPanelViewParameters', function (require) { "use strict"; var core = require('web.core'); + var _lt = core._lt; -var PeriodOptions = [ +// for FilterMenu +var DEFAULT_PERIOD = 'this_month'; +var 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}, @@ -20,14 +23,34 @@ var PeriodOptions = [ {description: _lt('Last Year'), optionId: 'last_year', groupId: 3}, ]; -var ComparisonOptions = [ +// for GroupBy menu +var GROUPABLE_TYPES = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime']; +var DEFAULT_INTERVAL = 'month'; +var INTERVAL_OPTIONS = [ + {description: _lt('Day'), optionId: 'day', groupId: 1}, + {description: _lt('Week'), optionId: 'week', groupId: 1}, + {description: _lt('Month'), optionId: 'month', groupId: 1}, + {description: _lt('Quarter'), optionId: 'quarter', groupId: 1}, + {description: _lt('Year'), optionId: 'year', groupId: 1}, + ]; + +// for TimeRangeMenu +var DEFAULT_TIMERANGE = DEFAULT_PERIOD; +var TIME_RANGE_OPTIONS = PERIOD_OPTIONS; +var COMPARISON_TIME_RANGE_OPTIONS = [ {description: _lt('Previous Period'), optionId: 'previous_period'}, {description: _lt('Previous Year'), optionId: 'previous_year'} ]; return { - PeriodOptions: PeriodOptions, - ComparisonOptions: ComparisonOptions, + 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, + PERIOD_OPTIONS: PERIOD_OPTIONS, + TIME_RANGE_OPTIONS: TIME_RANGE_OPTIONS, }; -}); +}); \ 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 new file mode 100644 index 00000000000..8b9ae4d44d6 --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/search/favorite_menu.js @@ -0,0 +1,103 @@ +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({ + /** + * @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, + }; + this.$menu = this.$('.o_dropdown_menu'); + 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); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _closeSubMenus: function () { + _.invoke(this.subMenus, 'closeMenu'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _onBootstrapClose: function () { + this._super.apply(this, arguments); + this._closeSubMenus(); + }, + /** + * @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 new file mode 100644 index 00000000000..b05f8dfe268 --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js @@ -0,0 +1,8 @@ +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 new file mode 100644 index 00000000000..70a242dd0f8 --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/search/filter_menu.js @@ -0,0 +1,219 @@ +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 time = require('web.time'); + +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 () { + this.$menu = this.$('.o_dropdown_menu'); + this.$menu.addClass('o_filters_menu'); + this._renderGeneratorMenu(); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add a proposition inside the custom filter edition menu. + * + * @private + * @returns {$.Deferred} + */ + _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); + 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), + }; + }); + // TO DO intercepts 'new_filters' and decide what to do whith filters + // rewrite web.search_filters? + 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(); + } + }, + /** + * @override + * @private + */ + _renderMenuItems: function () { + var self= this; + this._super.apply(this, arguments); + // the following code adds tooltip on date options in order + // to alert the user of the meaning of intervals + var $options = this.$('.o_item_option'); + $options.each(function () { + var $option = $(this); + $option.tooltip({ + delay: { show: 500, hide: 0 }, + title: function () { + var itemId = $option.attr('data-item_id'); + var optionId = $option.attr('data-option_id'); + var fieldName = _.findWhere(self.items, {id: itemId}).fieldName; + var domain = Domain.prototype.constructDomain(fieldName, optionId, 'date', true); + var evaluatedDomain = Domain.prototype.stringToArray(domain); + var dateFormat = time.getLangDateFormat(); + var dateStart = moment(evaluatedDomain[1][2], "YYYY-MM-DD", 'en').format(dateFormat); + var dateEnd = moment(evaluatedDomain[2][2], "YYYY-MM-DD", 'en').format(dateFormat); + if (optionId === 'today' || optionId === 'yesterday') { + return dateStart; + } + return _.str.sprintf(_t('From %s To %s'), dateStart, dateEnd); + } + }); + }); + }, + /** + * 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 new file mode 100644 index 00000000000..b67bf9ee75d --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/search/groupby_menu.js @@ -0,0 +1,214 @@ +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 (type IGroupBy below) + * interface IGroupBy { + * itemId: string; unique id associated with the groupby + * fieldName: string; field name without interval! + * description: string; label printed on screen + * groupId: string; + * isActive: boolean; determines if the groupby is considered active + * hasOptions: boolean; true when the groupBy has fieldType 'date' or 'datetime' (optional) + * options: array of objects with 'optionId' and 'description' keys; (optional) + * defaultOptionId: string refers to an optionId that should be activated by default + * currentOptionId: string refers to an optionId that is activated if item is active (optional) + * } + * @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; + 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 && _.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 ? '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 && 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 () { + this.$menu = this.$('.o_dropdown_menu'); + this.$menu.addClass('o_group_by_menu'); + this._renderGeneratorMenu(); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // 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.currentOptionId = false; + } + 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'); + }, + /** + * @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 new file mode 100644 index 00000000000..56d43ee6708 --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js @@ -0,0 +1,160 @@ +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 + */ + _addGroupByMenu: function ($node, groupableFields) { + this.sortedFieldNames = _.sortBy(Object.keys(groupableFields), function (fieldName) { + return groupableFields[fieldName].string; + }); + this.groupableFields = groupableFields; + var groupBys = this._getGroupBys(this.model.get().groupedBy); + this.groupByMenu = new GroupByMenu(this, groupBys, this.groupableFields); + this.groupByMenu.insertAfter($node.find('div:first')); + }, + /** + * This method puts the active groupBys in a convenient form. + * + * @private + * @param {string[]} activeGroupBys + * returns {string[]} groupBysNormalized + */ + _normalizeActiveGroupBys: function (activeGroupBys) { + var groupBysNormalized = activeGroupBys.map(function (groupBy) { + return {fieldName: groupBy.split(':')[0], interval: groupBy.split(':')[1] || false}; + }); + return groupBysNormalized; + }, + /** + * 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) { + var self = this; + var groupBys = []; + var groupBysNormalized = this._normalizeActiveGroupBys(activeGroupBys); + this.sortedFieldNames.forEach(function (fieldName) { + var field = self.groupableFields[fieldName]; + var groupByActivity = _.findWhere(groupBysNormalized, {fieldName: fieldName}); + var groupBy = { + id: fieldName, + isActive: groupByActivity ? true : false, + description: field.string, + }; + if (_.contains(['date', 'datetime'], field.type)) { + groupBy.hasOptions = true; + groupBy.options = controlPanelViewParameters.INTERVAL_OPTIONS; + groupBy.currentOptionId = groupByActivity && groupByActivity.interval ? + groupByActivity.interval : + false; + } + groupBys.push(groupBy); + }); + return groupBys; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onItemOptionClicked: function (ev) { + var fieldName = ev.data.id; + var optionId = ev.data.optionId; + var activeGroupBys = this.model.get().groupedBy; + var currentOptionId = activeGroupBys.reduce( + function (optionId, groupby) { + if (groupby.split(':')[0] === fieldName){ + optionId = groupby.split(':')[1] || controlPanelViewParameters.DEFAULT_INTERVAL; + } + return optionId; + }, + false + ); + var groupByFieldNames = _.map(activeGroupBys, function (groupby) { + return groupby.split(':')[0]; + }); + var indexOfGroupby = groupByFieldNames.indexOf(fieldName); + if (indexOfGroupby === -1) { + activeGroupBys.push(fieldName + ':' + optionId); + } else if (currentOptionId === optionId) { + activeGroupBys.splice(indexOfGroupby, 1); + } else { + activeGroupBys.splice(indexOfGroupby, 1, fieldName + ':' + optionId); + } + this._setGroupby(activeGroupBys); + this.groupByMenu.update(this._getGroupBys(activeGroupBys)); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onMenuItemClicked: function (ev) { + var fieldName = ev.data.id; + var activeGroupBys = this.model.get().groupedBy; + var groupByFieldNames = _.map(activeGroupBys, function (groupby) { + return groupby.split(':')[0]; + }); + var 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 new file mode 100644 index 00000000000..f9fa15fe67d --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/search/search_bar.js @@ -0,0 +1,225 @@ +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, { + '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.filters = params.filters; + this.filterFields = params.filterFields; + this.groupBys = params.groupBys; + + this.autoCompleteSources = []; + this.searchFacets = []; + }, + /** + * @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 $.when.apply($, 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 = _.invoke(this.autoCompleteSources, 'getAutocompletionValues', req.term); + $.when.apply($, defs).then(function () { + callback(_(arguments).chain() + .compact() + .flatten(true) + .value()); + }); + }, + /** + * @private + * @param {Object} facet + * @returns {Deferred} + */ + _renderFacet: function (facet) { + var searchFacet = new SearchFacet(this, facet); + this.searchFacets.push(searchFacet); + return searchFacet.insertBefore(this.$('input')); + }, + /** + * @private + * @returns {Deferred} + */ + _setupAutoCompletion: function () { + var self = this; + this._setupAutoCompletionWidgets(); + this.autoComplete = new AutoComplete(this, { + 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)); + } + }); + _.each(this.filters, function (filter) { + self.autoCompleteSources.push(new (registry.get('filter'))(self, filter)); + }); + _.each(this.groupBys, function (filter) { + self.autoCompleteSources.push(new (registry.get('groupby'))(self, filter)); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} e + * @param {Object} ui + * @param {Object} ui.item selected completion item + */ + _onAutoCompleteSelected: function (e, ui) { + e.preventDefault(); + var filter = ui.item.facet.filter; + if (filter.type === 'field') { + var values = filter.autoCompleteValues; + values.push(ui.item.facet.values[0]); + this.trigger_up('autocompletion_filter', { + filterId: filter.id, + autoCompleteValues: values, + }); + } else { + this.trigger_up('autocompletion_filter', { + filterId: filter.id, + }); + } + }, + /** + * @private + * @param {KeyEvent} e + */ + _onKeydown: function (e) { + switch(e.which) { + case $.ui.keyCode.LEFT: + this._focusPreceding(); + e.preventDefault(); + break; + case $.ui.keyCode.RIGHT: + this._focusFollowing(); + e.preventDefault(); + break; + case $.ui.keyCode.BACKSPACE: + if (this.$input.val() === '') { + this.trigger_up('facet_removed'); + } + 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 new file mode 100644 index 00000000000..f1ac0ee0f2e --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js @@ -0,0 +1,485 @@ +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 {Deferred} + */ + 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 $.when(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 $.when([{ + 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: '' + _.escape(this.attrs.string) + '', + value: '' + _.escape(value) + ''}); + }, + /** + * @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} 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 $.when(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 $.when(); } + 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 $.when(null); } + return $.when.call(null, [{ + 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 $.when(null); + } + + var m = moment(v, t === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD'); + if (!m.isValid()) { return $.when(null); } + var dateString = field_utils.format[t](m, {type: t}); + var label = this._getAutocompletionLabel(dateString); + var facet = this._getFacetValue(dateString, m.toDate()); + return $.when([{ + label: label, + facet: facet, + }]); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getAutocompletionLabel: function (value) { + return _.str.sprintf(_.str.escapeHTML( + _t("Search %(field)s at: %(value)s")), { + field: '' + _.escape(this.attrs.string) + '', + value: '' + value + ''}); + }, + /** + * @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 $.when(null); } + var label = this._getAutocompletionLabel(value); + return $.when([{ + 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 new file mode 100644 index 00000000000..fd5d1052b0b --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js @@ -0,0 +1,37 @@ +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 new file mode 100644 index 00000000000..ca9a7ad6225 --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/search/search_facet.js @@ -0,0 +1,129 @@ +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', + '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(); + }, + + //-------------------------------------------------------------------------- + // 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) { + var optionValue =_.findWhere(filter.options, { + optionId: filter.currentOptionId, + }); + description += ': ' + optionValue.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 + */ + _onFacetRemove: function () { + this.trigger_up('facet_removed', {group: this.facet}); + }, + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown: function (ev) { + 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/search/search_filters.js b/addons/web/static/src/js/views/control_panel/search/search_filters.js similarity index 92% rename from addons/web/static/src/js/views/search/search_filters.js rename to addons/web/static/src/js/views/control_panel/search/search_filters.js index 4455a7b9bdf..6ff84dce9c7 100644 --- a/addons/web/static/src/js/views/search/search_filters.js +++ b/addons/web/static/src/js/views/control_panel/search/search_filters.js @@ -4,12 +4,13 @@ odoo.define('web.search_filters', function (require) { 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(/** @lends instance.web.search.ExtendedSearchProposition# */{ +var ExtendedSearchProposition = Widget.extend({ template: 'SearchView.extended_search.proposition', events: { 'change .o_searchview_extended_prop_field': 'changed', @@ -25,10 +26,7 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext }, }, /** - * @constructs instance.web.search.ExtendedSearchProposition - * @extends instance.web.Widget - * - * @param parent + * @override * @param fields */ init: function (parent, fields) { @@ -46,8 +44,8 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext }, 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;})); + 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) { @@ -71,7 +69,7 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext } var type = field.type; - var Field = core.search_filters_registry.getAny([type, "char"]); + var Field = search_filters_registry.getAny([type, "char"]); this.value = new Field(this, field); _.each(this.value.operators, function (operator) { @@ -84,8 +82,9 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext }, get_filter: function () { - if (this.attrs.selected === null || this.attrs.selected === undefined) + 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]; @@ -172,6 +171,15 @@ var Char = Field.extend({ {value: "∃", text: _lt("is set")}, {value: "∄", text: _lt("is not set")} ], + get_domain: function (field, operator) { + switch (operator.value) { + case '∃': return [[field.name, '!=', false]]; + case '∄': return [[field.name, '=', false]]; + // does not work in all cases + // default: return "[('" + field.name + "', '" + operator.value + "', '" + this.get_value() + "')]"; + default: return [[field.name, operator.value, this.get_value()]]; + } + }, get_value: function () { return this.$el.val(); } @@ -358,23 +366,17 @@ var Boolean = Field.extend({ } }); -core.search_filters_registry - .add('char', Char) - .add('text', Char) - .add('one2many', Char) - .add('many2one', Char) - .add('many2many', Char) - .add('datetime', DateTime) - .add('date', Date) - .add('integer', Integer) - .add('float', Float) - .add('monetary', Float) - .add('boolean', Boolean) - .add('selection', Selection) - .add('id', Id); - return { - ExtendedSearchProposition: ExtendedSearchProposition + 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 new file mode 100644 index 00000000000..d6cbb9be2d0 --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/search/search_filters_registry.js @@ -0,0 +1,30 @@ +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 new file mode 100644 index 00000000000..8b6c6552c47 --- /dev/null +++ b/addons/web/static/src/js/views/control_panel/search/time_range_menu.js @@ -0,0 +1,106 @@ +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/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js index 9c354be6765..5feefce716f 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -132,7 +132,7 @@ var FormController = BasicController.extend({ * @param {jQueryElement} $node */ renderButtons: function ($node) { - var $footer = this.footerToButtons ? this.$('footer') : null; + var $footer = this.footerToButtons ? this.renderer.$('footer') : null; var mustRenderFooterButtons = $footer && $footer.length; if (!this.defaultButtons && !mustRenderFooterButtons) { return; @@ -219,7 +219,7 @@ var FormController = BasicController.extend({ var self = this; return this._super.apply(this, arguments).then(function (changedFields) { // the title could have been changed - self.set('title', self.getTitle()); + self._setTitle(self.getTitle()); self._updateEnv(); if (_t.database.multi_lang && changedFields.length) { @@ -250,6 +250,9 @@ var FormController = BasicController.extend({ * @override */ update: function (params, options) { + if ('currentId' in params && !params.currentId) { + this.mode = 'edit'; // if there is no record, we are in 'edit' mode + } params = _.extend({viewType: 'form', mode: this.mode}, params); return this._super(params, options); }, @@ -395,7 +398,7 @@ var FormController = BasicController.extend({ */ _update: function () { var title = this.getTitle(); - this.set('title', title); + this._setTitle(title); this._updateButtons(); this._updateSidebar(); return this._super.apply(this, arguments).then(this.autofocus.bind(this)); @@ -406,7 +409,7 @@ var FormController = BasicController.extend({ _updateButtons: function () { if (this.$buttons) { if (this.footerToButtons) { - var $footer = this.$('footer'); + var $footer = this.renderer.$('footer'); if ($footer.length) { this.$buttons.empty().append($footer); } diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js index 3dd92818276..52bbc044d1f 100644 --- a/addons/web/static/src/js/views/form/form_renderer.js +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -5,6 +5,7 @@ var BasicRenderer = require('web.BasicRenderer'); var config = require('web.config'); var core = require('web.core'); var dom = require('web.dom'); +var viewUtils = require('web.viewUtils'); var _t = core._t; var qweb = core.qweb; @@ -439,7 +440,7 @@ var FormRenderer = BasicRenderer.extend({ * @returns {jQueryElement} */ _renderHeaderButton: function (node) { - var $button = this._renderButtonFromNode(node); + var $button = viewUtils.renderButtonFromNode(node); // Current API of odoo for rendering buttons is "if classes are given // use those on top of the 'btn' and 'btn-{size}' classes, otherwise act @@ -639,7 +640,7 @@ var FormRenderer = BasicRenderer.extend({ * @returns {jQueryElement} */ _renderStatButton: function (node) { - var $button = this._renderButtonFromNode(node, { + var $button = viewUtils.renderButtonFromNode(node, { extraClass: 'oe_stat_button', }); $button.append(_.map(node.children, this._renderNode.bind(this))); @@ -685,7 +686,7 @@ var FormRenderer = BasicRenderer.extend({ * @returns {jQueryElement} */ _renderTagButton: function (node) { - var $button = this._renderButtonFromNode(node); + var $button = viewUtils.renderButtonFromNode(node); $button.append(_.map(node.children, this._renderNode.bind(this))); this._addOnClickAction($button, node); this._handleAttributes($button, node); diff --git a/addons/web/static/src/js/views/form/form_view.js b/addons/web/static/src/js/views/form/form_view.js index 01474a01180..d49f2b005fa 100644 --- a/addons/web/static/src/js/views/form/form_view.js +++ b/addons/web/static/src/js/views/form/form_view.js @@ -18,7 +18,8 @@ var FormView = BasicView.extend({ display_name: _lt('Form'), icon: 'fa-edit', multi_record: false, - searchable: false, + withSearchBar: false, + searchMenuTypes: [], jsLibs: [], viewType: 'form', /** @@ -27,17 +28,14 @@ var FormView = BasicView.extend({ init: function (viewInfo, params) { this._super.apply(this, arguments); - var mode = params.mode || params.context.form_view_initial_mode || - (params.currentId ? 'readonly' : 'edit'); + var mode = params.mode || (params.currentId ? 'readonly' : 'edit'); this.loadParams.type = 'record'; this.controllerParams.disableAutofocus = params.disable_autofocus; this.controllerParams.hasSidebar = params.hasSidebar; this.controllerParams.toolbarActions = viewInfo.toolbar; this.controllerParams.footerToButtons = params.footerToButtons; - if ('action' in params && 'flags' in params.action) { - this.controllerParams.footerToButtons = params.action.flags.footerToButtons; - } + var defaultButtons = 'default_buttons' in params ? params.default_buttons : true; this.controllerParams.defaultButtons = defaultButtons; this.controllerParams.mode = mode; @@ -63,6 +61,26 @@ var FormView = BasicView.extend({ // Private //-------------------------------------------------------------------------- + /** + * @override + * @param {string} [action.target] + */ + _extractParamsFromAction: function (action) { + var params = this._super.apply(this, arguments); + var inDialog = action.target === 'new'; + var inline = action.target === 'inline'; + var fullscreen = action.target === 'fullscreen'; + params.withControlPanel = !(inDialog || inline); + params.footerToButtons = inDialog; + params.hasSearchView = inDialog ? false : params.hasSearchView; + params.searchMenuTypes = inDialog ? [] : params.searchMenuTypes; + if (inDialog || inline || fullscreen) { + params.mode = 'edit'; + } else if (action.context && action.context.form_view_initial_mode) { + params.mode = action.context.form_view_initial_mode; + } + return params; + }, /** * Loads the subviews for x2many fields when they are not inline * diff --git a/addons/web/static/src/js/views/graph/graph_controller.js b/addons/web/static/src/js/views/graph/graph_controller.js index 27bd59e476d..15f7fbeaa35 100644 --- a/addons/web/static/src/js/views/graph/graph_controller.js +++ b/addons/web/static/src/js/views/graph/graph_controller.js @@ -6,13 +6,11 @@ odoo.define('web.GraphController', function (require) { var core = require('web.core'); var AbstractController = require('web.AbstractController'); -var GroupByMenuInterfaceMixin = require('web.GroupByMenuInterfaceMixin'); +var GroupByMenuMixin = require('web.GroupByMenuMixin'); var qweb = core.qweb; -var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{ - className: 'o_graph', - +var GraphController = AbstractController.extend(GroupByMenuMixin,{ /** * @override * @param {Widget} parent @@ -24,19 +22,24 @@ var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{ * @param {string[]} params.groupableFields, */ init: function (parent, model, renderer, params) { - GroupByMenuInterfaceMixin.init.call(this); + GroupByMenuMixin.init.call(this); this._super.apply(this, arguments); this.measures = params.measures; // this parameter condition the appearance of a 'Group By' // button in the control panel owned by the graph view. this.isEmbedded = params.isEmbedded; + // this parameter determines what is the list of fields // that may be used within the groupby menu available when // the view is embedded this.groupableFields = params.groupableFields; - // extends custom_events key. This allow the controller - // to listen to information comming from the groupby menu. - // this is used when the view is embedded. + }, + /** + * @override + */ + start: function () { + this.$el.addClass('o_graph_controller'); + return this._super.apply(this, arguments); }, /** * @todo check if this can be removed (mostly duplicate with @@ -62,18 +65,20 @@ var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{ * @override * @returns {Object} */ - getContext: function () { + getOwnedQueryParams: function () { var state = this.model.get(); return { - graph_measure: state.measure, - graph_mode: state.mode, - graph_groupbys: state.groupedBy, - // this parameter is not used anywher for now - // the idea would be to seperate intervals from - // fieldnames in groupbys. This could be done - // in graph view only or everywhere but this is - // a big refactoring. - graph_intervalMapping: state.intervalMapping, + context: { + graph_measure: state.measure, + graph_mode: state.mode, + graph_groupbys: state.groupedBy, + // this parameter is not used anywher for now + // the idea would be to seperate intervals from + // fieldnames in groupbys. This could be done + // in graph view only or everywhere but this is + // a big refactoring. + graph_intervalMapping: state.intervalMapping, + } }; }, /** @@ -110,10 +115,10 @@ var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{ * override * * @private - * @param {string[]} groupbys + * @param {string[]} groupBys */ - _setGroupby: function (groupbys) { - this.update({groupBy: groupbys}); + _setGroupby: function (groupBys) { + this.update({groupBy: groupBys}); }, /** diff --git a/addons/web/static/src/js/views/graph/graph_renderer.js b/addons/web/static/src/js/views/graph/graph_renderer.js index e68a4446ba5..fe4efab1b20 100644 --- a/addons/web/static/src/js/views/graph/graph_renderer.js +++ b/addons/web/static/src/js/views/graph/graph_renderer.js @@ -23,7 +23,7 @@ var CHART_TYPES = ['pie', 'bar', 'line']; var MAX_LEGEND_LENGTH = 25 * (Math.max(1, config.device.size_class)); return AbstractRenderer.extend({ - className: "o_graph_container", + className: "o_graph_renderer", /** * @override * @param {Widget} parent diff --git a/addons/web/static/src/js/views/graph/graph_view.js b/addons/web/static/src/js/views/graph/graph_view.js index a55da16c6da..4567daf0ead 100644 --- a/addons/web/static/src/js/views/graph/graph_view.js +++ b/addons/web/static/src/js/views/graph/graph_view.js @@ -36,7 +36,8 @@ var GraphView = AbstractView.extend({ Renderer: GraphRenderer, }, viewType: 'graph', - enableTimeRangeMenu: 'true', + searchMenuTypes: ['filter', 'groupBy', 'timeRange', 'favorite'], + /** * @override */ diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js index 25fb88c7bd4..2383a0ac730 100644 --- a/addons/web/static/src/js/views/kanban/kanban_controller.js +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -238,7 +238,7 @@ var KanbanController = BasicController.extend({ self.renderer.updateColumn(db_id, data); }); }); - }).fail(this.reload.bind(this)); + }).fail(this.reload.bind(this, {})); }, /** * @private @@ -279,7 +279,7 @@ var KanbanController = BasicController.extend({ } else if (this.on_create && this.on_create !== 'quick_create') { // Execute the given action this.do_action(this.on_create, { - on_close: this.reload.bind(this), + on_close: this.reload.bind(this, {}), additional_context: state.context, }); } else { diff --git a/addons/web/static/src/js/views/kanban/kanban_view.js b/addons/web/static/src/js/views/kanban/kanban_view.js index 523f0ef1c57..9f589ffe2a7 100644 --- a/addons/web/static/src/js/views/kanban/kanban_view.js +++ b/addons/web/static/src/js/views/kanban/kanban_view.js @@ -30,13 +30,11 @@ var KanbanView = BasicView.extend({ */ init: function (viewInfo, params) { this._super.apply(this, arguments); - this.loadParams.limit = this.loadParams.limit || 40; // in mobile, columns are lazy-loaded, so set 'openGroupByDefault' to // false so that they will won't be loaded by the initial load this.loadParams.openGroupByDefault = config.device.isMobile ? false : true; this.loadParams.type = 'list'; - this.loadParams.groupBy = this.arch.attrs.default_group_by ? [this.arch.attrs.default_group_by] : (params.groupBy || []); var progressBar; utils.traverse(this.arch, function (n) { var isProgressBar = (n.tag === 'progressbar'); @@ -106,6 +104,11 @@ var KanbanView = BasicView.extend({ } return true; }, + _updateMVCParams: function () { + this._super.apply(this, arguments); + var defaultGroupBy = this.arch.attrs.default_group_by; + this.loadParams.groupBy = defaultGroupBy ? [defaultGroupBy] : (this.loadParams.groupedBy || []); + }, }); return KanbanView; diff --git a/addons/web/static/src/js/views/kanban/quick_create_form_view.js b/addons/web/static/src/js/views/kanban/quick_create_form_view.js index 71bd0073f84..af769d62095 100644 --- a/addons/web/static/src/js/views/kanban/quick_create_form_view.js +++ b/addons/web/static/src/js/views/kanban/quick_create_form_view.js @@ -56,10 +56,6 @@ var QuickCreateFormModel = BasicModel.extend({ }); var QuickCreateFormController = FormController.extend({ - custom_events: _.extend({}, FormController.prototype.custom_events, { - env_updated: '_onEnvUpdated', - }), - //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- @@ -83,23 +79,10 @@ var QuickCreateFormController = FormController.extend({ getChanges: function () { return this.model.getChanges(this.handle); }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Stops the propagation of the 'env_updated' event to prevent interferences - * with the kanban controller. - * - * @private - */ - _onEnvUpdated: function (ev) { - ev.stopPropagation(); - }, }); var QuickCreateFormView = FormView.extend({ + withControlPanel: false, config: _.extend({}, FormView.prototype.config, { Model: QuickCreateFormModel, Renderer: QuickCreateFormRenderer, diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js index ab3adbf5922..fd5429d7c3e 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -87,10 +87,10 @@ var ListController = BasicController.extend({ /* * @override */ - getContext: function () { - var context = this._super.apply(this, arguments); - context.orderedBy = this.model.get(this.handle, {raw: true}).orderedBy || []; - return context; + getOwnedQueryParams: function () { + var state = this._super.apply(this, arguments); + var orderedBy = this.model.get(this.handle, {raw: true}).orderedBy || []; + return _.extend({}, state, {orderedBy: orderedBy}); }, /** * Returns the list of currently selected res_ids (with the check boxes on diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js index 87542170dd4..ee081aa3c5b 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -8,6 +8,7 @@ var dom = require('web.dom'); var field_utils = require('web.field_utils'); var Pager = require('web.Pager'); var utils = require('web.utils'); +var viewUtils = require('web.viewUtils'); var _t = core._t; @@ -329,7 +330,7 @@ var ListRenderer = BasicRenderer.extend({ */ _renderButton: function (record, node) { var self = this; - var $button = this._renderButtonFromNode(node, { + var $button = viewUtils.renderButtonFromNode(node, { extraClass: node.attrs.icon ? 'o_icon_button' : undefined, textAsTitle: !!node.attrs.icon, }); diff --git a/addons/web/static/src/js/views/list/list_view.js b/addons/web/static/src/js/views/list/list_view.js index bc80017743b..291cb8de8a0 100644 --- a/addons/web/static/src/js/views/list/list_view.js +++ b/addons/web/static/src/js/views/list/list_view.js @@ -42,7 +42,6 @@ var ListView = BasicView.extend({ this.controllerParams.editable = this.arch.attrs.editable; this.controllerParams.hasSidebar = params.hasSidebar; this.controllerParams.toolbarActions = viewInfo.toolbar; - this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf; this.controllerParams.mode = mode; this.controllerParams.selectedRecords = selectedRecords; @@ -55,6 +54,15 @@ var ListView = BasicView.extend({ this.loadParams.limit = this.loadParams.limit || 80; this.loadParams.type = 'list'; }, + + //-------------------------------------------------------------------------- + // Private + //--- + + _updateMVCParams: function () { + this._super.apply(this, arguments); + this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf; + }, }); return ListView; diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js index 9dfe5dc11b5..a4833b40d81 100644 --- a/addons/web/static/src/js/views/pivot/pivot_controller.js +++ b/addons/web/static/src/js/views/pivot/pivot_controller.js @@ -22,7 +22,7 @@ var _t = core._t; var QWeb = core.qweb; var PivotController = AbstractController.extend({ - template: 'PivotView', + contentTemplate: 'PivotView', events: { 'click .o_pivot_header_cell_opened': '_onOpenHeaderClick', 'click .o_pivot_header_cell_closed': '_onClosedHeaderClick', @@ -83,12 +83,14 @@ var PivotController = AbstractController.extend({ * @override method from AbstractController * @returns {Object} */ - getContext: function () { + getOwnedQueryParams: function () { var state = this.model.get(); return { - pivot_measures: state.measures, - pivot_column_groupby: state.colGroupBys, - pivot_row_groupby: state.rowGroupBys, + context: { + pivot_measures: state.measures, + pivot_column_groupby: state.colGroupBys, + pivot_row_groupby: state.rowGroupBys, + } }; }, /** @@ -171,6 +173,13 @@ var PivotController = AbstractController.extend({ .css(cssProps) .addClass('show'); }, + /** + * @override + * @private + */ + _startRenderer: function () { + return this.renderer.appendTo(this.$('.o_pivot')); + }, /** * @private */ diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js index d85076f8271..38d8d80a9d7 100644 --- a/addons/web/static/src/js/views/pivot/pivot_view.js +++ b/addons/web/static/src/js/views/pivot/pivot_view.js @@ -28,7 +28,8 @@ var PivotView = AbstractView.extend({ Renderer: PivotRenderer, }, viewType: 'pivot', - enableTimeRangeMenu: 'true', + searchMenuTypes: ['filter', 'groupBy', 'timeRange', 'favorite'], + /** * @override * @param {Object} params @@ -117,10 +118,9 @@ var PivotView = AbstractView.extend({ this.controllerParams.groupableFields = groupableFields; // retrieve form and list view ids from the action to open those views // when a data cell of the pivot view is clicked - this.controllerParams.views = [ - _findView(params.action && params.action.views, 'list'), - _findView(params.action && params.action.views, 'form'), + _findView(params.actionViews, 'list'), + _findView(params.actionViews, 'form'), ]; function _findView(views, viewType) { var view = _.find(views, function (view) { diff --git a/addons/web/static/src/js/views/search/favorites_menu.js b/addons/web/static/src/js/views/search/favorites_menu.js deleted file mode 100644 index 46949e901a3..00000000000 --- a/addons/web/static/src/js/views/search/favorites_menu.js +++ /dev/null @@ -1,335 +0,0 @@ -odoo.define('web.FavoriteMenu', function (require) { -"use strict"; - -var config = require('web.config'); -var core = require('web.core'); -var data_manager = require('web.data_manager'); -var Domain = require('web.Domain'); -var pyUtils = require('web.py_utils'); -var session = require('web.session'); -var Widget = require('web.Widget'); - -var _t = core._t; - -return Widget.extend({ - template: 'SearchView.FavoriteMenu', - events: { - 'click .dropdown-item': function (ev) { - ev.preventDefault(); - }, - 'click .o_save_search': function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - this.toggle_save_menu(); - }, - 'click .o_save_name button': 'save_favorite', - 'hidden.bs.dropdown': '_closeMenus', - 'keyup .o_save_name input': function (ev) { - if (ev.which === $.ui.keyCode.ENTER) { - this.save_favorite(); - } - }, - }, - init: function (parent, query, target_model, action, filters) { - this._super.apply(this,arguments); - this.searchview = parent; - this.query = query; - this.target_model = target_model; - this.action = action; - this.action_id = action.id; - this.filters = {}; - this.isMobile = config.device.isMobile; - _.each(filters, this.add_filter.bind(this)); - }, - start: function () { - var self = this; - this.$filters = {}; - this.$save_search = this.$('.o_save_search'); - this.$save_name = this.$('.o_save_name'); - this.$inputs = this.$save_name.find('input'); - this.$user_divider = this.$('.dropdown-divider.user_filter'); - this.$shared_divider = this.$('.dropdown-divider.shared_filter'); - this.$inputs.eq(0).val(this.searchview.get_title()); - var $shared_filter = this.$inputs.eq(1), - $default_filter = this.$inputs.eq(2); - $shared_filter.click(function () {$default_filter.prop('checked', false);}); - $default_filter.click(function () {$shared_filter.prop('checked', false);}); - - this.query - .on('remove', function (facet) { - if (facet.get('is_custom_filter')) { - self.clear_selection(); - } - }) - .on('reset', this.proxy('clear_selection')); - - _.each(this.filters, this.append_filter.bind(this)); - - return this._super(); - }, - toggle_save_menu: function (is_open) { - this.$save_search - .toggleClass('o_closed_menu', !(_.isUndefined(is_open)) ? !is_open : undefined) - .toggleClass('o_open_menu', is_open); - this.$save_name.toggle(is_open); - if (this.$save_search.hasClass('o_open_menu')) { - this.$save_name.find('input').first().focus(); - } - }, - _closeMenus: function () { - this.toggle_save_menu(false); - }, - save_favorite: function () { - var self = this, - filter_name = this.$inputs[0].value, - default_filter = this.$inputs[1].checked, - shared_filter = this.$inputs[2].checked; - if (!filter_name.length){ - this.do_warn(_t("Error"), _t("Filter name is required.")); - this.$inputs.first().focus(); - return; - } - if (_.chain(this.filters) - .pluck('name') - .contains(filter_name).value()) { - this.do_warn(_t("Error"), _t("Filter with same name already exists.")); - this.$inputs.first().focus(); - return; - } - var user_context = this.getSession().user_context; - // get search params with domains not evaluated - var search = this.searchview.build_search_data(true); - var domain = pyUtils.assembleDomains(search.domains, 'AND'); - var controllerContext; - this.trigger_up('get_controller_context', { - callback: function (ctx) { - if (ctx && ctx.orderedBy) { - var sort = _.map(ctx.orderedBy, function (obj) { - var order = obj.asc ? 'ASC' : 'DESC'; - return obj.name + ' ' + order; - }); - self.searchview.dataset.set_sort(sort); - } - controllerContext = _.omit(ctx, ['orderedBy']); - }, - }); - - var results = pyUtils.eval_domains_and_contexts({ - domains: [], - contexts: [user_context].concat(search.contexts.concat(controllerContext || [])), - group_by_seq: search.groupbys || [], - }); - if (!_.isEmpty(results.group_by)) { - results.context.group_by = results.group_by; - } - // Don't save user_context keys in the custom filter, otherwise end - // up with e.g. wrong uid or lang stored *and used in subsequent - // reqs* - var ctx = results.context; - _(_.keys(session.user_context)).each(function (key) { - delete ctx[key]; - }); - var filter = { - name: filter_name, - user_id: shared_filter ? false : session.uid, - model_id: this.target_model, - context: results.context, - domain: domain, - sort: JSON.stringify(this.searchview.dataset._sort), - is_default: default_filter, - action_id: this.action_id, - }; - return this._createFilter(filter).then(function (id) { - filter.id = id; - self.toggle_save_menu(false); - self.$save_name.find('input').val('').prop('checked', false); - self.add_filter(filter); - self.append_filter(filter); - self.toggle_filter(filter, true); - }); - }, - get_default_filter: function () { - var personal_filter = _.find(this.filters, function (filter) { - return filter.user_id && filter.is_default; - }); - if (personal_filter) { - return personal_filter; - } - return _.find(this.filters, function (filter) { - return !filter.user_id && filter.is_default; - }); - }, - /** - * Generates a mapping key (in the filters and $filter mappings) for the - * filter descriptor object provided (as returned by ``get_filters``). - * - * The mapping key is guaranteed to be unique for a given (user_id, name) - * pair. - * - * @param {Object} filter - * @param {String} filter.name - * @param {Number|Pair} [filter.user_id] - * @return {String} mapping key corresponding to the filter - */ - key_for: function (filter) { - var user_id = filter.user_id, - action_id = filter.action_id, - uid = (user_id instanceof Array) ? user_id[0] : user_id, - act_id = (action_id instanceof Array) ? action_id[0] : action_id; - return _.str.sprintf('(%s)(%s)%s', uid, act_id, filter.name); - }, - /** - * Generates a :js:class:`~instance.web.search.Facet` descriptor from a - * filter descriptor - * - * @param {Object} filter - * @param {String} filter.name - * @param {Object} [filter.context] - * @param {Array} [filter.domain] - * @return {Object} - */ - facet_for: function (filter) { - var self = this; - return { - category: _t("Custom Filter"), - icon: 'fa-star', - field: { - get_context: function () { - var sortParsed = JSON.parse(filter.sort || "[]"); - var orderedBy = []; - _.each(sortParsed, function (sort) { - orderedBy.push({ - name: sort[0] === '-' ? sort.slice(1) : sort, - asc: sort[0] === '-' ? false : true, - }); - }); - - return _.defaults({}, filter.context, {orderedBy : orderedBy}); - }, - get_groupby: function () { return [filter.context]; }, - // facet is not used - get_domain: function (facet, noEvaluation) { - noEvaluation = noEvaluation || false; - var userContext = self.getSession().user_context; - var domain = pyUtils.assembleDomains([filter.domain]); - if (!noEvaluation) { - return Domain.prototype.stringToArray(domain, userContext); - } - return domain; - } - }, - _id: filter.id, - is_custom_filter: true, - values: [{label: filter.name, value: null}] - }; - }, - clear_selection: function () { - this.$('.selected').removeClass('selected'); - }, - /** - * Adds a filter description to the filters dict - * @param {Object} [filter] the filter description - */ - add_filter: function (filter) { - this.filters[this.key_for(filter)] = filter; - }, - /** - * Creates a $filter JQuery node, adds it to the $filters dict and appends it to the filter menu - * @param {Object} [filter] the filter description - */ - append_filter: function (filter) { - var self = this; - var key = this.key_for(filter); - - if (filter.user_id) { - this.$user_divider.show(); - } else { - this.$shared_divider.show(); - } - if (!(key in this.$filters)) { - var $filter = $('
    ', {class: 'position-relative'}) - .addClass(filter.user_id ? 'o-searchview-custom-private' - : 'o-searchview-custom-public') - .append($('', {href: '#', class: 'dropdown-item'}).text(filter.name)) - .append($('', { - class: 'fa fa-trash-o o-remove-filter', - on: { - click: function (event) { - event.stopImmediatePropagation(); - self.remove_filter(filter, $filter, key); - }, - }, - })) - .insertBefore(filter.user_id ? this.$user_divider : this.$shared_divider); - this.$filters[key] = $filter; - } - this.$filters[key].unbind('click').click(function () { - self.toggle_filter(filter); - }); - }, - toggle_filter: function (filter, preventSearch) { - var current = this.query.find(function (facet) { - return facet.get('_id') === filter.id; - }); - if (current) { - this.query.remove(current); - this.$filters[this.key_for(filter)].find('.dropdown-item').removeClass('selected'); - return; - } - this.query.reset([this.facet_for(filter)], { - preventSearch: preventSearch || false}); - - // Load sort settings on view - if (!_.isUndefined(filter.sort)){ - var sort_items = JSON.parse(filter.sort); - this.searchview.dataset.set_sort(sort_items); - } - - this.$filters[this.key_for(filter)].find('.dropdown-item').addClass('selected'); - }, - remove_filter: function (filter, $filter, key) { - var self = this; - var global_warning = _t("This filter is global and will be removed for everybody if you continue."), - warning = _t("Are you sure that you want to remove this filter?"); - if (!confirm(filter.user_id ? warning : global_warning)) { - return; - } - return data_manager - .delete_filter(filter) - .done(function () { - $filter.remove(); - delete self.$filters[key]; - delete self.filters[key]; - var has_user_filter = _.find(self.filters, function(filter) { return filter.user_id; }); - var has_shared_filer = _.find(self.filters, function(filter) { return !filter.user_id; }); - if (!has_user_filter) { - self.$user_divider.hide(); - } - if (!has_shared_filer) { - self.$shared_divider.hide(); - } - }); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Creates a new search view filter. - * - * @private - * @param {Object} filter the filter description - * @returns {$.Deferred} resolved with the RPC's result when it succeeds - */ - _createFilter: function (filter) { - var def = $.Deferred(); - this.trigger_up('create_filter', { - filter: filter, - on_success: def.resolve.bind(def), - }); - return def; - }, -}); - -}); diff --git a/addons/web/static/src/js/views/search/filters_menu.js b/addons/web/static/src/js/views/search/filters_menu.js deleted file mode 100644 index f61bbff8045..00000000000 --- a/addons/web/static/src/js/views/search/filters_menu.js +++ /dev/null @@ -1,253 +0,0 @@ -odoo.define('web.FiltersMenu', 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 time = require('web.time'); -var TimeRangeMenuOptions = require('web.TimeRangeMenuOptions'); - -var QWeb = core.qweb; -var _t = core._t; - -var PERIOD_OPTIONS = TimeRangeMenuOptions.PeriodOptions; - -var DEFAULT_PERIOD = 'this_month'; - -var FiltersMenu = DropdownMenu.extend({ - custom_events: { - remove_proposition: '_onRemoveProposition', - confirm_proposition: '_onConfirmProposition', - }, - events: _.extend({}, DropdownMenu.prototype.events, { - 'click .o_add_custom_filter': '_onAddCustomFilterClick', - 'click .o_add_condition': '_appendProposition', - 'click .o_apply_filter': '_onApplyClick', - ':hover .o_item_option': '_onOptionHover', - }), - - /** - * @override - * @param {Widget} parent - * @param {Object[]} filters list of filters (type IFilter below) - * interface IFilter { - * itemId: string; unique id associated with the filter - * domain: string; - * isPeriod: boolean - * fieldName: string; fieldName used to generate a domain in case isDate is true (and domain empty) - * description: string; label printed on screen - * groupId: string; - * isActive: boolean; (optional) determines if the filter is considered active - * isOpen: boolean; (optional) in case there are options the submenu presenting the options - * is opened or closed according to isOpen - * isRemovable: boolean; (optional) can be removed from menu - * options: array of objects with 'optionId' and 'description' keys; (optional) - * defaultOptionId: string refers to an optionId (optional) - * currentOptionId: string refers to an optionId that is activated if item is active (optional) - * } - * @param {Object} fields - */ - init: function (parent, filters, fields) { - // determines where the filter menu is displayed and its style - this.isMobile = config.device.isMobile; - // determines list of options used by filter of type 'date' - this.periodOptions = PERIOD_OPTIONS; - this.defaultOptionId = DEFAULT_PERIOD; - // 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}; - var dropdownHeader = { - category: 'filterCategory', - title: _t('Filters'), - icon: 'fa fa-filter', - symbol: this.isMobile ? 'fa fa-chevron-right float-right mt4' : false, - }; - this._super(parent, dropdownHeader, filters, this.fields); - }, - - /** - * render the template used to add a new custom filter and append it - * to the basic dropdown menu - * - * @private - */ - start: function () { - this.$menu = this.$('.o_dropdown_menu'); - this.$menu.addClass('o_filters_menu'); - var generatorMenu = QWeb.render('FiltersMenuGenerator', {widget: this}); - this.$menu.append(generatorMenu); - this.$addCustomFilter = this.$menu.find('.o_add_custom_filter'); - this.$addFilterMenu = this.$menu.find('.o_add_filter_menu'); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Add a proposition inside the custom filter edition menu - * - * @private - * @returns {$.Deferred} - */ - _appendProposition: function () { - 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 selection, creates it and add it to the menu - * - * @private - */ - _commitSearch: function () { - var self = this; - var filters = _.invoke(this.propositions, 'get_filter'); - _.each(filters, function (filter) { - filter.attrs.domain = Domain.prototype.arrayToString(filter.attrs.domain); - }); - var groupId = _.uniqueId('__group__'); - var data = []; - filters.forEach(function (filter) { - var filterName = _.uniqueId('__filter__'); - var filterItem = { - itemId: filterName, - description: filter.attrs.string, - groupId: groupId, - isActive: true, - }; - self._prepareItem(filterItem); - data.push({ - itemId: filterName, - groupId: groupId, - filter: filter, - }); - self.items.push(filterItem); - }); - this._renderMenuItems(); - this.trigger_up('new_filters', data); - _.invoke(this.propositions, 'destroy'); - this.propositions = []; - this._toggleCustomFilterMenu(); - }, - /** - * override - * - * @private - * @param {Object} item - */ - _prepareItem: function (item) { - if (item.isPeriod) { - item.options = this.periodOptions; - } - this._super.apply(this, arguments); - }, - /** - * override - * - * @private - */ - _renderMenuItems: function () { - var self= this; - this._super.apply(this, arguments); - // the following code adds tooltip on date options in order - // to alert the user of the meaning of intervals - var $options = this.$('.o_filters_menu .o_item_option'); - $options.each(function () { - var $option = $(this); - $option.tooltip({ - delay: { show: 500, hide: 0 }, - title: function () { - var itemId = $option.attr('data-item_id'); - var optionId = $option.attr('data-option_id'); - var fieldName = _.findWhere(self.items, {itemId: itemId}).fieldName; - var domain = Domain.prototype.constructDomain(fieldName, optionId, 'date', true); - var evaluatedDomain = Domain.prototype.stringToArray(domain); - var dateFormat = time.getLangDateFormat(); - var dateStart = moment(evaluatedDomain[1][2], "YYYY-MM-DD", 'en').format(dateFormat); - var dateEnd = moment(evaluatedDomain[2][2], "YYYY-MM-DD", 'en').format(dateFormat); - if (optionId === 'today' || optionId === 'yesterday') { - return dateStart; - } - return _.str.sprintf(_t('From %s To %s'), dateStart, dateEnd); - } - }); - }); - }, - /** - * Hide and display the sub menu which allows adding custom filters - * - * @private - */ - _toggleCustomFilterMenu: function () { - var self = this; - this.generatorMenuIsOpen = !this.generatorMenuIsOpen; - var def; - if (this.generatorMenuIsOpen && !this.propositions.length) { - def = this._appendProposition(); - } - if (!this.generatorMenuIsOpen) { - _.invoke(this.propositions, 'destroy'); - this.propositions = []; - } - $.when(def).then(function () { - self.$addCustomFilter - .toggleClass('o_closed_menu', !self.generatorMenuIsOpen) - .toggleClass('o_open_menu', self.generatorMenuIsOpen); - self.$('.o_add_filter_menu').toggle(); - }); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {MouseEvent} event - */ - _onAddCustomFilterClick: function (event) { - event.preventDefault(); - event.stopPropagation(); - this._toggleCustomFilterMenu(); - }, - /** - * @private - * @param {MouseEvent} event - */ - _onApplyClick: function (event) { - event.stopPropagation(); - this._commitSearch(); - }, - /** - * @private - * @param {OdooEvent} event - */ - _onConfirmProposition: function (event) { - event.stopPropagation(); - this._commitSearch(); - }, - /** - * @private - * @param {OdooEvent} event - */ - _onRemoveProposition: function (event) { - event.stopPropagation(); - this.propositions = _.without(this.propositions, event.target); - if (!this.propositions.length) { - this.$('.o_apply_filter').prop('disabled', true); - } - event.target.destroy(); - }, -}); - -return FiltersMenu; - -}); diff --git a/addons/web/static/src/js/views/search/groupby_menu.js b/addons/web/static/src/js/views/search/groupby_menu.js deleted file mode 100644 index 207455c1a47..00000000000 --- a/addons/web/static/src/js/views/search/groupby_menu.js +++ /dev/null @@ -1,232 +0,0 @@ -odoo.define('web.GroupByMenu', function (require) { -"use strict"; - -var config = require('web.config'); -var core = require('web.core'); -var DropdownMenu = require('web.DropdownMenu'); - -var QWeb = core.qweb; -var _t = core._t; - -var GROUPABLE_TYPES = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime']; - -var DEFAULT_INTERVAL = 'month'; - -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 (type IGroupBy below) - * interface IGroupBy { - * itemId: string; unique id associated with the groupby - * fieldName: string; field name without interval! - * description: string; label printed on screen - * groupId: string; - * isDate: boolean; - * isActive: boolean; (optional) determines if the groupby is considered active - * isOpen: boolean; (optional) in case there are options the submenu presenting the options - * is opened or closed according to isOpen - * isRemovable: boolean; (optional) can be removed from menu - * options: array of objects with 'optionId' and 'description' keys; (optional) - * currentOptionId: string refers to an optionId that is activated if item is active (optional) - * } - * @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; - 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.intervalOptions = [ - {description: 'Day', optionId: 'day', groupId: 1}, - {description: 'Week', optionId: 'week', groupId: 1}, - {description: 'Month', optionId: 'month', groupId: 1}, - {description: 'Quarter', optionId: 'quarter', groupId: 1}, - {description: 'Year', optionId: 'year', groupId: 1}, - ]; - this.defaultOptionId = DEFAULT_INTERVAL; - this.groupableFields = []; - _.each(fields, function (field, name) { - if (field.sortable && _.contains(GROUPABLE_TYPES, field.type)) { - self.groupableFields.push(_.extend({}, field, { - name: name, - isDate: _.contains(['date', 'datetime'], field.type), - })); - } - }); - this.groupableFields = _.sortBy(this.groupableFields, 'string'); - _.each(groupbys, this._prepareItem.bind(this)); - // determines the list of field names that can be added to the menu - // via the 'Add Custom Groupby' menu - this.presentedFields = _.filter(this.groupableFields, function (field) { - var groupByFields = _.pluck(groupbys, 'fieldName'); - return !_.contains(groupByFields, field.name); - }); - // determines where the filter menu is displayed and partly its style - this.isMobile = config.device.isMobile; - // the default style of the groupby menu can be changed here using the options key 'headerStyle' - var style; - if (options && options.headerStyle === 'primary') { - style = { - el: {class: 'btn-group o_graph_groupbys_menu o_dropdown', attrs: {'role': 'group'}}, - mainButton: {class: 'btn btn-primary dropdown-toggle'}, - }; - } - var dropdownHeader = { - category: 'groupByCategory', - title: _t('Group By'), - icon: 'fa fa-bars', - symbol: this.isMobile ? 'fa fa-chevron-right float-right mt4' : false, - style: style, - }; - this._super(parent, dropdownHeader, groupbys, options); - }, - - /** - * render the template used to add a new custom groupby and append it - * to the basic dropdown menu - * - * @private - */ - start: function () { - this.$menu = this.$('.o_dropdown_menu'); - this.$menu.addClass('o_group_by_menu'); - var $generatorMenu = QWeb.render('GroupbyMenuGenerator', {widget: this}); - this.$menu.append($generatorMenu); - this.$addCustomGroup = this.$menu.find('.o_add_custom_group'); - }, - - //-------------------------------------------------------------------------- - // 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 = _.findWhere(this.groupableFields, {name: fieldName}); - var groupbyName = _.uniqueId('__groupby__'); - var groupby = { - itemId: groupbyName, - description: field.string, - fieldName: fieldName, - groupId: _.uniqueId('__group__'), - isActive: true, - }; - var eventData = _.clone(groupby); - this._prepareItem(groupby); - if (groupby.hasOptions) { - groupby.defaultOptionId = DEFAULT_INTERVAL; - groupby.currentOptionId = DEFAULT_INTERVAL; - groupby.isDate = true; - eventData.optionId = groupby.currentOptionId; - eventData.isDate = true; - } - this.items.push(groupby); - var fieldIndex = this.presentedFields.indexOf(field); - this.presentedFields.splice(fieldIndex, 1); - this._renderGeneratorMenu(); - this._renderMenuItems(); - this.trigger_up('new_groupby', eventData); - }, - /** - * override - * - * @private - * @param {Object} item - */ - _prepareItem: function (item) { - if (_.contains(['date', 'datetime'], this.fields[item.fieldName].type)) { - item.options = this.intervalOptions; - } - // super has to be called here because we need to add options to groupby - // before to call it since some keys in a groupby are initialized using - // the keys 'options' and 'defaultOptionId' - this._super.apply(this, arguments); - }, - /** - * @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'); - }, - /** - * @private - */ - _toggleCustomGroupMenu: function () { - this.generatorMenuIsOpen = !this.generatorMenuIsOpen; - this._renderGeneratorMenu(); - if (this.generatorMenuIsOpen) { - this.$groupSelector.focus(); - } - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * toggle the 'Add Custom Group' - * - * @private - * @param {MouseEvent} event - */ - _onAddCustomGroupClick: function (event) { - event.preventDefault(); - event.stopPropagation(); - this._toggleCustomGroupMenu(); - }, - /* - * override - * - * @private - * @param {jQueryEvent} event - */ - _onBootstrapClose: function () { - this._super.apply(this, arguments); - this.generatorMenuIsOpen = false; - this._renderGeneratorMenu(); - }, - /** - * @private - * @param {MouseEvent} event - */ - _onButtonApplyClick: function (event) { - event.stopPropagation(); - var fieldName = this.$groupSelector.val(); - this._addGroupby(fieldName); - this._toggleCustomGroupMenu(); - }, - /** - * @private - * @param {MouseEvent} event - */ - _onGroupSelectorClick: function (event) { - event.stopPropagation(); - }, -}); - -return GroupByMenu; - -}); diff --git a/addons/web/static/src/js/views/search/groupby_menu_interface_mixin.js b/addons/web/static/src/js/views/search/groupby_menu_interface_mixin.js deleted file mode 100644 index d782eb99015..00000000000 --- a/addons/web/static/src/js/views/search/groupby_menu_interface_mixin.js +++ /dev/null @@ -1,117 +0,0 @@ -odoo.define('web.GroupByMenuInterfaceMixin', function (require) { -"use strict"; - -var GroupByMenu = require('web.GroupByMenu'); - -/** - * 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 GroupByMenuInterfaceMixin - */ -var GroupByMenuInterfaceMixin = { - - init: function () { - this.custom_events = _.extend({}, this.custom_events, { - menu_item_toggled: '_onMenuItemToggled', - item_option_changed : '_onItemOptionChanged',}); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * this function instantiate a widget GroupByMenu and - * incorporate it to the control panel of the graph view. - * This is used for instance in the dashboard view in enterprise - * where there is no Group by 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 - */ - _addGroupByMenu: function ($node, groupableFields) { - var groupbys = []; - var activeGroupbys = []; - _.each(this.model.get().groupedBy, function (groupby) { - activeGroupbys.push({fieldName: groupby.split(':')[0], interval: groupby.split(':')[1] || false}); - }); - var groupId = '__group__1'; - _.each(groupableFields, function (field, fieldName) { - var groupby = _.findWhere(activeGroupbys, {fieldName: fieldName}); - groupbys.push({ - isActive: groupby ? true : false, - description: field.string, - itemId: fieldName, - fieldName: fieldName, - groupId: groupId, - defaultOptionId: groupby ? groupby.interval : false, - }); - }); - groupbys = _.sortBy(groupbys, 'description'); - var groupByMenu = new GroupByMenu(this, groupbys, groupableFields, {headerStyle: 'primary'}); - groupByMenu.insertAfter($node.find('div:first')); - }, - - /** - * 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) { - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * - * @private - * @param {OdooEvent} ev - */ - _onItemOptionChanged: function (ev) { - var fieldName = ev.data.itemId; - var interval = ev.data.optionId; - var groupbys = this.model.get().groupedBy; - var groupbysNormalized = _.map(groupbys, function (groupby) { - return groupby.split(':')[0]; - }); - var indexOfGroupby = groupbysNormalized.indexOf(fieldName); - groupbys.splice(indexOfGroupby, 1, fieldName + ':' + interval); - this._setGroupby(groupbys); - }, - /** - * - * @private - * @param {OdooEvent} ev - */ - _onMenuItemToggled: function (ev) { - var fieldName = ev.data.itemId; - var interval = ev.data.optionId; - var groupbys = this.model.get().groupedBy; - var groupbysNormalized = _.map(groupbys, function (groupby) { - return groupby.split(':')[0]; - }); - var indexOfGroupby = groupbysNormalized.indexOf(fieldName); - if (indexOfGroupby === -1) { - groupbys.push(fieldName + (interval ? (':' + interval) : '')); - } else { - groupbys.splice(indexOfGroupby, 1); - } - this._setGroupby(groupbys); - }, -}; - -return GroupByMenuInterfaceMixin; - -}); \ No newline at end of file diff --git a/addons/web/static/src/js/views/search/search_inputs.js b/addons/web/static/src/js/views/search/search_inputs.js deleted file mode 100644 index 43fbea08f9c..00000000000 --- a/addons/web/static/src/js/views/search/search_inputs.js +++ /dev/null @@ -1,840 +0,0 @@ -odoo.define('web.search_inputs', function (require) { -"use strict"; - -var Context = require('web.Context'); -var core = require('web.core'); -var Domain = require('web.Domain'); -var field_utils = require('web.field_utils'); -var pyUtils = require('web.py_utils'); -var time = require('web.time'); -var utils = require('web.utils'); -var Widget = require('web.Widget'); - -var _t = core._t; -var _lt = core._lt; - -var Input = Widget.extend( /** @lends instance.web.search.Input# */{ - /** - * @constructs instance.web.search.Input - * @extends instance.web.Widget - * - * @param parent - */ - init: function (parent) { - this._super(parent); - this.searchview = parent; - this.load_attrs({}); - }, - /** - * Fetch auto-completion values for the widget. - * - * The completion values should be an array of objects with keys category, - * label, value prefixed with an object with keys type=section and label - * - * @param {String} value value to complete - * @returns {jQuery.Deferred} - */ - complete: function (value) { - return $.when(null); - }, - /** - * Returns a Facet instance for the provided defaults if they apply to - * this widget, or null if they don't. - * - * This default implementation will try calling - * :js:func:`instance.web.search.Input#facet_for` if the widget's name - * matches the input key - * - * @param {Object} defaults - * @returns {jQuery.Deferred} - */ - facet_for_defaults: function (defaults) { - if (!this.attrs || - !(this.attrs.name in defaults && defaults[this.attrs.name])) { - return $.when(null); - } - return this.facet_for(defaults[this.attrs.name]); - }, - get_context: function () { - throw new Error( - "get_context not implemented for widget " + this.attrs.type); - }, - get_groupby: function () { - throw new Error( - "get_groupby not implemented for widget " + this.attrs.type); - }, - get_domain: function () { - throw new Error( - "get_domain not implemented for widget " + this.attrs.type); - }, - load_attrs: function (attrs) { - if (!_.isObject(attrs.modifiers)) { - attrs.modifiers = attrs.modifiers ? JSON.parse(attrs.modifiers) : {}; - } - this.attrs = attrs; - }, - /** - * Returns whether the input is "visible". The default behavior is to - * query the ``modifiers.invisible`` flag on the input's description or - * view node. - * - * @returns {Boolean} - */ - visible: function () { - return !this.attrs.modifiers.invisible; - }, -}); - -var Field = Input.extend( /** @lends instance.web.search.Field# */ { - template: 'SearchView.field', - default_operator: '=', - /** - * @constructs instance.web.search.Field - * @extends instance.web.search.Input - * - * @param view_section - * @param field - * @param parent - */ - init: function (view_section, field, parent) { - this._super(parent); - this.load_attrs(_.extend({}, field, view_section.attrs)); - }, - facet_for: function (value) { - return $.when({ - field: this, - category: this.attrs.string || this.attrs.name, - values: [{label: String(value), value: value}] - }); - }, - value_from: function (facetValue) { - return facetValue.get('value'); - }, - get_context: function (facet) { - var self = this; - // A field needs a context to send when active - var context = this.attrs.context; - if (_.isEmpty(context) || !facet.values.length) { - return; - } - var contexts = facet.values.map(function (facetValue) { - return new Context(context) - .set_eval_context({self: self.value_from(facetValue)}); - }); - - if (contexts.length === 1) { return contexts[0]; } - - return _.extend(new Context(), { - __contexts: contexts - }); - }, - get_groupby: function () {}, - /** - * 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) - * - * @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} domain to include in the resulting search - */ - make_domain: function (name, operator, facet) { - return [[name, operator, this.value_from(facet)]]; - }, - get_domain: function (facet, noDomainEvaluation) { - if (!facet.values.length) { return; } - - var value_to_domain; - var self = this; - var domain = this.attrs.filter_domain; - if (domain) { - value_to_domain = function (facetValue) { - return Domain.prototype.stringToArray( - domain, - {self: self.value_from(facetValue), raw_value: facetValue.attributes.value} - ); - }; - } else { - value_to_domain = function (facetValue) { - return self.make_domain( - self.attrs.name, - self.attrs.operator || self.default_operator, - facetValue - ); - }; - } - var domains = facet.values.map(value_to_domain); - - // here 'noDomainEvaluation' means 'string representation' - if (noDomainEvaluation) { - domains = domains.map(Domain.prototype.arrayToString); - return pyUtils.assembleDomains(domains, 'OR'); - } - if (domains.length === 1) { - return domains[0]; - } - _.each(domains, Domain.prototype.normalizeArray); - var ors = _.times(domains.length - 1, _.constant("|")); - return ors.concat.apply(ors, domains); - } -}); - -/** - * Implementation of the ``char`` OpenERP field type: - * - * * Default operator is ``ilike`` rather than ``=`` - * - * * The Javascript and the HTML values are identical (strings) - * - * @class - * @extends instance.web.search.Field - */ -var CharField = Field.extend( /** @lends instance.web.search.CharField# */ { - default_operator: 'ilike', - complete: function (value) { - if (_.isEmpty(value)) { return $.when(null); } - var label = _.str.sprintf(_.str.escapeHTML( - _t("Search %(field)s for: %(value)s")), { - field: '' + _.escape(this.attrs.string) + '', - value: '' + _.escape(value) + ''}); - return $.when([{ - label: label, - facet: { - category: this.attrs.string, - field: this, - values: [{label: value, value: value}] - } - }]); - } -}); - -var NumberField = Field.extend(/** @lends instance.web.search.NumberField# */{ - complete: function (value) { - var val = this.parse(value); - if (isNaN(val)) { return $.when(); } - var label = _.str.sprintf( - _t("Search %(field)s for: %(value)s"), { - field: '' + _.escape(this.attrs.string) + '', - value: '' + _.escape(value) + ''}); - return $.when([{ - label: label, - facet: { - category: this.attrs.string, - field: this, - values: [{label: value, value: val}] - } - }]); - }, -}); - -/** - * @class - * @extends instance.web.search.NumberField - */ -var IntegerField = NumberField.extend(/** @lends instance.web.search.IntegerField# */{ - error_message: _t("not a valid integer"), - parse: function (value) { - try { - return field_utils.parse.integer(value); - } catch (e) { - return NaN; - } - } -}); - -/** - * @class - * @extends instance.web.search.NumberField - */ -var FloatField = NumberField.extend(/** @lends instance.web.search.FloatField# */{ - error_message: _t("not a valid number"), - parse: function (value) { - try { - return field_utils.parse.float(value); - } catch (e) { - return NaN; - } - } -}); - -/** - * Utility function for m2o & selection fields taking a selection/name_get pair - * (value, name) and converting it to a Facet descriptor - * - * @param {instance.web.search.Field} field holder field - * @param {Array} pair pair value to convert - */ -function facet_from(field, pair) { - return { - field: field, - category: field.attrs.string, - values: [{label: pair[1], value: pair[0]}] - }; -} - -/** - * @class - * @extends instance.web.search.Field - */ -var SelectionField = Field.extend(/** @lends instance.web.search.SelectionField# */{ - // This implementation is a basic @@ -1124,10 +1131,10 @@
    - -