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 @@
    - -