diff options
Diffstat (limited to 'addons')
107 files changed, 7152 insertions, 6255 deletions
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. + * If this flag is set to true, the client action will create a control + * panel whenever it is created. + * + * @type boolean */ - on_attach_callback: function () {}, + hasControlPanel: false, + /** - * Called each time the action is detached from the DOM. + * If true, this flag indicates that the client action should automatically + * fetch the <arch> of a search view (or control panel view). Note that + * to do that, it also needs a specific modelName. + * + * For example, the Discuss application adds the following line in its + * constructor: + * + * this.controlPanelParams.modelName = 'mail.message'; + * + * @type boolean */ - on_detach_callback: function () {}, + loadControlPanel: false, + /** - * Called by the action manager when action is restored (typically, when the - * user clicks on the action in the breadcrumb) + * 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. * - * @returns {Deferred|undefined} + * @type boolean */ - willRestore: function () {}, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- + withSearchBar: 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. + * 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. * - * @returns {Deferred} resolved if the action can be removed, rejected otherwise + * @type string[] */ - canBeRemoved: function () { - return $.when(); - }, + searchMenuTypes: [], + /** - * 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 + * + * @param {Widget} parent + * @param {Object} action + * @param {Object} [options] */ - getContext: function () { + 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, + }; }, /** - * Gives the focus to the action + * The willStart method is actually quite complicated if the client action + * has a controlPanel, because it needs to prepare it. + * + * @override */ - giveFocus: function () { + 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(); }, /** - * 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 + * @override */ - 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}], }); @@ -368,6 +369,18 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi .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 * * @private 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', @@ -64,17 +62,6 @@ var ActionManager = Widget.extend({ 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. */ on_attach_callback: function () { @@ -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,34 +550,52 @@ 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 - */ - _pushController: function (controller, options) { - options = options || {}; + * @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) { 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<Object>} 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', }), @@ -33,6 +24,26 @@ ActionManager.include({ //-------------------------------------------------------------------------- /** + * 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 = {}; @@ -114,59 +125,6 @@ ActionManager.include({ //-------------------------------------------------------------------------- /** - * 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: $('<div>'), - 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<Object>} 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)); }); }, /** @@ -368,49 +320,6 @@ ActionManager.include({ }); }, /** - * 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, * the Widget to use... @@ -483,12 +392,14 @@ 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']); @@ -496,60 +407,6 @@ ActionManager.include({ 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 * view. @@ -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); }); }, @@ -680,18 +553,6 @@ ActionManager.include({ //-------------------------------------------------------------------------- /** - * @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 * method of a given model) or 'action' (execute a given action). @@ -803,67 +664,6 @@ ActionManager.include({ }); }, /** - * 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 * @param {string} ev.data.controllerID the id of the controller that @@ -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 = $('<li>', {class: 'breadcrumb-item'}) - .append(is_last ? li_content : $('<a>', {href: '#'}).html(li_content)) - .toggleClass('active', is_last); - if (!is_last) { - $bc.click(function (ev) { - ev.preventDefault(); - self.trigger_up('breadcrumb_clicked', {controllerID: bc.controllerID}); - }); - } - 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. @@ -96,6 +95,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. * * @param {function} callback function with parameters (key, value). @@ -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($('<div>'))); // 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: $('<div>'), + $sidebar: $('<div>'), + $pager: $('<div>'), + }; - if (this.withControlPanel) { - elements = { - $buttons: $('<div>'), - $sidebar: $('<div>'), - $pager: $('<div>'), - }; - - 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,8 +373,8 @@ 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); } @@ -401,6 +382,13 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { 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(); @@ -479,6 +456,36 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { } }, /** + * 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 * @@ -503,6 +510,20 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { }); }, /** + * 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 @@ -369,6 +381,20 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { } }, /** + * 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 * * @private @@ -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 @@ -518,35 +518,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 * rendering of the widget will be started and the associated deferred will 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<Object>} the result of `getSearchState` + */ + importState: function (state) { + var defs = []; + this.model.importState(state); + defs.push(this.getSearchQuery()); + defs.push(this.renderer.updateState(this.model.get())); + return $.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 $('<div>').appendTo(this.$('.o_search_options')); + }, + /** + * @private + */ + _renderBreadcrumbs: function () { + var self = this; + var breadcrumbsDescriptors = this._breadcrumbs.concat({title: this._title}); + var breadcrumbs = breadcrumbsDescriptors.map(function (bc, index) { + return self._renderBreadcrumbsItem(bc, index, breadcrumbsDescriptors.length); + }); + this.$('.breadcrumb').html(breadcrumbs); + }, + /** + * Render a breadcrumbs' li jQuery element. + * + * @private + * @param {Object} bc + * @param {string} bc.title + * @param {string} bc.controllerID + * @param {integer} index + * @param {integer} length + * @returns {jQueryElement} $bc + */ + _renderBreadcrumbsItem: function (bc, index, length) { + var self = this; + var is_last = (index === length-1); + var li_content = bc.title && _.escape(bc.title.trim()) || data.noDisplayContent; + var $bc = $('<li>', {class: 'breadcrumb-item'}) + .append(is_last ? li_content : $('<a>', {href: '#'}).html(li_content)) + .toggleClass('active', is_last); + if (!is_last) { + $bc.click(function (ev) { + ev.preventDefault(); + self.trigger_up('breadcrumb_clicked', {controllerID: bc.controllerID}); + }); + } + 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: '<search/>', fields: {}}] a + * search fieldsview + * @param {string} [params.viewInfo.arch] + * @param {boolean} [params.withBreadcrumbs=true] if set to false, + * breadcrumbs won't be rendered + * @param {boolean} [params.withSearchBar=true] if set to false, no default + * search bar will be rendered + * @param {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: '<search/>', fields: {}}; + var context = _.extend({}, params.context); + var domain = params.domain || []; + var action = params.action || {}; + + this.searchDefaults = {}; + Object.keys(context).forEach(function (key) { + var match = /^search_default_(.*)$/.exec(key); + if (match) { + self.searchDefaults[match[1]] = context[key]; + delete context[key]; + } + }); + + this.arch = viewUtils.parseArch(viewInfo.arch); + this.fields = viewInfo.fields; + + this.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 <search> + */ + _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 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', @@ -19,42 +19,46 @@ SearchView.include({ }), //-------------------------------------------------------------------------- - // Public + // Private //-------------------------------------------------------------------------- /** + * Overridden to correctly place submenu inside the mobile section. + * + * @private * @override */ - renderFacets: function () { - this._super.apply(this, arguments); - this.$('.o_mobile_search_clear_facets') - .toggleClass('o_hidden', !this.query.length); + _getSubMenusPlace: function () { + return this.$('.o_mobile_search_filter'); }, /** + * @private * @override */ - toggle_visibility: function (is_visible) { - // Do not do anything, toggling visibility of searchview is handled - // explicitly for mobile + _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 - //-------------------------------------------------------------------------- - /** + * @private * @override */ - _getButtonsElement: function () { - return this.$('.o_mobile_search_filter'); + _renderSearchBar: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self.searchBar.$el.detach().insertAfter(self.$('.o_mobile_search_header')); + }); }, /** - * Toggle mobile search view screen + * 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 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<null|Array>} + */ + getAutocompletionValues: function (value) { + var result; + value = value.toLowerCase(); + if (fuzzy.test(value, this.filter.description)) { + result = [{ + label: _.str.sprintf(this.completion_label.toString(), + _.escape(this.filter.description)), + facet: { + filter: this.filter, + }, + }]; + } + return $.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: '<em>' + _.escape(this.attrs.string) + '</em>', + value: '<strong>' + _.escape(value) + '</strong>'}); + }, + /** + * @private + * @param {any} value + * @returns {Object} + */ + _getFacetValue: function (value) { + return { + filter: this.filter, + values: [{label: value, value: value}], + }; + }, + /** + * Function creating the returned domain for the field, override this + * methods in children if you only need to customize the field's domain + * without more complex alterations or tests (and without the need to + * change override the handling of filter_domain) + * + * @private + * @param {String} name the field's name + * @param {String} operator the field's operator (either attribute-specified or default operator for the field + * @param {Number|String} facet parsed value for the field + * @returns {Array<Array>} domain to include in the resulting search + */ + _makeDomain: function (name, operator, facet) { + return [[name, operator, this._valueFrom(facet)]]; + }, + /** + * @private + * @param {Object} facetValue + * @param {any} facetValue.value + * @returns {any} + */ + _valueFrom: function (facetValue) { + return facetValue.value; + }, +}); + +var CharField = Field.extend({ + default_operator: 'ilike', + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getAutocompletionValues: function (value) { + if (_.isEmpty(value)) { return $.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: '<em>' + _.escape(this.attrs.string) + '</em>', + value: '<strong>' + value + '</strong>'}); + }, + /** + * @override + * @param {any} rawValue + */ + _getFacetValue: function (value, rawValue) { + var facet = this._super.apply(this, arguments); + facet.values[0].value = rawValue; + return facet; + }, + /** + * @override + */ + _valueFrom: function (facetValue) { + return time.date_to_str(facetValue.value); + }, +}); + +var DateTimeField = DateField.extend({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _valueFrom: function (facetValue) { + return time.datetime_to_str(facetValue.value); + }, +}); + +var ManyToOneField = CharField.extend({ + default_operator: {}, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getAutocompletionValues: function (value) { + if (_.isEmpty(value)) { return $.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 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; @@ -64,6 +62,26 @@ var FormView = BasicView.extend({ //-------------------------------------------------------------------------- /** + * @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 * * @private 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, + } }; }, /** @@ -172,6 +174,13 @@ var PivotController = AbstractController.extend({ .addClass('show'); }, /** + * @override + * @private + */ + _startRenderer: function () { + return this.renderer.appendTo(this.$('.o_pivot')); + }, + /** * @private */ _update: function () { 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<Number, String>} [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 = $('<div>', {class: 'position-relative'}) - .addClass(filter.user_id ? 'o-searchview-custom-private' - : 'o-searchview-custom-public') - .append($('<a>', {href: '#', class: 'dropdown-item'}).text(filter.name)) - .append($('<span>', { - 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<null|Array>} - */ - 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<null|Object>} - */ - 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<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: '<em>' + _.escape(this.attrs.string) + '</em>', - value: '<strong>' + _.escape(value) + '</strong>'}); - 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: '<em>' + _.escape(this.attrs.string) + '</em>', - value: '<strong>' + _.escape(value) + '</strong>'}); - 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 <select> field, but it may have to be - // altered to be more in line with the GTK client, which uses a combo box - // (~ jquery.autocomplete): - // * If an option was selected in the list, behave as currently - // * If something which is not in the list was entered (via the text input), - // the default domain should become (`ilike` string_value) but **any - // ``context`` or ``filter_domain`` becomes falsy, idem if ``@operator`` - // is specified. So at least get_domain needs to be quite a bit - // overridden (if there's no @value and there is no filter_domain and - // there is no @operator, return [[name, 'ilike', str_val]] - template: 'SearchView.field.selection', - init: function () { - this._super.apply(this, arguments); - // prepend empty option if there is no empty option in the selection list - this.prepend_empty = !_(this.attrs.selection).detect(function (item) { - return !item[1]; - }); - }, - complete: function (needle) { - var self = this; - var results = _(this.attrs.selection).chain() - .filter(function (sel) { - var value = sel[0], label = sel[1]; - if (value === undefined || !label) { return false; } - return label.toLowerCase().indexOf(needle.toLowerCase()) !== -1; - }) - .map(function (sel) { - return { - label: _.escape(sel[1]), - indent: true, - facet: facet_from(self, sel) - }; - }).value(); - if (_.isEmpty(results)) { return $.when(null); } - return $.when.call(null, [{ - label: _.escape(this.attrs.string) - }].concat(results)); - }, - facet_for: function (value) { - var match = _(this.attrs.selection).detect(function (sel) { - return sel[0] === value; - }); - if (!match) { return $.when(null); } - return $.when(facet_from(this, match)); - } -}); - -var BooleanField = SelectionField.extend(/** @lends instance.web.search.BooleanField# */{ - /** - * @constructs instance.web.search.BooleanField - * @extends instance.web.search.BooleanField - */ - init: function () { - this._super.apply(this, arguments); - this.attrs.selection = [ - [true, _t("Yes")], - [false, _t("No")] - ]; - } -}); - -/** - * @class - * @extends instance.web.search.DateField - */ -var DateField = Field.extend(/** @lends instance.web.search.DateField# */{ - value_from: function (facetValue) { - var serverFormat = (this.attrs && this.attrs.type === 'datetime') ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD'; - return facetValue.get('valueAsMoment').clone().format(serverFormat); - }, - complete: function (needle) { - // Make sure the needle 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](needle, {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 date_string = field_utils.format[t](m, {type: t}); - var label = _.str.sprintf(_.str.escapeHTML( - _t("Search %(field)s at: %(value)s")), { - field: '<em>' + _.escape(this.attrs.string) + '</em>', - value: '<strong>' + date_string + '</strong>'}); - return $.when([{ - label: label, - facet: { - category: this.attrs.string, - field: this, - values: [{label: date_string, value: m.toDate(), valueAsMoment: m.clone()}] - } - }]); - } -}); - -/** - * Implementation of the ``datetime`` openerp field type: - * - * * Uses the same widget as the ``date`` field type (a simple date) - * - * * Builds a slighly more complex, it's a datetime range (includes time) - * spanning the whole day selected by the date widget - * - * @class - * @extends instance.web.DateField - */ -var DateTimeField = DateField; - -var ManyToOneField = CharField.extend({ - default_operator: {}, - init: function (view_section, field, parent) { - this._super(view_section, field, parent); - this.searchview = parent; - }, - - complete: function (value) { - if (_.isEmpty(value)) { return $.when(null); } - var label = _.str.sprintf(_.str.escapeHTML( - _t("Search %(field)s for: %(value)s")), { - field: '<em>' + _.escape(this.attrs.string) + '</em>', - value: '<strong>' + _.escape(value) + '</strong>'}); - return $.when([{ - label: label, - facet: { - category: this.attrs.string, - field: this, - values: [{label: value, value: value, operator: 'ilike'}] - }, - expand: this.expand.bind(this), - }]); - }, - - expand: function (needle) { - var self = this; - // FIXME: "concurrent" searches (multiple requests, mis-ordered responses) - var context = pyUtils.eval( - 'contexts', [this.searchview.dataset.get_context()]); - 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: needle, - args: args, - limit: 8, - context: context - }, - }) - .then(function (results) { - if (_.isEmpty(results)) { return null; } - return _(results).map(function (result) { - return { - label: _.escape(result[1]), - facet: facet_from(self, result) - }; - }); - }); - }, - facet_for: function (value) { - var self = this; - if (value instanceof Array) { - if (value.length === 2 && _.isString(value[1])) { - return $.when(facet_from(this, value)); - } - utils.assert(value.length <= 1, - _t("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]; - } - var context = pyUtils.eval('contexts', [this.searchview.dataset.get_context()]); - - return this._rpc({ - model: this.attrs.relation, - method: 'name_get', - args: [value], - context: context, - }) - .then(function (names) { - if (_(names).isEmpty()) { return null; } - return facet_from(self, names[0]); - }); - }, - value_from: function (facetValue) { - return facetValue.get('label'); - }, - make_domain: function (name, operator, facetValue) { - operator = facetValue.get('operator') || operator; - - switch(operator){ - case this.default_operator: - return [[name, '=', facetValue.get('value')]]; - case 'ilike': - return [[name, 'ilike', facetValue.get('value')]]; - case 'child_of': - return [[name, 'child_of', facetValue.get('value')]]; - } - return this._super(name, operator, facetValue); - }, - get_context: function (facet) { - var values = facet.values; - if (_.isEmpty(this.attrs.context) && values.length === 1) { - var c = {}; - var v = values.at(0); - if (v.get('operator') !== 'ilike') { - c['default_' + this.attrs.name] = v.get('value'); - } - return c; - } - return this._super(facet); - } -}); - -var FilterGroup = Input.extend(/** @lends instance.web.search.FilterGroup# */{ - template: 'SearchView.filters', - events: { - click: 'toggle_filter', - }, - - icon: "fa-filter", - completion_label: _lt("Filter on: %s"), - - /** - * Inclusive group of filters, creates a continuous "button" with clickable - * sections (the normal display for filters is to be a self-contained button) - * - * @constructs instance.web.search.FilterGroup - * @extends instance.web.search.Input - * - * @param {Array<instance.web.search.Filter>} filters elements of the group - * @param {instance.web.SearchView} parent parent in which the filters are contained - * @param {Object} intervalMapping, a key is a field name and the corresponding value - * is the current interval used - * (necessarily the field is of type 'date' or 'datetime') - */ - init: function (filters, parent, intervalMapping, periodMapping) { - // If all filters are group_by and we're not initializing a GroupbyGroup, - // create a GroupbyGroup instead of the current FilterGroup - if (!(this instanceof GroupbyGroup) && - _(filters).all(function (f) { - if (!f.attrs.context) { return false; } - var c = pyUtils.eval('context', f.attrs.context); - return !_.isEmpty(c.group_by);})) { - return new GroupbyGroup(filters, parent, intervalMapping); - } - this._super(parent); - this.filters = filters; - this.searchview = parent; - this.intervalMapping = intervalMapping; - this.periodMapping = periodMapping || []; - this.searchview.query.on('add remove change reset', this.proxy('search_change')); - }, - start: function () { - return $.when(null); - }, - /** - * Handles change of the search query: any of the group's filter which is - * in the search query should be visually checked in the drawer - */ - search_change: function () { - var self = this; - var $filters = this.$el ? this.$el.removeClass('selected') : $(); - var facet = this.searchview.query.find(_.bind(this.match_facet, this)); - if (!facet) { return; } - facet.values.each(function (v) { - var i = _(self.filters).indexOf(v.get('value')); - if (i === -1) { return; } - $filters.filter(function () { - return Number($(this).data('index')) === i; - }).addClass('selected'); - }); - }, - /** - * Matches the group to a facet, in order to find if the group is - * represented in the current search query - */ - match_facet: function (facet) { - return facet.get('field') === this; - }, - make_facet: function (values) { - return { - cat: 'filterCategory', - category: _t("Filter"), - icon: this.icon, - separator: _t(" or "), - values: values, - field: this - }; - }, - make_value: function (filter) { - return { - label: filter.attrs.string || filter.attrs.help || filter.attrs.name, - value: filter - }; - }, - facet_for_defaults: function (defaults) { - var self = this; - var fs = _(this.filters).chain() - .filter(function (f) { - return f.attrs && f.attrs.name && !!defaults[f.attrs.name]; - }).map(function (f) { - return self.make_value(f); - }).value(); - if (_.isEmpty(fs)) { return $.when(null); } - return $.when(this.make_facet(fs)); - }, - /** - * Fetches contexts for all enabled filters in the group - * - * @param {openerp.web.search.Facet} facet - * @return {*} combined contexts of the enabled filters in this group - */ - get_context: function (facet) { - var contexts = facet.values.chain() - .map(function (f) { return f.get('value').attrs.context; }) - .without('{}') - .reject(_.isEmpty) - .value(); - - if (!contexts.length) { return; } - if (contexts.length === 1) { return contexts[0]; } - return _.extend(new Context(), { - __contexts: contexts - }); - }, - /** - * Fetches group_by sequence for all enabled filters in the group - * - * @param {VS.model.SearchFacet} facet - * @return {Array} enabled filters in this group - */ - get_groupby: function (facet) { - var self = this; - return facet.values.chain() - .map(function (f) { - var fieldName = f.get('value').attrs.fieldName; - var defaultInterval = f.get('value').attrs.defaultInterval; - var couple = _.findWhere(self.intervalMapping, {groupby: f.get('value')}); - var interval = couple ? couple.interval: undefined; - var context = _.clone(f.get('value').attrs.context); - if (context && interval) { - if (defaultInterval) { - context = context.replace(defaultInterval, interval); - } else if (fieldName){ - context = context.replace(fieldName, fieldName + ':' + interval); - } - } - return context; - }) - .without('{}') - .reject(_.isEmpty) - .value(); - }, - /** - * Handles domains-fetching for all the filters within it: groups them. - * - * @param {VS.model.SearchFacet} facet - * @param {boolean} noEvaluation determines if the domains are evaluated or not. - By default, domains are evaluated. - * @return {*} combined domains of the enabled filters in this group - */ - get_domain: function (facet, noEvaluation) { - noEvaluation = noEvaluation || false; - var self = this; - var userContext = this.getSession().user_context; - var domains = facet.values.chain() - .map(function (f) { - var filter = f.get('value'); - var attributes = filter.attrs; - var domain = attributes.domain; - var fieldName = attributes.date; - if (!domain && fieldName) { - var couple = _.findWhere(self.periodMapping, {filter: filter}); - var period = couple ? couple.period: 'this_month'; - var type = couple ? couple.type: 'date'; - domain = Domain.prototype.constructDomain(fieldName, period, type); - } - return domain; - }) - .without('[]') - .reject(_.isEmpty) - .map(function (domain) { - domain = domain.replace(/%%/g, '%'); - return domain; - }) - .value(); - - if (!domains.length) { - return; - } - // domains belong the same groups. We need to make a union. - var domain = pyUtils.assembleDomains(domains, 'OR'); - if (!noEvaluation) { - return Domain.prototype.stringToArray(domain, userContext); - } - return domain; - }, - toggle_filter: function (e) { - e.preventDefault(); - e.stopPropagation(); - this.toggle(this.filters[Number($(e.currentTarget).data('index'))]); - }, - toggle: function (filter, options) { - this.searchview.query.toggle(this.make_facet([this.make_value(filter)]), options); - }, - is_visible: function () { - return _.some(this.filters, function (filter) { - return !filter.attrs.invisible; - }); - }, - complete: function (item) { - var self = this; - item = item.toLowerCase(); - var facet_values = _(this.filters).chain() - .filter(function (filter) { return filter.visible(); }) - .filter(function (filter) { - var at = { - string: filter.attrs.string || '', - help: filter.attrs.help || '', - name: filter.attrs.name || '' - }; - var include = _.str.include; - return include(at.string.toLowerCase(), item) - || include(at.help.toLowerCase(), item) - || include(at.name.toLowerCase(), item); - }) - .map(this.make_value) - .value(); - if (_(facet_values).isEmpty()) { return $.when(null); } - return $.when(_.map(facet_values, function (facet_value) { - return { - label: _.str.sprintf(self.completion_label.toString(), - _.escape(facet_value.label)), - facet: self.make_facet([facet_value]) - }; - })); - }, - /* - * @param {Object} intervalMapping - */ - updateIntervalMapping: function (intervalMapping) { - this.intervalMapping = intervalMapping; - }, - /* - * @param {Object} periodMapping - */ - updatePeriodMapping: function (periodMapping) { - this.intervalMapping = periodMapping; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - -}); - -var GroupbyGroup = FilterGroup.extend({ - icon: 'fa-bars', - completion_label: _lt("Group by: %s"), - init: function (filters, parent) { - this._super.apply(this, arguments); - this.searchview = parent; - // Not flanders: facet unicity is handled through the - // (category, field) pair of facet attributes. This is all well and - // good for regular filter groups where a group matches a facet, but for - // groupby we want a single facet. So cheat: add an attribute on the - // view which proxies to the first GroupbyGroup, so it can be used - // for every GroupbyGroup and still provides the various methods needed - // by the search view. Use weirdo name to avoid risks of conflicts - if (!this.searchview._s_groupby) { - this.searchview._s_groupby = { - help: "See GroupbyGroup#init", - get_context: this.proxy('get_context'), - get_domain: this.proxy('get_domain'), - get_groupby: this.proxy('get_groupby') - }; - } - }, - match_facet: function (facet) { - return facet.get('field') === this.searchview._s_groupby; - }, - make_facet: function (values) { - return { - cat: 'groupByCategory', - category: _t("Group By"), - icon: this.icon, - separator: " > ", - values: values, - field: this.searchview._s_groupby - }; - } -}); - -var Filter = Input.extend(/** @lends instance.web.search.Filter# */{ - template: 'SearchView.filter', - /** - * Implementation of the OpenERP filters (button with a context and/or - * a domain sent as-is to the search view) - * - * Filters are only attributes holder, the actual work (compositing - * domains and contexts, converting between facets and filters) is - * performed by the filter group. - * - * @constructs instance.web.search.Filter - * @extends instance.web.search.Input - * - * @param node - * @param parent - */ - init: function (node, parent) { - this._super(parent); - this.load_attrs(node.attrs); - }, - facet_for: function () { return $.when(null); }, - get_context: function () { }, - get_domain: function () { }, -}); - -/** - * Registry of search fields, called by :js:class:`instance.web.SearchView` to - * find and instantiate its field widgets. - */ - -core.search_widgets_registry - .add('char', CharField) - .add('text', CharField) - .add('html', CharField) - .add('boolean', BooleanField) - .add('integer', IntegerField) - .add('id', IntegerField) - .add('float', FloatField) - .add('monetary', FloatField) - .add('selection', SelectionField) - .add('datetime', DateTimeField) - .add('date', DateField) - .add('many2one', ManyToOneField) - .add('many2many', CharField) - .add('one2many', CharField); - - -return { - FilterGroup: FilterGroup, - Filter: Filter, - Field: Field, - GroupbyGroup: GroupbyGroup, -}; - -}); diff --git a/addons/web/static/src/js/views/search/search_view.js b/addons/web/static/src/js/views/search/search_view.js deleted file mode 100644 index 69a45649793..00000000000 --- a/addons/web/static/src/js/views/search/search_view.js +++ /dev/null @@ -1,1244 +0,0 @@ -odoo.define('web.SearchView', function (require) { -"use strict"; - -var AutoComplete = require('web.AutoComplete'); -var config = require('web.config'); -var core = require('web.core'); -var Domain = require('web.Domain'); -var FavoriteMenu = require('web.FavoriteMenu'); -var FiltersMenu = require('web.FiltersMenu'); -var GroupByMenu = require('web.GroupByMenu'); -var pyUtils = require('web.py_utils'); -var search_inputs = require('web.search_inputs'); -var TimeRangeMenu = require('web.TimeRangeMenu'); -var TimeRangeMenuOptions = require('web.TimeRangeMenuOptions'); -var utils = require('web.utils'); -var Widget = require('web.Widget'); - -var _t = core._t; -var ComparisonOptions = TimeRangeMenuOptions.ComparisonOptions; -var PeriodOptions = TimeRangeMenuOptions.PeriodOptions; - -var Backbone = window.Backbone; - -var FacetValue = Backbone.Model.extend({}); - -var FacetValues = Backbone.Collection.extend({ - model: FacetValue -}); - -var DEFAULT_INTERVAL = 'month'; -var DEFAULT_PERIOD = 'this_month'; - -var Facet = Backbone.Model.extend({ - initialize: function (attrs) { - var values = attrs.values; - delete attrs.values; - - Backbone.Model.prototype.initialize.apply(this, arguments); - - this.values = new FacetValues(values || []); - this.values.on('add remove change reset', function (_, options) { - this.trigger('change', this, options); - }, this); - }, - get: function (key) { - if (key !== 'values') { - return Backbone.Model.prototype.get.call(this, key); - } - return this.values.toJSON(); - }, - set: function (key, value) { - if (key !== 'values') { - return Backbone.Model.prototype.set.call(this, key, value); - } - this.values.reset(value); - }, - toJSON: function () { - var out = {}; - var attrs = this.attributes; - for(var att in attrs) { - if (!attrs.hasOwnProperty(att) || att === 'field') { - continue; - } - out[att] = attrs[att]; - } - out.values = this.values.toJSON(); - return out; - } -}); - -var SearchQuery = Backbone.Collection.extend({ - model: Facet, - initialize: function () { - Backbone.Collection.prototype.initialize.apply( - this, arguments); - this.on('change', function (facet) { - if(!facet.values.isEmpty()) { return; } - - this.remove(facet, {silent: true}); - }, this); - }, - add: function (values, options) { - options = options || {}; - - if (!values) { - values = []; - } else if (!(values instanceof Array)) { - values = [values]; - } - - _(values).each(function (value) { - var model = this._prepareModel(value, options); - var previous = this.detect(function (facet) { - return facet.get('category') === model.get('category') && - facet.get('field') === model.get('field'); - }); - if (previous) { - previous.values.add(model.get('values'), _.omit(options, 'at', 'merge')); - return; - } - Backbone.Collection.prototype.add.call(this, model, options); - }, this); - // warning: in backbone 1.0+ add is supposed to return the added models, - // but here toggle may delegate to add and return its value directly. - // return value of neither seems actually used but should be tested - // before change, probably - return this; - }, - toggle: function (value, options) { - options = options || {}; - - var facet = this.detect(function (facet) { - return facet.get('category') === value.category - && facet.get('field') === value.field; - }); - if (!facet) { - return this.add(value, options); - } - - var changed = false; - _(value.values).each(function (val) { - var already_value = facet.values.detect(function (v) { - return v.get('value') === val.value - && v.get('label') === val.label; - }); - // toggle value - if (already_value) { - facet.values.remove(already_value, {silent: true}); - } else { - facet.values.add(val, {silent: true}); - } - changed = true; - }); - // "Commit" changes to values array as a single call, so observers of - // change event don't get misled by intermediate incomplete toggling - // states - facet.trigger('change', facet); - return this; - } -}); - -var InputView = Widget.extend({ - template: 'SearchView.InputView', - events: { - focus: function () { this.trigger('focused', this); }, - blur: function () { this.$el.val(''); this.trigger('blurred', this); }, - keydown: 'onKeydown', - }, - onKeydown: function (e) { - switch (e.which) { - case $.ui.keyCode.BACKSPACE: - if(this.$el.val() === '') { - var preceding = this.getParent().siblingSubview(this, -1); - if (preceding && (preceding instanceof FacetView)) { - preceding.model.destroy(); - } - } - break; - - case $.ui.keyCode.LEFT: // Stop propagation to parent if not at beginning of input value - if(this.el.selectionStart > 0) { - e.stopPropagation(); - } - break; - - case $.ui.keyCode.RIGHT: // Stop propagation to parent if not at end of input value - if(this.el.selectionStart < this.$el.val().length) { - e.stopPropagation(); - } - break; - } - } -}); - -var FacetView = Widget.extend({ - template: 'SearchView.FacetView', - events: { - 'focus': function () { this.trigger('focused', this); }, - 'blur': function () { - this.trigger('blurred', this); }, - 'click': function (e) { - if ($(e.target).hasClass('o_facet_remove')) { - this.model.destroy(); - return false; - } - this.$el.focus(); - e.stopPropagation(); - }, - 'keydown': function (e) { - var keys = $.ui.keyCode; - switch (e.which) { - case keys.BACKSPACE: - case keys.DELETE: - this.model.destroy(); - return false; - } - } - }, - /* - * @param {Widget} parent - * @param {Object} model - * @param {Object} intervalMapping, a key is a field name and the corresponding value - * is the current interval used - * (necessarily the field is of type 'date' or 'datetime') - * @param {Object} periodMapping, a key is a field name and the corresponding value - * is the current period used - * (necessarily the field is of type 'date' or 'datetime') - */ - init: function (parent, model, intervalMapping, periodMapping) { - this._super(parent); - this.model = model; - this.intervalMapping = intervalMapping; - this.periodMapping = periodMapping; - this.model.on('change', this.model_changed, this); - }, - destroy: function () { - this.model.off('change', this.model_changed, this); - this._super(); - }, - start: function () { - var self = this; - var $e = this.$('.o_facet_values').last(); - return $.when(this._super()).then(function () { - return $.when.apply(null, self.model.values.map(function (value, index) { - if (index > 0) { - $('<span/>', {html: self.model.get('separator') || _t(" or ")}).addClass('o_facet_values_sep').appendTo($e); - } - var couple; - var option; - if (value.attributes.value && value.attributes.value.attrs) { - if (value.attributes.value.attrs.isPeriod) { - couple = _.findWhere(self.periodMapping, {filter: value.attributes.value}); - option = couple ? couple.period : value.attributes.value.attrs.default_period; - } - if (value.attributes.value.attrs.isDate) { - couple = _.findWhere(self.intervalMapping, {groupby: value.attributes.value}); - option = couple ? - couple.interval : - (value.attributes.value.attrs.defaultInterval || DEFAULT_INTERVAL); - } - } - return new FacetValueView(self, value, option).appendTo($e); - })); - }); - }, - model_changed: function () { - this.$el.text(this.$el.text() + '*'); - } -}); - -var FacetValueView = Widget.extend({ - template: 'SearchView.FacetView.Value', - /* - * @param {Widget} parent - * @param {Object} model - * @param {Object} option (optional) is used in case the facet value - * corresponds to a groupby with an associated 'date' - * 'datetime' field. - * @param {Object} comparison (optional) is used in case the facet value - * comes from the time range menu and comparison is active - */ - init: function (parent, model, option) { - this._super(parent); - this.model = model; - - var optionDescription = _.extend({}, { - day: 'Day', - week: 'Week', - month: 'Month', - quarter: 'Quarter', - year: 'Year', - }, { - today: 'Today', - this_week: 'This Week', - this_month: 'This Month', - this_quarter: 'This Quarter', - this_year: 'This Year', - yesterday: 'Yesterday', - last_week: 'Last Week', - last_month: 'Last Month', - last_quarter: 'Last Quarter', - last_year: 'Last Year', - last_7_days: 'Last 7 Days', - last_30_days: 'Last 30 Days', - last_365_days: 'Last 365 Days', - }); - if (option) { - var optionLabel = optionDescription[option]; - this.optionLabel = _t(optionLabel); - } - this.model.on('change', this.model_changed, this); - }, - destroy: function () { - this.model.off('change', this.model_changed, this); - this._super(); - }, - model_changed: function () { - this.$el.text(this.$el.text() + '*'); - } -}); - -var SearchView = Widget.extend({ - events: { - 'click .o_searchview_more': function (e) { - $(e.target).toggleClass('fa-search-plus fa-search-minus'); - var visibleSearchMenu = this.call('local_storage', 'getItem', 'visible_search_menu'); - this.call('local_storage', 'setItem', 'visible_search_menu', visibleSearchMenu !== 'true'); - this.toggle_buttons(); - }, - 'keydown .o_searchview_input, .o_searchview_facet': function (e) { - switch(e.which) { - case $.ui.keyCode.LEFT: - this.focusPreceding(e.target); - e.preventDefault(); - break; - case $.ui.keyCode.RIGHT: - if(!this.autocomplete.is_expandable()) { - this.focusFollowing(e.target); - } - e.preventDefault(); - break; - case $.ui.keyCode.DOWN: - if (!this.autocomplete.is_expanded()) { - e.preventDefault(); - this.trigger_up('navigation_move', {direction: 'down'}); - break; - } - } - }, - }, - custom_events: { - menu_item_toggled: '_onItemToggled', - item_option_changed: '_onItemOptionChanged', - new_groupby: '_onNewGroupby', - new_filters: '_onNewFilters', - time_range_modified: '_onTimeRangeModified', - time_range_removed: '_onTimeRangeRemoved', - }, - defaults: _.extend({}, Widget.prototype.defaults, { - hidden: false, - disable_custom_filters: false, - disable_groupby: false, - disable_favorites: false, - disable_filters: false, - disableTimeRangeMenu: true, - }), - template: "SearchView", - - /** - * @constructs SearchView - * @extends View - * - * @param parent - * @param dataset - * @param fvg - * @param {Object} [options] - * @param {Boolean} [options.hidden=false] hide the search view - * @param {Boolean} [options.disable_custom_filters=false] do not load custom filters from ir.filters - */ - init: function (parent, dataset, fvg, options) { - this._super.apply(this, arguments); - this.options = options; - this.dataset = dataset; - this.fields_view = this._processFieldsView(_.clone(fvg)); - - this.fields = this.fields_view.fields; - this.query = undefined; - this.title = this.options.action && this.options.action.name; - this.action = this.options.action || {}; - this.search_fields = []; - - this.hasFavorites = false; - - this.noDateFields = true; - var field; - for (var key in this.fields) { - field = this.fields[key]; - if (_.contains(['date', 'datetime'], field.type) && field.sortable) { - this.noDateFields = false; - break; - } - } - this.activeItemIds = { - groupByCategory: [], - filterCategory: [] - }; - this.groupsMapping = []; - this.groupbysMapping = []; - this.filtersMapping = []; - this.intervalMapping = []; - this.periodMapping = []; - - this.filters = []; - this.groupbys = []; - this.timeRanges = options.action && options.action.context ? - options.action.context.time_ranges : undefined; - var visibleSearchMenu = this.call('local_storage', 'getItem', 'visible_search_menu'); - this.visible_filters = (visibleSearchMenu !== 'false'); - this.input_subviews = []; // for user input in searchbar - this.search_defaults = this.options.search_defaults || {}; - this.headless = this.options.hidden && _.isEmpty(this.search_defaults); - this.$buttons = this.options.$buttons; - - this.filters_menu = undefined; - this.groupby_menu = undefined; - this.favorite_menu = undefined; - }, - willStart: function () { - var self = this; - var def; - if (!this.options.disable_favorites) { - def = this.loadFilters(this.dataset, this.action.id).then(function (filters) { - self.favorite_filters = filters; - }); - } - return $.when(this._super(), def); - }, - start: function () { - var self= this; - if (this.headless) { - this.do_hide(); - } - this.toggle_visibility(false); - this.setup_global_completion(); - this.query = new SearchQuery() - .on('add change reset remove', this.proxy('do_search')) - .on('change', this.proxy('renderChangedFacets')) - .on('add reset remove', this.proxy('renderFacets')); - this.$('.o_searchview_more') - .toggleClass('fa-search-minus', this.visible_filters) - .toggleClass('fa-search-plus', !this.visible_filters); - var def; - this.prepare_search_inputs(); - var $buttons = this._getButtonsElement(); - if ($buttons) { - if (!this.options.disable_favorites) { - this.favorite_menu = new FavoriteMenu(this, this.query, this.dataset.model, this.action, this.favorite_filters); - def = this.favorite_menu.appendTo($buttons); - } - } - return $.when(def) - .then(this.set_default_filters.bind(this)) - .then(function () { - var menu_defs = []; - self.timeRangeMenu = self._createTimeRangeMenu(); - menu_defs.push(self.timeRangeMenu.prependTo($buttons)); - self.timeRangeMenu.do_hide(); - self.displayedTimeRangeMenu = self.options.disableTimeRangeMenu !== undefined && - !self.options.disableTimeRangeMenu; - self.displayTimeRangeMenu(self.displayedTimeRangeMenu); - if (!self.options.disable_groupby) { - self.groupby_menu = self._createGroupByMenu(); - menu_defs.push(self.groupby_menu.prependTo($buttons)); - } - if (!self.options.disable_filters) { - self.filters_menu = self._createFiltersMenu(); - menu_defs.push(self.filters_menu.prependTo($buttons)); - } - return $.when.apply($, menu_defs); - }); - }, - /* - * - * @param {boolean} - */ - displayTimeRangeMenu: function (b) { - if (!b || this.noDateFields) { - this.timeRangeMenu.do_hide(); - } else { - this.timeRangeMenu.do_show(); - } - }, - on_attach_callback: function () { - this._focusInput(); - }, - get_title: function () { - return this.title; - }, - set_default_filters: function () { - var self = this, - default_custom_filter = this.$buttons && this.favorite_menu && this.favorite_menu.get_default_filter(); - if (!self.options.disable_custom_filters && default_custom_filter) { - this.hasFavorites = true; - return this.favorite_menu.toggle_filter(default_custom_filter, true); - } - if (!_.isEmpty(this.search_defaults) || this.timeRanges) { - var inputs = this.search_fields.concat(this.filters, this.groupbys); - var search_defaults = _.invoke(inputs, 'facet_for_defaults', this.search_defaults); - var defaultTimeRange = this._searchDefaultTimeRange(); - search_defaults.push(defaultTimeRange); - return $.when.apply(null, search_defaults).then(function () { - var facets = _.compact(arguments); - self.query.reset(facets, {preventSearch: true}); - }); - } - this.query.reset([], {preventSearch: true}); - return $.when(); - }, - /** - * Performs the search view collection of widget data. - * - * If the collection went well (all fields are valid), then triggers - * :js:func:`instance.web.SearchView.on_search`. - * - * If at least one field failed its validation, triggers - * :js:func:`instance.web.SearchView.on_invalid` instead. - * - * @param [_query] - * @param {Object} [options] - */ - do_search: function (_query, options) { - if (options && options.preventSearch) { - return; - } - var search = this.build_search_data(); - this.trigger_up('search', search); - }, - /** - * @param {boolean} noDomainEvaluation determines if domain are evaluated or not. - * By default, domains are evaluated. - * - * Extract search data from the view's facets. - * - * Result is an object with 3 (own) properties: - * - * domains - * Array of domains - * contexts - * Array of contexts - * groupbys - * Array of domains, in groupby order rather than view order - * - * @return {Object} - */ - build_search_data: function (noDomainEvaluation) { - var domains = [], contexts = [], groupbys = []; - noDomainEvaluation = noDomainEvaluation || false; - this.query.each(function (facet) { - // field is actually a FilterGroup! - var field = facet.get('field'); - var domain = field.get_domain(facet, noDomainEvaluation); - if (domain) { - domains.push(domain); - } - var context = field.get_context(facet, noDomainEvaluation); - if (context) { - contexts.push(context); - } - var group_by = field.get_groupby(facet); - if (group_by) { - groupbys.push.apply(groupbys, group_by); - } - }); - var intervalMappingNormalized = {}; - _.each(this.intervalMapping, function (couple) { - var fieldName = couple.groupby.fieldName; - var interval = couple.interval; - intervalMappingNormalized[fieldName] = interval; - }); - return { - domains: domains, - contexts: contexts, - groupbys: groupbys, - intervalMapping: intervalMappingNormalized, - }; - }, - toggle_visibility: function (is_visible) { - this.do_toggle(!this.headless && is_visible); - if (this.$buttons) { - this.$buttons.toggle(!this.headless && is_visible && this.visible_filters); - } - this._focusInput(); - }, - /** - * puts the focus on the search input - */ - _focusInput: function () { - if (!config.device.touch && config.device.size_class >= config.device.SIZES.MD) { - this.$('input').focus(); - } - }, - toggle_buttons: function (is_visible) { - this.visible_filters = is_visible || !this.visible_filters; - if (this.$buttons) { - this.$buttons.toggle(this.visible_filters); - } - }, - /** - * Sets up search view's view-wide auto-completion widget - */ - setup_global_completion: function () { - var self = this; - this.autocomplete = new AutoComplete(this, { - source: this.proxy('complete_global_search'), - select: this.proxy('select_completion'), - get_search_string: function () { - return self.$('.o_searchview_input').val().trim(); - }, - }); - this.autocomplete.appendTo(this.$('.o_searchview_input_container')); - }, - /** - * Provide auto-completion result for req.term (an array to `resp`) - * - * @param {Object} req request to complete - * @param {String} req.term searched term to complete - * @param {Function} resp response callback - */ - complete_global_search: function (req, resp) { - var inputs = this.search_fields.concat(this.filters, this.groupbys); - $.when.apply(null, _(inputs).chain() - .filter(function (input) { return input.visible(); }) - .invoke('complete', req.term) - .value()).then(function () { - resp(_(arguments).chain() - .compact() - .flatten(true) - .value()); - }); - }, - /** - * Action to perform in case of selection: create a facet (model) - * and add it to the search collection - * - * @param {Object} e selection event, preventDefault to avoid setting value on object - * @param {Object} ui selection information - * @param {Object} ui.item selected completion item - */ - select_completion: function (e, ui) { - var facet = ui.item.facet; - e.preventDefault(); - if(facet && facet.values && facet.values.length && String(facet.values[0].value).trim() !== "") { - this.query.add(facet); - } else { - this.query.trigger('add'); - } - }, - subviewForRoot: function (subview_root) { - return _(this.input_subviews).detect(function (subview) { - return subview.$el[0] === subview_root; - }); - }, - siblingSubview: function (subview, direction, wrap_around) { - var index = _(this.input_subviews).indexOf(subview) + direction; - if (wrap_around && index < 0) { - index = this.input_subviews.length - 1; - } else if (wrap_around && index >= this.input_subviews.length) { - index = 0; - } - return this.input_subviews[index]; - }, - focusPreceding: function (subview_root) { - return this.siblingSubview( - this.subviewForRoot(subview_root), -1, true) - .$el.focus(); - }, - focusFollowing: function (subview_root) { - return this.siblingSubview( - this.subviewForRoot(subview_root), +1, true) - .$el.focus(); - }, - /** - */ - renderFacets: function () { - var self = this; - var started = []; - _.invoke(this.input_subviews, 'destroy'); - this.input_subviews = []; - - var activeItemIds = { - groupByCategory: [], - filterCategory: [], - }; - var timeRangeMenuIsActive; - this.query.each(function (facet) { - var values = facet.get('values'); - if (facet.attributes.cat === "groupByCategory") { - activeItemIds.groupByCategory = activeItemIds.groupByCategory.concat( - _.uniq( - values.reduce( - function (acc, value) { - var groupby = value.value; - var description = _.findWhere(self.groupbysMapping, {groupby: groupby}); - if (description) { - acc.push(description.groupbyId); - } - return acc; - }, - [] - ) - ) - ); - } - if (facet.attributes.cat === "filterCategory") { - activeItemIds.filterCategory = activeItemIds.filterCategory.concat( - _.uniq( - values.reduce( - function (acc, value) { - var filter = value.value; - var description = _.findWhere(self.filtersMapping, {filter: filter}); - if (description) { - acc.push(description.filterId); - } - return acc; - }, - [] - ) - ) - ); - } - if (facet.attributes.cat === "timeRangeCategory") { - timeRangeMenuIsActive = true; - } - var f = new FacetView(this, facet, this.intervalMapping, this.periodMapping); - started.push(f.appendTo(self.$('.o_searchview_input_container'))); - self.input_subviews.push(f); - }, this); - - var i = new InputView(this); - started.push(i.appendTo(self.$('.o_searchview_input_container'))); - self.input_subviews.push(i); - _.each(this.input_subviews, function (childView) { - childView.on('focused', self, self.proxy('childFocused')); - childView.on('blurred', self, self.proxy('childBlurred')); - }); - - $.when.apply(null, started).then(function () { - if (!config.device.isMobile) { - // in mobile mode, we would rathor not focusing manually the - // input, because it opens up the integrated keyboard, which is - // not what you expect when you just selected a filter. - _.last(self.input_subviews).$el.focus(); - } - if (self.groupby_menu) { - self.groupby_menu.updateItemsStatus(activeItemIds.groupByCategory); - } - if (self.filters_menu) { - self.filters_menu.updateItemsStatus(activeItemIds.filterCategory); } - if (self.displayedTimeRangeMenu && !timeRangeMenuIsActive) { - self.timeRangeMenu.deactivate(); - } - }); - }, - childFocused: function () { - this.$el.addClass('active'); - }, - childBlurred: function () { - this.$el.val('').removeClass('active').trigger('blur'); - this.autocomplete.close(); - }, - /** - * Call the renderFacets method with the correct arguments. - * This is due to the fact that change events are called with two arguments - * (model, options) while add, reset and remove events are called with - * (collection, model, options) as arguments - */ - renderChangedFacets: function (model, options) { - this.renderFacets(undefined, model, options); - }, - // it should parse the arch field of the view, instantiate the corresponding - // filters/fields, and put them in the correct variables: - // * this.search_fields is a list of all the fields, - // * this.filters: groups of filters - // * this.group_by: group_bys - prepare_search_inputs: function () { - var self = this, - arch = this.fields_view.arch; - - var filters = [].concat.apply([], _.map(arch.children, function (item) { - return item.tag !== 'group' ? eval_item(item) : item.children.map(eval_item); - })); - function eval_item (item) { - var category = 'filters'; - if (item.attrs.context) { - try { - var context = pyUtils.eval('context', item.attrs.context); - if (context.group_by) { - category = 'group_by'; - item.attrs.fieldName = context.group_by.split(':')[0]; - item.attrs.isDate = _.contains(['date', 'datetime'], self.fields[item.attrs.fieldName].type); - item.attrs.defaultInterval = context.group_by.split(':')[1]; - } - } catch (e) {} - } - if (item.attrs.date) { - item.attrs.default_period = item.attrs.default_period || DEFAULT_PERIOD; - item.attrs.type = self.fields[item.attrs.date].type; - } - item.attrs.isPeriod = !!item.attrs.date; - return { - item: item, - category: category, - }; - } - var current_group = [], - current_category = 'filters', - categories = {filters: this.filters, group_by: this.groupbys, timeRanges: this.timeRanges}; - - _.each(filters.concat({category:'filters', item: 'separator'}), function (filter) { - if (filter.item.tag === 'filter' && filter.category === current_category) { - return current_group.push(new search_inputs.Filter(filter.item, self)); - } - if (current_group.length) { - var group = new search_inputs.FilterGroup(current_group, self, self.intervalMapping, self.periodMapping); - categories[current_category].push(group); - current_group = []; - } - if (filter.item.tag === 'field') { - var attrs = filter.item.attrs; - var field = self.fields_view.fields[attrs.name]; - - // M2O combined with selection widget is pointless and broken in search views, - // but has been used in the past for unsupported hacks -> ignore it - if (field.type === "many2one" && attrs.widget === "selection") { - attrs.widget = undefined; - } - var Obj = core.search_widgets_registry.getAny([attrs.widget, field.type]); - if (Obj) { - self.search_fields.push(new (Obj) (filter.item, field, self)); - } - } - if (filter.item.tag === 'filter') { - current_group.push(new search_inputs.Filter(filter.item, self)); - } - current_category = filter.category; - }); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Updates 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 {Array<Object>} newFilters list of filters to add, described by - * objects with keys domain (the domain as an Array), and help (the text - * to display in the facet) - * @param {Array<Object>} filtersToRemove list of filters to remove - * (previously added ones) - * @returns {Array<Object>} list of added filters (to pass as filtersToRemove - * for a further call to this function) - */ - updateFilters: function (newFilters, filtersToRemove) { - var self = this; - var addedFilters = _.map(newFilters, function (filter) { - var domain = filter.domain; - if (domain instanceof Array) { - domain = Domain.prototype.arrayToString(domain); - } - filter = { - attrs: {domain: domain, help: filter.help}, - }; - var filterWidget = new search_inputs.Filter(filter); - var filterGroup = new search_inputs.FilterGroup([filterWidget], self, self.intervalMapping, self.periodMapping); - var facet = filterGroup.make_facet([filterGroup.make_value(filter)]); - self.query.add([facet], {silent: true}); - - return _.last(self.query.models); - }); - - _.each(filtersToRemove, function (filter) { - self.query.remove(filter, {silent: true}); - }); - - this.query.trigger('reset'); - - return addedFilters; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - - /** - * Will return $element where Filters, Group By and Favorite buttons are - * going to be pushed. This method is overriden by the mobile search view - * to add these buttons somewhere else in the dom. - * - * @private - * @returns {jQueryElement} - */ - _getButtonsElement: function () { - return this.$buttons; - }, - /** - * Create a groupby menu. Note that this method has a side effect: it - * builds a mapping from a filter name to a 'search filter'. - * - * @private - * @returns {Widget} the processed fieldsView - */ - _createFiltersMenu: function () { - var self = this; - var filters = []; - - this.filters.forEach(function (group) { - var groupId = _.uniqueId('__group__'); - group.filters.forEach(function (filter) { - if (!filter.attrs.invisible) { - var filterId = _.uniqueId('__filter__'); - var isPeriod = filter.attrs.isPeriod; - var defaultPeriod = filter.attrs.default_period; - var isActive = !self.hasFavorites && !!self.search_defaults[filter.attrs.name]; - filters.push({ - itemId: filterId, - description: filter.attrs.string || filter.attrs.help || - filter.attrs.name || filter.attrs.domain || 'Ω', - domain: filter.attrs.domain, - fieldName: filter.attrs.date, - isPeriod: isPeriod, - defaultOptionId: defaultPeriod, - isActive: isActive, - groupId: groupId, - }); - self.filtersMapping.push({filterId: filterId, filter: filter, groupId: groupId}); - if (isPeriod) { - self.periodMapping.push({filter: filter, period: defaultPeriod, type: filter.attrs.type}); - } - } - }); - self.groupsMapping.push({groupId: groupId, group: group, category: 'Filters'}); - }); - - return new FiltersMenu(self, filters, self.fields); - }, - - /** - * Create a groupby menu. Note that this method has a side effect: it - * builds a mapping from a filter name to a 'search filter'. - * - * @private - * @returns {Widget} the processed fieldsView - */ - _createGroupByMenu: function () { - var self = this; - var groupbys = []; - - this.groupbys.forEach(function (group) { - var groupId = _.uniqueId('__group__'); - group.filters.forEach(function (groupby) { - if (!groupby.attrs.invisible) { - var groupbyId = _.uniqueId('__groupby__'); - var fieldName = groupby.attrs.fieldName; - var isDate = groupby.attrs.isDate; - var defaultInterval = groupby.attrs.defaultInterval || DEFAULT_INTERVAL; - var isActive = !self.hasFavorites && !!self.search_defaults[groupby.attrs.name]; - groupbys.push({ - itemId: groupbyId, - description: groupby.attrs.string || groupby.attrs.help || groupby.attrs.name - || groupby.attrs.fieldName || 'Ω', - isDate: isDate, - fieldName: fieldName, - defaultOptionId: defaultInterval, - isActive: isActive, - groupId: groupId, - }); - if (isDate) { - self.intervalMapping.push({groupby: groupby, interval: defaultInterval}); - } - self.groupbysMapping.push({groupbyId: groupbyId, groupby: groupby, groupId: groupId}); - } - }); - self.groupsMapping.push({groupId: groupId, group: group, category: 'Group By'}); - group.updateIntervalMapping(self.intervalMapping); - }); - return new GroupByMenu(this, groupbys, this.fields); - }, - /** - * Create a time range menu. - * - * @private - * @returns {Widget} the range menu - */ - _createTimeRangeMenu: function () { - return new TimeRangeMenu(this, this.fields, this.timeRanges); - }, - /** - * Processes a fieldsView in place. In particular, parses its arch. - * - * @todo: this function is also defined in AbstractView ; this code - * duplication could be removed once the SearchView will be rewritten. - * @private - * @param {Object} fv - * @param {string} fv.arch - * @returns {Object} the processed fieldsView - */ - _processFieldsView: function (fv) { - var doc = $.parseXML(fv.arch).documentElement; - fv.arch = utils.xml_to_json(doc, true); - return fv; - }, - /** - * @returns {Deferred} - */ - _searchDefaultTimeRange: function () { - if (this.timeRanges) { - var timeRange = "[]"; - var timeRangeDescription; - var comparisonTimeRange = "[]"; - var comparisonTimeRangeDescription; - - var dateField = { - name: this.timeRanges.field, - type: this.fields[this.timeRanges.field].type, - description: this.fields[this.timeRanges.field].string, - }; - - timeRange = Domain.prototype.constructDomain( - dateField.name, - this.timeRanges.range, - dateField.type - ); - timeRangeDescription = _.findWhere( - PeriodOptions, - {optionId: this.timeRanges.range} - ).description; - - if (this.timeRanges.comparison_range) { - comparisonTimeRange = Domain.prototype.constructDomain( - dateField.name, - this.timeRanges.range, - dateField.type, - null, - this.timeRanges.comparison_range - ); - comparisonTimeRangeDescription = _.findWhere( - ComparisonOptions, - {optionId: this.timeRanges.comparison_range} - ).description; - } - - return $.when({ - cat: 'timeRangeCategory', - category: _t("Time Range"), - icon: 'fa fa-calendar', - field: { - get_context: function (facet, noDomainEvaluation) { - if (!noDomainEvaluation) { - timeRange = Domain.prototype.stringToArray(timeRange); - comparisonTimeRange = Domain.prototype.stringToArray(comparisonTimeRange); - } - return { - timeRangeMenuData: { - timeRange: timeRange, - timeRangeDescription: timeRangeDescription, - comparisonTimeRange: comparisonTimeRange, - comparisonTimeRangeDescription: comparisonTimeRangeDescription, - } - }; - }, - get_groupby: function () {}, - get_domain: function () {} - }, - isRange: true, - values: [{ - label: dateField.description + ': ' + timeRangeDescription + - ( - comparisonTimeRangeDescription ? - (' / ' + comparisonTimeRangeDescription) : - '' - ), - value: null, - }], - }); - } else { - return $.when(); - } - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * - * this function is called in response to an event 'on_item_toggled'. - * this kind of event is triggered by the filters menu or the groupby menu - * when a user has clicked on a item (a filter or a groupby). - * The query is modified accordingly to the new state (active or not) of that item - * - * @private - * @param {OdooEvent} event - */ - _onItemToggled: function (event) { - var group; - if (event.data.category === 'groupByCategory') { - var groupby = _.findWhere(this.groupbysMapping, {groupbyId: event.data.itemId}).groupby; - group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group; - if (event.data.optionId) { - var interval = event.data.optionId; - _.findWhere(this.intervalMapping, {groupby: groupby}).interval = interval; - group.updateIntervalMapping(this.intervalMapping); - } - group.toggle(groupby); - } - if (event.data.category === 'filterCategory') { - var filter = _.findWhere(this.filtersMapping, {filterId: event.data.itemId}).filter; - group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group; - if (event.data.optionId) { - var period = event.data.optionId; - _.findWhere(this.periodMapping, {filter: filter}).period = period; - group.updatePeriodMapping(this.periodMapping); - } - group.toggle(filter); - } - }, - /** - * this function is called when a new groupby has been added to the groupby menu - * via the 'Add Custom Groupby' submenu. The query is modified with the new groupby - * added to it as active. The communication betwenn the groupby menu and the search view - * is maintained by properly updating the mappings. - * @private - * @param {OdooEvent} event - */ - _onNewGroupby: function (event) { - var isDate = event.data.isDate; - var attrs = { - context:"{'group_by':'" + event.data.fieldName + "''}", - name: event.data.description, - fieldName: event.data.fieldName, - isDate: isDate, - }; - var groupby = new search_inputs.Filter({attrs: attrs}, this); - if (event.data.optionId) { - var interval = event.data.optionId; - this.intervalMapping.push({groupby: groupby, interval: interval}); - } - var group = new search_inputs.FilterGroup([groupby], this, this.intervalMapping, this.periodMapping); - this.groupbysMapping.push({ - groupbyId: event.data.itemId, - groupby: groupby, - groupId: event.data.groupId, - }); - this.groupsMapping.push({ - groupId: event.data.groupId, - group: group, - category: 'Group By', - }); - group.toggle(groupby); - }, - /** - * this function is called when a new filter has been added to the filters menu - * via the 'Add Custom Filter' submenu. The query is modified with the new filter - * added to it as active. The communication betwenn the filters menu and the search view - * is maintained by properly updating the mappings. - * @private - * @param {OdooEvent} event - */ - _onNewFilters: function (event) { - var self= this; - var filter; - var filters = []; - var groupId; - - _.each(event.data, function (filterDescription) { - filter = new search_inputs.Filter(filterDescription.filter, this); - filters.push(filter); - self.filtersMapping.push({ - filterId: filterDescription.itemId, - filter: filter, - groupId: filterDescription.groupId, - }); - // filters belong to the same group - if (!groupId) { - groupId = filterDescription.groupId; - } - }); - var group = new search_inputs.FilterGroup(filters, this, this.intervalMapping, this.periodMapping); - filters.forEach(function (filter) { - group.toggle(filter, {silent: true}); - }); - this.groupsMapping.push({ - groupId: groupId, - group: group, - category: 'Filters', - }); - this.query.trigger('reset'); - }, - /** - * this function is called when the option related to an item (filter or groupby) has been - * changed by the user. The query is modified appropriately. - * - * @private - * @param {OdooEvent} event - */ - _onItemOptionChanged: function (event) { - var group; - if (event.data.category === 'groupByCategory') { - var groupby = _.findWhere(this.groupbysMapping, {groupbyId: event.data.itemId}).groupby; - var interval = event.data.optionId; - _.findWhere(this.intervalMapping, {groupby: groupby}).interval = interval; - group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group; - group.updateIntervalMapping(this.intervalMapping); - this.query.trigger('reset'); - } - if (event.data.category === 'filterCategory') { - var filter = _.findWhere(this.filtersMapping, {filterId: event.data.itemId}).filter; - var period = event.data.optionId; - _.findWhere(this.periodMapping, {filter: filter}).period = period; - group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group; - group.updatePeriodMapping(this.periodMapping); - this.query.trigger('reset'); - } - }, - /* - * @private - * @param {JQueryEvent} event - */ - _onTimeRangeModified: function () { - var facet = this.timeRangeMenu.facetFor(); - var current = this.query.find(function (facet) { - return facet.get('cat') === 'timeRangeCategory'; - }); - if (current) { - this.query.remove(current, {silent: true}); - } - this.query.add(facet); - }, - /* - * @private - */ - _onTimeRangeRemoved: function () { - var current = this.query.find(function (facet) { - return facet.get('cat') === 'timeRangeCategory'; - }); - if (current) { - this.query.remove(current); - } - }, -}); - -_.extend(SearchView, { - SearchQuery: SearchQuery, - Facet: Facet, -}); - -return SearchView; - -}); diff --git a/addons/web/static/src/js/views/search/time_range_menu.js b/addons/web/static/src/js/views/search/time_range_menu.js deleted file mode 100644 index 0378c132aab..00000000000 --- a/addons/web/static/src/js/views/search/time_range_menu.js +++ /dev/null @@ -1,198 +0,0 @@ -odoo.define('web.TimeRangeMenu', function (require) { -"use strict"; - -var config = require('web.config'); -var core = require('web.core'); -var Domain = require('web.Domain'); -var TimeRangeMenuOptions = require('web.TimeRangeMenuOptions'); -var Widget = require('web.Widget'); - -var _t = core._t; -var ComparisonOptions = TimeRangeMenuOptions.ComparisonOptions; -var PeriodOptions = TimeRangeMenuOptions.PeriodOptions; - -var TimeRangeMenu = Widget.extend({ - template: 'web.TimeRangeMenu', - events: { - 'click .o_apply_range': '_onApplyButtonClick', - 'click .o_comparison_checkbox': '_onCheckBoxClick', - }, - - /** - * override - * @param {Widget} parent - * @param {Object} fields - * @param {Object} configuration - * - */ - init: function(parent, fields, configuration) { - var self = this; - this.isMobile = config.device.isMobile; - this.symbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : 'caret'; - this._super(parent); - this.dateFields = []; - _.each(fields, function (field, name) { - if (field.sortable && _.contains(['date', 'datetime'], field.type)) { - self.dateFields.push(_.extend({}, field, { - name: name, - })); - } - }); - this.periodOptions = PeriodOptions; - this.periodGroups = PeriodOptions.reduce( - function (acc, option) { - if (!_.contains(acc, option.groupId)) { - acc.push(option.groupId); - } - return acc; - }, - [] - ); - - this.comparisonOptions = ComparisonOptions; - - // Following steps determine initial configuration - this.isActive = false; - this.timeRangeId = undefined; - this.comparisonIsSelected = false; - this.comparisonTimeRangeId = undefined; - this.dateField = {}; - if (configuration && configuration.field && configuration.range) { - this.isActive = true; - var dateField = _.findWhere(this.dateFields, {name: configuration.field}); - this.dateField = { - name: dateField.name, - description: dateField.string, - type: dateField.type, - }; - this.timeRangeId = configuration.range; - if (configuration.comparison_range) { - this.comparisonIsSelected = true; - this.comparisonTimeRangeId = configuration.comparison_range; - } - } - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - deactivate: function () { - this.isActive = false; - this.comparisonIsSelected = false; - this.renderElement(); - }, - /** - * Generates a :js:class:`~instance.web.search.Facet` descriptor from a - * filter descriptor - * - * @returns {Object} - */ - facetFor: function () { - var fieldDescription; - var timeRange = "[]"; - var timeRangeDescription; - var comparisonTimeRange = "[]"; - var comparisonTimeRangeDescription; - - if (this.isActive) { - fieldDescription = this.dateField.description; - if (this.timeRangeId !== 'custom') { - timeRange = Domain.prototype.constructDomain( - this.dateField.name, - this.timeRangeId, - this.dateField.type - ); - timeRangeDescription = _.findWhere( - this.periodOptions, - {optionId: this.timeRangeId} - ).description; - } - if (this.comparisonIsSelected) { - comparisonTimeRange = Domain.prototype.constructDomain( - this.dateField.name, - this.timeRangeId, - this.dateField.type, - null, - this.comparisonTimeRangeId - ); - comparisonTimeRangeDescription = _.findWhere( - this.comparisonOptions, - {optionId: this.comparisonTimeRangeId} - ).description; - } - } - - return { - cat: 'timeRangeCategory', - category: _t("Time Range"), - icon: 'fa fa-calendar', - field: { - get_context: function (facet, noDomainEvaluation) { - if (!noDomainEvaluation) { - timeRange = Domain.prototype.stringToArray(timeRange); - comparisonTimeRange = Domain.prototype.stringToArray(comparisonTimeRange); - } - return { - timeRangeMenuData: { - timeRange: timeRange, - timeRangeDescription: timeRangeDescription, - comparisonTimeRange: comparisonTimeRange, - comparisonTimeRangeDescription: comparisonTimeRangeDescription, - } - }; - }, - get_groupby: function () {}, - get_domain: function () {} - }, - isRange: true, - values: [{ - label: fieldDescription + ': ' + timeRangeDescription + - ( - comparisonTimeRangeDescription ? - (' / ' + comparisonTimeRangeDescription) : - '' - ), - value: null, - }], - }; - }, - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onApplyButtonClick: function () { - this.isActive = true; - var dateFieldName = this.$('.o_date_field_selector').val(); - this.timeRangeId = this.$('.o_time_range_selector').val(); - if (this.comparisonIsSelected) { - this.comparisonTimeRangeId = this.$('.o_comparison_time_range_selector').val(); - } - this.dateField = { - name: dateFieldName, - type: _.findWhere(this.dateFields, {name: dateFieldName}).type, - description: _.findWhere(this.dateFields, {name: dateFieldName}).string, - }; - - this.renderElement(); - this.trigger_up('time_range_modified'); - }, - /** - * @private - * - * @param {JQueryEvent} ev - */ - _onCheckBoxClick: function (ev) { - ev.stopPropagation(); - this.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/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js index ee11afbc3bd..fb9cd5afa0c 100644 --- a/addons/web/static/src/js/views/view_dialogs.js +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -3,13 +3,10 @@ odoo.define('web.view_dialogs', function (require) { var config = require('web.config'); var core = require('web.core'); -var data = require('web.data'); var Dialog = require('web.Dialog'); var dom = require('web.dom'); var ListController = require('web.ListController'); var ListView = require('web.ListView'); -var pyUtils = require('web.py_utils'); -var SearchView = require('web.SearchView'); var view_registry = require('web.view_registry'); var _t = core._t; @@ -21,9 +18,6 @@ var _t = core._t; var ViewDialog = Dialog.extend({ custom_events: _.extend({}, Dialog.prototype.custom_events, { push_state: '_onPushState', - env_updated: function (event) { - event.stopPropagation(); - }, }), /** * @constructor @@ -45,10 +39,6 @@ var ViewDialog = Dialog.extend({ this.domain = options.domain || []; this.context = options.context || {}; this.options = _.extend(this.options || {}, options || {}); - - // FIXME: remove this once a dataset won't be necessary anymore to interact - // with data_manager and instantiate views - this.dataset = new data.DataSet(this, this.res_model, this.context); }, //-------------------------------------------------------------------------- @@ -182,7 +172,7 @@ var FormViewDialog = ViewDialog.extend({ if (this.options.fields_view) { fields_view_def = $.when(this.options.fields_view); } else { - fields_view_def = this.loadFieldView(this.dataset, this.options.view_id, 'form'); + fields_view_def = this.loadFieldView(this.res_model, this.context, this.options.view_id, 'form'); } fields_view_def.then(function (viewInfo) { @@ -271,17 +261,23 @@ var FormViewDialog = ViewDialog.extend({ }); var SelectCreateListController = ListController.extend({ - // Override the ListView to handle the custom events 'open_record' (triggered when clicking on a - // row of the list) such that it triggers up 'select_record' with its res_id. - custom_events: _.extend({}, ListController.prototype.custom_events, { - open_record: function (event) { - var selectedRecord = this.model.get(event.data.id); - this.trigger_up('select_record', { - id: selectedRecord.res_id, - display_name: selectedRecord.data.display_name, - }); - }, - }), + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Override to select the clicked record instead of opening it + * + * @override + * @private + */ + _onOpenRecord: function (ev) { + var selectedRecord = this.model.get(ev.data.id); + this.trigger_up('select_record', { + id: selectedRecord.res_id, + display_name: selectedRecord.data.display_name, + }); + }, }); /** @@ -299,13 +295,6 @@ var SelectCreateDialog = ViewDialog.extend({ event.stopPropagation(); this.$footer.find(".o_select_button").prop('disabled', !event.data.selection.length); }, - search: function (event) { - event.stopPropagation(); // prevent this event from bubbling up to the action manager - var d = event.data; - var searchData = this._process_search_data(d.domains, d.contexts, d.groupbys); - this.list_controller.reload(searchData); - }, - get_controller_context: '_onGetControllerContext', }), /** @@ -320,7 +309,7 @@ var SelectCreateDialog = ViewDialog.extend({ this._super.apply(this, arguments); _.defaults(this.options, { initial_view: 'search' }); this.on_selected = this.options.on_selected || (function () {}); - this.initial_ids = this.options.initial_ids; + this.initialIDs = this.options.initial_ids; }, open: function () { @@ -328,26 +317,13 @@ var SelectCreateDialog = ViewDialog.extend({ return this.create_edit_record(); } var self = this; - var user_context = this.getSession().user_context; - var _super = this._super.bind(this); - var context = pyUtils.eval_domains_and_contexts({ - domains: [], - contexts: [user_context, this.context] - }).context; - var search_defaults = {}; - _.each(context, function (value_, key) { - var match = /^search_default_(.*)$/.exec(key); - if (match) { - search_defaults[match[1]] = value_; - } - }); - this.loadViews(this.dataset.model, this.dataset.get_context().eval(), [[false, 'list'], [false, 'search']], {}) - .then(this.setup.bind(this, search_defaults)) + this.loadViews(this.res_model, this.context, [[false, 'list'], [false, 'search']], {}) + .then(this.setup.bind(this)) .then(function (fragment) { self.opened().then(function () { dom.append(self.$el, fragment, { - callbacks: [{widget: self.list_controller}], + callbacks: [{widget: self.listController}], in_DOM: true, }); self.set_buttons(self.__buttons); @@ -357,65 +333,50 @@ var SelectCreateDialog = ViewDialog.extend({ return this; }, - setup: function (search_defaults, fields_views) { + setup: function (fieldsViews) { var self = this; var fragment = document.createDocumentFragment(); - var searchDef = $.Deferred(); - - // Set the dialog's header and its search view - var $header = $('<div/>').addClass('o_modal_header').appendTo(fragment); - var $pager = $('<div/>').addClass('o_pager').appendTo($header); - var options = { - $buttons: $('<div/>').addClass('o_search_options').appendTo($header), - search_defaults: search_defaults, - }; - var searchview = new SearchView(this, this.dataset, fields_views.search, options); - searchview.prependTo($header).done(function () { - var d = searchview.build_search_data(); - if (self.initial_ids) { - d.domains.push([["id", "in", self.initial_ids]]); - self.initial_ids = undefined; - } - var searchData = self._process_search_data(d.domains, d.contexts, d.groupbys); - searchDef.resolve(searchData); - }); - - return $.when(searchDef).then(function (searchResult) { - // Set the list view - var listView = new ListView(fields_views.list, _.extend({ - context: searchResult.context, - domain: searchResult.domain, - groupBy: searchResult.groupBy, - modelName: self.dataset.model, - hasSelectors: !self.options.disable_multiple_selection, - readonly: true, - }, self.options.list_view_options)); - listView.setController(SelectCreateListController); - return listView.getController(self); - }).then(function (controller) { - self.list_controller = controller; - // Set the dialog's buttons + var domain = this.domain; + if (this.initialIDs) { + domain = domain.concat([['id', 'in', this.initialIDs]]); + } + var listView = new ListView(fieldsViews.list, _.extend({ + action: { + controlPanelFieldsView: fieldsViews.search, + }, + action_buttons: false, + context: this.context, + domain: domain, + hasSelectors: !this.options.disable_multiple_selection, + modelName: this.res_model, + readonly: true, + withBreadcrumbs: false, + }, this.options.list_view_options)); + listView.setController(SelectCreateListController); + return listView.getController(this).then(function (controller) { + self.listController = controller; + // render the footer buttons self.__buttons = [{ text: _t("Cancel"), - classes: "btn-secondary o_form_button_cancel", + classes: 'btn-secondary o_form_button_cancel', close: true, }]; if (!self.options.no_create) { self.__buttons.unshift({ text: _t("Create"), - classes: "btn-primary", + classes: 'btn-primary', click: self.create_edit_record.bind(self) }); } if (!self.options.disable_multiple_selection) { self.__buttons.unshift({ text: _t("Select"), - classes: "btn-primary o_select_button", + classes: 'btn-primary o_select_button', disabled: true, close: true, click: function () { - var records = self.list_controller.getSelectedRecords(); + var records = self.listController.getSelectedRecords(); var values = _.map(records, function (record) { return { id: record.res_id, @@ -426,28 +387,11 @@ var SelectCreateDialog = ViewDialog.extend({ }, }); } - return self.list_controller.appendTo(fragment); + return self.listController.appendTo(fragment); }).then(function () { - searchview.toggle_visibility(true); - self.list_controller.do_show(); - self.list_controller.renderPager($pager); return fragment; }); }, - _process_search_data: function (domains, contexts, groupbys) { - var results = pyUtils.eval_domains_and_contexts({ - domains: [this.domain].concat(domains), - contexts: [this.context].concat(contexts), - group_by_seq: groupbys || [], - eval_context: this.getSession().user_context, - }); - var context = _.omit(results.context, function (value, key) { return key.indexOf('search_default_') === 0; }); - return { - context: context, - domain: results.domain, - groupBy: results.group_by, - }; - }, create_edit_record: function () { var self = this; var dialog = new FormViewDialog(this, _.extend({}, this.options, { @@ -469,23 +413,6 @@ var SelectCreateDialog = ViewDialog.extend({ this.trigger_up('form_dialog_discarded'); return true; }, - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Handles a context request: provides to the caller the context of the - * list controller. - * - * @private - * @param {OdooEvent} ev - * @param {function} ev.data.callback used to send the requested context - */ - _onGetControllerContext: function (ev) { - ev.stopPropagation(); - var context = this.list_controller && this.list_controller.getContext(); - ev.data.callback(context || {}); - } }); return { diff --git a/addons/web/static/src/js/views/view_utils.js b/addons/web/static/src/js/views/view_utils.js index 57ae9547d2f..94485c1401a 100644 --- a/addons/web/static/src/js/views/view_utils.js +++ b/addons/web/static/src/js/views/view_utils.js @@ -1,7 +1,10 @@ -odoo.define('web.viewUtils', function () { +odoo.define('web.viewUtils', function (require) { "use strict"; -var utils = { +var dom = require('web.dom'); +var utils = require('web.utils'); + +var viewUtils = { /** * Returns the value of a group dataPoint, i.e. the value of the groupBy * field for the records in that group. @@ -40,8 +43,45 @@ var utils = { } return true; }, + /** + * @param {string} arch view arch + * @returns {Object} parsed arch + */ + parseArch: function (arch) { + var doc = $.parseXML(arch).documentElement; + var stripWhitespaces = doc.nodeName.toLowerCase() !== 'kanban'; + return utils.xml_to_json(doc, stripWhitespaces); + }, + /** + * Renders a button according to a given arch node element. + * + * @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); + }, }; -return utils; +return viewUtils; }); diff --git a/addons/web/static/src/js/widgets/dropdown_menu.js b/addons/web/static/src/js/widgets/dropdown_menu.js index e92c714d838..11873718576 100644 --- a/addons/web/static/src/js/widgets/dropdown_menu.js +++ b/addons/web/static/src/js/widgets/dropdown_menu.js @@ -6,81 +6,48 @@ var Widget = require('web.Widget'); var QWeb = core.qweb; - - var DropdownMenu = Widget.extend({ template: 'web.DropdownMenu', + events: { 'click .o_menu_item': '_onItemClick', 'click .o_item_option': '_onOptionClick', - 'click span.o_trash_button': '_onTrashClick', + 'click span.o_trash_button': '_onTrashButtonClick', 'hidden.bs.dropdown': '_onBootstrapClose', 'click .dropdown-item-text': '_onDropDownItemTextClick', }, - /** - * override - * - * @param {Widget} parent - * @param {Object} dropdownHeader object used to customize the dropdown menu. - * @param {String} dropdownHeader.title - * @param {String} dropdownHeader.icon - * @param {String} dropdownHeader.symbol - * @param {String} dropdownHeader.category descripbes the type of items - * @param {String} dropdownHeader.style the button style - * @param {Object[]} items list of menu items - * - * Menu items: - * - * * itemId: string; (optional) unique id associated with the item - * * description: string; label printed on screen - * * groupId: string; - * * isActive: boolean; (optional) determines if the item 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) - */ - init: function (parent, dropdownHeader, items) { + init: function (parent, items) { this._super(parent); - this.dropdownCategory = dropdownHeader.category; - this.dropdownTitle = dropdownHeader.title; - this.dropdownIcon = dropdownHeader.icon; - this.dropdownSymbol = dropdownHeader.symbol || false; + // should be specified + this.dropdownCategory = null; + this.dropdownTitle = null; + this.dropdownIcon = null; + this.dropdownSymbol = false; // this parameter fixes the menu style. By default, // the style used is the one used in the search view - this.dropdownStyle = dropdownHeader.style || { + this.dropdownStyle = { el: {class: 'btn-group o_dropdown', attrs: {}}, - mainButton: {class: 'o_dropdown_toggler_btn btn btn-secondary dropdown-toggle' + (this.dropdownSymbol ? ' o-no-caret' : '')}, - }; + mainButton: { + class: 'o_dropdown_toggler_btn btn btn-secondary ' + + 'dropdown-toggle ' + + (this.dropdownSymbol ? 'o-no-caret' : '') + }, + }; this.items = items; - _.each(this.items, this._prepareItem.bind(this)); - }, - /** - * override - */ - start: function () { - this.$menu = this.$('.o_dropdown_menu'); + this.openItems = {}; }, + //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** - * @param {string[]} activeItemIds + * @param {Object} items */ - updateItemsStatus: function (activeItemIds) { - _.each(this.items, function (item) { - if (!_.contains(activeItemIds, item.itemId)) { - item.isActive = false; - item.currentOptionId = false; - } else { - item.isActive = true; - item.currentOptionId = item.hasOptions ? - (item.currentOptionId || item.defaultOptionId) : - false; - } - }); + update: function (items) { + this.items = items; this._renderMenuItems(); }, @@ -90,97 +57,11 @@ var DropdownMenu = Widget.extend({ /** * @private - * @param {string} itemId - */ - _removeItem: function (itemId) { - var item, itemIndex; - item = _.findWhere(this.items, {itemId: itemId}); - itemIndex = this.items.indexOf(item); - this.items.splice(itemIndex, 1); - this._renderMenuItems(); - var eventData = { - category: this.dropdownCategory, - itemId: itemId, - groupId: item.groupId, - }; - this.trigger_up('menu_item_deleted', eventData); - }, - /** - * @private */ _renderMenuItems: function () { var newMenuItems = QWeb.render('DropdownMenu.MenuItems', {widget: this}); this.$el.find('.o_menu_item, .dropdown-divider[data-removable="1"]').remove(); - this.$('.o_dropdown_menu').prepend($(newMenuItems)); - }, - /** - * this function computes some default values for some of the item keys - * according to values passed at initialization - * - * @private - * @param {Object} item - */ - _prepareItem: function (item) { - item.itemId = item.itemId || _.uniqueId('__item__'); - item.isOpen = item.isOpen || false; - item.isRemovable = item.isRemovable || false; - if (item.options && item.options.length !== 0) { - item.options = _.sortBy(item.options, 'groupId'); - item.hasOptions = true; - item.defaultOptionId = item.defaultOptionId || this.defaultOptionId; - item.currentOptionId = item.isActive && item.defaultOptionId ? item.defaultOptionId : false; - } - }, - /** - * @private - * @param {string} itemId - */ - _toggleMenuItem: function (itemId) { - var item = _.findWhere(this.items, {itemId: itemId}); - if (!item.hasOptions) { - item.isActive = !item.isActive; - var eventData = { - category: this.dropdownCategory, - itemId: itemId, - isActive: item.isActive, - groupId: item.groupId, - }; - this._renderMenuItems(); - this.trigger_up('menu_item_toggled', eventData); - } - if (item.hasOptions) { - item.isOpen = !item.isOpen; - this._renderMenuItems(); - } - }, - /** - * @private - * @param {string} itemId - * @param {string} optionId - */ - _toggleItemOption: function (itemId, optionId) { - var item = _.findWhere(this.items, {itemId: itemId}); - var initialState = item.isActive; - if (item.currentOptionId === optionId) { - item.isActive = false; - item.currentOptionId = false; - } else { - item.isActive = true; - item.currentOptionId = optionId; - } - var eventData = { - category: this.dropdownCategory, - itemId: itemId, - groupId: item.groupId, - isActive: item.isActive, - optionId: item.currentOptionId, - }; - this._renderMenuItems(); - if (item.isActive !== initialState) { - this.trigger_up('menu_item_toggled', eventData); - } else { - this.trigger_up('item_option_changed', eventData); - } + this.$('.o_dropdown_menu').prepend(newMenuItems); }, //-------------------------------------------------------------------------- @@ -195,9 +76,7 @@ var DropdownMenu = Widget.extend({ * @param {jQueryEvent} event */ _onBootstrapClose: function () { - _.each(this.items, function (item) { - item.isOpen = false; - }); + this.openItems = {}; this._renderMenuItems(); }, /** @@ -216,8 +95,16 @@ var DropdownMenu = Widget.extend({ _onItemClick: function (event) { event.preventDefault(); event.stopPropagation(); - var itemId = $(event.currentTarget).data('id'); - this._toggleMenuItem(itemId); + var id = $(event.currentTarget).data('id'); + var item = this.items.find(function (item) { + return item.id === id; + }); + if (item.hasOptions) { + this.openItems[id] = !this.openItems[id]; + this._renderMenuItems(); + } else { + this.trigger_up('menu_item_clicked', {id: id}); + } }, /** * @private @@ -227,20 +114,19 @@ var DropdownMenu = Widget.extend({ event.preventDefault(); event.stopPropagation(); var optionId = $(event.currentTarget).data('option_id'); - var itemId = $(event.currentTarget).data('item_id'); - this._toggleItemOption(itemId, optionId); + var id = $(event.currentTarget).data('item_id'); + this.trigger_up('item_option_clicked', {id: id, optionId: optionId}); }, /** * @private * @param {MouseEvent} event + * + * To implement in child */ - _onTrashClick: function (event) { - event.stopPropagation(); - var itemId = $(event.currentTarget).data('id'); - this._removeItem(itemId); - }, + _onTrashButtonClick: function (event) { + }, }); return DropdownMenu; -}); +});
\ No newline at end of file diff --git a/addons/web/static/src/js/widgets/old/dropdown_menu.js b/addons/web/static/src/js/widgets/old/dropdown_menu.js new file mode 100644 index 00000000000..429fdd03067 --- /dev/null +++ b/addons/web/static/src/js/widgets/old/dropdown_menu.js @@ -0,0 +1,236 @@ +odoo.define('web.OldDropdownMenu', function (require) { +"use strict"; + +var core = require('web.core'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; + + + +var OldDropdownMenu = Widget.extend({ + template: 'web.OldDropdownMenu', + events: { + 'click .o_menu_item': '_onItemClick', + 'click .o_item_option': '_onOptionClick', + 'click span.o_trash_button': '_onTrashClick', + 'hidden.bs.dropdown': '_onBootstrapClose', + }, + + /** + * override + * + * @param {Widget} parent + * @param {Object} dropdownHeader object used to customize the dropdown menu. + * @param {String} dropdownHeader.title + * @param {String} dropdownHeader.icon + * @param {String} dropdownHeader.symbol + * @param {String} dropdownHeader.category descripbes the type of items + * @param {String} dropdownHeader.style the button style + * @param {Object[]} items list of menu items + * + * Menu items: + * + * * itemId: string; (optional) unique id associated with the item + * * description: string; label printed on screen + * * groupId: string; + * * isActive: boolean; (optional) determines if the item 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) + */ + init: function (parent, dropdownHeader, items) { + this._super(parent); + this.dropdownCategory = dropdownHeader.category; + this.dropdownTitle = dropdownHeader.title; + this.dropdownIcon = dropdownHeader.icon; + this.dropdownSymbol = dropdownHeader.symbol || false; + // this parameter fixes the menu style. By default, + // the style used is the one used in the search view + this.dropdownStyle = dropdownHeader.style || { + el: {class: 'btn-group o_dropdown', attrs: {}}, + mainButton: {class: 'o_dropdown_toggler_btn btn btn-secondary dropdown-toggle' + (this.dropdownSymbol ? ' o-no-caret' : '')}, + }; + this.items = items; + _.each(this.items, this._prepareItem.bind(this)); + }, + /** + * override + */ + start: function () { + this.$menu = this.$('.o_dropdown_menu'); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {string[]} activeItemIds + */ + updateItemsStatus: function (activeItemIds) { + _.each(this.items, function (item) { + if (!_.contains(activeItemIds, item.itemId)) { + item.isActive = false; + item.currentOptionId = false; + } else { + item.isActive = true; + item.currentOptionId = item.hasOptions ? + (item.currentOptionId || item.defaultOptionId) : + false; + } + }); + this._renderMenuItems(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {string} itemId + */ + _removeItem: function (itemId) { + var item, itemIndex; + item = _.findWhere(this.items, {itemId: itemId}); + itemIndex = this.items.indexOf(item); + this.items.splice(itemIndex, 1); + this._renderMenuItems(); + var eventData = { + category: this.dropdownCategory, + itemId: itemId, + groupId: item.groupId, + }; + this.trigger_up('menu_item_deleted', eventData); + }, + /** + * @private + */ + _renderMenuItems: function () { + var newMenuItems = QWeb.render('OldDropdownMenu.MenuItems', {widget: this}); + this.$el.find('.o_menu_item, .dropdown-divider[data-removable="1"]').remove(); + this.$('.o_dropdown_menu').prepend($(newMenuItems)); + }, + /** + * this function computes some default values for some of the item keys + * according to values passed at initialization + * + * @private + * @param {Object} item + */ + _prepareItem: function (item) { + item.itemId = item.itemId || _.uniqueId('__item__'); + item.isOpen = item.isOpen || false; + item.isRemovable = item.isRemovable || false; + if (item.options && item.options.length !== 0) { + item.options = _.sortBy(item.options, 'groupId'); + item.hasOptions = true; + item.defaultOptionId = item.defaultOptionId || this.defaultOptionId; + item.currentOptionId = item.isActive && item.defaultOptionId ? item.defaultOptionId : false; + } + }, + /** + * @private + * @param {string} itemId + */ + _toggleMenuItem: function (itemId) { + var item = _.findWhere(this.items, {itemId: itemId}); + if (!item.hasOptions) { + item.isActive = !item.isActive; + var eventData = { + category: this.dropdownCategory, + itemId: itemId, + isActive: item.isActive, + groupId: item.groupId, + }; + this._renderMenuItems(); + this.trigger_up('menu_item_toggled', eventData); + } + if (item.hasOptions) { + item.isOpen = !item.isOpen; + this._renderMenuItems(); + } + }, + /** + * @private + * @param {string} itemId + * @param {string} optionId + */ + _toggleItemOption: function (itemId, optionId) { + var item = _.findWhere(this.items, {itemId: itemId}); + var initialState = item.isActive; + if (item.currentOptionId === optionId) { + item.isActive = false; + item.currentOptionId = false; + } else { + item.isActive = true; + item.currentOptionId = optionId; + } + var eventData = { + category: this.dropdownCategory, + itemId: itemId, + groupId: item.groupId, + isActive: item.isActive, + optionId: item.currentOptionId, + }; + this._renderMenuItems(); + if (item.isActive !== initialState) { + this.trigger_up('menu_item_toggled', eventData); + } else { + this.trigger_up('item_option_changed', eventData); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This method is called when Bootstrap trigger a 'close' event (for example + * when the user clicks outside the dropdown menu). + * + * @private + * @param {jQueryEvent} event + */ + _onBootstrapClose: function () { + _.each(this.items, function (item) { + item.isOpen = false; + }); + this._renderMenuItems(); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onItemClick: function (event) { + event.preventDefault(); + event.stopPropagation(); + var itemId = $(event.currentTarget).data('id'); + this._toggleMenuItem(itemId); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onOptionClick: function (event) { + event.preventDefault(); + event.stopPropagation(); + var optionId = $(event.currentTarget).data('option_id'); + var itemId = $(event.currentTarget).data('item_id'); + this._toggleItemOption(itemId, optionId); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onTrashClick: function (event) { + event.stopPropagation(); + var itemId = $(event.currentTarget).data('id'); + this._removeItem(itemId); + }, +}); + +return OldDropdownMenu; + +}); diff --git a/addons/web/static/src/js/widgets/pie_chart.js b/addons/web/static/src/js/widgets/pie_chart.js index 663e2b4cc98..30944d69584 100644 --- a/addons/web/static/src/js/widgets/pie_chart.js +++ b/addons/web/static/src/js/widgets/pie_chart.js @@ -34,14 +34,16 @@ var PieChart = Widget.extend({ }); this.subViewParams = { - context: record.context, - domain: domain, - groupBy: [], modelName: record.model, withControlPanel: false, isEmbedded: true, mode: 'pie', }; + this.subViewParams.searchQuery = { + context: record.context, + domain: domain, + groupBy: [], + }; this.viewInfo = { arch: arch, diff --git a/addons/web/static/src/scss/control_panel.scss b/addons/web/static/src/scss/control_panel.scss index b4f7d189f9e..dd74c8ac010 100644 --- a/addons/web/static/src/scss/control_panel.scss +++ b/addons/web/static/src/scss/control_panel.scss @@ -19,10 +19,6 @@ } } - &.o_breadcrumb_full > .breadcrumb { - width: 100%; - } - > .o_cp_searchview { width: 50%; min-height: $o-cp-breadcrumb-height; diff --git a/addons/web/static/src/scss/dropdown_menu.scss b/addons/web/static/src/scss/dropdown_menu.scss index 0ff7fd7f561..6e6c3a6b640 100644 --- a/addons/web/static/src/scss/dropdown_menu.scss +++ b/addons/web/static/src/scss/dropdown_menu.scss @@ -42,4 +42,8 @@ content: "\f00c"; } } + .o_trash_button { + padding-left: 8px; + @include o-position-absolute($right: $dropdown-item-padding-x * 0.5); + } }
\ No newline at end of file diff --git a/addons/web/static/src/scss/graph_view.scss b/addons/web/static/src/scss/graph_view.scss index 547e72ffab1..71ec5fbca9c 100644 --- a/addons/web/static/src/scss/graph_view.scss +++ b/addons/web/static/src/scss/graph_view.scss @@ -1,5 +1,5 @@ -.o_graph { - .o_graph_container { +.o_graph_controller { + .o_graph_renderer { height: 100%; overflow: auto; .o_graph_svg_container { diff --git a/addons/web/static/src/scss/search_view.scss b/addons/web/static/src/scss/search_view.scss index 4079d082d17..22baf6d0a0e 100644 --- a/addons/web/static/src/scss/search_view.scss +++ b/addons/web/static/src/scss/search_view.scss @@ -14,6 +14,7 @@ } .o_searchview_input_container { + position: relative; display: flex; flex-flow: row wrap; @@ -182,7 +183,6 @@ cursor: pointer; } .o_add_filter_menu { // apply and add buttons - display: none; // Needed by the webclient @include o-search-options-dropdown-custom-item; } } @@ -195,7 +195,6 @@ max-height: calc(100vh - 125px); overflow: auto; - .dropdown-divider {display: none;} // hide the divider by default (no favorite) .o-searchview-custom-private, .o-searchview-custom-public { .o-remove-filter { @include o-position-absolute(50%, 12px); diff --git a/addons/web/static/src/scss/search_view_mobile.scss b/addons/web/static/src/scss/search_view_mobile.scss index 4b44fa1d85c..c3c264649ea 100644 --- a/addons/web/static/src/scss/search_view_mobile.scss +++ b/addons/web/static/src/scss/search_view_mobile.scss @@ -54,6 +54,10 @@ .o_dropdown_toggler_btn { width: 100%; text-align: left; + + &:after { + display: none; + } } // We disable the backdrop in this case because it prevents any diff --git a/addons/web/static/src/scss/webclient_layout.scss b/addons/web/static/src/scss/webclient_layout.scss index c03e519021b..a030ac88535 100644 --- a/addons/web/static/src/scss/webclient_layout.scss +++ b/addons/web/static/src/scss/webclient_layout.scss @@ -9,30 +9,24 @@ html { display: flex; flex-flow: column nowrap; - > .o_main { + > .o_action_manager { + direction: ltr; //Define direction attribute here so when rtlcss preprocessor run, it converts it to rtl flex: 1 1 auto; - display: flex; - min-height: 0; // Allow flex to shrink this element as much as it wants height: 100%; + overflow: hidden; - .o_main_content { - flex: 1 1 auto; + > .o_action { + height: 100%; display: flex; flex-flow: column nowrap; - - .o_control_panel { + > .o_cp_controller { flex: 0 0 auto; } - .o_content { + > .o_content { flex: 1 1 auto; - height: 100%; + position: relative; // Allow to redistribute the 100% height to its child overflow: auto; - position: relative; - - > .o_view_controller { - @include o-position-absolute(0, 0, 0, 0); // Get the 100% height of its flex parent - height: 100%; - } + height: 100%; } } } diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 661aab2d9b6..102e70efa1e 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -180,10 +180,17 @@ </div> </form> +<div t-name="Action" class="o_action"> + <!-- CP --> + <div class="o_content"/> +</div> + <t t-name="ControlPanel"> <div class="o_control_panel"> <ol class="breadcrumb" role="navigation"/> - <div class="o_cp_searchview" role="search"/> + <div class="o_cp_searchview" role="search"> + <t t-call="SearchView"/> + </div> <div class="o_cp_left"> <div class="o_cp_buttons" role="toolbar" aria-label="Control panel toolbar"/> <aside class="o_cp_sidebar"/> @@ -204,7 +211,7 @@ <t t-name="ControlPanel.SwitchButton"> <button type="button" t-att-accesskey="view.accessKey" - t-attf-class="btn btn-secondary fa fa-lg #{view.icon} o_cp_switch_#{view.type}" + t-attf-class="btn btn-secondary fa fa-lg #{view.icon} o_cp_switch_#{view.type} #{viewType === view.type ? ' active' : ''}" t-attf-aria-label="View #{view.type}" t-att-data-view-type="view.type" t-attf-title="View #{view.type}" tabindex="-1"/> </t> @@ -276,7 +283,7 @@ <a role="menuitem" href="#" data-action="edit" data-model="ir.ui.view" t-att-data-id="view.fieldsView.view_id" class="dropdown-item"> Edit View: <t t-esc="view.displayName"/> </a> - <a t-if="searchview and searchview.$el.is(':visible')" role="menuitem" href="#" data-action="edit" data-model="ir.ui.view" t-att-data-id="action.searchFieldsView.view_id" class="dropdown-item">Edit SearchView</a> + <a t-if="controlPanelView and controlPanelView.$el.is(':visible')" role="menuitem" href="#" data-action="edit" data-model="ir.ui.view" t-att-data-id="action.controlPanelFieldsView.view_id" class="dropdown-item">Edit ControlPanelView</a> </t> </t> </t> @@ -1073,29 +1080,29 @@ <t t-if="widget.items.length !== 0"> <t t-set="group" t-value="0"/> <t t-foreach="widget.items" t-as="item"> - <div t-if="group !== item.groupId" class="dropdown-divider" data-removable="1"/> + <div t-if="group !== item.groupNumber" class="dropdown-divider" data-removable="1"/> <div class="o_menu_item" - t-att-data-id="item.itemId"> + t-att-data-id="item.id"> <a href="#" t-attf-class="dropdown-item#{item.isActive ? ' selected ' : ''}"> <t t-esc="item.description"/> - <span t-if="item.isRemovable" t-att-data-id="item.itemId" class="o_trash_button" > <span class="fa fa-trash-o"></span> </span> - <span t-if="item.hasOptions" t-att-data-id="item.itemId" class="o_submenu_switcher"> - <span t-att-class="item.isOpen ? 'fa fa-caret-down' : 'fa fa-caret-right'"></span> + <span t-if="item.isRemovable" t-att-data-id="item.id" class="o_trash_button" > <span class="fa fa-trash-o"></span> </span> + <span t-if="item.hasOptions" t-att-data-id="item.id" class="o_submenu_switcher"> + <span t-att-class="widget.openItems[item.id] ? 'fa fa-caret-down' : 'fa fa-caret-right'"></span> </span> </a> - <ul t-if="item.hasOptions && item.isOpen" class="list-unstyled"> + <ul t-if="item.hasOptions && widget.openItems[item.id]" class="list-unstyled"> <t t-set="subgroup" t-value="0"/> - <t t-foreach="_.values(item.options)" t-as="option"> - <li t-if="subgroup !== option.groupId" class="dropdown-divider" data-removable="1"/> + <t t-foreach="item.options" t-as="option"> + <li t-if="subgroup !== option.groupNumber" class="dropdown-divider" data-removable="1"/> <li class="o_item_option" - t-att-data-item_id="item.itemId" t-att-data-option_id="option.optionId"> + t-att-data-item_id="item.id" t-att-data-option_id="option.optionId"> <a href="#" t-attf-class="dropdown-item#{item.currentOptionId === option.optionId ? ' selected': ''} pl-5"><t t-esc="option.description"/></a> </li> - <t t-set="subgroup" t-value="option.groupId"/> + <t t-set="subgroup" t-value="option.groupNumber"/> </t> </ul> </div> - <t t-set="group" t-value="item.groupId"/> + <t t-set="group" t-value="item.groupNumber"/> </t> </t> </t> @@ -1109,9 +1116,9 @@ </div> </div> -<t t-name="GroupbyMenuGenerator"> - <div t-if="Object.keys(widget.presentedFields).length !== 0 && widget.items.length !== 0" class="dropdown-divider o_generator_menu"/> - <button t-if="Object.keys(widget.presentedFields).length !== 0" type="button" class="dropdown-item o_generator_menu o_add_custom_group">Add Custom Group</button> +<t t-name="GroupByMenuGenerator"> + <div t-if="widget.presentedFields.length !== 0 && widget.items.length !== 0" class="dropdown-divider o_generator_menu"/> + <button t-if="widget.presentedFields.length !== 0" type="button" class="dropdown-item o_generator_menu o_add_custom_group">Add Custom Group</button> <div t-if="widget.generatorMenuIsOpen" class="dropdown-item-text o_generator_menu"> <select class="w-auto o_input o_group_selector o_add_group"> <t t-foreach="widget.presentedFields" t-as="field"> @@ -1124,10 +1131,10 @@ </div> </t> -<t t-name="FiltersMenuGenerator"> - <div class="dropdown-divider"/> - <button t-if="Object.keys(widget.fields).length !== 0" type="button" class="dropdown-item o_add_custom_filter o_closed_menu">Add Custom Filter</button> - <div class="dropdown-item-text o_add_filter_menu"> +<t t-name="FilterMenuGenerator"> + <div t-if="Object.keys(widget.fields).length !== 0 && widget.items.length !== 0" class="dropdown-divider o_generator_menu"/> + <button t-if="Object.keys(widget.fields).length !== 0" type="button" class="dropdown-item o_generator_menu o_add_custom_filter">Add Custom Filter</button> + <div t-if="widget.generatorMenuIsOpen" class="dropdown-item-text o_generator_menu o_add_filter_menu"> <button class="btn btn-primary o_apply_filter" type="button">Apply</button> <button class="btn btn-secondary o_add_condition" type="button"> <span class="fa fa-plus-circle"/> @@ -1145,15 +1152,15 @@ <div class="dropdown-item-text"> <label for="date_field_selector">Based On</label> <select id="date_field_selector" class="o_input o_date_field_selector"> - <t t-foreach="widget.dateFields" t-as="field"> - <t t-if="widget.dateField.name === field.name"> - <option t-att-value="field.name" selected="selected"> - <t t-esc="field.string"/> + <t t-foreach="widget.timeRanges" t-as="timeRange"> + <t t-if="widget.configuration.id === timeRange.id"> + <option t-att-value="timeRange.id" selected="selected"> + <t t-esc="timeRange.description"/> </option> </t> <t t-else=""> - <option t-att-value="field.name"> - <t t-esc="field.string"/> + <option t-att-value="timeRange.id"> + <t t-esc="timeRange.description"/> </option> </t> </t> @@ -1166,7 +1173,7 @@ <optgroup t-if="group !== 1"/> <t t-foreach="widget.periodOptions" t-as="option"> <t t-if="option.groupId === group"> - <t t-if="widget.timeRangeId === option.optionId"> + <t t-if="widget.configuration.timeRangeId === option.optionId"> <option t-att-value="option.optionId" selected="selected"> <t t-esc="option.description"/> </option> @@ -1182,14 +1189,14 @@ </select> </div> <div class="dropdown-item-text"> - <t t-set="isSelected" t-value="widget.comparisonIsSelected"/> + <t t-set="isSelected" t-value="widget.configuration.comparisonIsSelected"/> <div class="custom-control custom-checkbox"> <input type="checkbox" id="comparison_checkbox" class="custom-control-input o_comparison_checkbox" t-att-checked="isSelected ? 'checked' : undefined"/> <label for="comparison_checkbox" class="custom-control-label">Compare To</label> </div> - <select t-att-class="'o_input o_comparison_time_range_selector' + (widget.comparisonIsSelected ? '' : ' o_hidden')"> - <t t-foreach="widget.comparisonOptions" t-as="option"> - <t t-if="widget.comparisonTimeRangeId === option.optionId"> + <select t-att-class="'o_input o_comparison_time_range_selector' + (widget.configuration.comparisonIsSelected ? '' : ' o_hidden')"> + <t t-foreach="widget.comparisonTimeRangeOptions" t-as="option"> + <t t-if="widget.configuration.comparisonTimeRangeId === option.optionId"> <option t-att-value="option.optionId" selected="selected"> <t t-esc="option.description"/> </option> @@ -1208,41 +1215,27 @@ </div> </div> - <div t-name="SearchView" class="o_searchview" role="search" aria-autocomplete="list"> <span class="o_searchview_more fa" title="Advanced Search..." role="img" aria-label="Advanced Search..."/> - <div class="o_searchview_input_container"/> </div> -<t t-name="SearchViewMobile"> - <div class="o_searchview" role="search" aria-autocomplete="list"> - <button class="o_enable_searchview btn fa fa-search"/> - <div class="o_mobile_search o_hidden"> - <div class="o_mobile_search_header"> - <span class="o_mobile_search_close float-left mt16 mb16 mr8 ml16"> - <i class="fa fa-arrow-left"/> - <strong class="float-right ml8">FILTER</strong> - </span> - <span class="float-right o_mobile_search_clear_facets mt16 mr16">CLEAR</span> - </div> - <div class="o_searchview_input_container"/> - <div class="o_mobile_search_filter o_search_options mb8 mt8 ml16 mr16"/> - <div class="btn btn-primary o_mobile_search_show_result">SEE RESULT</div> - </div> - </div> -</t> -<input t-name="SearchView.InputView" type="text" class="o_searchview_input" accesskey="Q" placeholder="Search..." role="searchbox" aria-haspopup="true"/> -<div t-name="SearchView.FacetView" tabindex="0" class="o_searchview_facet" role="img" aria-label="search"> - <span t-if="widget.model.has('icon')" t-att-class="'fa ' + widget.model.get('icon') + ' o_searchview_facet_label'"/> - <span t-if="!widget.model.has('icon')" class="o_searchview_facet_label"> - <t t-esc="widget.model.get('category')"/> +<div t-name="SearchView.SearchBar" class="o_searchview_input_container"> + <input type="text" class="o_searchview_input" accesskey="Q" placeholder="Search..." role="searchbox" aria-haspopup="true"/> +</div> + +<div t-name="SearchView.SearchFacet" tabindex="0" class="o_searchview_facet" role="img" aria-label="search"> + <span t-if="widget.facet.type === 'field'" class="o_searchview_facet_label"> + <t t-esc="widget.facet.filters[0].attrs.string"/> </span> - <div class="o_facet_values"/> + <span t-else="1" t-att-class="'fa ' + widget.icon + ' o_searchview_facet_label'"/> + <div class="o_facet_values"> + <t t-foreach="widget.facetValues" t-as="facetValue"> + <span t-if="facetValue_index > 0" class="o_facet_values_sep" t-esc="widget.separator"/> <span t-esc="facetValue"/> + </t> + </div> <div class="fa fa-sm fa-remove o_facet_remove" role="img" aria-label="Remove" title="Remove"/> </div> -<span t-name="SearchView.FacetView.Value"> - <t t-esc="widget.model.get('label') + (widget.optionLabel ? (': ' + widget.optionLabel) : '')"/> -</span> + <t t-name="SearchView.autocomplete"> <div class="dropdown-menu o_searchview_autocomplete" role="menu"/> </t> @@ -1259,65 +1252,6 @@ </a> </t> </t> -<t t-name="SearchView.field"> - <label t-att-class="'oe_label' + (attrs.help ? '_help' : '')" - t-att-title="attrs.help" - t-att-for="element_id" - t-att-style="style"> - <t t-esc="attrs.string || attrs.name"/> - <span t-if="attrs.help">?</span> - </label> - <div t-att-style="style"> - <input type="text" size="15" class="o_input" - t-att-name="attrs.name" - t-att-autofocus="attrs.default_focus === '1' ? 'autofocus' : undefined" - t-att-id="element_id" - t-att-value="defaults[attrs.name] || ''"/> - <t t-if="filters.length" t-raw="filters.render(defaults)"/> - </div> -</t> -<t t-name="SearchView.date"> - <label t-att-class="'oe_label' + (attrs.help ? '_help' : '')" - t-att-title="attrs.help" - t-att-for="element_id" - t-att-style="style"> - <t t-esc="attrs.string || attrs.name"/> - <span t-if="attrs.help">?</span> - </label> - <div t-att-style="style"> - <span t-att-id="element_id"/> - <t t-if="filters.length" t-raw="filters.render(defaults)"/> - </div> -</t> -<t t-name="SearchView.field.selection"> - <label t-att-title="attrs.help" - t-att-class="'oe_label' + (attrs.help ? '_help' : '')" - t-att-for="element_id" - t-att-style="style"> - <t t-esc="attrs.string || attrs.name"/> - <span t-if="attrs.help">?</span> - </label> - <div t-att-style="style"> - <select class="o_input" - t-att-name="attrs.name" - t-att-id="element_id" - t-att-autofocus="attrs.default_focus === '1' || undefined"> - <option t-if="prepend_empty"/> - <t t-foreach="attrs.selection" t-as="option"> - <t t-set="selected" t-value="defaults[attrs.name] === option[0]"/> - <option t-if="selected" - t-attf-selected="selected" - t-att-value="option_index"> - <t t-esc="option[1]"/> - </option> - <option t-if="!selected" t-att-value="option_index"> - <t t-esc="option[1]"/> - </option> - </t> - </select> - <t t-if="filters.length" t-raw="filters.render(defaults)"/> - </div> -</t> <t t-name="SearchView.extended_search.proposition"> <div role="menuitem" class="dropdown-item-text o_filter_condition"> <span class="o_or_filter">or</span> @@ -1351,30 +1285,56 @@ </select> </t> -<div t-name="SearchView.FavoriteMenu" class="btn-group o_dropdown"> - <button t-attf-class="o_dropdown_toggler_btn btn btn-secondary dropdown-toggle#{widget.isMobile ? ' o-no-caret' : ''}" data-toggle="dropdown" aria-expanded="false" tabindex="-1" aria-label="Favorites" title="Favorites"> - <span class="fa fa-star"/> Favorites <span t-if="widget.isMobile" class="fa fa-chevron-right float-right mt4"/> - </button> - <div class="dropdown-menu o_favorites_menu" role="menu"> - <div class="dropdown-divider user_filter"/> - <div class="dropdown-divider shared_filter"/> - <a role="menuitem" href="#" class="dropdown-item o_save_search o_closed_menu">Save current search</a> - <div role="menuitem" class="dropdown-item-text o_save_name"> - <input type="text" class="o_input"></input> +<t t-name="MobileControlPanel"> + <div class="o_control_panel"> + <ol class="breadcrumb" role="navigation"/> + <div class="o_cp_searchview" role="search"> + <t t-call="MobileSearchView"/> </div> - <div role="menuitem" class="dropdown-item-text o_save_name"> - <div class="custom-control custom-checkbox"> - <input type="checkbox" id="o_fav_menu_use_by_default" class="custom-control-input"/> - <label for="o_fav_menu_use_by_default" class="custom-control-label">Use by default</label> - </div> + <div class="o_cp_left"> + <div class="o_cp_buttons" role="toolbar" aria-label="Control panel toolbar"/> + <aside class="o_cp_sidebar"/> </div> - <div role="menuitem" class="dropdown-item-text o_save_name"> - <div class="custom-control custom-checkbox"> - <input type="checkbox" id="o_fav_menu_share_all_users" class="custom-control-input"/> - <label for="o_fav_menu_share_all_users" class="custom-control-label">Share with all users <span class="fa fa-users" role="img" aria-label="Users" title="Users"/></label> + <div class="o_cp_right"> + <div class="btn-group o_search_options" role="search"/> + <nav class="o_cp_pager" role="search" aria-label="Pager"/> + <nav class="btn-group o_cp_switch_buttons" role="toolbar" aria-label="View switcher"/> + </div> + </div> +</t> + +<t t-name="MobileSearchView"> + <div class="o_searchview" role="search" aria-autocomplete="list"> + <button class="o_enable_searchview btn fa fa-search"/> + <div class="o_mobile_search o_hidden"> + <div class="o_mobile_search_header"> + <span class="o_mobile_search_close float-left mt16 mb16 mr8 ml16"> + <i class="fa fa-arrow-left"/> + <strong class="float-right ml8">FILTER</strong> + </span> + <span class="float-right o_mobile_search_clear_facets mt16 mr16">CLEAR</span> </div> + <div class="o_mobile_search_filter o_search_options mb8 mt8 ml16 mr16"/> + <div class="btn btn-primary o_mobile_search_show_result">SEE RESULT</div> + </div> + </div> +</t> + +<div t-name="AddNewFavoriteMenu"> + <button type="button" class="dropdown-item o_generator_menu o_add_favorite o_menu_header">Save Current Search</button> + <div t-if="widget.isOpen" class="dropdown-item-text o_generator_menu"> + <div role="menuitem" class="dropdown-item-text o_favorite_name"> + <input type="text" class="o_input"></input> + </div> + <div class="custom-control custom-checkbox"> + <input type="checkbox" id="o_favorite_use_by_default" class="custom-control-input"/> + <label for="o_favorite_use_by_default" class="custom-control-label">Use by default</label> + </div> + <div class="custom-control custom-checkbox"> + <input type="checkbox" id="o_favorite_share_all_users" class="custom-control-input"/> + <label for="o_favorite_share_all_users" class="custom-control-label">Share with all users <span class="fa fa-users" role="img" aria-label="Users" title="Users"/></label> </div> - <div class="dropdown-item-text o_save_name"> + <div class="dropdown-item-text o_save_favorite"> <button role="menuitem" type="button" class="btn btn-primary">Save</button> </div> </div> diff --git a/addons/web/static/tests/chrome/action_manager_mobile_tests.js b/addons/web/static/tests/chrome/action_manager_mobile_tests.js index b105c861c26..d1bc7a5e489 100644 --- a/addons/web/static/tests/chrome/action_manager_mobile_tests.js +++ b/addons/web/static/tests/chrome/action_manager_mobile_tests.js @@ -99,7 +99,7 @@ QUnit.module('ActionManager', { assert.containsOnce(actionManager, '.o_form_view'); // go back to lazy loaded view - testUtils.dom.click($('.o_control_panel .breadcrumb .breadcrumb-item:first')); + testUtils.dom.click(actionManager.$('.o_control_panel .breadcrumb .breadcrumb-item:first')); assert.containsNone(actionManager, '.o_form_view'); assert.containsNone(actionManager, '.o_list_view'); assert.containsOnce(actionManager, '.o_kanban_view'); @@ -125,9 +125,9 @@ QUnit.module('ActionManager', { actionManager.doAction(1); - assert.containsOnce($('.o_control_panel'), '.o_cp_switch_buttons button[data-toggle="dropdown"]'); - assert.hasClass($('.o_cp_switch_buttons .o_cp_switch_kanban'), 'active'); - assert.hasClass($('.o_cp_switch_buttons .o_switch_view_button_icon'), 'fa-th-large'); + assert.containsOnce(actionManager.$('.o_control_panel'), '.o_cp_switch_buttons button[data-toggle="dropdown"]'); + assert.hasClass(actionManager.$('.o_cp_switch_buttons .o_cp_switch_kanban'), 'active'); + assert.hasClass(actionManager.$('.o_cp_switch_buttons .o_switch_view_button_icon'), 'fa-th-large'); actionManager.destroy(); }); diff --git a/addons/web/static/tests/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js index 3eea9bda3a0..e1b14b4d363 100644 --- a/addons/web/static/tests/chrome/action_manager_tests.js +++ b/addons/web/static/tests/chrome/action_manager_tests.js @@ -6,7 +6,6 @@ var NotificationService = require('web.NotificationService'); var AbstractAction = require('web.AbstractAction'); var AbstractStorageService = require('web.AbstractStorageService'); var BasicFields = require('web.basic_fields'); -var ControlPanelMixin = require('web.ControlPanelMixin'); var core = require('web.core'); var ListController = require('web.ListController'); var StandaloneFieldManagerMixin = require('web.StandaloneFieldManagerMixin'); @@ -647,9 +646,9 @@ QUnit.module('ActionManager', { assert.expect(2); var ClientAction = AbstractAction.extend({ - className: 'o_client_action_test', start: function () { this.$el.text('Hello World'); + this.$el.addClass('o_client_action_test'); }, }); core.action_registry.add('HelloWorldTest', ClientAction); @@ -983,7 +982,7 @@ QUnit.module('ActionManager', { QUnit.test('should not push a loaded state of a client action', function (assert) { assert.expect(4); - var ClientAction = Widget.extend({ + var ClientAction = AbstractAction.extend({ init: function (parent, action, options) { this._super.apply(this, arguments); this.controllerID = options.controllerID; @@ -1033,8 +1032,8 @@ QUnit.module('ActionManager', { QUnit.test('change a param of an ir.actions.client in the url', function (assert) { assert.expect(7); - var ClientAction = AbstractAction.extend(ControlPanelMixin, { - className: 'o_client_action', + var ClientAction = AbstractAction.extend({ + hasControlPanel: true, init: function (parent, action) { this._super.apply(this, arguments); var context = action.context; @@ -1042,11 +1041,13 @@ QUnit.module('ActionManager', { }, start: function () { assert.step('start'); - this.$el.text(this.a); + this.$('.o_content').text(this.a); + this.$el.addClass('o_client_action'); this.trigger_up('push_state', { controllerID: this.controllerID, state: {a: this.a}, }); + return this._super.apply(this, arguments); }, }); core.action_registry.add('ClientAction', ClientAction); @@ -1060,7 +1061,7 @@ QUnit.module('ActionManager', { // execute the client action actionManager.doAction(9); - assert.strictEqual(actionManager.$('.o_client_action').text(), 'default value', + assert.strictEqual(actionManager.$('.o_client_action .o_content').text(), 'default value', "should have rendered the client action"); assert.strictEqual($('.o_control_panel .breadcrumb-item').length, 1, "there should be one controller in the breadcrumbs"); @@ -1071,7 +1072,7 @@ QUnit.module('ActionManager', { a: 'new value', }); - assert.strictEqual(actionManager.$('.o_client_action').text(), 'new value', + assert.strictEqual(actionManager.$('.o_client_action .o_content').text(), 'new value', "should have rerendered the client action with the correct param"); assert.strictEqual($('.o_control_panel .breadcrumb-item').length, 1, "there should still be one controller in the breadcrumbs"); @@ -1672,9 +1673,9 @@ QUnit.module('ActionManager', { assert.expect(3); var ClientAction = AbstractAction.extend({ - className: 'o_client_action_test', start: function () { this.$el.text('Hello World'); + this.$el.addClass('o_client_action_test'); }, }); core.action_registry.add('HelloWorldTest', ClientAction); @@ -1700,11 +1701,13 @@ QUnit.module('ActionManager', { QUnit.test('client action with control panel', function (assert) { assert.expect(4); - var ClientAction = AbstractAction.extend(ControlPanelMixin, { - className: 'o_client_action_test', + var ClientAction = AbstractAction.extend({ + hasControlPanel: true, start: function () { - this.$el.text('Hello World'); - this.set('title', 'Hello'); // AAB: drop this and replace by getTitle() + this.$('.o_content').text('Hello World'); + this.$el.addClass('o_client_action_test'); + this._setTitle('Hello'); + return this._super.apply(this, arguments); }, }); core.action_registry.add('HelloWorldTest', ClientAction); @@ -1718,7 +1721,7 @@ QUnit.module('ActionManager', { "there should be one controller in the breadcrumbs"); assert.strictEqual($('.o_control_panel .breadcrumb-item').text(), 'Hello', "breadcrumbs should still display the title of the controller"); - assert.strictEqual(actionManager.$('.o_client_action_test').text(), + assert.strictEqual(actionManager.$('.o_client_action_test .o_content').text(), 'Hello World', "should have correctly rendered the client action"); actionManager.destroy(); @@ -1728,12 +1731,7 @@ QUnit.module('ActionManager', { QUnit.test('state is pushed for client actions', function (assert) { assert.expect(2); - var ClientAction = AbstractAction.extend(ControlPanelMixin, { - className: 'o_client_action_test', - start: function () { - this.$el.text('Hello World'); - }, - }); + var ClientAction = AbstractAction.extend({}); var actionManager = createActionManager({ intercepts: { push_state: function () { @@ -1754,16 +1752,18 @@ QUnit.module('ActionManager', { QUnit.test('breadcrumb is updated on title change', function (assert) { assert.expect(2); - var ClientAction = Widget.extend(ControlPanelMixin, { - className: 'o_client_action_test', + var ClientAction = AbstractAction.extend({ + hasControlPanel: true, events: { click: function () { - this.set("title", 'new title'); + this._setTitle('new title'); }, }, start: function () { - this.set("title", 'initial title'); - this.$el.text('Hello World'); + this._setTitle('initial title'); + this.$('.o_content').text('Hello World'); + this.$el.addClass('o_client_action_test'); + return this._super.apply(this, arguments); }, }); var actionManager = createActionManager(); @@ -2985,9 +2985,11 @@ QUnit.module('ActionManager', { assert.expect(4); testUtils.mock.patch(ListController, { - getContext: function () { + getOwnedQueryParams: function () { return { - shouldBeInFilterContext: true, + context: { + shouldBeInFilterContext: true, + } }; }, }); @@ -3031,9 +3033,9 @@ QUnit.module('ActionManager', { // save filter testUtils.dom.click($('.o_control_panel .o_search_options .o_dropdown_toggler_btn:contains(Favorites)')); - testUtils.dom.click($('.o_control_panel .o_save_search')); - $('.o_control_panel .o_save_name input[type=text]').val('some name'); // name the filter - testUtils.dom.click($('.o_control_panel .o_save_name button')); + testUtils.dom.click($('.o_control_panel .o_add_favorite')); + $('.o_control_panel .o_favorite_name input[type=text]').val('some name'); // name the filter + testUtils.dom.click($('.o_control_panel .o_save_favorite button')); testUtils.mock.unpatch(ListController); actionManager.destroy(); @@ -3064,47 +3066,6 @@ QUnit.module('ActionManager', { actionManager.destroy(); }); - QUnit.test("doAction with option 'keepSearchView'", function (assert) { - assert.expect(4); - - this.actions.push({ - id: 33, - name: 'Partners', - res_model: 'partner', - search_view_id: [1, 'a specific search view'], - type: 'ir.actions.act_window', - views: [[false, 'list']], - }); - - var checkRPC = false; - var actionManager = createActionManager({ - actions: this.actions, - archs: this.archs, - data: this.data, - mockRPC: function (route, args) { - if (checkRPC && route === '/web/dataset/search_read') { - assert.deepEqual(args.domain, [['bar', '=', 1]], - "should search with the correct domain"); - } - return this._super.apply(this, arguments); - }, - }); - - actionManager.doAction(33); - - checkRPC = true; - testUtils.dom.click($('.o_control_panel .o_search_options .o_dropdown_toggler_btn:contains(Filters)')); - testUtils.dom.click($('.o_control_panel .o_filters_menu a:contains(Bar)')); - assert.strictEqual($('.o_control_panel .o_facet_values').text().trim(), 'Bar', - "the filter on Bar should appear in the search view"); - - actionManager.doAction(3, {keepSearchView: true}); - assert.strictEqual($('.o_control_panel .o_facet_values').text().trim(), 'Bar', - "the filter on Bar should still be in the search view"); - - actionManager.destroy(); - }); - QUnit.test("current act_window action is stored in session_storage", function (assert) { assert.expect(1); @@ -3259,12 +3220,14 @@ QUnit.module('ActionManager', { assert.expect(4); var ClientAction = AbstractAction.extend({ - className: 'o_test', on_attach_callback: function () { assert.step('on_attach_callback'); assert.ok(actionManager.currentDialogController, "the currentDialogController should have been set already"); }, + start: function () { + this.$el.addClass('o_test'); + }, }); core.action_registry.add('test', ClientAction); diff --git a/addons/web/static/tests/chrome/search_tests.js b/addons/web/static/tests/chrome/search_tests.js deleted file mode 100644 index c2d290134ed..00000000000 --- a/addons/web/static/tests/chrome/search_tests.js +++ /dev/null @@ -1,172 +0,0 @@ -odoo.define('web.search_tests', function (require) { -"use strict"; - -var SearchView = require('web.SearchView'); - -QUnit.module('chrome', {}, function () { - - QUnit.module('Search View'); - - QUnit.test('Adding a facet to the query creates a facet and a value', function (assert) { - assert.expect(3); - - var query = new SearchView.SearchQuery(); - var field = {}; - query.add({ - category: 'Foo', - field: field, - values: [{label: 'Value', value: 3}] - }); - - var facet = query.at(0); - assert.strictEqual(facet.get('category'), 'Foo'); - assert.strictEqual(facet.get('field'), field); - assert.deepEqual(facet.get('values'), [{label: 'Value', value: 3}]); - }); - - QUnit.test('Adding two facets', function (assert) { - assert.expect(3); - - var query = new SearchView.SearchQuery(); - query.add([ - { category: 'Foo', field: {}, values: [{label: 'Value', value: 3}] }, - { category: 'Bar', field: {}, values: [{label: 'Value 2', value: 4}] } - ]); - - assert.strictEqual(query.length, 2); - assert.strictEqual(query.at(0).values.length, 1); - assert.strictEqual(query.at(1).values.length, 1); - }); - - QUnit.test('If a facet already exists, add values to it', function (assert) { - assert.expect(2); - - var query = new SearchView.SearchQuery(); - var field = {}; - query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); - query.add({category: 'A', field: field, values: [{label: 'V2', value: 1}]}); - - assert.strictEqual(query.length, 1, "adding an existing facet should merge new values into old facet"); - var facet = query.at(0); - assert.deepEqual(facet.get('values'), [ - {label: 'V1', value: 0}, - {label: 'V2', value: 1} - ]); - }); - - QUnit.test('Facet being implicitly changed should trigger change, not add', function (assert) { - assert.expect(2); - - var query = new SearchView.SearchQuery(); - var field = {}, added = false, changed = false; - query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); - query.on('add', function () { added = true; }) - .on('change', function () { changed = true; }); - query.add({category: 'A', field: field, values: [{label: 'V2', value: 1}]}); - - assert.ok(!added, "query.add adding values to a facet should not trigger an add"); - assert.ok(changed, "query.add adding values to a facet should not trigger a change"); - }); - - QUnit.test('Toggling a facet, value which does not exist should add it', function (assert) { - assert.expect(3); - - var query = new SearchView.SearchQuery(); - var field = {}; - query.toggle({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); - - assert.strictEqual(query.length, 1, "Should have created a single facet"); - var facet = query.at(0); - assert.strictEqual(facet.values.length, 1, "Facet should have a single value"); - assert.deepEqual(facet.get('values'), [{label: 'V1', value: 0}], - "Facet's value should match input"); - }); - - QUnit.test('Toggling a facet which exists with a value which does not should add the value to the facet', function (assert) { - assert.expect(3); - - var field = {}; - var query = new SearchView.SearchQuery(); - query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); - query.toggle({category: 'A', field: field, values: [{label: 'V2', value: 1}]}); - - assert.strictEqual(query.length, 1, "Should have edited the existing facet"); - var facet = query.at(0); - assert.strictEqual(facet.values.length, 2, "Should have added the value to the existing facet"); - assert.deepEqual(facet.get('values'), [ - {label: 'V1', value: 0}, - {label: 'V2', value: 1} - ]); - }); - - QUnit.test('Toggling a facet which exists with a value which does as well should remove the value from the facet', function (assert) { - assert.expect(3); - - var field = {}; - var query = new SearchView.SearchQuery(); - query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); - query.add({category: 'A', field: field, values: [{label: 'V2', value: 1}]}); - - query.toggle({category: 'A', field: field, values: [{label: 'V2', value: 1}]}); - - assert.strictEqual(query.length, 1, 'Should have the same single facet'); - var facet = query.at(0); - assert.strictEqual(facet.values.length, 1, "Should only have one value left in the facet"); - assert.deepEqual(facet.get('values'), [ - {label: 'V1', value: 0} - ]); - }); - - QUnit.test('Toggling off the last value of a facet should remove the facet', function (assert) { - assert.expect(1); - - var field = {}; - var query = new SearchView.SearchQuery(); - query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); - - query.toggle({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); - - assert.strictEqual(query.length, 0, 'Should have removed the facet'); - }); - - QUnit.test('Intermediate emptiness should not remove the facet', function (assert) { - assert.expect(3); - - var field = {}; - var query = new SearchView.SearchQuery(); - query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]}); - - query.toggle({category: 'A', field: field, values: [ - {label: 'V1', value: 0}, - {label: 'V2', value: 1} - ]}); - - assert.strictEqual(query.length, 1, 'Should not have removed the facet'); - var facet = query.at(0); - assert.strictEqual(facet.values.length, 1, "Should have one value"); - assert.deepEqual(facet.get('values'), [ - {label: 'V2', value: 1} - ]); - }); - - QUnit.test('Reseting with multiple facets should still work to load defaults', function (assert) { - assert.expect(3); - - var query = new SearchView.SearchQuery(); - var field = {}; - query.reset([ - {category: 'A', field: field, values: [{label: 'V1', value: 0}]}, - {category: 'A', field: field, values: [{label: 'V2', value: 1}]}]); - - assert.strictEqual(query.length, 1, 'Should have created a single facet'); - assert.strictEqual(query.at(0).values.length, 2, 'the facet should have merged two values'); - assert.deepEqual(query.at(0).get('values'), [ - {label: 'V1', value: 0}, - {label: 'V2', value: 1} - ]); - }); - - // TODO add a test to check that combining two filters with a or normalizes the two filters before - // Note: maybe domain.js will be improved to make this easier -}); -}); diff --git a/addons/web/static/tests/core/registry_tests.js b/addons/web/static/tests/core/registry_tests.js index d28e429da24..f87238fafb8 100644 --- a/addons/web/static/tests/core/registry_tests.js +++ b/addons/web/static/tests/core/registry_tests.js @@ -19,35 +19,6 @@ QUnit.module('core', {}, function () { assert.strictEqual(registry.get('foo'), foo); }); - QUnit.test('extension', function (assert) { - assert.expect(2); - - var foo = {}; - var foo2 = {}; - var registry = new Registry({ - foo: foo, - }); - var registry2 = registry.extend({foo: foo2}); - assert.strictEqual(registry.get('foo'), foo); - assert.strictEqual(registry2.get('foo'), foo2); - }); - - QUnit.test('remain-linked', function (assert) { - assert.expect(2); - - var foo = {}; - var foo2 = {}; - var registry = new Registry({ - foo: foo, - }); - - var registry2 = registry.extend(); - - registry.add('foo2', foo2); - - assert.strictEqual(registry.get('foo2'), foo2); - assert.strictEqual(registry2.get('foo2'), foo2); - }); QUnit.test('multiget', function (assert) { assert.expect(1); @@ -64,17 +35,18 @@ QUnit.module('core', {}, function () { "Registry getAny should find first defined key"); }); - QUnit.test('extended-multiget', function (assert) { - assert.expect(1); + QUnit.test('keys and values are properly ordered', function (assert) { + assert.expect(2); - var foo = {}; - var bar = {}; - var registry = new Registry({ - foo: foo, - bar: bar, - }); - var registry2 = registry.extend(); - assert.strictEqual(registry2.getAny(['qux', 'grault', 'bar', 'foo']), bar); + var registry = new Registry(); + + registry + .add('fred', 'foo', 3) + .add('george', 'bar', 2) + .add('ronald', 'qux', 4); + + assert.deepEqual(registry.keys(), ['george', 'fred', 'ronald']); + assert.deepEqual(registry.values(), ['bar', 'foo', 'qux']); }); }); diff --git a/addons/web/static/tests/fields/basic_fields_mobile_tests.js b/addons/web/static/tests/fields/basic_fields_mobile_tests.js index 05f1b93226e..fd302ebeb36 100644 --- a/addons/web/static/tests/fields/basic_fields_mobile_tests.js +++ b/addons/web/static/tests/fields/basic_fields_mobile_tests.js @@ -164,7 +164,7 @@ QUnit.module('basic_fields', { }); var val = '<script>throw Error();</script><script>throw Error();</script>'; - testUtils.fields.editInput(form.$('input'), val); + testUtils.fields.editInput(form.$('input.o_field_widget[name="foo"]'), val); // save testUtils.form.clickSave(form); diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index 32450f3d56b..f5568abe28e 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -18,9 +18,6 @@ var DebouncedField = basicFields.DebouncedField; var JournalDashboardGraph = basicFields.JournalDashboardGraph; var _t = core._t; -var DOM = testUtils.DOM; -var mock = testUtils.mock; - QUnit.module('fields', {}, function () { QUnit.module('basic_fields', { @@ -166,7 +163,7 @@ QUnit.module('basic_fields', { }); // change the value - testUtils.fields.editInput(form.$('input'), 'new value'); + testUtils.fields.editInput(form.$('input[name=foo]'), 'new value'); assert.verifySteps([], "_setValue shouldn't have been called yet"); // save @@ -236,12 +233,12 @@ QUnit.module('basic_fields', { "checkbox should now be unchecked"); // check the checkbox by clicking on label - testUtils.dom.click(form.$('label:first')); + testUtils.dom.click(form.$('.o_form_view label:first')); assert.containsOnce(form, '.o_field_boolean input:checked', "checkbox should now be checked"); // uncheck it back - testUtils.dom.click(form.$('label:first')); + testUtils.dom.click(form.$('.o_form_view label:first')); assert.containsNone(form, '.o_field_boolean input:checked', "checkbox should now be unchecked"); @@ -394,17 +391,17 @@ QUnit.module('basic_fields', { assert.containsOnce(list, 'button i.fa.fa-circle.text-muted', "should have 1 muted button"); - assert.hasAttrValue(list.$('button').first(), 'title', "Reported in last payslips", - "active buttons should have proper tooltip"); - assert.hasAttrValue(list.$('button').last(), 'title', "To Report in Payslip", - "inactive buttons should have proper tooltip"); + assert.hasAttrValue(list.$('.o_list_view button').first(), 'title', + "Reported in last payslips", "active buttons should have proper tooltip"); + assert.hasAttrValue(list.$('.o_list_view button').last(), 'title', + "To Report in Payslip", "inactive buttons should have proper tooltip"); // clicking on first button to check the state is properly changed - testUtils.dom.click(list.$('button').first()); + testUtils.dom.click(list.$('.o_list_view button').first()); assert.containsN(list, 'button i.fa.fa-circle.o_toggle_button_success', 3, "should have 3 green buttons"); - testUtils.dom.click(list.$('button').first()); + testUtils.dom.click(list.$('.o_list_view button').first()); assert.containsN(list, 'button i.fa.fa-circle.o_toggle_button_success', 4, "should have 4 green buttons"); list.destroy(); @@ -553,14 +550,14 @@ QUnit.module('basic_fields', { 'The value should be displayed properly.'); testUtils.form.clickEdit(form); - assert.strictEqual(form.$('input').val(), '0.000', + assert.strictEqual(form.$('input[name=qux]').val(), '0.000', 'The value should be rendered with correct precision.'); - testUtils.fields.editInput(form.$('input'), '108.2458938598598'); - assert.strictEqual(form.$('input').val(), '108.2458938598598', + testUtils.fields.editInput(form.$('input[name=qux]'), '108.2458938598598'); + assert.strictEqual(form.$('input[name=qux]').val(), '108.2458938598598', 'The value should not be formated yet.'); - testUtils.fields.editInput(form.$('input'), '18.8958938598598'); + testUtils.fields.editInput(form.$('input[name=qux]'), '18.8958938598598'); testUtils.form.clickSave(form); assert.strictEqual(form.$('.o_field_widget').first().text(), '18.896', 'The new value should be rounded properly.'); @@ -691,13 +688,13 @@ QUnit.module('basic_fields', { 'The value should be displayed properly.'); testUtils.form.clickEdit(form); - assert.strictEqual(form.$('input').val(), '-8.9', + assert.strictEqual(form.$('.o_field_widget[name=qux] input').val(), '-8.9', 'The input should be rendered without the currency symbol.'); - assert.strictEqual(form.$('input').parent().children().first().text(), '$', + assert.strictEqual(form.$('.o_field_widget[name=qux] input').parent().children().first().text(), '$', 'The input should be preceded by a span containing the currency symbol.'); testUtils.fields.editInput(form.$('.o_field_monetary input'), '109.2458938598598'); - assert.strictEqual(form.$('input').val(), '109.2458938598598', + assert.strictEqual(form.$('.o_field_widget[name=qux] input').val(), '109.2458938598598', 'The value should not be formated yet.'); testUtils.form.clickSave(form); @@ -730,7 +727,7 @@ QUnit.module('basic_fields', { 'Float field with option type must have a type attribute.'); assert.hasAttrValue(form.$('.o_field_widget'), 'type', 'number', 'Float field with option type must have a type attribute equals to "number".'); - testUtils.fields.editInput(form.$('input'), '123456.7890'); + testUtils.fields.editInput(form.$('input[name=qux]'), '123456.7890'); testUtils.form.clickSave(form); testUtils.form.clickEdit(form); assert.strictEqual(form.$('.o_field_widget').val(), '123456.789', @@ -763,7 +760,7 @@ QUnit.module('basic_fields', { assert.hasAttrValue(form.$('.o_field_widget'), 'type', 'text', 'Float field with option type must have a text type (default type).'); - testUtils.fields.editInput(form.$('input'), '123456.7890'); + testUtils.fields.editInput(form.$('input[name=qux]'), '123456.7890'); testUtils.form.clickSave(form); testUtils.form.clickEdit(form); assert.strictEqual(form.$('.o_field_widget').val(), '123,456.8', @@ -1086,7 +1083,7 @@ QUnit.module('basic_fields', { }, }); - testUtils.fields.editInput(form.$('input'), '<script>throw Error();</script>'); + testUtils.fields.editInput(form.$('input[name=foo]'), '<script>throw Error();</script>'); testUtils.form.clickSave(form); assert.strictEqual(form.$('.o_field_widget').text(), '<script>throw Error();</script>', 'the value should have been properly escaped'); @@ -3329,13 +3326,13 @@ QUnit.module('basic_fields', { 'The value should be displayed properly.'); testUtils.form.clickEdit(form); - assert.strictEqual(form.$('input').val(), '9.10', + assert.strictEqual(form.$('.o_field_widget[name=qux] input').val(), '9.10', 'The input should be rendered without the currency symbol.'); - assert.strictEqual(form.$('input').parent().children().first().text(), '$', + assert.strictEqual(form.$('.o_field_widget[name=qux] input').parent().children().first().text(), '$', 'The input should be preceded by a span containing the currency symbol.'); testUtils.fields.editInput(form.$('.o_field_monetary input'), '108.2458938598598'); - assert.strictEqual(form.$('input').val(), '108.2458938598598', + assert.strictEqual(form.$('.o_field_widget[name=qux] input').val(), '108.2458938598598', 'The value should not be formated yet.'); testUtils.form.clickSave(form); @@ -3370,13 +3367,13 @@ QUnit.module('basic_fields', { 'The value should be displayed properly.'); testUtils.form.clickEdit(form); - assert.strictEqual(form.$('input').first().val(), '0.00', + assert.strictEqual(form.$('.o_field_widget[name=qux] input').val(), '0.00', 'The input should be rendered without the currency symbol.'); - assert.strictEqual(form.$('input').parent().children().eq(1).text(), '€', + assert.strictEqual(form.$('.o_field_widget[name=qux] input').parent().children().eq(1).text(), '€', 'The input should be followed by a span containing the currency symbol.'); - testUtils.fields.editInput(form.$('input').first(), '108.2458938598598'); - assert.strictEqual(form.$('input').first().val(), '108.2458938598598', + testUtils.fields.editInput(form.$('.o_field_widget[name=qux] input'), '108.2458938598598'); + assert.strictEqual(form.$('.o_field_widget[name=qux] input').val(), '108.2458938598598', 'The value should not be formated yet.'); testUtils.form.clickSave(form); @@ -3427,13 +3424,13 @@ QUnit.module('basic_fields', { 'The value should be displayed properly.'); testUtils.form.clickEdit(form); - assert.strictEqual(form.$('input').first().val(), '99.1234', + assert.strictEqual(form.$('.o_field_widget[name=qux] input').val(), '99.1234', 'The input should be rendered without the currency symbol.'); - assert.strictEqual(form.$('input').parent().children().eq(1).text(), 'Bs.F', + assert.strictEqual(form.$('.o_field_widget[name=qux] input').parent().children().eq(1).text(), 'Bs.F', 'The input should be followed by a span containing the currency symbol.'); - testUtils.fields.editInput(form.$('input').first(), '99.111111111'); - assert.strictEqual(form.$('input').first().val(), '99.111111111', + testUtils.fields.editInput(form.$('.o_field_widget[name=qux] input'), '99.111111111'); + assert.strictEqual(form.$('.o_field_widget[name=qux] input').val(), '99.111111111', 'The value should not be formated yet.'); testUtils.form.clickSave(form); @@ -3743,11 +3740,11 @@ QUnit.module('basic_fields', { 'Integer field should be considered set for value 0.'); testUtils.form.clickEdit(form); - assert.strictEqual(form.$('input').val(), '0', + assert.strictEqual(form.$('input[name=int_field]').val(), '0', 'The value should be rendered correctly in edit mode.'); - testUtils.fields.editInput(form.$('input'), '-18'); - assert.strictEqual(form.$('input').val(), '-18', + testUtils.fields.editInput(form.$('input[name=int_field]'), '-18'); + assert.strictEqual(form.$('input[name=int_field]').val(), '-18', 'The value should be correctly displayed in the input.'); testUtils.form.clickSave(form); @@ -3831,7 +3828,7 @@ QUnit.module('basic_fields', { assert.hasAttrValue(form.$('.o_field_widget'), 'type', 'number', 'Integer field with option type must have a type attribute equals to "number".'); - testUtils.fields.editInput(form.$('input'), '1234567890'); + testUtils.fields.editInput(form.$('input[name=int_field]'), '1234567890'); testUtils.form.clickSave(form); testUtils.form.clickEdit(form); assert.strictEqual(form.$('.o_field_widget').val(), '1234567890', @@ -3864,7 +3861,7 @@ QUnit.module('basic_fields', { assert.hasAttrValue(form.$('.o_field_widget'), 'type', 'text', 'Integer field without option type must have a text type (default type).'); - testUtils.fields.editInput(form.$('input'), '1234567890'); + testUtils.fields.editInput(form.$('input[name=int_field]'), '1234567890'); testUtils.form.clickSave(form); testUtils.form.clickEdit(form); assert.strictEqual(form.$('.o_field_widget').val(), '1,234,567,890', @@ -3903,11 +3900,11 @@ QUnit.module('basic_fields', { 'The formatted time value should be displayed properly.'); testUtils.form.clickEdit(form); - assert.strictEqual(form.$('input').val(), '09:06', + assert.strictEqual(form.$('input[name=qux]').val(), '09:06', 'The value should be rendered correctly in the input.'); - testUtils.fields.editInput(form.$('input'), '-11:48'); - assert.strictEqual(form.$('input').val(), '-11:48', + testUtils.fields.editInput(form.$('input[name=qux]'), '-11:48'); + assert.strictEqual(form.$('input[name=qux]').val(), '-11:48', 'The new value should be displayed properly in the input.'); testUtils.form.clickSave(form); @@ -3945,10 +3942,10 @@ QUnit.module('basic_fields', { 'The formatted value should be displayed properly.'); testUtils.form.clickEdit(form); - assert.strictEqual(form.$('input').val(), '4.55', + assert.strictEqual(form.$('input[name=qux]').val(), '4.55', 'The value should be rendered correctly in the input.'); - testUtils.fields.editInput(form.$('input'), '2.3'); + testUtils.fields.editInput(form.$('input[name=qux]'), '2.3'); testUtils.form.clickSave(form); assert.strictEqual(form.$('.o_field_widget').first().text(), '2.30', @@ -4057,7 +4054,7 @@ QUnit.module('basic_fields', { View: ListView, model: 'partner', data: this.data, - arch: '<tree editable="bottom"><field name="foo" widget="phone"/></tree>', + arch: '<tree editable="bottom"><field name="foo" widget="phone"/></tree>', config: { device: { size_class: config.device.SIZES.LG, diff --git a/addons/web/static/tests/fields/relational_fields/field_many2many_tests.js b/addons/web/static/tests/fields/relational_fields/field_many2many_tests.js index e6f2c06173f..949af90fafe 100644 --- a/addons/web/static/tests/fields/relational_fields/field_many2many_tests.js +++ b/addons/web/static/tests/fields/relational_fields/field_many2many_tests.js @@ -262,7 +262,7 @@ QUnit.module('fields', {}, function () { 'the removed record should not be in kanban anymore'); // save the record - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -431,7 +431,7 @@ QUnit.module('fields', {}, function () { 'the updated row still has the correct values'); // save - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.containsN(form, '.o_list_view td.o_list_number', 2, 'should contain 2 subrecords'); assert.strictEqual(form.$('.o_list_view .o_data_row td:first').text(), @@ -545,7 +545,7 @@ QUnit.module('fields', {}, function () { 'new name', 'the updated row still has the correct values'); // save - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.containsN(form, '.o_list_view td.o_list_number', 2, 'should contain 2 subrecords'); assert.strictEqual(form.$('.o_list_view .o_data_row td:first').text(), @@ -687,73 +687,26 @@ QUnit.module('fields', {}, function () { } }); - assert.notOk(form.$('.custom-checkbox input').eq(0).prop('checked'), + assert.notOk(form.$('.o_form_view .custom-checkbox input').eq(0).prop('checked'), "first checkbox should not be checked"); - assert.notOk(form.$('.custom-checkbox input').eq(1).prop('checked'), + assert.notOk(form.$('.o_form_view .custom-checkbox input').eq(1).prop('checked'), "second checkbox should not be checked"); - assert.ok(form.$('.custom-checkbox input').eq(2).prop('checked'), + assert.ok(form.$('.o_form_view .custom-checkbox input').eq(2).prop('checked'), "third checkbox should be checked"); - testUtils.dom.click(form.$('.custom-checkbox input:checked')); - testUtils.dom.click(form.$('.custom-checkbox input').first()); - testUtils.dom.click(form.$('.custom-checkbox input').first()); - testUtils.dom.click(form.$('.custom-checkbox input').first()); + testUtils.dom.click(form.$('.o_form_view .custom-checkbox input:checked')); + testUtils.dom.click(form.$('.o_form_view .custom-checkbox input').first()); + testUtils.dom.click(form.$('.o_form_view .custom-checkbox input').first()); + testUtils.dom.click(form.$('.o_form_view .custom-checkbox input').first()); - assert.ok(form.$('.custom-checkbox input').eq(0).prop('checked'), + assert.ok(form.$('.o_form_view .custom-checkbox input').eq(0).prop('checked'), "first checkbox should be checked"); - assert.notOk(form.$('.custom-checkbox input').eq(1).prop('checked'), + assert.notOk(form.$('.o_form_view .custom-checkbox input').eq(1).prop('checked'), "second checkbox should not be checked"); - assert.notOk(form.$('.custom-checkbox input').eq(2).prop('checked'), + assert.notOk(form.$('.o_form_view .custom-checkbox input').eq(2).prop('checked'), "third checkbox should not be checked"); - testUtils.form.clickSave(form);; - - form.destroy(); - }); - - QUnit.test('many2many checkboxes with default values', function (assert) { - assert.expect(7); - - this.data.partner.fields.turtles.default = [3]; - this.data.partner.fields.turtles.type = "many2many"; - - var form = createView({ - View: FormView, - model: 'partner', - data: this.data, - arch: '<form string="Partners">' + - '<field name="turtles" widget="many2many_checkboxes">' + - '</field>' + - '</form>', - mockRPC: function (route, args) { - if (args.method === 'create') { - assert.deepEqual(args.args[0].turtles, [[6, false, [1]]], - "correct values should have been sent to create"); - } - return this._super.apply(this, arguments); - } - }); - - assert.notOk(form.$('.custom-checkbox input').eq(0).prop('checked'), - "first checkbox should not be checked"); - assert.notOk(form.$('.custom-checkbox input').eq(1).prop('checked'), - "second checkbox should not be checked"); - assert.ok(form.$('.custom-checkbox input').eq(2).prop('checked'), - "third checkbox should be checked"); - - testUtils.dom.click(form.$('.custom-checkbox input:checked')); - testUtils.dom.click(form.$('.custom-checkbox input').first()); - testUtils.dom.click(form.$('.custom-checkbox input').first()); - testUtils.dom.click(form.$('.custom-checkbox input').first()); - - assert.ok(form.$('.custom-checkbox input').eq(0).prop('checked'), - "first checkbox should be checked"); - assert.notOk(form.$('.custom-checkbox input').eq(1).prop('checked'), - "second checkbox should not be checked"); - assert.notOk(form.$('.custom-checkbox input').eq(2).prop('checked'), - "third checkbox should not be checked"); - - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -904,7 +857,7 @@ QUnit.module('fields', {}, function () { testUtils.dom.click($('.modal .modal-footer .btn-primary').first()); // there is nothing left to save -> should not do a 'write' RPC - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.verifySteps([ 'read', // read initial record (on partner) @@ -980,27 +933,27 @@ QUnit.module('fields', {}, function () { assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40, 'there should be 40 records displayed on page 1'); - testUtils.dom.click(form.$('.o_pager_next')); + testUtils.dom.click(form.$('.o_field_widget[name=timmy] .o_pager_next')); assert.verifySteps(['read', 'onchange', 'read', 'read']); assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(), '41-45 / 45', "pager should be correct"); assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 5, 'there should be 5 records displayed on page 2'); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(), '1-40 / 45', "pager should be correct"); assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40, 'there should be 40 records displayed on page 1'); - testUtils.dom.click(form.$('.o_pager_next')); + testUtils.dom.click(form.$('.o_field_widget[name=timmy] .o_pager_next')); assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(), '41-45 / 45', "pager should be correct"); assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 5, 'there should be 5 records displayed on page 2'); - testUtils.dom.click(form.$('.o_pager_next')); + testUtils.dom.click(form.$('.o_field_widget[name=timmy] .o_pager_next')); assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(), '1-40 / 45', "pager should be correct"); assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40, diff --git a/addons/web/static/tests/fields/relational_fields/field_many2one_tests.js b/addons/web/static/tests/fields/relational_fields/field_many2one_tests.js index 855b7695415..84fac135ad2 100644 --- a/addons/web/static/tests/fields/relational_fields/field_many2one_tests.js +++ b/addons/web/static/tests/fields/relational_fields/field_many2one_tests.js @@ -244,7 +244,7 @@ QUnit.module('fields', {}, function () { // save and close modal testUtils.dom.click($('.modal .modal-footer .btn-primary:first')); // save form - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); // click next on pager testUtils.dom.click(form.pager.$('.o_pager_next')); @@ -316,7 +316,7 @@ QUnit.module('fields', {}, function () { // save and close modal testUtils.dom.click($('.modal .modal-footer .btn-primary:first')); // save form - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); // click next on pager testUtils.dom.click(form.pager.$('.o_pager_next')); @@ -562,7 +562,7 @@ QUnit.module('fields', {}, function () { // save the main record, and check that no extra rpcs are done (record // is not dirty, only a related record was modified) - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.verifySteps(['read', 'get_formview_id', 'load_views', 'read', 'write', 'onchange', 'read']); form.destroy(); }); @@ -681,7 +681,7 @@ QUnit.module('fields', {}, function () { 'value of the m2o should have been correctly updated'); // save - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.strictEqual(form.$('a.o_form_uri').text(), 'Partner 20', "should display correct value after save"); @@ -709,7 +709,7 @@ QUnit.module('fields', {}, function () { // Remove value from many2one and then save, there should not have href with id and model on m2o anchor testUtils.form.clickEdit(form); form.$('.o_field_many2one input').val('').trigger('keyup').trigger('focusout'); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.hasAttrValue(form.$('a.o_form_uri'), 'href', "#", "href should have #"); @@ -795,10 +795,10 @@ QUnit.module('fields', {}, function () { testUtils.form.clickEdit(form); - assert.strictEqual(form.$('input').val(), "first record", + assert.strictEqual(form.$('.o_field_widget[name=trululu] input').val(), "first record", "actual field value should be displayed to be edited"); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.ok(form.$('a:contains(and some address)').length, "should still display additional result"); @@ -959,7 +959,7 @@ QUnit.module('fields', {}, function () { }, }); await testUtils.fields.many2one.searchAndClickItem('trululu', 'b'); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.verifySteps(['name_create'], "should wait for the name_create before creating the record"); @@ -1059,7 +1059,7 @@ QUnit.module('fields', {}, function () { testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); await testUtils.fields.many2one.searchAndClickItem('trululu', {search: 'b'}); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.verifySteps(['name_create'], "should wait for the name_create before creating the record"); @@ -1138,7 +1138,7 @@ QUnit.module('fields', {}, function () { assert.hasClass(form.$('.o_data_row:nth(1)'),'o_selected_row', "the second row should be in edition"); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.containsOnce(form, '.o_data_row', "there should be 1 row saved (the second one was empty and invalid)"); @@ -1674,7 +1674,7 @@ QUnit.module('fields', {}, function () { displayName = 'new value'; testUtils.dom.click(form.$('.o_data_cell')); testUtils.fields.editInput(form.$('.o_data_cell input'), displayName); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -1727,7 +1727,7 @@ QUnit.module('fields', {}, function () { }, }); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -1866,7 +1866,7 @@ QUnit.module('fields', {}, function () { // Saving the modal and then the original model testUtils.dom.click($modal.find('.modal-footer .btn-primary')); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.verifySteps(['onchange sequence', 'partner_type write']); @@ -1897,7 +1897,7 @@ QUnit.module('fields', {}, function () { }); testUtils.form.clickEdit(form); - testUtils.dom.click(form.$('input')); + testUtils.dom.click(form.$('.o_field_widget[name=product_id] input')); form.destroy(); }); @@ -1923,7 +1923,7 @@ QUnit.module('fields', {}, function () { }); testUtils.form.clickEdit(form); - testUtils.dom.click(form.$('input:eq(2)')); + testUtils.dom.click(form.$('.o_field_widget[name=trululu] input')); form.destroy(); }); @@ -1956,7 +1956,7 @@ QUnit.module('fields', {}, function () { }); assert.strictEqual(count, 3, "should have done 3 rpcs (default_get, onchange, name_get)"); - assert.strictEqual(form.$('input').val(), 'hello world', + assert.strictEqual(form.$('.o_field_widget[name=trululu] input').val(), 'hello world', "should have taken the correct display name"); form.destroy(); }); @@ -1973,15 +1973,15 @@ QUnit.module('fields', {}, function () { '</form>', res_id: 1, }); - assert.strictEqual(form.$('a').text(), '', 'the tag a should be empty'); + assert.strictEqual(form.$('a[name=product_id]').text(), '', 'the tag a should be empty'); testUtils.form.clickEdit(form); testUtils.fields.many2one.clickOpenDropdown('product_id'); testUtils.fields.many2one.clickItem('product_id','xphone'); - assert.strictEqual(form.$('input').val(), "xphone", "should have selected xphone"); + assert.strictEqual(form.$('.o_field_widget[name=product_id] input').val(), "xphone", "should have selected xphone"); - testUtils.form.clickDiscard(form);; - assert.strictEqual(form.$('a').text(), '', 'the tag a should be empty'); + testUtils.form.clickDiscard(form); + assert.strictEqual(form.$('a[name=product_id]').text(), '', 'the tag a should be empty'); form.destroy(); }); @@ -2103,7 +2103,7 @@ QUnit.module('fields', {}, function () { // select a new value then cancel the creation of the new one --> restore the previous testUtils.fields.many2one.clickOpenDropdown('product_id'); testUtils.fields.many2one.clickItem('product_id','o'); - assert.strictEqual(form.$('input').val(), "xphone", "should have selected xphone"); + assert.strictEqual(form.$('.o_field_many2one input').val(), "xphone", "should have selected xphone"); form.$('.o_field_many2one input').focus().val('new product').trigger('keyup').trigger('blur'); assert.strictEqual($('.modal').length, 1, "there should be one opened modal"); testUtils.dom.click($('.modal .modal-footer .btn:contains(Cancel)')); @@ -2739,7 +2739,7 @@ QUnit.module('fields', {}, function () { 'The display name field should have the right value'); // Save the whole thing - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); x2mList = form.$('.o_field_x2many_list[name=p]'); diff --git a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js index af508179830..56c44731be4 100644 --- a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js +++ b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js @@ -370,7 +370,7 @@ QUnit.module('fields', {}, function () { testUtils.fields.editInput($secondTarget, 9); testUtils.fields.editInput($secondTarget, $secondTarget.val() + 9); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -397,6 +397,11 @@ QUnit.module('fields', {}, function () { '</sheet>' + '</form>', res_id: 1, + intercepts: { + load_filters: function (event) { + throw new Error('Should not load filters'); + }, + }, }); @@ -700,7 +705,7 @@ QUnit.module('fields', {}, function () { assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "blipyopkawa", "should have the 3 rows in the new order"); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.deepEqual(_.map(this.data.turtle.records, function (turtle) { return _.pick(turtle, 'id', 'turtle_foo', 'turtle_int'); @@ -777,7 +782,7 @@ QUnit.module('fields', {}, function () { testUtils.dom.click(form.$('.o_data_cell').eq(1)); var $cell = form.$('.o_selected_row .o_input[name=turtle_foo]'); testUtils.fields.editSelect($cell, "hop"); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -839,7 +844,7 @@ QUnit.module('fields', {}, function () { testUtils.dom.click(form.$('.o_data_cell').eq(1)); var $cell = form.$('.o_selected_row .o_input[name=turtle_foo]'); testUtils.fields.editSelect($cell, "hop"); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.deepEqual(form.$('.o_many2many_tags_cell').text().trim().split(/\s+/), ["second", "record", "aaa"], @@ -960,7 +965,7 @@ QUnit.module('fields', {}, function () { "should still have the 3 rows in the correct order"); assert.strictEqual(turtleOnchange, 3, "should update all lines"); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -1004,7 +1009,7 @@ QUnit.module('fields', {}, function () { res_id: 1, }); - testUtils.dom.click(form.$('.o_pager_next')); + testUtils.dom.click(form.$('.o_field_widget[name=turtles] .o_pager_next')); assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "yopblipkawa", "should have the 3 rows in the correct order"); @@ -1021,8 +1026,8 @@ QUnit.module('fields', {}, function () { assert.strictEqual(form.$('.o_data_cell').text(), "blurpkawablip", "should display to record in 'turtle_int' order"); - testUtils.form.clickSave(form);; - testUtils.dom.click(form.$('.o_pager_next')); + testUtils.form.clickSave(form); + testUtils.dom.click(form.$('.o_field_widget[name=turtles] .o_pager_next')); assert.strictEqual(form.$('.o_data_cell:not(.o_handle_cell)').text(), "blurpkawablip", "should display to record in 'turtle_int' order"); @@ -1098,7 +1103,7 @@ QUnit.module('fields', {}, function () { testUtils.fields.editInput(form.$('.o_field_one2many .o_list_view tbody tr:first input:first'), 'blurp'); // the domain fail if the widget does not use the allready loaded data. - testUtils.form.clickDiscard(form);; + testUtils.form.clickDiscard(form); assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "blurp#21#22#23#24#25#26#27#28#29", "should display the records in order with the changes"); @@ -1130,7 +1135,7 @@ QUnit.module('fields', {}, function () { assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "#20#39#40#41#42#43#44#45#46#47", "should display the records in order after resequence (display record with turtle_int=0)"); - testUtils.form.clickDiscard(form);; + testUtils.form.clickDiscard(form); assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "#20#39#40#41#42#43#44#45#46#47", "should display the records in order after resequence"); @@ -1184,7 +1189,7 @@ QUnit.module('fields', {}, function () { }); testUtils.form.clickEdit(form); - testUtils.dom.click(form.$('.o_pager_next')); + testUtils.dom.click(form.$('.o_field_widget[name=turtles] .o_pager_next')); testUtils.dom.click(form.$('.o_field_one2many .o_list_view tbody tr:eq(1) td:first')); testUtils.fields.editInput(form.$('.o_field_one2many .o_list_view tbody tr:eq(1) input:first'), 'value 1'); @@ -1259,7 +1264,7 @@ QUnit.module('fields', {}, function () { }); testUtils.form.clickEdit(form); - testUtils.dom.click(form.$('.o_pager_next')); + testUtils.dom.click(form.$('.o_field_widget[name=turtles] .o_pager_next')); testUtils.dom.click(form.$('.o_field_one2many .o_list_view tbody tr:eq(1) td:first')); testUtils.fields.editInput(form.$('.o_field_one2many .o_list_view tbody tr:eq(1) input:first'), 'value 1'); @@ -1693,7 +1698,7 @@ QUnit.module('fields', {}, function () { assert.strictEqual(form.$('tbody tr:first td.o_data_cell:not(.o_handle_cell) input').val(), "blip", "should edit the correct row"); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "blipMy little Foo Valueyop", "should still have the 3 rows in the new order"); @@ -1904,7 +1909,7 @@ QUnit.module('fields', {}, function () { assert.strictEqual(form.$('.o_kanban_record:first span:contains(new record)').length, 0, 'new record should not be on page 1'); // save - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); // delete a record on page one checkRead = true; @@ -1920,7 +1925,7 @@ QUnit.module('fields', {}, function () { assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(), '1-40 / 45', "pager range should be correct"); // save - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); // add and delete records in both pages testUtils.form.clickEdit(form); @@ -1956,7 +1961,7 @@ QUnit.module('fields', {}, function () { assert.strictEqual(form.$('.o_kanban_record span:contains(new record page 2)').length, 1, 'new records should be on page 2'); // save - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -2088,7 +2093,7 @@ QUnit.module('fields', {}, function () { "first row of o2m should be readonly again"); // discard changes - testUtils.form.clickDiscard(form);; + testUtils.form.clickDiscard(form); assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'new value', "changes shouldn't have been discarded yet, waiting for user confirmation"); testUtils.dom.click($('.modal .modal-footer .btn-primary')); @@ -2100,7 +2105,7 @@ QUnit.module('fields', {}, function () { testUtils.dom.click(form.$('.o_field_one2many tbody td').first()); testUtils.fields.editInput(form.$('.o_field_one2many tbody td').first().find('input'), "new value"); testUtils.dom.click(form.$el); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); // FIXME: this next test doesn't pass as the save of updates of // relational data is temporarily disabled // assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'new value', @@ -2191,7 +2196,7 @@ QUnit.module('fields', {}, function () { "should have 1 delete button (another record is supposed to have been unlinked)"); // save and check that the correct command has been generated - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -2251,7 +2256,7 @@ QUnit.module('fields', {}, function () { "should have 1 remove button (another record is supposed to have been deleted)"); // save and check that the correct command has been generated - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); // FIXME: it would be nice to test that the view is re-rendered correctly, // but as the relational data isn't re-fetched, the rendering is ok even @@ -2451,7 +2456,7 @@ QUnit.module('fields', {}, function () { testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); testUtils.dom.click(form.$el); - assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 5', + assert.strictEqual(form.$('.o_field_widget[name=turtles] .o_cp_pager').text().trim(), '1-3 / 5', "pager should display the correct total"); form.destroy(); }); @@ -2522,7 +2527,7 @@ QUnit.module('fields', {}, function () { assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'new name', 'the remaining subrecord should be "new name"'); - testUtils.form.clickSave(form);; // save the record + testUtils.form.clickSave(form); // save the record assert.strictEqual(nbWrite, 1, "should have write the changes in DB"); form.destroy(); @@ -2622,7 +2627,7 @@ QUnit.module('fields', {}, function () { // enter another value and save testUtils.fields.editInput(form.$('.o_selected_row > td input'), 'gemuse'); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.containsN(form, 'tr.o_data_row', 2, "should have 2 data rows"); assert.strictEqual(form.$('td:contains(kartoffel)').length, 1, "should have one td with the new value"); @@ -2661,7 +2666,7 @@ QUnit.module('fields', {}, function () { "should have 3 data rows"); // cancel the edition - testUtils.form.clickDiscard(form);; + testUtils.form.clickDiscard(form); testUtils.dom.click($('.modal-footer button.btn-primary').first()); assert.containsOnce(form, 'tr.o_data_row', "should have 1 data rows"); @@ -2754,7 +2759,7 @@ QUnit.module('fields', {}, function () { // click on Add an item again, then click on save testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.containsNone(form, 'tr.o_data_row', "should still have 0 data rows"); @@ -2785,13 +2790,13 @@ QUnit.module('fields', {}, function () { testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); // the record currently being added should not count in the pager - assert.ok(!form.$('.o_cp_pager').is(':visible'), - "pager should not be visible"); + assert.isNotVisible(form.$('.o_field_widget[name=turtles] .o_cp_pager')); + form.destroy(); }); QUnit.test('editable one2many list, adding line, then discarding', function (assert) { - assert.expect(1); + assert.expect(2); this.data.turtle.records.push({ id: 4, turtle_foo: 'stephen hawking' }); this.data.partner.records[0].turtles = [1, 2, 3, 4]; @@ -2813,13 +2818,15 @@ QUnit.module('fields', {}, function () { // add a record, then discard testUtils.form.clickEdit(form); testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); - testUtils.form.clickDiscard(form);; + testUtils.form.clickDiscard(form); // confirm the discard operation testUtils.dom.click($('.modal .modal-footer .btn-primary')); - assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 4', - "pager should still be visible"); + assert.isVisible(form.$('.o_field_widget[name=turtles] .o_cp_pager')); + assert.strictEqual(form.$('.o_field_widget[name=turtles] .o_cp_pager').text().trim(), '1-3 / 4', + "pager should display correct values"); + form.destroy(); }); @@ -2849,9 +2856,8 @@ QUnit.module('fields', {}, function () { testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); // go on next page. The new record is not valid and should be discarded - testUtils.dom.click(form.$('.o_pager_next')); - assert.containsOnce(form, 'tr.o_data_row', - "should have 1 data rows"); + testUtils.dom.click(form.$('.o_field_widget[name=turtles] .o_pager_next')); + assert.containsOnce(form, 'tr.o_data_row'); form.destroy(); }); @@ -2885,15 +2891,15 @@ QUnit.module('fields', {}, function () { // go to next page. The new record is not valid, but dirty. we should // see a confirm dialog - testUtils.dom.click(form.$('.o_pager_next')); + testUtils.dom.click(form.$('.o_field_widget[name=turtles] .o_pager_next')); - assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 4', + assert.strictEqual(form.$('.o_field_widget[name=turtles] .o_cp_pager').text().trim(), '1-3 / 4', "pager should still display the correct total"); // click on cancel testUtils.dom.click($('.modal .modal-footer .btn-secondary')); - assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 4', + assert.strictEqual(form.$('.o_field_widget[name=turtles] .o_cp_pager').text().trim(), '1-3 / 4', "pager should again display the correct total"); assert.containsOnce(form, '.o_field_one2many input.o_field_invalid', "there should be an invalid input in the one2many"); @@ -2927,16 +2933,14 @@ QUnit.module('fields', {}, function () { testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); // go on next page - testUtils.dom.click(form.$('.o_pager_next')); + testUtils.dom.click(form.$('.o_field_widget[name=turtles] .o_pager_next')); // discard - testUtils.form.clickDiscard(form);; + testUtils.form.clickDiscard(form); testUtils.dom.click($('.modal .modal-footer .btn-primary')); - assert.containsOnce(form, 'tr.o_data_row', - "should have 1 data row"); - assert.ok(!form.$('.o_cp_pager').is(':visible'), - "pager should not be visible"); + assert.containsOnce(form, 'tr.o_data_row'); + assert.isNotVisible(form.$('.o_field_widget[name=turtles] .o_cp_pager')); form.destroy(); }); @@ -3079,7 +3083,7 @@ QUnit.module('fields', {}, function () { "onchange should have been triggered"); // save and check everything is fine - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.strictEqual(form.$('.o_data_row td:contains(aubergine)').length, 1, "should have one row with turtle_foo value"); assert.strictEqual(form.$('.o_data_row td:contains(9)').length, 1, @@ -3284,7 +3288,7 @@ QUnit.module('fields', {}, function () { testUtils.form.clickEdit(form); testUtils.dom.click(form.$('.o_field_one2many tbody td').first()); testUtils.fields.editInput(form.$('.o_field_one2many tbody td').first().find('input'), "new value"); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'from onchange', "display name of first record in o2m list should be 'new value'"); @@ -3430,7 +3434,7 @@ QUnit.module('fields', {}, function () { testUtils.dom.click($('.bootstrap-datetimepicker-widget .month').eq(1)); testUtils.dom.click($('.day:contains(22)')); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.verifySteps(['read', 'read', 'onchange', 'write', 'read', 'read']); form.destroy(); @@ -3501,7 +3505,7 @@ QUnit.module('fields', {}, function () { testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); testUtils.fields.editInput(form.$('.o_field_widget[name=display_name]'), 'z'); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -3566,7 +3570,7 @@ QUnit.module('fields', {}, function () { testUtils.dom.click(form.$('.o_field_one2many .o_list_view tbody tr:first td:first')); testUtils.fields.editInput(form.$('.o_field_one2many .o_list_view tbody tr:first input:first'), 'blurp'); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -3664,7 +3668,7 @@ QUnit.module('fields', {}, function () { assert.containsN(form, '.o_field_widget', delta, "all (non visible) field widgets should have been destroyed"); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.strictEqual(form.$('.o_data_row').text(), "first20second-20", "should correctly refresh the records after save"); @@ -3958,7 +3962,7 @@ QUnit.module('fields', {}, function () { "should display 'xpad' in a td"); // save the record - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -4186,7 +4190,7 @@ QUnit.module('fields', {}, function () { "should not have unselected the row after edition"); testUtils.fields.editInput(form.$('td input.o_field_widget'), 'abc'); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.strictEqual(form.$('td:contains(abc)').length, 1, "should have a row with the correct value"); @@ -4531,7 +4535,7 @@ QUnit.module('fields', {}, function () { }); testUtils.dom.click(form.$('td.o_field_x2many_list_row_add a')); testUtils.fields.editInput(form.$('td input[name="turtle_foo"]'), 'cat'); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -4768,7 +4772,7 @@ QUnit.module('fields', {}, function () { testUtils.dom.click(form.$('.o_field_one2many .o_list_view tbody tr:eq(0) td:first')); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.strictEqual(nbFetchSpecialDomain, 1, "should only fetch special domain once"); form.destroy(); @@ -5068,7 +5072,7 @@ QUnit.module('fields', {}, function () { assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "firstxenomorphe second record new linexphone first record ", 'should display the name, one2many and many2many value'); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "firstxenomorphe second record new linexphone first record ", 'should display the name, one2many and many2many value after save'); @@ -5263,7 +5267,7 @@ QUnit.module('fields', {}, function () { // confirm the changes in the modal testUtils.dom.click($('.modal .modal-footer .btn-primary')); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -5401,7 +5405,7 @@ QUnit.module('fields', {}, function () { 'My little Foo Value', "should have a value for the foo field"); // save the view to force a create of the new record in the one2many - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.containsOnce(form, '.o_field_widget .o_kanban_view', "should have one inner kanban view for the one2many field"); assert.strictEqual(form.$('.o_field_widget .o_kanban_view .o_kanban_record:not(.o_kanban_ghost)').length, 1, @@ -5489,7 +5493,7 @@ QUnit.module('fields', {}, function () { assert.hasClass(form.$('tr.o_data_row:first'),'o_selected_row', "first row should be selected"); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -5536,7 +5540,7 @@ QUnit.module('fields', {}, function () { assert.hasClass(form.$('tr.o_data_row:eq(1)'),'o_selected_row', "second row should be selected"); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -6049,7 +6053,7 @@ QUnit.module('fields', {}, function () { testUtils.dom.click(form.$('.o_data_cell:first')); testUtils.fields.editInput(form.$('.o_field_widget[name=display_name]'), 'abc'); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -6254,16 +6258,13 @@ QUnit.module('fields', {}, function () { testUtils.form.clickEdit(form); testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); - assert.containsN(form, 'tr.o_data_row', 41, - "should have 41 data rows on the current page"); - assert.hasClass(form.$('tr.o_data_row').last(),'o_selected_row', - "last row should be selected"); + assert.containsN(form, 'tr.o_data_row', 41); + assert.hasClass(form.$('tr.o_data_row').last(), 'o_selected_row'); testUtils.fields.editInput(form.$('.o_data_row input[name="turtle_foo"]'), 'rainbow dash'); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); - assert.containsN(form, 'tr.o_data_row', 40, - "should have 40 data rows on the current page"); + assert.containsN(form, 'tr.o_data_row', 40); assert.verifySteps(['read', 'read', 'default_get', 'write', 'read', 'read']); form.destroy(); @@ -6342,27 +6343,23 @@ QUnit.module('fields', {}, function () { testUtils.form.clickEdit(form); - testUtils.dom.click(form.$('.o_pager_next')); + testUtils.dom.click(form.$('.o_field_widget[name=turtles] .o_pager_next')); - assert.containsN(form, 'tr.o_data_row', 2, - "should have 2 data rows on the current page"); + assert.containsN(form, 'tr.o_data_row', 2); testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); - assert.containsN(form, 'tr.o_data_row', 3, - "should have 3 data rows on the current page (2 records and the created line)"); + assert.containsN(form, 'tr.o_data_row', 3); - assert.hasClass(form.$('tr.o_data_row').first(),'o_selected_row', - "first row should be selected"); + assert.hasClass(form.$('tr.o_data_row').first(), 'o_selected_row'); assert.strictEqual(form.$('tr.o_data_row input').val(), 'default foo', "selected input should have correct string"); testUtils.fields.editInput(form.$('.o_data_row input[name="turtle_foo"]'), 'rainbow dash'); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); - assert.containsN(form, 'tr.o_data_row', 40, - "should have 40 data rows on the current page"); + assert.containsN(form, 'tr.o_data_row', 40); assert.verifySteps(['read', 'read', 'read', 'default_get', 'write', 'read', 'read']); form.destroy(); @@ -6571,7 +6568,7 @@ QUnit.module('fields', {}, function () { assert.strictEqual(form.$('.o_data_cell').text(), '1 record', "should correctly display the value of the inner o2m"); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -6634,7 +6631,7 @@ QUnit.module('fields', {}, function () { // we make sure here that when we save, the values are the current // values displayed in the field. - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -6664,7 +6661,7 @@ QUnit.module('fields', {}, function () { }); testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); testUtils.fields.editInput(form.$('input[name="turtle_foo"]'), 'pinky pie'); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -6730,7 +6727,7 @@ QUnit.module('fields', {}, function () { "should have added the new row to the nested one2many"); testUtils.dom.clickFirst($('.modal .modal-footer .btn-primary')); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); form.destroy(); }); @@ -6857,7 +6854,7 @@ QUnit.module('fields', {}, function () { var inputText = 'ninja'; testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); testUtils.fields.editInput(form.$('.o_input[name="turtle_foo"]'), inputText); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.strictEqual($('.o_data_cell').text(), "blipyopkawa" + inputText); form.destroy(); @@ -6898,7 +6895,7 @@ QUnit.module('fields', {}, function () { var inputText = 'ninja'; testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); testUtils.fields.editInput(form.$('.o_input[name="turtle_foo"]'), inputText); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.strictEqual($('.o_data_cell').text(), inputText + "blipyopkawa"); form.destroy(); @@ -7187,7 +7184,7 @@ QUnit.module('fields', {}, function () { }); assert.strictEqual(form.$('.o_data_row').text(), 'yopblip', 'data has been properly loaded'); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.strictEqual(form.$('.o_data_row').text(), 'yopblip', 'data has been properly saved'); @@ -7414,7 +7411,7 @@ QUnit.module('fields', {}, function () { testUtils.dom.click(form.$('.o_field_x2many_list_row_add a:eq(1)')); // click add pasta testUtils.dom.click(form.$('.o_field_x2many_list_row_add a:eq(2)')); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.strictEqual($('.o_data_cell').text(), "pizzapasta"); form.destroy(); @@ -7638,7 +7635,7 @@ QUnit.module('fields', {}, function () { // open first partner so changes from previous action are applied testUtils.dom.click(form.$('.o_data_row:eq(0)')); testUtils.dom.click($('.modal .btn-primary')); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.strictEqual(numUserOnchange, 2, 'there should 2 and only 2 onchange from closing the partner modal'); @@ -7796,7 +7793,7 @@ QUnit.module('fields', {}, function () { form.$('.o_input[name="turtle_foo"]'), 'ninja', ['input', $.Event('keydown', {which: $.ui.keyCode.TAB, keyCode: $.ui.keyCode.TAB,})] - );; + ); assert.strictEqual(form.$('.o_field_text').text(), 'blipninja', 'current line should be saved'); @@ -7900,11 +7897,11 @@ QUnit.module('fields', {}, function () { testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); - testUtils.fields.editInput(form.$('input'), 'some foo value'); + testUtils.fields.editInput(form.$('.o_field_widget[name=turtles] input'), 'some foo value'); // click to add a second row to unselect the current one, then save testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); assert.containsOnce(form, '.o_form_readonly', "form view should be in readonly"); @@ -7977,7 +7974,7 @@ QUnit.module('fields', {}, function () { 'The display name field should have the right value'); // Save the whole thing - testUtils.form.clickSave(form);; + testUtils.form.clickSave(form); x2mList = form.$('.o_field_x2many_list[name=p]'); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index ab7a603fb80..6c3493828ad 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -615,7 +615,7 @@ QUnit.module('relational_fields', { list.destroy(); }); - QUnit.test('widget selection, edition and on many2one field', function (assert) { + QUnit.test('widget selection, edition and on many2one field', function (assert) { assert.expect(18); this.data.partner.onchanges = {product_id: function () {}}; @@ -640,7 +640,7 @@ QUnit.module('relational_fields', { }, }); - assert.ok(!form.$('select').length, "should not have a select tag in dom"); + assert.containsNone(form.$('.o_form_view'), 'select'); assert.strictEqual(form.$('.o_field_widget[name=product_id]').text(), 'xphone', "should have rendered the many2one field correctly"); assert.strictEqual(form.$('.o_field_widget[name=trululu]').text(), '', @@ -650,11 +650,10 @@ QUnit.module('relational_fields', { testUtils.form.clickEdit(form); - assert.containsN(form, 'select', 3, - "should have 3 select tag in dom"); - assert.strictEqual(form.$('select[name="product_id"] option:contains(xphone)').length, 1, + assert.containsN(form.$('.o_form_view'), 'select', 3); + assert.containsOnce(form, 'select[name="product_id"] option:contains(xphone)', "should have fetched xphone option"); - assert.strictEqual(form.$('select[name="product_id"] option:contains(xpad)').length, 1, + assert.containsOnce(form, 'select[name="product_id"] option:contains(xpad)', "should have fetched xpad option"); assert.strictEqual(form.$('select[name="product_id"]').val(), "37", "should have correct product_id value"); @@ -757,7 +756,7 @@ QUnit.module('relational_fields', { }, }); - testUtils.fields.editSelect(form.$('select'), "false"); + testUtils.fields.editSelect(form.$('.o_form_view select'), 'false'); testUtils.form.clickSave(form); form.destroy(); @@ -1297,11 +1296,11 @@ QUnit.module('relational_fields', { // update foo, which will trigger an onchange and update timmy // -> m2mtags input should not have taken the focus - form.$('input:first').focus(); - testUtils.fields.editInput(form.$('input:first'), 'trigger onchange'); + form.$('input[name=foo]').focus(); + testUtils.fields.editInput(form.$('input[name=foo]'), 'trigger onchange'); assert.containsNone(form, '.o_field_many2manytags .badge', "should contain no tags"); - assert.strictEqual(form.$('input:first').get(0), document.activeElement, + assert.strictEqual(form.$('input[name=foo]').get(0), document.activeElement, "foo input should have kept the focus"); // add a tag -> m2mtags input should still have the focus @@ -1864,7 +1863,6 @@ QUnit.module('relational_fields', { QUnit.test('name_create in form dialog', async function (assert) { assert.expect(2); - var form = createView({ View: FormView, model: 'partner', @@ -1891,7 +1889,7 @@ QUnit.module('relational_fields', { testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')); await testUtils.fields.many2one.searchAndClickItem('product_id', - {selector:'.modal', search:'new record'}); + {selector: '.modal', search: 'new record'}); assert.verifySteps(['name_create']); @@ -1922,7 +1920,7 @@ QUnit.module('relational_fields', { res_id: 1, }); - // Cuurent Form + // Current Form assert.equal(form.$('.o_form_uri.o_field_widget[name=reference]').text(), 'xphone', 'the field reference of the form should have the right value'); diff --git a/addons/web/static/tests/fields/special_fields.js b/addons/web/static/tests/fields/special_fields.js index 8654c3e0a9f..6acc2be521b 100644 --- a/addons/web/static/tests/fields/special_fields.js +++ b/addons/web/static/tests/fields/special_fields.js @@ -209,7 +209,7 @@ QUnit.module('special_fields', { '</form>', }); testUtils.form.clickEdit(form); - assert.strictEqual(form.$('select').length, 1, "should have the select field"); + assert.containsOnce(form, 'select[name=tz]'); form.destroy(); }); diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js index 759962e444c..2897b02214c 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -24,11 +24,12 @@ var testUtilsMock = require('web.test_utils_mock'); var testUtilsModal = require('web.test_utils_modal'); var testUtilsPivot = require('web.test_utils_pivot'); var tools = require('web.tools'); + function deprecated(fn, type) { var msg = `Helper 'testUtils.${fn.name}' is deprecated. ` + `Please use 'testUtils.${type}.${fn.name}' instead.`; return tools.deprecated(fn, msg); -}; +} // Loading static files cannot be properly simulated when their real content is // really needed. This is the case for static XML files so we load them here, @@ -108,6 +109,7 @@ return $.when( createActionManager: testUtilsCreate.createActionManager, createDebugManager: testUtilsCreate.createDebugManager, createAsyncView: testUtilsCreate.createAsyncView, + createControlPanel: testUtilsCreate.createControlPanel, createView: testUtilsCreate.createView, createModel: testUtilsCreate.createModel, createParent: testUtilsCreate.createParent, diff --git a/addons/web/static/tests/helpers/test_utils_create.js b/addons/web/static/tests/helpers/test_utils_create.js index d4d4e2a6b55..0adb13d466f 100644 --- a/addons/web/static/tests/helpers/test_utils_create.js +++ b/addons/web/static/tests/helpers/test_utils_create.js @@ -12,9 +12,9 @@ odoo.define('web.test_utils_create', function (require) { var ActionManager = require('web.ActionManager'); var config = require('web.config'); -var ControlPanel = require('web.ControlPanel'); -var dom = require('web.dom'); +var ControlPanelView = require('web.ControlPanelView'); var DebugManager = require('web.DebugManager'); +var dom = require('web.dom'); var testUtilsMock = require('web.test_utils_mock'); var Widget = require('web.Widget'); @@ -70,8 +70,13 @@ function createActionManager (params) { actionManager.destroy = originalDestroy; widget.destroy(); }; - actionManager.appendTo(widget.$el); - + var fragment = document.createDocumentFragment(); + actionManager.appendTo(fragment).then(function () { + dom.append(widget.$el, fragment, { + callbacks: [{ widget: actionManager }], + in_DOM: true, + }); + }); return actionManager; } @@ -113,32 +118,46 @@ function createAsyncView(params) { $target = $('body'); $target.addClass('debug'); } + // reproduce the DOM environment of views + var $webClient = $('<div>').addClass('o_web_client').prependTo($target); + var $actionManager = $('<div>').addClass('o_action_manager').appendTo($webClient); + // add mock environment: mock server, session, fieldviewget, ... var mockServer = testUtilsMock.addMockEnvironment(widget, params); var viewInfo = testUtilsMock.fieldsViewGet(mockServer, params); + // create the view - var viewOptions = { + var View = params.View; + var viewOptions = _.defaults({}, params.viewOptions, { modelName: params.model || 'foo', ids: 'res_id' in params ? [params.res_id] : undefined, currentId: 'res_id' in params ? params.res_id : undefined, domain: params.domain || [], context: params.context || {}, - groupBy: params.groupBy || [], - }; + }); + // patch the View to handle the groupBy given in params, as we can't give it + // in init (unlike the domain and context which can be set in the action) + testUtilsMock.patch(View, { + _updateMVCParams: function () { + this._super.apply(this, arguments); + this.loadParams.groupedBy = params.groupBy || viewOptions.groupBy || []; + }, + }); if (params.hasSelectors) { viewOptions.hasSelectors = params.hasSelectors; } - _.extend(viewOptions, params.viewOptions); - - var view = new params.View(viewInfo, viewOptions); - - // reproduce the DOM environment of views - var $web_client = $('<div>').addClass('o_web_client').prependTo($target); - var controlPanel = new ControlPanel(widget); - controlPanel.appendTo($web_client); - var $content = $('<div>').addClass('o_content').appendTo($web_client); + var view; + if (viewInfo.type === 'controlpanel' || viewInfo.type === 'search') { + // TODO: probably needs to create an helper just for that + view = new params.View({ + viewInfo: viewInfo, + modelName: params.model || 'foo', + }); + } else { + view = new params.View(viewInfo, viewOptions); + } if (params.interceptsPropagate) { _.each(params.interceptsPropagate, function (cb, name) { @@ -155,16 +174,15 @@ function createAsyncView(params) { // when it will be called the second time (by its parent) delete view.destroy; widget.destroy(); - }; - // link the view to the control panel - view.set_cp_bus(controlPanel.get_bus()); + testUtilsMock.unpatch(View); + }; // render the view in a fragment as they must be able to render correctly // without being in the DOM var fragment = document.createDocumentFragment(); return view.appendTo(fragment).then(function () { - dom.append($content, fragment, { + dom.prepend($actionManager, fragment, { callbacks: [{ widget: view }], in_DOM: true, }); @@ -176,7 +194,95 @@ function createAsyncView(params) { }); }); } +/** + * create a controlPanel from various parameters. + * + * It returns an instance of ControlPanelController + * + * @param {Object} [params={}] + * @param {Object} [params.action={}] + * @param {Object} [params.context={}] + * @param {Object} [params.debug=false] if true, the widget will be appended in + * the DOM. Also, RPCs and uncaught OdooEvent will be logged + * @param {string} [params.domain=[]] + * @param {integer} [params.fieldDebounce=0] the debounce value to use for the + * duration of the test. + * @param {Object} params.intercepts an object with event names as key, and + * callback as value. Each key,value will be used to intercept the event. + * Note that this is particularly useful if you want to intercept events going + * up in the init process of the view, because there are no other way to do it + * after this method returns + * @param {string} [params.modelName] + * @param {string[]} [params.searchMenuTypes = ['filter', 'groupBy', 'favorite']] + * determines search menus displayed. + * @param {string} [params.template] the QWeb template to render + * @param {Object} [params.viewInfo={arch: '<controlpanel/>', fields: {}}] + a controlpanel (or search) fieldsview + * @param {string} [params.viewInfo.arch] + * @param {boolean} [params.context.no_breadcrumbs=false] if set to true, + * breadcrumbs won't be rendered + * @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 + * + * @returns {Deferred<ControlPanel>} resolves with an instance of the ControlPanelController + */ +function createControlPanel(params) { + params = params || {}; + var $target = $('#qunit-fixture'); + if (params.debug) { + $target = $('body'); + $target.addClass('debug'); + } + // reproduce the DOM environment of a view control panel + var $webClient = $('<div>').addClass('o_web_client').prependTo($target); + var $actionManager = $('<div>').addClass('o_action_manager').appendTo($webClient); + var $action = $('<div>').addClass('o_action').appendTo($actionManager); + + var widget = new Widget(); + // add mock environment: mock server, session, fieldviewget, ... + var mockServer = testUtilsMock.addMockEnvironment(widget, params); + if (!params.viewInfo) { + try { + params.viewInfo = testUtilsMock.fieldsViewGet(mockServer, params); + } catch (e) { + // if an error occurs we keep params.viewInfo undefined. + // It will be set to {arch: '<controlpanel/>', fields: {}} in + // ControlPanelView init function. + } + } + + var viewOptions = _.defaults({}, params.viewOptions, { + modelName: params.model, + searchMenuTypes: params.searchMenuTypes, + viewInfo: params.viewInfo, + }); + var controlPanelView = new ControlPanelView(viewOptions); + return controlPanelView.getController(widget).then(function (controlPanel) { + // override the controlPanel's 'destroy' so that it calls 'destroy' on + // the widget instead, as the widget is the parent of the controlPanel + // and the mockServer. + controlPanel.__destroy = controlPanel.destroy; + controlPanel.destroy = function () { + // remove the override to properly destroy the controlPanel and its + // children when it will be called the second time (by its parent) + delete controlPanel.destroy; + widget.destroy(); + }; + // render the controlPanel in a fragment as it must be able to render + // correctly without being in the DOM + var fragment = document.createDocumentFragment(); + return controlPanel.appendTo(fragment).then(function () { + dom.prepend($action, fragment, { + callbacks: [{ widget: controlPanel }], + in_DOM: true, + }); + return controlPanel; + }); + }); +} /** * Create and return an instance of DebugManager with all rpcs going through a * mock method, assuming that the user has access rights, and is an admin. @@ -279,6 +385,7 @@ function createView(params) { return { createActionManager: createActionManager, createAsyncView: createAsyncView, + createControlPanel: createControlPanel, createDebugManager: createDebugManager, createModel: createModel, createParent: createParent, diff --git a/addons/web/static/tests/tools/debug_manager_tests.js b/addons/web/static/tests/tools/debug_manager_tests.js index 6929d55cce5..fde90105bfb 100644 --- a/addons/web/static/tests/tools/debug_manager_tests.js +++ b/addons/web/static/tests/tools/debug_manager_tests.js @@ -109,7 +109,7 @@ QUnit.module('DebugManager', {}, function () { 'ir.default': { // We just need this to be defined fields: {}, }, - } + }; var form = testUtils.createView({ View: FormView, @@ -138,6 +138,7 @@ QUnit.module('DebugManager', {}, function () { // Simulate update debug manager from web client var action = { + controlPanelFieldsView: {}, views: [{ fieldsView: { view_id: 1, diff --git a/addons/web/static/tests/views/abstract_view_tests.js b/addons/web/static/tests/views/abstract_view_tests.js index 01f1daafeb4..3d4cc423ddd 100644 --- a/addons/web/static/tests/views/abstract_view_tests.js +++ b/addons/web/static/tests/views/abstract_view_tests.js @@ -3,9 +3,9 @@ odoo.define('web.abstract_view_tests', function (require) { var AbstractView = require('web.AbstractView'); var ajax = require('web.ajax'); -var ListView = require('web.ListView'); var testUtils = require('web.test_utils'); +var createActionManager = testUtils.createActionManager; var createAsyncView = testUtils.createAsyncView; QUnit.module('Views', { @@ -112,22 +112,36 @@ QUnit.module('Views', { defs.c.resolve(); }); - QUnit.test('groupBy attribute can be a string, instead of a list of strings', function (assert) { - assert.expect(2); + QUnit.test('group_by from context can be a string, instead of a list of strings', function (assert) { + assert.expect(1); - var list = testUtils.createView({ - View: ListView, - model: 'foo', + var actionManager = createActionManager({ + actions: [{ + id: 1, + name: 'Foo', + res_model: 'foo', + type: 'ir.actions.act_window', + views: [[false, 'list']], + context: { + group_by: 'bar', + }, + }], + archs: { + 'foo,false,list': '<tree><field name="foo"/><field name="bar"/></tree>', + 'foo,false,search': '<search></search>', + }, data: this.data, - arch: '<tree><field name="foo"/><field name="bar"/></tree>', - groupBy: 'bar', - mockRPC: function (route, args) { - assert.strictEqual(args.method, 'read_group'); - assert.deepEqual(args.kwargs.groupby, ['bar']); + mockRPC: function(route, args) { + if (args.method === 'read_group') { + assert.deepEqual(args.kwargs.groupby, ['bar']); + } return this._super.apply(this, arguments); }, }); - list.destroy(); + + actionManager.doAction(1); + + actionManager.destroy(); }); }); diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js index 9f421e9c6e9..3f4c6376d5b 100644 --- a/addons/web/static/tests/views/calendar_tests.js +++ b/addons/web/static/tests/views/calendar_tests.js @@ -253,17 +253,17 @@ QUnit.module('Views', { actionManager.doAction(1); // displays month mode by default - assert.strictEqual(actionManager.controlPanel.$('.breadcrumb-item').text(), + assert.strictEqual($('.o_control_panel .breadcrumb-item').text(), 'Meetings Test (Dec 11 – 17, 2016)', "should display the current week"); // switch to day mode - testUtils.dom.click(actionManager.controlPanel.$('.o_calendar_button_day')); - assert.strictEqual(actionManager.controlPanel.$('.breadcrumb-item').text(), + testUtils.dom.click($('.o_control_panel .o_calendar_button_day')); + assert.strictEqual($('.o_control_panel .breadcrumb-item').text(), 'Meetings Test (December 12, 2016)', "should display the current day"); // switch to month mode - testUtils.dom.click(actionManager.controlPanel.$('.o_calendar_button_month')); - assert.strictEqual(actionManager.controlPanel.$('.breadcrumb-item').text(), + testUtils.dom.click($('.o_control_panel .o_calendar_button_month')); + assert.strictEqual($('.o_control_panel .breadcrumb-item').text(), 'Meetings Test (December 2016)', "should display the current month"); actionManager.destroy(); @@ -2058,7 +2058,9 @@ QUnit.module('Views', { // call destroy function of controller to ensure that it correctly destroys everything calendar.__destroy(); - assert.strictEqual(instanceNumber, initialInstanceNumber + 3, "every widget must be destroyed exept the parent"); + // + 1 (parent) + assert.strictEqual(instanceNumber, initialInstanceNumber + 1, + "every widget must be destroyed exept the parent"); calendar.destroy(); @@ -2116,8 +2118,8 @@ QUnit.module('Views', { testUtils.mock.unpatch(Dialog); }); - QUnit.test('calendar is configured to hide the groupby menu', function (assert) { - assert.expect(2); + QUnit.test('calendar is configured to have no groupBy menu', function (assert) { + assert.expect(1); var archs = { 'event,1,calendar': '<calendar class="o_calendar_test" '+ @@ -2144,9 +2146,8 @@ QUnit.module('Views', { }); actionManager.doAction(1); - var $groupBy = actionManager.controlPanel.$('span.fa.fa-bars'); - assert.strictEqual($groupBy.length, 1, 'just making sure we have the groupby menu'); - assert.ok(!$groupBy.is(':visible'), 'groupby menu should not be visible'); + assert.containsNone(actionManager.$('.o_control_panel .o_search_options span.fa.fa-bars'), + "the control panel has no groupBy menu"); actionManager.destroy(); }); diff --git a/addons/web/static/tests/views/control_panel_tests.js b/addons/web/static/tests/views/control_panel_tests.js new file mode 100644 index 00000000000..513cdb32e08 --- /dev/null +++ b/addons/web/static/tests/views/control_panel_tests.js @@ -0,0 +1,334 @@ +odoo.define('web.control_panel_tests', function (require) { +"use strict"; + +var ControlPanelView = require('web.ControlPanelView'); +var testUtils = require('web.test_utils'); + +var createControlPanel = testUtils.createControlPanel; + +function createControlPanelFactory(arch, fields, params) { + params = params || {}; + arch = arch || "<search></search>"; + fields = fields || {}; + var viewInfo = {arch: arch, fields: fields}; + var controlPanelFactory = new ControlPanelView({viewInfo: viewInfo, context: {}}); + return controlPanelFactory; +} + +QUnit.module('Views', { + beforeEach: function () { + this.data = { + partner: { + fields: { + display_name: { string: "Displayed name", type: 'char' }, + foo: {string: "Foo", type: "char", default: "My little Foo Value", store: true, sortable: true}, + date_field: {string: "Date", type: "date", store: true, sortable: true}, + float_field: {string: "Float", type: "float"}, + bar: {string: "Bar", type: "many2one", relation: 'partner'}, + }, + records: [], + onchanges: {}, + }, + }; + } +}, function () { + QUnit.module('ControlPanelView'); + + QUnit.module('Control Panel Arch Parsing'); + + QUnit.test('empty arch', function (assert) { + assert.expect(1); + + var controlPanelFactory = createControlPanelFactory(); + assert.deepEqual( + controlPanelFactory.loadParams.groups, + [], + "there should be no group at all" + ); + }); + + QUnit.test('parse one field tag', function (assert) { + assert.expect(1); + var arch = "<search>" + + "<field name=\"bar\"/>" + + "</search>"; + var fields = this.data.partner.fields; + var controlPanelFactory = createControlPanelFactory(arch, fields); + assert.deepEqual( + controlPanelFactory.loadParams.groups, + [[{ + attrs: { + name: "bar", + string: "Bar" + }, + autoCompleteValues: [], + description: "bar", + isDefault: false, + type: "field" + }]], + "there should be one group with one field" + ); + }); + + QUnit.test('parse one separator tag', function (assert) { + assert.expect(1); + var arch = "<search>" + + "<separator/>" + + "</search>"; + var fields = this.data.partner.fields; + var controlPanelFactory = createControlPanelFactory(arch, fields); + assert.deepEqual( + controlPanelFactory.loadParams.groups, + [], + "there should be no group at all"); + }); + + QUnit.test('parse one separator tag and one field tag', function (assert) { + assert.expect(1); + var arch = "<search>" + + "<separator/>" + + "<field name=\"bar\"/>" + + "</search>"; + var fields = this.data.partner.fields; + var controlPanelFactory = createControlPanelFactory(arch, fields); + assert.deepEqual( + controlPanelFactory.loadParams.groups, + [[ + { + attrs: { + name: "bar", + string: "Bar" + }, + autoCompleteValues: [], + description: "bar", + isDefault: false, + type: "field" + } + ]], + "there should be one group with one field" + ); + }); + + QUnit.test('parse one filter tag', function (assert) { + assert.expect(1); + var arch = "<search>" + + "<filter name=\"filter\" string=\"Hello\" " + + "domain=\"[]\"/>" + + "</search>"; + var fields = this.data.partner.fields; + var controlPanelFactory = createControlPanelFactory(arch, fields); + assert.deepEqual( + controlPanelFactory.loadParams.groups, + [[{ + description: "Hello", + groupNumber: 2, + domain: "[]", + isDefault: false, + type: "filter" + }]], + "there should be one group with one filter" + ); + }); + + QUnit.test('parse one groupBy tag', function (assert) { + assert.expect(1); + var arch = "<search>" + + "<groupBy name=\"groupby\" string=\"Hi\" " + + "context=\"{\'group_by\': \'date_field:day\'}\"/>" + + "</search>"; + var fields = this.data.partner.fields; + var controlPanelFactory = createControlPanelFactory(arch, fields); + assert.deepEqual( + controlPanelFactory.loadParams.groups, + [[ + { + currentOptionId: false, + defaultOptionId: "day", + description: "Hi", + fieldName: "date_field", + fieldType: "date", + groupNumber: 2, + hasOptions: true, + isDefault: false, + options: [ + { + description: "Day", + groupId: 1, + optionId: "day" + }, + { + description: "Week", + groupId: 1, + optionId: "week" + }, + { + description: "Month", + groupId: 1, + optionId: "month" + }, + { + description: "Quarter", + groupId: 1, + optionId: "quarter" + }, + { + description: "Year", + groupId: 1, + optionId: "year" + } + ], + type: "groupBy" + } + ]], + "there should be one group with one groupBy with options" + ); + }); + + QUnit.test('parse two filter tags', function (assert) { + assert.expect(1); + var arch = "<search>" + + "<filter name=\"filter_1\" string=\"Hello One\" " + + "domain=\"[]\"/>" + + "<filter name=\"filter_2\" string=\"Hello Two\" " + + "domain=\"[(\'bar\', \'=\', 3)]\"/>" + + "</search>"; + var fields = this.data.partner.fields; + var controlPanelFactory = createControlPanelFactory(arch, fields); + assert.deepEqual( + controlPanelFactory.loadParams.groups, + [[ + { + "description": "Hello One", + "domain": "[]", + "groupNumber": 2, + "isDefault": false, + "type": "filter" + }, + { + "description": "Hello Two", + "domain": "[('bar', '=', 3)]", + "groupNumber": 2, + "isDefault": false, + "type": "filter" + } + ]], + 'there should be one group of two filters' + ); + }); + + QUnit.test('parse two filter tags separated by a separator', function (assert) { + assert.expect(1); + var arch = "<search>" + + "<filter name=\"filter_1\" string=\"Hello One\" " + + "domain=\"[]\"/>" + + "<separator/>" + + "<filter name=\"filter_2\" string=\"Hello Two\" " + + "domain=\"[(\'bar\', \'=\', 3)]\"/>" + + "</search>"; + + var fields = this.data.partner.fields; + var controlPanelFactory = createControlPanelFactory(arch, fields); + assert.deepEqual( + controlPanelFactory.loadParams.groups, + [ + [ + { + description: "Hello One", + domain: "[]", + groupNumber: 2, + isDefault: false, + type: "filter" + } + ], + [ + { + description: "Hello Two", + domain: "[('bar', '=', 3)]", + groupNumber: 4, + isDefault: false, + type: "filter" + } + ] + ], + "there should be two groups of one filter" + ); + }); + + QUnit.test('parse one filter tag and one field', function (assert) { + assert.expect(1); + var arch = "<search>" + + "<filter name=\"filter\" string=\"Hello\" domain=\"[]\"/>" + + "<field name=\"bar\"/>" + + "</search>"; + var fields = this.data.partner.fields; + var controlPanelFactory = createControlPanelFactory(arch, fields); + assert.deepEqual( + controlPanelFactory.loadParams.groups, + [ + [ + { + description: "Hello", + domain: "[]", + groupNumber: 2, + isDefault: false, + type: "filter" + } + ], + [ + { + attrs: { + name: "bar", + string: "Bar" + }, + autoCompleteValues: [], + description: "bar", + isDefault: false, + type: "field" + } + ] + ], + "there should be one group with a filter and one group with a field" + ); + }); + + QUnit.test('parse two field tags', function (assert) { + assert.expect(1); + var arch = "<search>" + + "<field name=\"foo\"/>" + + "<field name=\"bar\"/>" + + "</search>"; + var fields = this.data.partner.fields; + var controlPanelFactory = createControlPanelFactory(arch, fields); + assert.deepEqual( + controlPanelFactory.loadParams.groups, + [ + [ + { + attrs: { + name: "foo", + string: "Foo" + }, + autoCompleteValues: [], + description: "foo", + isDefault: false, + type: "field" + } + ], + [ + { + attrs: { + name: "bar", + string: "Bar" + }, + autoCompleteValues: [], + description: "bar", + isDefault: false, + type: "field" + } + ] + ], + "there should be two groups of a single field" + ); + }); +}); +}); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index 0adafa2161a..a120a6a7547 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -159,8 +159,7 @@ QUnit.module('Views', { assert.containsOnce(form, 'label:contains(f3_description)'); assert.containsOnce(form, 'div.o_field_one2many table'); assert.containsOnce(form, 'tbody td:not(.o_list_record_selector) .custom-checkbox input:checked'); - assert.strictEqual(form.get('title'), 'second record', - "title should be display_name of record"); + assert.containsOnce(form, '.o_control_panel .breadcrumb:contains(second record)'); assert.containsNone(form, 'label.o_form_label_empty:contains(timmy)'); form.destroy(); @@ -1510,8 +1509,8 @@ QUnit.module('Views', { testUtils.form.clickCreate(form); assert.strictEqual(form.mode, 'edit', 'form view should be in edit mode'); - assert.strictEqual(form.$('input:first').val(), "My little Foo Value", - "should have correct default_get value"); + assert.strictEqual(form.$('input[name=foo]').val(), "My little Foo Value", + "should have correct default_get value"); testUtils.form.clickSave(form); assert.strictEqual(form.mode, 'readonly', 'form view should be in readonly mode'); assert.strictEqual(this.data.partner.records.length, n + 1, "should have created a record"); @@ -1668,9 +1667,9 @@ QUnit.module('Views', { return this._super.apply(this, arguments); }, }).then(function (form) { - assert.ok(form.$('input').val(), 'aaa', - 'default value should be correctly displayed'); - assert.strictEqual(nameGetCount, 1, 'should have done one name_get'); + assert.strictEqual(form.$('.o_field_widget[name=trululu] input').val(), 'aaa', + "default value should be correctly displayed"); + assert.strictEqual(nameGetCount, 1, "should have done one name_get"); form.destroy(); done(); }); @@ -1689,11 +1688,13 @@ QUnit.module('Views', { res_id: 1, }); - assert.strictEqual(form.get('title'), 'first record', + assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'first record', "should have the display name of the record as title"); + testUtils.form.clickCreate(form); - assert.strictEqual(form.get('title'), _t("New"), - "should have the display name of the record as title"); + assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), _t("New"), + "should have the display name of the record as title"); + form.destroy(); }); @@ -1717,13 +1718,13 @@ QUnit.module('Views', { }, }); - assert.strictEqual(form.get('title'), 'first record', + assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'first record', "should have the display name of the record as title"); testUtils.dom.click(form.sidebar.$('.o_dropdown_toggler_btn:contains(Action)')); testUtils.dom.click(form.sidebar.$('a:contains(Duplicate)')); - assert.strictEqual(form.get('title'), 'first record (copy)', + assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'first record (copy)', "should have duplicated the record"); assert.strictEqual(form.mode, "edit", 'should be in edit mode'); @@ -1782,36 +1783,13 @@ QUnit.module('Views', { }, }); - assert.strictEqual(form.get('title'), 'first record', + assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'first record', "should have the display name of the record as title"); assert.containsNone(form.sidebar, 'a:contains(Duplicate)', "should not contains a 'Duplicate' action"); form.destroy(); }); - QUnit.test('buttons in footer are moved to $buttons if necessary', function (assert) { - // not sure about this test... - assert.expect(2); - - var form = createView({ - View: FormView, - model: 'partner', - data: this.data, - arch: '<form string="Partners">' + - '<field name="foo"/>' + - '<footer>' + - '<button string="Create" type="object" class="infooter"/>' + - '</footer>' + - '</form>', - res_id: 1, - viewOptions: {footerToButtons: true}, - }); - - assert.containsOnce(form.$buttons, 'button.infooter', "footer button should be in footer"); - assert.containsNone(form, 'button.infooter', "footer button should not be in form"); - form.destroy(); - }); - QUnit.test('clicking on stat buttons in edit mode', function (assert) { assert.expect(9); @@ -1891,13 +1869,13 @@ QUnit.module('Views', { }, }); - assert.strictEqual(form.getTitle(), 'second record', + assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'second record', "should have correct display_name"); testUtils.form.clickEdit(form); testUtils.fields.editInput(form.$('input[name=name]'), 'some other name'); testUtils.dom.click('.oe_stat_button'); - assert.strictEqual(form.getTitle(), 'GOLDORAK', + assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'GOLDORAK', "should have correct display_name"); form.destroy(); @@ -1916,7 +1894,7 @@ QUnit.module('Views', { arch: '<form string="Partners">' + '<field name="foo"/>' + '<button string="Do something" class="btn-primary" name="abc" type="object"/>' + - '<button string="Discard" class="btn-secondary" special="cancel"/>' + + '<button string="Or discard" class="btn-secondary" special="cancel"/>' + '</form>', res_id: 1, mockRPC: function (route, args) { @@ -1935,13 +1913,13 @@ QUnit.module('Views', { // make the record dirty testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); - testUtils.dom.clickFirst(form.$('button')); + testUtils.dom.click(form.$('button:contains(Do something)')); assert.strictEqual(writeCount, 1, "should have triggered a write"); assert.strictEqual(executeActionCount, 1, "should have triggered a execute action"); testUtils.fields.editInput(form.$('input[name=foo]'), 'abcdef'); - testUtils.dom.click(form.$('button').eq(1)); + testUtils.dom.click(form.$('button:contains(Or discard)')); assert.strictEqual(writeCount, 1, "should not have triggered a write"); assert.strictEqual(executeActionCount, 2, "should have triggered a execute action"); @@ -1975,7 +1953,7 @@ QUnit.module('Views', { }); testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); - testUtils.dom.click(form.$('button')); + testUtils.dom.click(form.$('.o_form_view button')); assert.verifySteps(['read', 'write', 'read', 'execute_action']); form.destroy(); @@ -2124,14 +2102,16 @@ QUnit.module('Views', { // switch to edit mode testUtils.form.clickEdit(form); - assert.strictEqual(form.$('input').val(), 'yop', 'input should contain yop'); + assert.strictEqual(form.$('input[name=foo]').val(), 'yop', + "input should contain yop"); // click on discard testUtils.form.clickDiscard(form); - assert.containsNone(document.body, '.modal', 'no confirm modal should be displayed'); - assert.strictEqual(form.$('.o_field_widget').text(), 'yop', 'field in readonly should display yop'); + assert.containsNone(document.body, '.modal', "no confirm modal should be displayed"); + assert.strictEqual(form.$('.o_field_widget').text(), 'yop', + "field in readonly should display yop"); - assert.strictEqual(nbWrite, 0, 'no write RPC should have been done'); + assert.strictEqual(nbWrite, 0, "no write RPC should have been done"); form.destroy(); }); @@ -2155,23 +2135,26 @@ QUnit.module('Views', { // switch to edit mode and edit the foo field testUtils.form.clickEdit(form); - assert.strictEqual(form.$('input').val(), 'yop', 'input should contain yop'); + assert.strictEqual(form.$('input[name=foo]').val(), 'yop', "input should contain yop"); testUtils.fields.editInput(form.$('input[name=foo]'), 'new value'); - assert.strictEqual(form.$('input').val(), 'new value', 'input should contain new value'); + assert.strictEqual(form.$('input[name=foo]').val(), 'new value', + "input should contain new value"); // click on discard and cancel the confirm request testUtils.form.clickDiscard(form); assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed"); testUtils.dom.click('.modal-footer .btn-secondary'); - assert.strictEqual(form.$('input').val(), 'new value', 'input should still contain new value'); + assert.strictEqual(form.$('input[name=foo]').val(), 'new value', + "input should still contain new value"); // click on discard and confirm testUtils.form.clickDiscard(form); assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed"); testUtils.dom.click('.modal-footer .btn-primary'); - assert.strictEqual(form.$('.o_field_widget').text(), 'yop', 'field in readonly should display yop'); + assert.strictEqual(form.$('.o_field_widget').text(), 'yop', + "field in readonly should display yop"); - assert.strictEqual(nbWrite, 0, 'no write RPC should have been done'); + assert.strictEqual(nbWrite, 0, "no write RPC should have been done"); form.destroy(); }); @@ -2240,7 +2223,8 @@ QUnit.module('Views', { testUtils.fields.many2one.clickOpenDropdown('product_id'); testUtils.fields.many2one.clickHighlightedItem('product_id'); - assert.strictEqual(form.$('input').val(), 'xphone', 'input should contain xphone'); + assert.strictEqual(form.$('.o_field_widget[name=product_id] input').val(), 'xphone', + "input should contain xphone"); // click on discard and confirm testUtils.form.clickDiscard(form); @@ -2269,11 +2253,13 @@ QUnit.module('Views', { }); // edit the foo field - assert.strictEqual(form.$('input').val(), 'ABC', 'input should contain ABC'); + assert.strictEqual(form.$('input[name=foo]').val(), 'ABC', + "input should contain ABC"); testUtils.form.clickDiscard(form); - assert.containsNone(document.body, '.modal', 'there should not be a confirm modal'); + assert.containsNone(document.body, '.modal', + "there should not be a confirm modal"); form.destroy(); }); @@ -2301,22 +2287,27 @@ QUnit.module('Views', { }); // edit the foo field - assert.strictEqual(form.$('input').val(), 'ABC', 'input should contain ABC'); + assert.strictEqual(form.$('input[name=foo]').val(), 'ABC', + "input should contain ABC"); testUtils.fields.editInput(form.$('input[name=foo]'), 'DEF'); // discard the changes and check it has properly been discarded testUtils.form.clickDiscard(form); - assert.containsOnce(document.body, '.modal', 'there should be a confirm modal'); - assert.strictEqual(form.$('input').val(), 'DEF', 'input should be DEF'); + assert.containsOnce(document.body, '.modal', "there should be a confirm modal"); + assert.strictEqual(form.$('input[name=foo]').val(), 'DEF', + "input should be DEF"); testUtils.dom.click('.modal-footer .btn-primary'); // click on confirm - assert.strictEqual(form.$('input').val(), 'ABC', 'input should now be ABC'); + assert.strictEqual(form.$('input[name=foo]').val(), 'ABC', + "input should now be ABC"); // redirty and discard the field foo (to make sure initial changes haven't been lost) testUtils.fields.editInput(form.$('input[name=foo]'), 'GHI'); testUtils.form.clickDiscard(form); - assert.strictEqual(form.$('input').val(), 'GHI', 'input should be GHI'); + assert.strictEqual(form.$('input[name=foo]').val(), 'GHI', + "input should be GHI"); testUtils.dom.click('.modal-footer .btn-primary') // click on confirm - assert.strictEqual(form.$('input').val(), 'ABC', 'input should now be ABC'); + assert.strictEqual(form.$('input[name=foo]').val(), 'ABC', + "input should now be ABC"); form.destroy(); }); @@ -2345,11 +2336,12 @@ QUnit.module('Views', { testUtils.dom.click(form.sidebar.$('.o_dropdown_toggler_btn:contains(Action)')); testUtils.dom.click(form.sidebar.$('a:contains(Duplicate)')); - assert.strictEqual(form.$('input').val(), 'tralala', 'input should contain ABC'); + assert.strictEqual(form.$('input[name=foo]').val(), 'tralala', + "input should contain ABC"); testUtils.form.clickDiscard(form); - assert.containsNone(document.body, '.modal', 'there should not be a confirm modal'); + assert.containsNone(document.body, '.modal', "there should not be a confirm modal"); form.destroy(); }); @@ -2376,30 +2368,32 @@ QUnit.module('Views', { }, }); - assert.strictEqual(form.pager.$('.o_pager_value').text(), "1", 'pager value should be 1'); - assert.strictEqual(form.pager.$('.o_pager_limit').text(), "2", 'pager limit should be 2'); + assert.strictEqual(form.$('.o_pager_value').text(), '1', "pager value should be 1"); + assert.strictEqual(form.$('.o_pager_limit').text(), '2', "pager limit should be 2"); // switch to edit mode testUtils.form.clickEdit(form); - assert.strictEqual(form.$('input').val(), 'yop', 'input should contain yop'); + assert.strictEqual(form.$('input[name=foo]').val(), 'yop', "input should contain yop"); // edit the foo field testUtils.fields.editInput(form.$('input[name=foo]'), 'new value'); - assert.strictEqual(form.$('input').val(), 'new value', 'input should contain new value'); + assert.strictEqual(form.$('input[name=foo]').val(), 'new value', + "input should contain new value"); // click on the pager to switch to the next record and cancel the confirm request testUtils.dom.click('.o_pager_next'); // click on next - assert.containsOnce(document.body, '.modal', 'a confirm modal should be displayed'); + assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed"); testUtils.dom.click('.modal-footer .btn-secondary'); // click on cancel - assert.strictEqual(form.$('input').val(), 'new value', 'input should still contain new value'); - assert.strictEqual(form.pager.$('.o_pager_value').text(), "1", 'pager value should still be 1'); + assert.strictEqual(form.$('input[name=foo]').val(), 'new value', + "input should still contain new value"); + assert.strictEqual(form.$('.o_pager_value').text(), '1', "pager value should still be 1"); // click on the pager to switch to the next record and confirm testUtils.dom.click('.o_pager_next'); // click on next - assert.containsOnce(document.body, '.modal', 'a confirm modal should be displayed'); + assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed"); testUtils.dom.click('.modal-footer .btn-primary'); // click on confirm - assert.strictEqual(form.$('input').val(), 'blip', 'input should contain blip'); - assert.strictEqual(form.pager.$('.o_pager_value').text(), "2", 'pager value should be 2'); + assert.strictEqual(form.$('input[name=foo]').val(), 'blip', "input should contain blip"); + assert.strictEqual(form.$('.o_pager_value').text(), '2', "pager value should be 2"); assert.strictEqual(nbWrite, 0, 'no write RPC should have been done'); form.destroy(); @@ -2422,22 +2416,24 @@ QUnit.module('Views', { }, res_id: 1, }); - assert.strictEqual(form.pager.$('.o_pager_value').text(), "1", 'pager value should be 1'); + assert.strictEqual(form.$('.o_pager_value').text(), '1', "pager value should be 1"); // switch to edit mode testUtils.form.clickEdit(form); - assert.strictEqual(form.$('input').val(), 'yop', 'input should contain yop'); + assert.strictEqual(form.$('input[name=foo]').val(), 'yop', "input should contain yop"); // edit the foo field testUtils.fields.editInput(form.$('input[name=foo]'), 'new value'); - assert.strictEqual(form.$('input').val(), 'new value', 'input should contain new value'); + assert.strictEqual(form.$('input[name=foo]').val(), 'new value', + "input should contain new value"); testUtils.form.clickSave(form); // click on the pager to switch to the next record and cancel the confirm request testUtils.dom.click('.o_pager_next'); // click on next - assert.containsNone(document.body, '.modal:visible', 'no confirm modal should be displayed'); - assert.strictEqual(form.pager.$('.o_pager_value').text(), "2", 'pager value should be 2'); + assert.containsNone(document.body, '.modal:visible', + "no confirm modal should be displayed"); + assert.strictEqual(form.$('.o_pager_value').text(), '2', "pager value should be 2"); assert.containsN(form, '.o_priority .fa-star-o', 2, 'priority widget should have been rendered with correct value'); @@ -2448,21 +2444,23 @@ QUnit.module('Views', { 'priority widget should have been updated'); testUtils.dom.click('.o_pager_next'); // click on next - assert.containsNone(document.body, '.modal:visible', 'no confirm modal should be displayed'); - assert.strictEqual(form.pager.$('.o_pager_value').text(), "1", 'pager value should be 1'); + assert.containsNone(document.body, '.modal:visible', + "no confirm modal should be displayed"); + assert.strictEqual(form.$('.o_pager_value').text(), '1', "pager value should be 1"); // switch to edit mode testUtils.form.clickEdit(form); - assert.strictEqual(form.$('input').val(), 'new value', 'input should contain yop'); + assert.strictEqual(form.$('input[name=foo]').val(), 'new value', + "input should contain yop"); // edit the foo field testUtils.fields.editInput(form.$('input[name=foo]'), 'wrong value'); testUtils.form.clickDiscard(form); - assert.containsOnce(document.body, '.modal', 'a confirm modal should be displayed'); + assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed"); testUtils.dom.click('.modal-footer .btn-primary'); // click on confirm testUtils.dom.click('.o_pager_next'); // click on next - assert.strictEqual(form.pager.$('.o_pager_value').text(), "2", 'pager value should be 2'); + assert.strictEqual(form.$('.o_pager_value').text(), '2', "pager value should be 2"); form.destroy(); }); @@ -2763,15 +2761,13 @@ QUnit.module('Views', { }); testUtils.form.clickSave(form); - assert.hasClass(form.$('label'),'o_field_invalid', - "label should be tagged as invalid"); - assert.hasClass(form.$('input'),'o_field_invalid', - "input should be tagged as invalid"); + assert.hasClass(form.$('label.o_form_label'),'o_field_invalid'); + assert.hasClass(form.$('input[name=foo]'),'o_field_invalid'); + + testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); - testUtils.fields.editInput(form.$('input'), "tralala"); + assert.containsNone(form, '.o_field_invalid'); - assert.containsNone(form, '.o_field_invalid', - "nothing should be marked as invalid"); form.destroy(); }); @@ -2867,13 +2863,11 @@ QUnit.module('Views', { testUtils.form.clickEdit(form); - assert.strictEqual(form.$('input[name=int_field]').val(), '9', - "'int_field' value should be 9 before the change"); + assert.strictEqual(form.$('input[name=int_field]').val(), '9'); - testUtils.fields.editInput(form.$('input').first(), "tralala"); + testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); - assert.strictEqual(form.$('input[name=int_field]').val(), '10', - "the onchange should have been correctly applied"); + assert.strictEqual(form.$('input[name=int_field]').val(), '10'); form.destroy(); }); @@ -3103,7 +3097,7 @@ QUnit.module('Views', { // switch to edit mode testUtils.form.clickEdit(form); - testUtils.fields.editInput(form.$('input'), 'let us trigger an onchange'); + testUtils.fields.editInput(form.$('input[name=foo]'), 'let us trigger an onchange'); var $o2m = form.$('.o_field_one2many'); assert.strictEqual($o2m.find('.o_data_row').length, 2, "there should be two linked record"); @@ -3149,7 +3143,7 @@ QUnit.module('Views', { "should have proper initial content"); testUtils.form.clickEdit(form); - testUtils.fields.editInput(form.$('input'), "tralala"); + testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala'); assert.strictEqual($('div[name="p"] .o_data_row td').text().trim(), "goldNo records", "should have proper initial content"); @@ -3177,7 +3171,7 @@ QUnit.module('Views', { testUtils.form.clickEdit(form); testUtils.dom.click(form.$('.o_data_cell').first()); - testUtils.fields.editInput(form.$('input'), ''); + testUtils.fields.editInput(form.$('input[name=display_name]'), ''); testUtils.dom.click(form.$('.fa-trash-o').eq(1)); assert.strictEqual($('.modal').find('.modal-title').first().text(), "Warning", @@ -3237,7 +3231,7 @@ QUnit.module('Views', { // switch to edit mode testUtils.form.clickEdit(form); - testUtils.fields.editInput(form.$('input'), 'let us trigger an onchange'); + testUtils.fields.editInput(form.$('input[name=foo]'), 'let us trigger an onchange'); var $m2m = form.$('.o_field_many2many'); assert.strictEqual($m2m.find('.o_data_row').length, 2, "there should be two linked records"); @@ -3426,24 +3420,22 @@ QUnit.module('Views', { testUtils.form.clickEdit(form); - testUtils.fields.editInput(form.$('input').first(), "1"); + testUtils.fields.editInput(form.$('input[name=foo]'), '1'); assert.strictEqual(onchangeNbr, 0, "no onchange has been called yet"); - testUtils.fields.editInput(form.$('input').first(), "12"); + testUtils.fields.editInput(form.$('input[name=foo]'), '12'); assert.strictEqual(onchangeNbr, 0, "no onchange has been called yet"); return waitForFinishedOnChange().then(function () { assert.strictEqual(onchangeNbr, 1, "one onchange has been called"); // add something in the input, then focus another input - testUtils.fields.editInput(form.$('input').first(), "123"); - form.$('input').first().change(); - assert.strictEqual(onchangeNbr, 2, - "one onchange has been called immediately"); + testUtils.fields.editInput(form.$('input[name=foo]'), '123'); + form.$('input[name=foo]').change(); + assert.strictEqual(onchangeNbr, 2, "one onchange has been called immediately"); return waitForFinishedOnChange(); }).then(function () { - assert.strictEqual(onchangeNbr, 2, - "no extra onchange should have been called"); + assert.strictEqual(onchangeNbr, 2, "no extra onchange should have been called"); form.destroy(); done(); @@ -3558,7 +3550,7 @@ QUnit.module('Views', { testUtils.form.clickEdit(form); // edit a value - testUtils.fields.editInput(form.$('input').first(), "1234"); + testUtils.fields.editInput(form.$('input[name=foo]'), '1234'); // discard changes testUtils.form.clickDiscard(form); @@ -3599,7 +3591,7 @@ QUnit.module('Views', { }, }); - testUtils.fields.editInput(form.$('input'), "1234"); + testUtils.fields.editInput(form.$('input[name=foo]'), '1234'); // save the value and discard directly testUtils.form.clickSave(form); @@ -3657,7 +3649,7 @@ QUnit.module('Views', { }); testUtils.form.clickEdit(form); - testUtils.fields.editInput(form.$('input:first'), 'trigger an onchange'); + testUtils.fields.editInput(form.$('input[name=foo]'), 'trigger an onchange'); form.destroy(); }); @@ -3718,7 +3710,7 @@ QUnit.module('Views', { }); testUtils.form.clickEdit(form); - testUtils.fields.editInput(form.$('input:first'), 'trigger an onchange'); + testUtils.fields.editInput(form.$('input[name=foo]'), 'trigger an onchange'); assert.strictEqual(form.$('.o_data_row td:first').text(), 'foo changed', "onchange should have been correctly applied on field in o2m list"); @@ -3778,7 +3770,7 @@ QUnit.module('Views', { assert.strictEqual(form.$('.o_data_row td:first').text(), 'My little Foo Value', "the initial value should be the default one"); - testUtils.fields.editInput(form.$('input:first'), 'trigger an onchange'); + testUtils.fields.editInput(form.$('input[name=foo]'), 'trigger an onchange'); assert.strictEqual(form.$('.o_data_row td:first').text(), 'foo changed', "onchange should have been correctly applied on field in o2m list"); @@ -3927,7 +3919,7 @@ QUnit.module('Views', { }); testUtils.form.clickEdit(form); - testUtils.fields.editInput(form.$('input:first'), 'coucou'); + testUtils.fields.editInput(form.$('input[name=foo]'), 'coucou'); form.destroy(); }); @@ -4494,31 +4486,32 @@ QUnit.module('Views', { } }); - QUnit.test('footers are not duplicated on rerender', function (assert) { - assert.expect(2); + QUnit.test('buttons in footer are moved to $buttons if necessary', function (assert) { + assert.expect(4); var form = createView({ View: FormView, model: 'partner', data: this.data, - arch: - '<form>' + - '<field name="foo"/>' + - '<footer>' + - '<button>Hello</button>' + - '</footer>' + + arch: '<form string="Partners">' + + '<field name="foo"/>' + + '<footer>' + + '<button string="Create" type="object" class="infooter"/>' + + '</footer>' + '</form>', res_id: 1, - viewOptions: { - footerToButtons: true, - }, + viewOptions: {footerToButtons: true}, }); - assert.containsNone(form, 'footer', - "footer should have been moved outside of the form view"); - form.reload(); - assert.containsNone(form, 'footer', - "footer should still have been moved outside of the form view"); + assert.containsOnce(form.$('.o_control_panel'), 'button.infooter'); + assert.containsNone(form.$('.o_form_view'), 'button.infooter'); + + // check that this still works after a reload + testUtils.form.reload(form); + + assert.containsOnce(form.$('.o_control_panel'), 'button.infooter'); + assert.containsNone(form.$('.o_form_view'), 'button.infooter'); + form.destroy(); }); @@ -4850,7 +4843,7 @@ QUnit.module('Views', { }); QUnit.test('display toolbar', function (assert) { - assert.expect(7); + assert.expect(8); var form = createView({ View: FormView, @@ -4886,22 +4879,16 @@ QUnit.module('Views', { return this._super.apply(this, arguments); }, }); - // The toolbar displayed changes if the module 'document' is installed. - // So if the module is installed, the assertion checks for 3 dropdowns - // if not only 2 dropdowns are displayed. - var $dropdowns = $('.o_web_client .o_control_panel .btn-group .o_dropdown_toggler_btn'); - var $actions = $('.o_web_client .o_control_panel .btn-group .dropdown-menu')[1].children; - if ('attachment_indexation.document' in odoo.__DEBUG__.services) { - assert.strictEqual($dropdowns.length, 3, - "there should be 3 dropdowns (print, attachment, action) in the toolbar."); - $actions = $('.o_web_client .o_control_panel .btn-group .dropdown-menu')[2].children; - } else { - assert.strictEqual($dropdowns.length, 2, - "there should be 2 dropdowns (print, action) in the toolbar."); - } - assert.strictEqual($actions.length, 3, - "there should be 3 actions"); - var $customAction = $('.o_web_client .o_control_panel .btn-group .dropdown-menu:last .dropdown-item:nth(2)'); + + var $printMenu = form.$('.o_cp_sidebar .o_dropdown:contains(Print)'); + assert.isNotVisible($printMenu); + var $actionMenu = form.$('.o_cp_sidebar .o_dropdown:contains(Action)'); + assert.isVisible($actionMenu); + + testUtils.dom.click($actionMenu.find('button')); // open Action menu + assert.strictEqual($actionMenu.find('.dropdown-item').length, 3, + 'there should be 3 actions'); + var $customAction = $actionMenu.find('.dropdown-item:last'); assert.strictEqual($customAction.text().trim(), 'Action partner', "the custom action should have 'Action partner' as name"); testUtils.mock.intercept(form, 'do_action', function (event) { @@ -4911,7 +4898,6 @@ QUnit.module('Views', { assert.deepEqual(context.active_ids, [1], "the active_ids should be an array with 1 inside."); }); - testUtils.dom.click($dropdowns.last()); testUtils.dom.click($customAction); form.destroy(); @@ -5020,7 +5006,7 @@ QUnit.module('Views', { }, }); - testUtils.dom.click(form.$('input:first')); + testUtils.dom.click(form.$('.o_field_widget[name=trululu] input')); form.destroy(); }); @@ -5217,7 +5203,7 @@ QUnit.module('Views', { // client-side sort on edit testUtils.form.clickEdit(form); testUtils.dom.click(form.$('.o_field_one2many tbody tr:eq(1) td:contains(yop)')); - testUtils.fields.editInput($('.modal input[name="foo"]'), 'zzz') + testUtils.fields.editInput($('.modal input[name="foo"]'), 'zzz'); testUtils.dom.click($('.modal-footer button:first')); assert.ok(form.$('.o_field_one2many tbody tr:eq(0) td:contains(zzz)').length, "record zzz should be first"); @@ -6690,7 +6676,9 @@ QUnit.module('Views', { // call destroy function of controller to ensure that it correctly destroys everything form.__destroy(); - assert.strictEqual(instanceNumber, initialInstanceNumber + 3, "every widget must be destroyed exept the parent"); + // + 4 (parent) + assert.strictEqual(instanceNumber, initialInstanceNumber + 1, + "every widget must be destroyed exept the parent"); form.destroy(); @@ -6971,7 +6959,7 @@ QUnit.module('Views', { // add a row and partially fill it form.$('.o_field_x2many_list_row_add a').click(); - form.$('input:nth(0)').val('abc').trigger('input'); + form.$('input[name=display_name]').val('abc').trigger('input'); // click button which will trigger_up 'execute_action' (this will save) values = { @@ -7054,14 +7042,13 @@ QUnit.module('Views', { fieldDebounce: 5000, }); - testUtils.fields.editInput(form.$('input'), 'some foo value'); + testUtils.fields.editInput(form.$('input[name=foo]'), 'some foo value'); // manually save the record, to prevent the field widget to notify the model of its new // value before being requested to form.saveRecord(); - assert.containsOnce(form, '.o_form_readonly', - "form view should be in readonly"); - assert.strictEqual(form.$el.text().trim(), 'some foo value', + assert.containsOnce(form, '.o_form_readonly', "form view should be in readonly"); + assert.strictEqual(form.$('.o_form_view').text().trim(), 'some foo value', "foo field should have correct value"); assert.verifySteps(['default_get', 'create', 'read']); diff --git a/addons/web/static/tests/views/graph_tests.js b/addons/web/static/tests/views/graph_tests.js index 30d295e1f8f..73f2179dc8f 100644 --- a/addons/web/static/tests/views/graph_tests.js +++ b/addons/web/static/tests/views/graph_tests.js @@ -118,7 +118,8 @@ QUnit.module('Views', { '<field name="bar"/>' + '</graph>', }); - assert.strictEqual(graph.$('label').text(), "Partners", "should have 'Partners as title'"); + assert.strictEqual(graph.$('.o_graph_renderer label').text(), "Partners", + "should have 'Partners as title'"); graph.destroy(); }); @@ -353,7 +354,7 @@ QUnit.module('Views', { }); }); - QUnit.test('getContext correctly returns mode, measure, groupbys and interval mapping', function (assert) { + QUnit.test('getOwnedQueryParams correctly returns mode, measure, groupbys and interval mapping', function (assert) { var done = assert.async(); assert.expect(4); @@ -366,11 +367,13 @@ QUnit.module('Views', { '</graph>', }); return concurrency.delay(0).then(function () { - assert.deepEqual(graph.getContext(), { - graph_mode: 'bar', - graph_measure: '__count__', - graph_groupbys: ['product_id'], - graph_intervalMapping: {}, + assert.deepEqual(graph.getOwnedQueryParams(), { + context: { + graph_mode: 'bar', + graph_measure: '__count__', + graph_groupbys: ['product_id'], + graph_intervalMapping: {}, + } }, "context should be correct"); testUtils.dom.click(graph.$buttons.find('.dropdown-toggle:contains(Measures)')); @@ -378,33 +381,39 @@ QUnit.module('Views', { return concurrency.delay(0); }).then(function () { - assert.deepEqual(graph.getContext(), { - graph_mode: 'bar', - graph_measure: 'foo', - graph_groupbys: ['product_id'], - graph_intervalMapping: {}, + assert.deepEqual(graph.getOwnedQueryParams(), { + context: { + graph_mode: 'bar', + graph_measure: 'foo', + graph_groupbys: ['product_id'], + graph_intervalMapping: {}, + }, }, "context should be correct"); testUtils.dom.click(graph.$buttons.find('button[data-mode="line"]')); return concurrency.delay(0); }).then(function () { - assert.deepEqual(graph.getContext(), { - graph_mode: 'line', - graph_measure: 'foo', - graph_groupbys: ['product_id'], - graph_intervalMapping: {}, + assert.deepEqual(graph.getOwnedQueryParams(), { + context: { + graph_mode: 'line', + graph_measure: 'foo', + graph_groupbys: ['product_id'], + graph_intervalMapping: {}, + }, }, "context should be correct"); testUtils.graph.reload(graph, {groupBy: ['product_id', 'color_id']}); // change groupbys return concurrency.delay(0); }).then(function () { - assert.deepEqual(graph.getContext(), { - graph_mode: 'line', - graph_measure: 'foo', - graph_groupbys: ['product_id', 'color_id'], - graph_intervalMapping: {}, + assert.deepEqual(graph.getOwnedQueryParams(), { + context: { + graph_mode: 'line', + graph_measure: 'foo', + graph_groupbys: ['product_id', 'color_id'], + graph_intervalMapping: {}, + }, }, "context should be correct"); graph.destroy(); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index bb66243fed8..c398e9df09f 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -155,21 +155,15 @@ QUnit.module('Views', { '<div><field name="foo"/></div>' + '</t></templates></kanban>', groupBy: ['bar'], - intercepts: { - env_updated: function (event) { - assert.deepEqual(event.data.env.ids, envIDs, - "should notify the environment with the correct ids"); - }, - }, }); // check archive/restore all actions in kanban header's config dropdown assert.containsOnce(kanban, '.o_kanban_header:first .o_kanban_config .o_column_archive_records'); assert.containsOnce(kanban, '.o_kanban_header:first .o_kanban_config .o_column_unarchive_records'); + assert.deepEqual(kanban.exportState().resIds, envIDs); // archive the records of the first column assert.containsN(kanban, '.o_kanban_group:last .o_kanban_record', 3); - envIDs = [4]; testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last')); testUtils.dom.click(kanban.$('.o_kanban_group:last .o_column_archive_records')); @@ -181,6 +175,8 @@ QUnit.module('Views', { assert.ok($('.modal').length, 'a confirm modal should be displayed'); testUtils.modal.clickButton('Ok'); assert.containsNone(kanban, '.o_kanban_group:last .o_kanban_record', "last column should not contain any records"); + envIDs = [4]; + assert.deepEqual(kanban.exportState().resIds, envIDs); kanban.destroy(); }); @@ -819,14 +815,10 @@ QUnit.module('Views', { '<div><field name="foo"/></div>' + '</t></templates></kanban>', groupBy: ['bar'], - intercepts: { - env_updated: function (event) { - assert.strictEqual(event.data.env.ids.length, nbRecords, - "should update the env with the records ids"); - }, - } }); + assert.strictEqual(kanban.exportState().resIds.length, nbRecords); + // click to add an element and cancel the quick creation by pressing ESC testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first()); @@ -867,6 +859,7 @@ QUnit.module('Views', { "should have created a partner"); assert.strictEqual(_.last(this.data.partner.records).name, "new partner", "should have correct name"); + assert.strictEqual(kanban.exportState().resIds.length, nbRecords); kanban.destroy(); }); @@ -2408,16 +2401,11 @@ QUnit.module('Views', { } return this._super(route, args); }, - intercepts: { - env_updated: function (event) { - assert.deepEqual(event.data.env.ids, envIDs, - "should notify the environment with the correct ids"); - }, - }, }); assert.containsN(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record', 2); assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2); assert.containsN(kanban, '.thisiseditable', 4); + assert.deepEqual(kanban.exportState().resIds, envIDs); var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first'); var $group = kanban.$('.o_kanban_group:nth-child(2)'); @@ -2427,6 +2415,8 @@ QUnit.module('Views', { assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record'); assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 3); assert.containsN(kanban, '.thisiseditable', 4); + assert.deepEqual(kanban.exportState().resIds, envIDs); + kanban.destroy(); }); @@ -2637,24 +2627,22 @@ QUnit.module('Views', { '</t></templates>' + '</kanban>', groupBy: ['product_id'], - intercepts: { - env_updated: function (event) { - assert.deepEqual(event.data.env.ids, envIDs, - "should notify the environment with the correct ids"); - }, - }, }); + assert.deepEqual(kanban.exportState().resIds, envIDs); + // fold the second group and check that the res_ids it contains are no // longer in the environment envIDs = [1, 3]; testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last')); testUtils.dom.click(kanban.$('.o_kanban_group:last .o_kanban_toggle_fold')); + assert.deepEqual(kanban.exportState().resIds, envIDs); // re-open the second group and check that the res_ids it contains are // back in the environment envIDs = [1, 3, 2, 4]; testUtils.dom.click(kanban.$('.o_kanban_group:last')); + assert.deepEqual(kanban.exportState().resIds, envIDs); kanban.destroy(); }); @@ -3182,27 +3170,6 @@ QUnit.module('Views', { kanban.destroy(); }); - QUnit.test('if view was grouped at start, it stays grouped', function (assert) { - assert.expect(1); - - var kanban = createView({ - View: KanbanView, - model: 'partner', - data: this.data, - arch: '<kanban class="o_kanban_test" on_create="quick_create">' + - '<field name="product_id"/>' + - '<templates><t t-name="kanban-box">' + - '<div><field name="foo"/></div>' + - '</t></templates>' + - '</kanban>', - groupBy: ['product_id'], - }); - kanban.update({groupBy: []}); - - assert.hasClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped'); - kanban.destroy(); - }); - QUnit.test('if view was not grouped at start, it can be grouped and ungrouped', function (assert) { assert.expect(3); @@ -3217,11 +3184,13 @@ QUnit.module('Views', { '</t></templates>' + '</kanban>', }); - assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped', "should not be grouped"); + + assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped'); kanban.update({groupBy: ['product_id']}); - assert.hasClass(kanban.$('.o_kanban_view'),'o_kanban_grouped', "should be grouped"); + assert.hasClass(kanban.$('.o_kanban_view'),'o_kanban_grouped'); kanban.update({groupBy: []}); - assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped', "should not be grouped"); + assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped'); + kanban.destroy(); }); @@ -3792,7 +3761,7 @@ QUnit.module('Views', { }); QUnit.test('resequence columns in grouped by m2o', function (assert) { - assert.expect(7); + assert.expect(6); this.data.product.fields.sequence = {string: "Sequence", type: "integer"}; var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test @@ -3807,12 +3776,6 @@ QUnit.module('Views', { '</t></templates>' + '</kanban>', groupBy: ['product_id'], - intercepts: { - env_updated: function (event) { - assert.deepEqual(event.data.env.ids, envIDs, - "should notify the environment with the correct ids"); - }, - }, }); assert.hasClass(kanban.$('.o_kanban_view'),'ui-sortable', @@ -3821,6 +3784,7 @@ QUnit.module('Views', { "should have two columns"); assert.strictEqual(kanban.$('.o_kanban_group:first').data('id'), 3, "first column should be id 3 before resequencing"); + assert.deepEqual(kanban.exportState().resIds, envIDs); // there is a 100ms delay on the d&d feature (jquery sortable) for // kanban columns, making it hard to test. So we rather bypass the d&d @@ -3831,6 +3795,7 @@ QUnit.module('Views', { assert.strictEqual(kanban.$('.o_kanban_group:first').data('id'), 5, "first column should be id 5 after resequencing"); + assert.deepEqual(kanban.exportState().resIds, envIDs); kanban.destroy(); }); @@ -3944,16 +3909,11 @@ QUnit.module('Views', { } return this._super.apply(this, arguments); }, - intercepts:{ - env_updated: function (event) { - assert.deepEqual(event.data.env.ids, envIDs, - "should notify the environment with the correct ids"); - }, - }, }); assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 2, "there should be 2 records in the column"); + assert.deepEqual(kanban.exportState().resIds, envIDs); // load more envIDs = [1, 2, 3, 4]; // id 3 will be loaded @@ -3961,14 +3921,15 @@ QUnit.module('Views', { assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 3, "there should now be 3 records in the column"); - assert.verifySteps([[2, undefined], [2, undefined], [2, 2]], "the records should be correctly fetched"); + assert.deepEqual(kanban.exportState().resIds, envIDs); // reload kanban.reload(); assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 3, "there should still be 3 records in the column after reload"); + assert.deepEqual(kanban.exportState().resIds, envIDs); kanban.destroy(); }); @@ -4471,7 +4432,7 @@ QUnit.module('Views', { }); QUnit.test('kanban with progressbars: correctly update env when archiving records', function (assert) { - assert.expect(3); + assert.expect(2); // add active field on partner model and make all records active this.data.partner.fields.active = {string: 'Active', type: 'char', default: true}; @@ -4493,22 +4454,16 @@ QUnit.module('Views', { '</t></templates>' + '</kanban>', groupBy: ['bar'], - intercepts: { - env_updated: function (ev) { - assert.step(ev.data.env.ids); - }, - }, }); + assert.deepEqual(kanban.exportState().resIds, [1, 2, 3, 4]); + // archive all records of the first column testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:first')); testUtils.dom.click(kanban.$('.o_column_archive_records:visible')); testUtils.dom.click($('.modal-footer button:first')); - assert.verifySteps([ - [1, 2, 3, 4], - [1, 2, 3], - ]); + assert.deepEqual(kanban.exportState().resIds, [1, 2, 3]); kanban.destroy(); }); @@ -4784,7 +4739,9 @@ QUnit.module('Views', { // call destroy function of controller to ensure that it correctly destroys everything kanban.__destroy(); - assert.strictEqual(instanceNumber, initialInstanceNumber + 3, "every widget must be destroyed exept the parent"); + // + 1 (parent) + assert.strictEqual(instanceNumber, initialInstanceNumber + 1, + "every widget must be destroyed exept the parent"); kanban.destroy(); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 95921062f7b..f5e0088173b 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -402,7 +402,7 @@ QUnit.module('Views', { // Ascending order on Date list.$('th.o_column_sortable:contains("Date")').click(); - var listContext = list.getContext(); + var listContext = list.getOwnedQueryParams(); assert.deepEqual(listContext, { orderedBy: [{ @@ -416,7 +416,7 @@ QUnit.module('Views', { list.destroy(); }); - QUnit.test('Loading a filter with a sort attribute', function (assert) { + QUnit.test('loading a filter with a sort attribute', function (assert) { assert.expect(2); this.data.foo.fields.foo.sortable = true; @@ -431,15 +431,6 @@ QUnit.module('Views', { '<field name="foo"/>' + '<field name="date"/>' + '</tree>', - context: { - orderedBy: [{ - name: 'date', - asc: true, - }, { - name: 'foo', - asc: false, - }] - }, mockRPC: function (route, args) { if (route === '/web/dataset/search_read') { if (searchReads === 0) { @@ -453,22 +444,34 @@ QUnit.module('Views', { } return this._super.apply(this,arguments); }, + intercepts: { + load_filters: function (event) { + return $.when([ + { + context: "{}", + domain: "[]", + id: 7, + is_default: true, + name: "My favorite", + sort: "[\"date asc\", \"foo desc\"]", + user_id: [2, "Mitchell Admin"], + }, { + context: "{}", + domain: "[]", + id: 8, + is_default: false, + name: "My second favorite", + sort: "[\"date desc\", \"foo asc\"]", + user_id: [2, "Mitchell Admin"], + } + ]).then(event.data.on_success); + }, + }, }); - // Simulate loading a filter - list.update({ - context: { - orderedBy: [{ - name: 'date', - asc: false, - }, { - name: 'foo', - asc: true, - }] - } - }); - - list.destroy() + testUtils.dom.click(list.$('.o_control_panel .o_search_options button.o_favorites_menu_button')); + testUtils.dom.click(list.$('.o_control_panel .o_search_options .o_favorites_menu .o_menu_item').eq(1)); + list.destroy(); }); QUnit.test('many2one field rendering', function (assert) { @@ -1443,7 +1446,7 @@ QUnit.module('Views', { }); // Change page - testUtils.dom.click(form.$('.o_pager_next')); + testUtils.dom.click(form.$('.o_field_widget[name=o2m] .o_pager_next')); assert.strictEqual(form.$('tbody tr:first').text(), 'Value 44', "record 44 should be first"); assert.strictEqual(form.$('tbody tr:eq(4)').text(), 'Value 48', @@ -1712,10 +1715,9 @@ QUnit.module('Views', { assert.containsNone(list, '.o_view_nocontent', "should not have a no content helper displayed"); assert.containsOnce(list, 'table', "should have rendered a table"); - assert.strictEqual(list.$el.css('height'), list.$('div.table-responsive').css('height'), + assert.strictEqual(list.$('.o_content').css('height'), list.$('div.table-responsive').css('height'), "the div for the table should take the full height"); - assert.hasClass(list.$('tbody tr:eq(0)'),'o_selected_row', "the date field td should be in edit mode"); assert.strictEqual(list.$('tbody tr:eq(0) td:eq(1)').text().trim(), "", @@ -2051,15 +2053,12 @@ QUnit.module('Views', { assert.strictEqual(event.data.res_id, 4, "'switch_view' event has been triggered"); }, - env_updated: function (event) { - assert.deepEqual(event.data.env.ids, envIDs, - "should notify the environment with the correct ids"); - }, }, }); assert.strictEqual(nbRPCs.readGroup, 1, "should have done one read_group"); assert.strictEqual(nbRPCs.searchRead, 0, "should have done no search_read"); + assert.deepEqual(list.exportState().resIds, envIDs); // basic rendering tests assert.containsOnce(list, 'tbody', "there should be 1 tbody"); @@ -2079,6 +2078,7 @@ QUnit.module('Views', { testUtils.dom.click(list.$('.o_group_header:first')); assert.strictEqual(nbRPCs.readGroup, 1, "should have done one read_group"); assert.strictEqual(nbRPCs.searchRead, 0, "should have done no search_read"); + assert.deepEqual(list.exportState().resIds, envIDs); var $openGroup = list.$('tbody:nth(1)'); assert.strictEqual(list.$('.o_group_name:first').text(), 'Value 1 (4)', @@ -2101,6 +2101,7 @@ QUnit.module('Views', { testUtils.dom.click($openGroup.find('.o_group_header:nth(2)')); assert.strictEqual(nbRPCs.readGroup, 0, "should have done no read_group"); assert.strictEqual(nbRPCs.searchRead, 1, "should have done one search_read"); + assert.deepEqual(list.exportState().resIds, envIDs); var $openSubGroup = list.$('tbody:nth(2)'); assert.containsN(list, 'tbody', 4, "there should be 4 tbodys"); @@ -2118,6 +2119,7 @@ QUnit.module('Views', { testUtils.dom.click(list.$('thead th:last')); assert.strictEqual(nbRPCs.readGroup, 2, "should have done two read_groups"); assert.strictEqual(nbRPCs.searchRead, 1, "should have done one search_read"); + assert.deepEqual(list.exportState().resIds, envIDs); $openSubGroup = list.$('tbody:nth(2)'); assert.containsN(list, 'tbody', 4, "there should be 4 tbodys"); @@ -2132,6 +2134,7 @@ QUnit.module('Views', { testUtils.dom.click(list.$('.o_group_header:nth(1)')); assert.strictEqual(nbRPCs.readGroup, 0, "should have done no read_group"); assert.strictEqual(nbRPCs.searchRead, 0, "should have done no search_read"); + assert.deepEqual(list.exportState().resIds, envIDs); assert.containsOnce(list, 'tbody', "there should be 1 tbody"); assert.containsN(list, '.o_group_header', 2, @@ -2202,14 +2205,14 @@ QUnit.module('Views', { "height of group header shouldn't have changed"); assert.hasClass(list.$('.o_group_header td:last'),'o_group_pager', "last cell of open group header should have classname 'o_group_header'"); - assert.strictEqual(list.$('.o_pager_value').text(), '1-3', + assert.strictEqual(list.$('.o_group_header .o_pager_value').text(), '1-3', "pager's value should be correct"); assert.containsN(list, '.o_data_row', 3, "open group should display 3 records"); // go to next page - testUtils.dom.click(list.$('.o_pager_next')); - assert.strictEqual(list.$('.o_pager_value').text(), '4-4', + testUtils.dom.click(list.$('.o_group_header .o_pager_next')); + assert.strictEqual(list.$('.o_group_header .o_pager_value').text(), '4-4', "pager's value should be correct"); assert.containsOnce(list, '.o_data_row', "open group should display 1 record"); @@ -2584,7 +2587,7 @@ QUnit.module('Views', { }); QUnit.test('display toolbar', function (assert) { - assert.expect(3); + assert.expect(6); var list = createView({ View: ListView, @@ -2605,13 +2608,20 @@ QUnit.module('Views', { }, }); - var $dropdowns = $('.o_web_client .o_control_panel .btn-group .o_dropdown_toggler_btn'); - assert.strictEqual($dropdowns.length, 2, - "there should be 2 dropdowns in the toolbar."); - var $actions = $('.o_web_client .o_control_panel .btn-group .dropdown-menu')[1].children; - assert.strictEqual($actions.length, 3, + var $printMenu = list.$('.o_cp_sidebar .o_dropdown:contains(Print)'); + assert.isNotVisible($printMenu); + var $actionMenu = list.$('.o_cp_sidebar .o_dropdown:contains(Action)'); + assert.isNotVisible($actionMenu); + + testUtils.dom.click(list.$('.o_list_record_selector:first input')); + + assert.isNotVisible($printMenu); + assert.isVisible($actionMenu); + + testUtils.dom.click($actionMenu.find('button')); // open Action menu + assert.strictEqual($actionMenu.find('.dropdown-item').length, 3, "there should be 3 actions"); - var $customAction = $('.o_web_client .o_control_panel .btn-group .dropdown-item:nth(2)'); + var $customAction = $actionMenu.find('.dropdown-item:last'); assert.strictEqual($customAction.text().trim(), 'Action event', "the custom action should have 'Action event' as name"); @@ -3859,7 +3869,9 @@ QUnit.module('Views', { // call destroy function of controller to ensure that it correctly destroys everything list.__destroy(); - assert.strictEqual(instanceNumber, initialInstanceNumber + 3, "every widget must be destroyed exept the parent"); + // + 1 (parent) + assert.strictEqual(instanceNumber, initialInstanceNumber + 1, + "every widget must be destroyed exept the parent"); list.destroy(); diff --git a/addons/web/static/tests/chrome/search_view_mobile_tests.js b/addons/web/static/tests/views/mobile_control_panel_tests.js index 05e2cd09a73..05e2cd09a73 100644 --- a/addons/web/static/tests/chrome/search_view_mobile_tests.js +++ b/addons/web/static/tests/views/mobile_control_panel_tests.js diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js index b03ebd0def1..46c28529bed 100644 --- a/addons/web/static/tests/views/pivot_tests.js +++ b/addons/web/static/tests/views/pivot_tests.js @@ -156,7 +156,7 @@ QUnit.module('Views', { _.extend(this.data.partner.fields, { foo: {string: "Foo", type: "integer", store: true}, foo2: {string: "Foo2", type: "integer", store: true} - }) + }); var pivot = createView({ View: PivotView, @@ -171,7 +171,7 @@ QUnit.module('Views', { // there should be only one displayed measure as the other one is invisible assert.containsOnce(pivot, '.o_pivot_measure_row'); // there should be only one measure besides count, as the other one is invisible - assert.containsN(document.body, '.dropdown-item', 2); + assert.containsN(pivot, '.o_cp_left .dropdown-item', 2); pivot.destroy(); }); @@ -860,7 +860,7 @@ QUnit.module('Views', { pivot.destroy(); }); - QUnit.test('getContext correctly returns measures and groupbys', function (assert) { + QUnit.test('getOwnedQueryParams correctly returns measures and groupbys', function (assert) { assert.expect(3); var pivot = createView({ @@ -873,28 +873,34 @@ QUnit.module('Views', { '</pivot>', }); - assert.deepEqual(pivot.getContext(), { - pivot_column_groupby: ['date:day'], - pivot_measures: ['foo'], - pivot_row_groupby: [], + assert.deepEqual(pivot.getOwnedQueryParams(), { + context: { + pivot_column_groupby: ['date:day'], + pivot_measures: ['foo'], + pivot_row_groupby: [], + }, }, "context should be correct"); // expand header on field customer testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed:nth(1)')); testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="customer"]:first')); - assert.deepEqual(pivot.getContext(), { - pivot_column_groupby: ['date:day', 'customer'], - pivot_measures: ['foo'], - pivot_row_groupby: [], + assert.deepEqual(pivot.getOwnedQueryParams(), { + context: { + pivot_column_groupby: ['date:day', 'customer'], + pivot_measures: ['foo'], + pivot_row_groupby: [], + }, }, "context should be correct"); // expand row on field product_id testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed')); testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="product_id"]:first')); - assert.deepEqual(pivot.getContext(), { - pivot_column_groupby: ['date:day', 'customer'], - pivot_measures: ['foo'], - pivot_row_groupby: ['product_id'], + assert.deepEqual(pivot.getOwnedQueryParams(), { + context: { + pivot_column_groupby: ['date:day', 'customer'], + pivot_measures: ['foo'], + pivot_row_groupby: ['product_id'], + }, }, "context should be correct"); pivot.destroy(); @@ -1195,12 +1201,16 @@ QUnit.module('Views', { // Set a domain testUtils.pivot.reload(pivot, {domain: [['product_id', '=', 41]]}); - var expectedContext = {pivot_column_groupby: ['customer'], - pivot_measures: ['foo'], - pivot_row_groupby: ['product_id']}; + var expectedContext = { + context: { + pivot_column_groupby: ['customer'], + pivot_measures: ['foo'], + pivot_row_groupby: ['product_id'], + }, + }; // Mock 'save as favorite' - assert.deepEqual(pivot.getContext(), expectedContext, + assert.deepEqual(pivot.getOwnedQueryParams(), expectedContext, 'The pivot view should have the right context'); var $xpadHeader = pivot.$('tbody .o_pivot_header_cell_closed[data-original-title=Product]'); diff --git a/addons/web/static/tests/views/search_filter_menu_tests.js b/addons/web/static/tests/views/search_filter_menu_tests.js new file mode 100644 index 00000000000..e907d70111e --- /dev/null +++ b/addons/web/static/tests/views/search_filter_menu_tests.js @@ -0,0 +1,167 @@ +odoo.define('web.search_filters_menu_tests', function (require) { +"use strict"; + +var FilterMenu = require('web.FilterMenu'); +var testUtils = require('web.test_utils'); +var Domain = require('web.Domain'); + +function createFilterMenu(filters, fields, params) { + params = params || {}; + var target = params.debug ? document.body : $('#qunit-fixture'); + var menu = new FilterMenu(null, filters, fields); + testUtils.mock.addMockEnvironment(menu, params); + menu.appendTo(target); + return menu; +} + +QUnit.module('FilterMenu', { + beforeEach: function () { + this.filters = [ + { + isActive: false, + description: 'some filter', + domain: '', + groupNumber: 1, + }, + ]; + this.fields = { + boolean_field: {string: "Boolean Field", type: "boolean", default: true, searchable: true}, + date_field: {string: "A date", type: "date", searchable: true}, + char_field: {string: "Char Field", type: "char", default: "foo", searchable: true, trim: true}, + }; + }, +}, function () { + + QUnit.test('simple rendering with no filter', function (assert) { + assert.expect(3); + + var filterMenu = createFilterMenu([], this.fields); + testUtils.dom.click(filterMenu.$('span.fa-filter')); + assert.containsNone(filterMenu, '.dropdown-divider'); + assert.containsNone(filterMenu, '.o_add_filter_menu'); + assert.containsOnce(filterMenu, '.o_add_custom_filter', + 'should have one element: a add custom filter item'); + filterMenu.destroy(); + }); + + QUnit.test('simple rendering with a filter', function (assert) { + assert.expect(2); + + var filterMenu = createFilterMenu(this.filters, this.fields); + assert.containsN(filterMenu, '.dropdown-divider, .dropdown-item, .o_add_custom_filter', 4, + 'should have 4 elements: a hidden separator, a filter, a separator, a add custom filter item'); + assert.containsOnce(filterMenu, '.o_add_custom_filter'); + filterMenu.destroy(); + }); + + QUnit.test('click on add custom filter opens the submenu', function (assert) { + assert.expect(3); + + var filterMenu = createFilterMenu([], this.fields); + // open menu dropdown + testUtils.dom.click(filterMenu.$('span.fa-filter')); + // open add custom filter submenu + testUtils.dom.click(filterMenu.$('.o_add_custom_filter')); + assert.containsNone(filterMenu, '.dropdown-divider'); + assert.isVisible(filterMenu.$('.o_add_filter_menu')); + assert.containsN(filterMenu, '.dropdown-item, .dropdown-item-text', 3, + 'should have 3 elements: a add custom filter item, a proposition, a apply button + add condition button'); + + filterMenu.destroy(); + }); + + QUnit.test('removing last prop disable the apply button', function (assert) { + assert.expect(2); + + var filterMenu = createFilterMenu([], this.fields); + // open menu dropdown and custom filter submenu + testUtils.dom.click(filterMenu.$('span.fa-filter')); + testUtils.dom.click(filterMenu.$('.o_add_custom_filter')); + + // remove the current unique proposition + testUtils.dom.click(filterMenu.$('.o_searchview_extended_delete_prop')); + + assert.containsNone(filterMenu, '.dropdown-divider'); + assert.containsN(filterMenu, '.dropdown-item, .dropdown-item-text', 2, + 'should have 2 elements: a add custom filter item, a apply button + add condition button'); + + filterMenu.destroy(); + }); + + QUnit.test('readding a proposition reenable apply button', function (assert) { + assert.expect(1); + + var filterMenu = createFilterMenu([], this.fields); + // open menu dropdown and custom filter submenu, remove existing prop + testUtils.dom.click(filterMenu.$('span.fa-filter')); + testUtils.dom.click(filterMenu.$('.o_add_custom_filter')); + testUtils.dom.click(filterMenu.$('.o_searchview_extended_delete_prop')); + // read a proposition + testUtils.dom.click(filterMenu.$('.o_add_condition')); + assert.ok(!filterMenu.$('.o_apply_filter').attr('disabled')); + + filterMenu.destroy(); + }); + + QUnit.test('adding a simple filter works', function (assert) { + assert.expect(6); + + delete this.fields.date_field; + var filterMenu = createFilterMenu([], this.fields, { + intercepts: { + new_filters: function (ev) { + var filter = ev.data.filters[0]; + assert.strictEqual(filter.type, 'filter'); + assert.strictEqual(filter.description, 'Boolean Field is true'); + assert.strictEqual(filter.domain, '[[\"boolean_field\",\"=\",True]]'); + filterMenu.update([{ + isActive: true, + description: '?', + domain: '?', + groupNumber: 1, + }]); + }, + }, + }); + // open menu dropdown and custom filter submenu, remove existing prop + testUtils.dom.click(filterMenu.$('span.fa-filter')); + testUtils.dom.click(filterMenu.$('.o_add_custom_filter')); + // click on apply to activate filter + testUtils.dom.click(filterMenu.$('.o_apply_filter')); + assert.containsNone(filterMenu, '.o_filter_condition'); + assert.containsN(filterMenu, '.dropdown-divider', 2); + assert.isNotVisible(filterMenu.$('.dropdown-divider').eq(0)); + filterMenu.destroy(); + }); + + QUnit.skip('commit search with an extended proposition with field char does not cause a crash', function (assert) { + assert.expect(0); + + this.fields = {many2one_field: {string: "Trululu", type: "many2one", relation: 'partner', selectable: true, searchable: true}}; + + var filterMenu = createFilterMenu([], this.fields, { + intercepts: { + new_filters: function (ev) { + var filter = ev.data.filters[0]; + Domain.prototype.stringToArray(filter.domain); + }, + } + }); + + // open menu dropdown and custom filter submenu, select trululu field and enter string "a", then click apply + testUtils.dom.click(filterMenu.$('span.fa-filter')); + testUtils.dom.click(filterMenu.$('.o_add_custom_filter')); + testUtils.fields.editSelect(filterMenu.$('.o_filter_condition select.o_input.o_searchview_extended_prop_field'), 'many2one_field'); + testUtils.fields.editInput(filterMenu.$('.o_filter_condition .o_searchview_extended_prop_value input'), "a"); + testUtils.dom.click(filterMenu.$('.o_apply_filter')); + + + // open custom filter submenu, select trululu field and enter string "'a'", then click apply + testUtils.dom.click(filterMenu.$('.o_add_custom_filter')); + testUtils.fields.editSelect(filterMenu.$('.o_filter_condition select.o_input.o_searchview_extended_prop_field'), 'many2one_field'); + testUtils.fields.editInput(filterMenu.$('.o_filter_condition .o_searchview_extended_prop_value input'), '"a"'); + testUtils.dom.click(filterMenu.$('.o_apply_filter')); + filterMenu.destroy(); + }); +}); +}); diff --git a/addons/web/static/tests/views/search_filters_menu_tests.js b/addons/web/static/tests/views/search_filters_menu_tests.js index a1a7617b7ad..e9246520f1d 100644 --- a/addons/web/static/tests/views/search_filters_menu_tests.js +++ b/addons/web/static/tests/views/search_filters_menu_tests.js @@ -1,27 +1,26 @@ odoo.define('web.search_filters_menu_tests', function (require) { "use strict"; -var FiltersMenu = require('web.FiltersMenu'); +var FilterMenu = require('web.FilterMenu'); var testUtils = require('web.test_utils'); -function createFiltersMenu(filters, fields, params) { +function createFilterMenu(filters, fields, params) { params = params || {}; var target = params.debug ? document.body : $('#qunit-fixture'); - var menu = new FiltersMenu(null, filters, fields); + var menu = new FilterMenu(null, filters, fields); testUtils.mock.addMockEnvironment(menu, params); menu.appendTo(target); return menu; } -QUnit.module('FiltersMenu', { +QUnit.module('FilterMenu', { beforeEach: function () { this.filters = [ { isActive: false, description: 'some filter', domain: '', - name: 'red', - groupId: 1, + groupNumber: 1, }, ]; this.fields = { @@ -35,7 +34,7 @@ QUnit.module('FiltersMenu', { QUnit.test('simple rendering with no filter', function (assert) { assert.expect(4); - var filterMenu = createFiltersMenu([], this.fields); + var filterMenu = createFilterMenu([], this.fields); testUtils.dom.click(filterMenu.$('span.fa-filter')); assert.containsOnce(filterMenu, '.dropdown-divider'); assert.ok(!filterMenu.$('.dropdown-divider').is(':visible')); @@ -48,7 +47,7 @@ QUnit.module('FiltersMenu', { QUnit.test('simple rendering with a filter', function (assert) { assert.expect(2); - var filterMenu = createFiltersMenu(this.filters, this.fields); + var filterMenu = createFilterMenu(this.filters, this.fields); assert.containsN(filterMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 5, 'should have 4 elements: a hidden, separator, a filter, a separator, a add custom filter item, a apply button + add condition button'); assert.containsOnce(filterMenu, '.o_add_custom_filter.o_closed_menu'); @@ -58,7 +57,7 @@ QUnit.module('FiltersMenu', { QUnit.test('click on add custom filter opens the submenu', function (assert) { assert.expect(3); - var filterMenu = createFiltersMenu([], this.fields); + var filterMenu = createFilterMenu([], this.fields); // open menu dropdown testUtils.dom.click(filterMenu.$('span.fa-filter')); // open add custom filter submenu @@ -74,7 +73,7 @@ QUnit.module('FiltersMenu', { QUnit.test('removing last prop disable the apply button', function (assert) { assert.expect(2); - var filterMenu = createFiltersMenu([], this.fields); + var filterMenu = createFilterMenu([], this.fields); // open menu dropdown and custom filter submenu testUtils.dom.click(filterMenu.$('span.fa-filter')); testUtils.dom.click(filterMenu.$('.o_add_custom_filter')); @@ -93,82 +92,48 @@ QUnit.module('FiltersMenu', { QUnit.test('readding a proposition reenable apply button', function (assert) { assert.expect(1); - var filterMenu = createFiltersMenu([], this.fields); - + var filterMenu = createFilterMenu([], this.fields); // open menu dropdown and custom filter submenu, remove existing prop testUtils.dom.click(filterMenu.$('span.fa-filter')); testUtils.dom.click(filterMenu.$('.o_add_custom_filter')); testUtils.dom.click(filterMenu.$('.o_searchview_extended_delete_prop')); - // read a proposition testUtils.dom.click(filterMenu.$('.o_add_condition')); - assert.ok(!filterMenu.$('.o_apply_filter').attr('disabled')); filterMenu.destroy(); }); QUnit.test('adding a simple filter works', function (assert) { - assert.expect(3); - delete this.fields.date_field; - - var filterMenu = createFiltersMenu([], this.fields); + assert.expect(6); + delete this.fields.date_field; + var filterMenu = createFilterMenu([], this.fields, { + debug: true, + intercepts: { + new_filters: function (ev) { + var filter = ev.data.filters[0]; + assert.strictEqual(filter.type, 'filter'); + assert.strictEqual(filter.description, 'Boolean Field is true'); + assert.strictEqual(filter.domain, '[[\"boolean_field\",\"=\",True]]'); + filterMenu.update([{ + isActive: true, + description: '?', + domain: '?', + groupNumber: 1, + }]); + }, + }, + }); // open menu dropdown and custom filter submenu, remove existing prop testUtils.dom.click(filterMenu.$('span.fa-filter')); testUtils.dom.click(filterMenu.$('.o_add_custom_filter')); - // click on apply to activate filter testUtils.dom.click(filterMenu.$('.o_apply_filter')); - assert.hasClass(filterMenu.$('.o_add_custom_filter'), 'o_closed_menu'); assert.containsNone(filterMenu, '.o_filter_condition'); assert.containsOnce(filterMenu, '.dropdown-divider:visible', 'there should be a separator between filters and add custom filter menu'); - - filterMenu.destroy(); - }); - - QUnit.test('adding a date filter', function (assert) { - assert.expect(5); - var filters = [{ - itemId: 1, - isActive: false, - description: 'some filter', - isPeriod: true, - name: 'Filter Date', - fieldName: 'date_field', - groupId: 1, - }, - ]; - - var filterMenu = createFiltersMenu(filters, this.fields, { - intercepts: { - menu_item_toggled: function (ev) { - assert.strictEqual(ev.data.optionId, 'this_quarter', - "should trigger event with proper period"); - }, - }, - }); - - // open menu dropdown - testUtils.dom.click(filterMenu.$('span.fa-filter')); - - assert.containsNone(filterMenu, '.o_menu_item ul', - "there should not be a sub menu item list"); - - // open date sub menu - testUtils.dom.click(filterMenu.$('.o_submenu_switcher')); - assert.containsOnce(filterMenu, '.o_menu_item ul', - "there should be a sub menu item list"); - - // click on This Quarter option - assert.doesNotHaveClass(filterMenu.$('.o_menu_item li[data-option_id="this_quarter"] .dropdown-item'), 'selected', - "menu item should not be selected"); - testUtils.dom.click(filterMenu.$('.o_menu_item li[data-option_id="this_quarter"] a')); - assert.hasClass(filterMenu.$('.o_menu_item li[data-option_id="this_quarter"] .dropdown-item'),'selected', - "menu item should be selected"); - filterMenu.destroy(); }); }); diff --git a/addons/web/static/tests/views/search_groupby_menu_tests.js b/addons/web/static/tests/views/search_groupby_menu_tests.js index 63967f57e86..efeb74103d5 100644 --- a/addons/web/static/tests/views/search_groupby_menu_tests.js +++ b/addons/web/static/tests/views/search_groupby_menu_tests.js @@ -3,11 +3,13 @@ odoo.define('web.search_groupby_menu_tests', function (require) { var GroupByMenu = require('web.GroupByMenu'); var testUtils = require('web.test_utils'); +var controlPanelViewParameters = require('web.controlPanelViewParameters'); +var INTERVAL_OPTIONS = controlPanelViewParameters.INTERVAL_OPTIONS; -function createGroupByMenu(groupbys, fields, params) { +function createGroupByMenu(groupBys, fields, params) { params = params || {}; var target = params.debug ? document.body : $('#qunit-fixture'); - var menu = new GroupByMenu(null, groupbys, fields); + var menu = new GroupByMenu(null, groupBys, fields); testUtils.mock.addMockEnvironment(menu, params); menu.appendTo(target); return menu; @@ -15,38 +17,42 @@ function createGroupByMenu(groupbys, fields, params) { QUnit.module('GroupByMenu', { beforeEach: function () { - this.groupbys = [ + this.groupBys = [ { - isActive: false, description: 'some group by', - fieldName: 'fieldname', - itemId: 'red', - groupId: 1, + groupNumber: 1, + isActive: false, }, ]; this.fields = { - fieldname: {sortable: true, string: 'Super Date', type: 'date', isDate: true} + fieldName: {sortable: true, string: 'Super Date', type: 'date'} }; }, }, function () { - QUnit.test('simple rendering', function (assert) { - assert.expect(2); + QUnit.test('simple rendering with no filter and no field', function (assert) { + assert.expect(1); - var groupByMenu = createGroupByMenu(this.groupbys, this.fields); + var groupByMenu = createGroupByMenu([], {}); + // open groupBy menu testUtils.dom.click(groupByMenu.$('button:first')); - assert.containsN(groupByMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 2, 'should have 2 elements'); - assert.strictEqual(groupByMenu.$('.dropdown-divider, .dropdown-item, .dropdown-item-text').eq(1).text().trim(), 'some group by', - 'should have proper filter name'); + assert.containsNone(groupByMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', + 'should have 0 element'); + groupByMenu.destroy(); }); - QUnit.test('simple rendering with no filter and no field', function (assert) { - assert.expect(1); + QUnit.test('simple rendering', function (assert) { + assert.expect(2); - var groupByMenu = createGroupByMenu([], {}); + var groupByMenu = createGroupByMenu(this.groupBys, this.fields); + // open groupBy menu testUtils.dom.click(groupByMenu.$('button:first')); - assert.containsNone(groupByMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 'should have 0 element'); + groupByMenu.$('button:first').click(); + assert.containsN(groupByMenu, '.dropdown-divider, .dropdown-item', 4); + assert.strictEqual(groupByMenu.$('.o_menu_item').text().trim(), 'some group by', + 'should have proper filter name'); + groupByMenu.destroy(); }); @@ -55,33 +61,52 @@ QUnit.module('GroupByMenu', { var groupByMenu = createGroupByMenu( [], - {fieldname: {sortable: true, string: 'Super Date', type: 'date', isDate: true}} + {fieldName: {sortable: true, string: 'Super Date', type: 'date'}} ); testUtils.dom.click(groupByMenu.$('button:first')); assert.containsOnce(groupByMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 'should have 1 element'); + groupByMenu.destroy(); }); QUnit.test('click on add custom group toggle group selector', function (assert) { assert.expect(2); - var groupByMenu = createGroupByMenu([], {fieldname: {sortable: true, string: 'Super Date', type: 'date', isDate: true}}); + var groupByMenu = createGroupByMenu([], + {fieldName: {sortable: true, string: 'Super Date', type: 'date'}} + ); testUtils.dom.click(groupByMenu.$('button:first')); var selector = groupByMenu.$('select.o_group_selector'); assert.ok(!selector.is(":visible"), 'should be invisible'); testUtils.dom.click(groupByMenu.$('.o_add_custom_group')); selector = groupByMenu.$('select.o_group_selector'); assert.ok(selector.is(":visible"), 'should be visible'); + groupByMenu.destroy(); }); - QUnit.test('select a group using the group selector add properly add that group to menu', function (assert) { - assert.expect(2); + QUnit.test('select a groupBy of no date type in Add Custom Group menu add properly that groupBy to menu', function (assert) { + assert.expect(6); var groupByMenu = createGroupByMenu( [], { fieldName: {sortable: true, name: 'candlelight', string: 'Candlelight', type: 'boolean'}, + }, + { + intercepts: { + new_groupBy: function (ev) { + assert.strictEqual(ev.data.description, 'Candlelight'); + assert.strictEqual(ev.data.fieldName, 'fieldName'); + assert.strictEqual(ev.data.fieldType, 'boolean'); + assert.strictEqual(ev.data.type, 'groupBy'); + groupByMenu.update([{ + description: 'Candlelight', + groupNumber: 1, + isActive: true, + }]); + }, + }, } ); testUtils.dom.click(groupByMenu.$('button:first')); @@ -93,62 +118,59 @@ QUnit.module('GroupByMenu', { groupByMenu.destroy(); }); - QUnit.test('click on a groupby filter (not of date type) should activate it', function (assert) { - assert.expect(5); - - this.groupbys = [{ - isActive: false, - description: 'another group by', - fieldName: 'float_field', - itemId: 'green', - groupId: 1, - }]; - this.fields = {float_field: {sortable: true, string: 'Super Float', type: 'float'}}; - - var groupByMenu = createGroupByMenu(this.groupbys, this.fields, { - intercepts: { - menu_item_toggled: function (ev) { - assert.strictEqual(ev.data.itemId, 'green'); - assert.strictEqual(ev.data.isActive, true); - }, - }, - }); - testUtils.dom.click(groupByMenu.$('button:first')); - assert.doesNotHaveClass(groupByMenu.$('.o_menu_item:first > .dropdown-item'), 'selected'); - testUtils.dom.click(groupByMenu.$('.o_menu_item a').first()); - assert.hasClass(groupByMenu.$('.o_menu_item:first > .dropdown-item'), 'selected'); - assert.ok(groupByMenu.$('.o_menu_item:first').is(':visible'), - 'group by filter should still be visible'); - groupByMenu.destroy(); - }); - - QUnit.test('click on a groupby filter of date type should open menu option', function (assert) { - assert.expect(4); - - var groupByMenu = createGroupByMenu(this.groupbys, - {fieldname: {sortable: true, string: 'Super Date', type: 'date', isDate: true}}); - testUtils.dom.click(groupByMenu.$('button:first')); - assert.doesNotHaveClass(groupByMenu.$('.o_menu_item:first > .dropdown-item'), 'selected'); - testUtils.dom.click(groupByMenu.$('.o_menu_item a').first()); - assert.doesNotHaveClass(groupByMenu.$('.o_menu_item:first > .dropdown-item'), 'selected'); - assert.ok(groupByMenu.$('.o_menu_item:first').is(':visible'), - 'group by filter should still be visible'); - assert.ok(groupByMenu.$('.o_item_option').length, 5); - groupByMenu.destroy(); - }); + QUnit.test('select a groupBy of date type in Add Custom Group menu add properly that groupBy to menu', function (assert) { + assert.expect(13); - QUnit.test('click on groupby filter should not change url', function (assert) { - assert.expect(0); + INTERVAL_OPTIONS = INTERVAL_OPTIONS.map(function (option) { + return _.extend(option, {description: option.description.toString()}); + }); - var groupByMenu = createGroupByMenu(this.groupbys, - {fieldname: {sortable: true, string: 'Super Date', type: 'date', isDate: true}} + var groupByMenu = createGroupByMenu( + [], + this.fields, + { + intercepts: { + new_groupBy: function (ev) { + assert.strictEqual(ev.data.description, 'Super Date'); + assert.strictEqual(ev.data.fieldName, 'fieldName'); + assert.strictEqual(ev.data.fieldType, 'date'); + assert.strictEqual(ev.data.type, 'groupBy'); + assert.strictEqual(ev.data.hasOptions, true); + assert.deepEqual(ev.data.options, controlPanelViewParameters.INTERVAL_OPTIONS); + assert.strictEqual(ev.data.defaultOptionId, controlPanelViewParameters.DEFAULT_INTERVAL); + assert.strictEqual(ev.data.currentOptionId, false); + groupByMenu.update([{ + description: 'Super Date', + fieldName: 'fieldName', + groupNumber: 1, + isActive: true, + hasOptions: true, + options: controlPanelViewParameters.INTERVAL_OPTIONS, + currentOptionId: controlPanelViewParameters.DEFAULT_INTERVAL, + }]); + }, + }, + } ); - testUtils.dom.click(groupByMenu.$('.o_dropdown_toggler_btn')); - groupByMenu.$el.click(function (event) { - // we do not want a click to get out and change the url, for example - throw new Error('No click should get out of the groupby menu'); - }); - testUtils.dom.click(groupByMenu.$('.o_menu_item a').first()); + // open groupBy menu + testUtils.dom.click(groupByMenu.$('button:first')); + // open Add Custom Group submenu + testUtils.dom.click(groupByMenu.$('.o_add_custom_group')); + // select fieldName + assert.strictEqual(groupByMenu.$('select').val(), 'fieldName', + 'the select value should be "fieldName"'); + // create new groupBy of type date + groupByMenu.$('button.o_apply_group').click(); + assert.strictEqual(groupByMenu.$('.o_menu_item > .dropdown-item.selected').length, 1, + 'there should be a groupby selected'); + assert.strictEqual(groupByMenu.$('.o_menu_item .o_submenu_switcher').length, 1, + 'there should be options available'); + // open options submenu + groupByMenu.$('.o_menu_item .o_submenu_switcher').click(); + assert.strictEqual(groupByMenu.$('.o_item_option').length, 5, + 'there should be five options available'); + assert.strictEqual(groupByMenu.$('.o_add_custom_group').length, 0, + 'there should be no more a Add Custome Group submenu'); groupByMenu.destroy(); }); diff --git a/addons/web/static/tests/views/search_view_tests.js b/addons/web/static/tests/views/search_view_tests.js index 0dbddbe1fae..fa3fe817c2a 100644 --- a/addons/web/static/tests/views/search_view_tests.js +++ b/addons/web/static/tests/views/search_view_tests.js @@ -3,9 +3,14 @@ odoo.define('web.search_view_tests', function (require) { var FormView = require('web.FormView'); var testUtils = require('web.test_utils'); + var createActionManager = testUtils.createActionManager; -var patchDate = testUtils.mock.patchDate; +var createControlPanel = testUtils.createControlPanel; var createView = testUtils.createView; +var patchDate = testUtils.mock.patchDate; + +var controlPanelViewParameters = require('web.controlPanelViewParameters'); +var PERIOD_OPTIONS_IDS = controlPanelViewParameters.PERIOD_OPTIONS.map(function (option) {return option.optionId;}); QUnit.module('Search View', { beforeEach: function () { @@ -188,21 +193,6 @@ QUnit.module('Search View', { '</search>', }; - this.periodOptions = [ - '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', - ]; // assuming that the current time is: 2017-03-22:01:00:00 this.periodDomains = [ @@ -265,7 +255,59 @@ QUnit.module('Search View', { ]; }, }, function () { - QUnit.module('Groupby Menu'); + QUnit.test('basic rendering', function (assert) { + assert.expect(1); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + actionManager.doAction(1); + + assert.strictEqual($('.o_searchview input.o_searchview_input')[0], document.activeElement, + "searchview input should be focused"); + + actionManager.destroy(); + }); + QUnit.test('navigation with facets', function (assert) { + assert.expect(4); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + actionManager.doAction(1); + + // add a facet + testUtils.dom.click(actionManager.$('.o_dropdown_toggler_btn:contains(Group By)')); + testUtils.dom.click(actionManager.$('.o_menu_item a')); + assert.strictEqual(actionManager.$('.o_searchview .o_searchview_facet').length, 1, + "there should be one facet"); + assert.strictEqual(actionManager.$('.o_searchview input.o_searchview_input')[0], document.activeElement, + "searchview input should be focused"); + + // press left to focus the facet + actionManager.$('.o_searchview_input_container').trigger($.Event('keydown', { + which: $.ui.keyCode.LEFT, + keyCode: $.ui.keyCode.LEFT, + })); + assert.strictEqual(actionManager.$('.o_searchview .o_searchview_facet')[0], document.activeElement, + "the facet should be focused"); + + // press right to focus the facet + actionManager.$('.o_searchview_input_container').trigger($.Event('keydown', { + which: $.ui.keyCode.RIGHT, + keyCode: $.ui.keyCode.RIGHT, + })); + assert.strictEqual(actionManager.$('.o_searchview input.o_searchview_input')[0], document.activeElement, + "searchview input should be focused"); + + actionManager.destroy(); + }); + + QUnit.module('GroupByMenu'); QUnit.test('click on groupby filter adds a facet', function (assert) { assert.expect(1); @@ -275,12 +317,12 @@ QUnit.module('Search View', { archs: this.archs, data: this.data, }); - actionManager.doAction(1); testUtils.dom.click($('.o_dropdown_toggler_btn:contains(Group By)')); testUtils.dom.click($('.o_menu_item a')); assert.strictEqual($('.o_searchview .o_searchview_facet .o_facet_values span').text().trim(), 'candle', 'should have a facet with candle name'); + actionManager.destroy(); }); @@ -358,14 +400,14 @@ QUnit.module('Search View', { data: this.data, }); actionManager.doAction(4); - testUtils.dom.click($('div.o_graph_groupbys_menu > button')); - testUtils.dom.click($('div.o_graph_groupbys_menu .o_menu_item').eq(1)); - testUtils.dom.click($('div.o_graph_groupbys_menu .o_menu_item .o_item_option > .dropdown-item').first()); - assert.doesNotHaveClass($('div.o_graph_groupbys_menu .o_menu_item > .dropdown-item').eq(1), 'selected', + testUtils.dom.click($('.o_graph_controller .o_control_panel .o_cp_buttons button').eq(1)); + testUtils.dom.click($('.o_graph_controller .o_group_by_menu .o_menu_item').eq(1)); + testUtils.dom.click($('.o_graph_controller .o_group_by_menu .o_menu_item .o_item_option > .dropdown-item').first()); + assert.doesNotHaveClass($('.o_graph_controller .o_group_by_menu .o_menu_item > .dropdown-item').eq(1), 'selected', 'groupby should be unselected'); testUtils.dom.click($('.o_search_options button span.fa-filter')); testUtils.dom.click($('.o_filters_menu .o_menu_item a').first()); - assert.doesNotHaveClass($('div.o_graph_groupbys_menu .o_menu_item > .dropdown-item').eq(1), 'selected', + assert.doesNotHaveClass($('.o_graph_controller .o_group_by_menu .o_menu_item > .dropdown-item').eq(1), 'selected', 'groupby should be still unselected'); actionManager.destroy(); }); @@ -459,7 +501,7 @@ QUnit.module('Search View', { actionManager.destroy(); }); - QUnit.module('Filters Menu'); + QUnit.module('FilterMenu'); QUnit.test('Search date and datetime fields. Support of timezones', function (assert) { assert.expect(4); @@ -628,7 +670,10 @@ QUnit.module('Search View', { var periodOptions = $('.o_menu_item .o_item_option').map(function () { return $(this).data('option_id'); }).toArray(); - assert.deepEqual(periodOptions, this.periodOptions, + + + + assert.deepEqual(periodOptions, PERIOD_OPTIONS_IDS, "13 period options should be available:"); testUtils.dom.click($('.o_menu_item .o_item_option[data-option_id="last_7_days"]')); @@ -660,7 +705,6 @@ QUnit.module('Search View', { data: this.data, intercepts: { create_filter: function (ev) { - console.log(ev.data); assert.equal( ev.data.filter.domain, "['&', " + @@ -676,11 +720,175 @@ QUnit.module('Search View', { testUtils.dom.click($('.o_filters_menu .o_menu_item a')); testUtils.dom.click($('.o_item_option[data-option_id="today"]')); testUtils.dom.click($('span.fa-star')); - testUtils.dom.click($('.o_favorites_menu .o_save_search')); - testUtils.dom.click($('.o_favorites_menu .o_save_name button')); + testUtils.dom.click($('.o_favorites_menu .o_add_favorite')); + testUtils.fields.editInput($('div.o_favorite_name input'), 'name for favorite'); + testUtils.dom.click($('.o_favorites_menu .o_save_favorite button')); actionManager.destroy(); }); + QUnit.test('save search filter in modal', function (assert) { + assert.expect(5); + this.data.partner.records.push({ + id: 7, + display_name: "Partner 6", + }, { + id: 8, + display_name: "Partner 7", + }, { + id: 9, + display_name: "Partner 8", + }, { + id: 10, + display_name: "Partner 9", + }); + this.data.partner.fields.date_field.searchable = true; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="bar"/>' + + '</group>' + + '</sheet>' + + '</form>', + archs: { + 'partner,false,list': '<tree><field name="display_name"/></tree>', + 'partner,false,search': '<search><field name="date_field"/></search>', + }, + res_id: 1, + }); + + testUtils.form.clickEdit(form); + + testUtils.fields.many2one.clickOpenDropdown('bar'); + testUtils.fields.many2one.clickItem('bar','Search'); + + assert.strictEqual($('tr.o_data_row').length, 9, "should display 9 records"); + + testUtils.dom.click($('button:contains(Filters)')); + testUtils.dom.click($('.o_add_custom_filter:visible')); + assert.strictEqual($('.o_filter_condition select.o_searchview_extended_prop_field').val(), 'date_field', + "date field should be selected"); + testUtils.dom.click($('.o_apply_filter')); + + assert.strictEqual($('tr.o_data_row').length, 0, "should display 0 records"); + + // Save this search + testUtils.mock.intercept(form, 'create_filter', function (event) { + assert.strictEqual(event.data.filter.name, "Awesome Test Customer Filter", "filter name should be correct"); + }); + testUtils.dom.click($('button:contains(Favorites)')); + testUtils.dom.click($('.o_add_favorite')); + var filterNameInput = $('.o_favorite_name .o_input[type="text"]:visible'); + assert.strictEqual(filterNameInput.length, 1, "should display an input field for the filter name"); + testUtils.fields.editInput(filterNameInput, 'Awesome Test Customer Filter'); + testUtils.dom.click($('.o_save_favorite button')); + + form.destroy(); + }); + + QUnit.test('save filters created via autocompletion works', function (assert) { + assert.expect(2); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + intercepts: { + create_filter: function (ev) { + assert.ok(ev.data.filter.domain === "[['foo', 'ilike', 'a']]"); + }, + }, + }); + + actionManager.doAction(10); + + $('.o_searchview_input').trigger($.Event('keypress', { + which: 97, + })); + + $('.o_searchview_input').trigger($.Event('keyup', { + which: $.ui.keyCode.ENTER, + keyCode: $.ui.keyCode.ENTER, + })); + + assert.strictEqual($('.o_searchview_input_container .o_facet_values span').text().trim(), "a"); + + testUtils.dom.click($('button .fa-star')); + testUtils.dom.click($('.o_favorites_menu .o_add_favorite')); + testUtils.fields.editInput($('div.o_favorite_name input'), 'name for favorite'); + testUtils.dom.click($('.o_favorites_menu div.o_save_favorite button')); + + actionManager.destroy(); + }); + + QUnit.test('delete an active favorite remove it both in list of favorite and in search bar', function (assert) { + assert.expect(2); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + intercepts: { + load_filters: function (event) { + return $.when([{ + context: "{}", + domain: "[]", + id: 7, + is_default: true, + name: "My favorite", + sort: "[]", + user_id: [2, "Mitchell Admin"], + }]).then(event.data.on_success); + }, + delete_filter: function (event) { + event.data.on_success(); + } + }, + }); + + actionManager.doAction(6); + testUtils.dom.click(actionManager.$('.o_control_panel .o_search_options button.o_favorites_menu_button')); + assert.containsOnce(actionManager, '.o_control_panel .o_searchview_input_container .o_facet_values'); + testUtils.dom.click(actionManager.$('.o_control_panel .o_search_options .o_favorites_menu span.o_trash_button')); + testUtils.modal.clickButton('Ok'); + assert.containsNone(actionManager, '.o_control_panel .o_searchview_input_container .o_facet_values'); + actionManager.destroy(); + }); + + QUnit.test('default favorite is not activated if key search_disable_custom_filters is set to true', async function (assert) { + assert.expect(1); + + var controlPanel = await createControlPanel({ + model: 'partner', + arch: '<controlpanel/>', + data: this.data, + intercepts: { + load_filters: function (event) { + return $.when([{ + context: "{}", + domain: "[]", + id: 7, + is_default: true, + name: "My favorite", + sort: "[]", + user_id: [2, "Mitchell Admin"], + }]).then(event.data.on_success); + }, + }, + context: { + search_disable_custom_filters: true, + }, + }); + + assert.containsNone(controlPanel, '.o_facet_values'); + controlPanel.destroy(); + }); + + QUnit.module('Search Arch'); + QUnit.test('arch order of groups of filters preserved', function (assert) { assert.expect(12); @@ -699,6 +907,8 @@ QUnit.module('Search View', { actionManager.destroy(); }); + QUnit.module('Autocompletion'); + QUnit.test('selection via autocompletion modifies appropriately submenus', function (assert) { assert.expect(4); @@ -748,42 +958,10 @@ QUnit.module('Search View', { actionManager.destroy(); }); - QUnit.test('save filters created via autocompletion works', function (assert) { - assert.expect(2); - - var actionManager = createActionManager({ - actions: this.actions, - archs: this.archs, - data: this.data, - intercepts: { - create_filter: function (ev) { - assert.ok(ev.data.filter.domain === "[['foo', 'ilike', 'a']]"); - }, - }, - }); - - actionManager.doAction(10); - - $('.o_searchview_input').trigger($.Event('keypress', { - which: 97, - })); - - $('.o_searchview_input').trigger($.Event('keyup', { - which: $.ui.keyCode.ENTER, - keyCode: $.ui.keyCode.ENTER, - })); - - assert.strictEqual($('.o_searchview_input_container .o_facet_values span').text().trim(), "a"); - - testUtils.dom.click($('button .fa-star')); - testUtils.dom.click($('.o_favorites_menu a.o_save_search')); - testUtils.dom.click($('.o_favorites_menu div.o_save_name button')); - - actionManager.destroy(); - }); + QUnit.module('TimeRangeMenu'); QUnit.test('time range menu stays hidden', function (assert) { - assert.expect(6); + assert.expect(4); var actionManager = createActionManager({ actions: this.actions, @@ -793,9 +971,8 @@ QUnit.module('Search View', { actionManager.doAction(1); - // check that the fifth dropdown is the time range menu and is hidden - assert.isNotVisible($('.btn-group.o_dropdown').eq(4)); - assert.hasClass($('.btn-group.o_dropdown').eq(4).children().eq(1), 'o_time_range_menu'); + // check that there is no time range menu + assert.containsNone(actionManager, '.o_control_panel .o_search_options .o_time_range_menu'); // check if search view has no facets assert.strictEqual($('.o_facet_values').length, 0); @@ -804,9 +981,8 @@ QUnit.module('Search View', { testUtils.dom.click($('.o_menu_item a').eq(0)); // check that there is a facet assert.strictEqual($('div.o_facet_values').length, 1); - // check that the fifth dropdown is the time range menu and is still hidden - assert.isNotVisible($('.btn-group.o_dropdown').eq(4)); - assert.hasClass($('.btn-group.o_dropdown').eq(4).children().eq(1), 'o_time_range_menu'); + // check that there is still no time range menu + assert.containsNone(actionManager, '.o_control_panel .o_search_options .o_time_range_menu'); actionManager.destroy(); }); @@ -857,7 +1033,7 @@ QUnit.module('Search View', { var periodOptions = $periodOptions.map(function () { return $(this).val(); }).toArray(); - assert.deepEqual(periodOptions, this.periodOptions, + assert.deepEqual(periodOptions, PERIOD_OPTIONS_IDS, "13 period options should be available:"); $periodOptions.each(function () { @@ -908,75 +1084,11 @@ QUnit.module('Search View', { views: [[false, 'pivot']], context: {time_ranges: {range: 'today', field: 'date_field'}} }); - actionManager.destroy(); }); - QUnit.test('save search filter in modal', function (assert) { - assert.expect(5); - this.data.partner.records.push({ - id: 7, - display_name: "Partner 6", - }, { - id: 8, - display_name: "Partner 7", - }, { - id: 9, - display_name: "Partner 8", - }, { - id: 10, - display_name: "Partner 9", - }); - this.data.partner.fields.date_field.searchable = true; - var form = createView({ - View: FormView, - model: 'partner', - data: this.data, - arch: '<form string="Partners">' + - '<sheet>' + - '<group>' + - '<field name="bar"/>' + - '</group>' + - '</sheet>' + - '</form>', - archs: { - 'partner,false,list': '<tree><field name="display_name"/></tree>', - 'partner,false,search': '<search><field name="date_field"/></search>', - }, - res_id: 1, - }); - - testUtils.form.clickEdit(form); - - testUtils.fields.many2one.clickOpenDropdown('bar'); - testUtils.fields.many2one.clickItem('bar','Search'); - - assert.strictEqual($('tr.o_data_row').length, 9, "should display 9 records"); - - testUtils.dom.click($('button:contains(Filters)')); - testUtils.dom.click($('.o_add_custom_filter:visible')); - assert.strictEqual($('.o_filter_condition select.o_searchview_extended_prop_field').val(), 'date_field', - "date field should be selected"); - testUtils.dom.click($('.o_apply_filter')); - - assert.strictEqual($('tr.o_data_row').length, 0, "should display 0 records"); - - // Save this search - testUtils.mock.intercept(form, 'create_filter', function (event) { - assert.strictEqual(event.data.filter.name, "Awesome Test Customer Filter", "filter name should be correct"); - }); - testUtils.dom.click($('button:contains(Favorites)')); - testUtils.dom.click($('.o_save_search')); - var filterNameInput = $('.o_save_name .o_input[type="text"]:visible'); - assert.strictEqual(filterNameInput.length, 1, "should display an input field for the filter name"); - testUtils.fields.editInput(filterNameInput, 'Awesome Test Customer Filter'); - testUtils.dom.click($('.o_save_name button')); - - form.destroy(); - }); - QUnit.test('Customizing filter does not close the filter dropdown', function (assert) { - assert.expect(4); + assert.expect(3); var self = this; _.each(this.data.partner.records.slice(), function (rec) { @@ -1002,39 +1114,30 @@ QUnit.module('Search View', { res_id: 1, }); - form.$('.o_input').click(); - $('.ui-autocomplete .ui-menu-item:contains(Search More)').mouseenter().click(); - - var $modal = $('.modal-dialog.modal-lg'); - assert.strictEqual($modal.length, 1, 'Modal Opened'); - - $modal.find('.o_search_options button:contains(Filters)').click(); - - var $filterDropDown = $modal.find('.o_dropdown_menu.show'); + testUtils.fields.many2one.clickOpenDropdown('bar'); + testUtils.fields.many2one.clickItem('bar', 'Search More'); - $filterDropDown.find('button:contains(Add Custom Filter)').click(); + assert.containsOnce(document.body, '.modal'); - assert.ok($filterDropDown.find('button:contains(Add Custom Filter)').hasClass('o_open_menu'), - 'The right dropdown is open'); + testUtils.dom.click($('.modal .o_filters_menu_button')); - var $filterInputs = $filterDropDown.find('.o_input'); + var $filterDropdown = $('.modal .o_filters_menu'); + testUtils.dom.click($filterDropdown.find('.o_add_custom_filter')); - assert.strictEqual($filterInputs.length, 3, - 'The custom filter builder has 3 elements'); + assert.containsN($filterDropdown, '.o_input', 3); // We really are interested in the click event // We do it twice on each input to make sure // the parent dropdown doesn't react to any of it - _.each($filterInputs, function (input) { + _.each($filterDropdown.find('input'), function (input) { var $input = $(input); $input.click(); $input.click(); }); - assert.ok($filterDropDown.is(':visible')); + assert.isVisible($filterDropdown); form.destroy(); }); - }); }); diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js index 023a977ad56..1df70b03acc 100644 --- a/addons/web/static/tests/views/view_dialogs_tests.js +++ b/addons/web/static/tests/views/view_dialogs_tests.js @@ -80,11 +80,6 @@ QUnit.module('Views', { }, }); - testUtils.mock.intercept(parent, 'env_updated', function () { - throw new Error("The environment should not be propagated to the action manager"); - }); - - new dialogs.FormViewDialog(parent, { res_model: 'partner', res_id: 1, @@ -155,10 +150,10 @@ QUnit.module('Views', { mockRPC: function (route, args) { if (args.method === 'read_group') { assert.deepEqual(args.kwargs, { - context: {group_by: "bar"}, - domain: [["display_name","like","a"], ["display_name","ilike","piou"], ["foo","ilike","piou"]], - fields:["display_name","foo","bar"], - groupby:["bar"], + context: {}, + domain: [["display_name","like","a"], "&", ["display_name","ilike","piou"], ["foo","ilike","piou"]], + fields: ["display_name","foo","bar"], + groupby: ["bar"], orderby: '', lazy: true }, "should search with the complete domain (domain + search), and group by 'bar'"); @@ -167,8 +162,8 @@ QUnit.module('Views', { search++; assert.deepEqual(args, { context: {}, - domain: [["display_name","like","a"], ["display_name","ilike","piou"], ["foo","ilike","piou"]], - fields:["display_name","foo"], + domain: [["display_name","like","a"], "&", ["display_name","ilike","piou"], ["foo","ilike","piou"]], + fields: ["display_name","foo"], model: "partner", limit: 80, sort: "" @@ -177,7 +172,7 @@ QUnit.module('Views', { assert.deepEqual(args, { context: {}, domain: [["display_name","like","a"]], - fields:["display_name","foo"], + fields: ["display_name","foo"], model: "partner", limit: 80, sort: "" @@ -423,9 +418,11 @@ QUnit.module('Views', { assert.expect(4); testUtils.mock.patch(ListController, { - getContext: function () { + getOwnedQueryParams: function () { return { - shouldBeInFilterContext: true, + context: { + shouldBeInFilterContext: true, + }, }; }, }); @@ -471,9 +468,9 @@ QUnit.module('Views', { // save filter testUtils.dom.click(dialog.$('.o_dropdown_toggler_btn:contains(Favorites)')); - testUtils.dom.click(dialog.$('.o_save_search')); - dialog.$('.o_save_name input[type=text]').val('some name'); // name the filter - testUtils.dom.click(dialog.$('.o_save_name button')); + testUtils.dom.click(dialog.$('.o_add_favorite')); + dialog.$('.o_favorite_name input[type=text]').val('some name'); // name the filter + testUtils.dom.click(dialog.$('.o_save_favorite button')); testUtils.mock.unpatch(ListController); parent.destroy(); diff --git a/addons/web/static/tests/widgets/dropdown_menu_tests.js b/addons/web/static/tests/widgets/dropdown_menu_tests.js index 83f5d3fecb7..709651c7cb1 100644 --- a/addons/web/static/tests/widgets/dropdown_menu_tests.js +++ b/addons/web/static/tests/widgets/dropdown_menu_tests.js @@ -4,10 +4,10 @@ odoo.define('web.dropdown_menu_tests', function (require) { var DropdownMenu = require('web.DropdownMenu'); var testUtils = require('web.test_utils'); -function createDropdownMenu(dropdownTitle, groups, params) { +function createDropdownMenu(items, params) { params = params || {}; var target = params.debug ? document.body : $('#qunit-fixture'); - var menu = new DropdownMenu(null, dropdownTitle, groups); + var menu = new DropdownMenu(null, items); testUtils.mock.addMockEnvironment(menu, params); menu.appendTo(target); return menu; @@ -18,74 +18,69 @@ QUnit.module('Web', { this.items = [ { isActive: false, - isOpen: false, description: 'Some Item', - itemId: 1, + id: 1, groupId: 1, + groupNumber: 1, }, { isActive: true, description: 'Some other Item', - itemId: 2, - options: [], - isRemovable: true, + id: 2, groupId: 2, + groupNumber: 2, }, ]; - this.dropdownHeader = { - title: "Menu", - icon: "fa fa-bars", - }; }, }, function () { QUnit.module('DropdownMenu'); QUnit.test('simple rendering', function (assert) { - assert.expect(2); + assert.expect(4); - var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items); - assert.containsN(dropdownMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 4, 'should have 4 elements counting the dividers'); - assert.strictEqual(dropdownMenu.$('.dropdown-divider, .dropdown-item, .dropdown-item-text').eq(1).text().trim(), 'Some Item', + var dropdownMenu = createDropdownMenu(this.items); + assert.containsN(dropdownMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 4, + 'should have 4 elements counting the dividers'); + assert.strictEqual(dropdownMenu.$('.o_menu_item').first().text().trim(), 'Some Item', 'first element should have "Some Item" description'); + assert.ok(!dropdownMenu.$('.o_menu_item > .dropdown-item').eq(0).hasClass('selected'), + 'first element should not be selected'); + assert.ok(dropdownMenu.$('.o_menu_item > .dropdown-item').eq(1).hasClass('selected'), + 'second element should be selected'); + dropdownMenu.destroy(); }); - QUnit.test('click on an item should toggle item', function (assert) { - assert.expect(9); + QUnit.test('click on an item without options should toggle it', function (assert) { + assert.expect(7); - var eventNumber = 0; - - var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items, { + var self = this; + var dropdownMenu = createDropdownMenu(this.items, { intercepts: { - menu_item_toggled: function (ev) { - eventNumber++; - assert.strictEqual(ev.data.itemId, 1); - if (eventNumber === 1) { - assert.strictEqual(ev.data.isActive, true); - } else { - assert.strictEqual(ev.data.isActive, false); - } + menu_item_clicked: function (ev) { + assert.strictEqual(ev.data.id, 1); + self.items[0].isActive = !self.items[0].isActive; + dropdownMenu.update(self.items); }, }, }); testUtils.dom.click(dropdownMenu.$('button:first')); - assert.doesNotHaveClass(dropdownMenu.$('.o_menu_item:first > .dropdown-item'), 'selected'); + assert.doesNotHaveClass(dropdownMenu.$('.o_menu_item:first > .dropdown-item').first(), 'selected'); testUtils.dom.click(dropdownMenu.$('.o_menu_item a').first()); assert.hasClass(dropdownMenu.$('.o_menu_item:first > .dropdown-item'), 'selected'); assert.ok(dropdownMenu.$('.o_menu_item:first').is(':visible'), 'item should still be visible'); testUtils.dom.click(dropdownMenu.$('.o_menu_item a').first()); - assert.doesNotHaveClass(dropdownMenu.$('.o_menu_item:first > .dropdown-item'), 'selected'); + assert.doesNotHaveClass(dropdownMenu.$('.o_menu_item:first > .dropdown-item').first(), 'selected'); assert.ok(dropdownMenu.$('.o_menu_item:first').is(':visible'), 'item should still be visible'); - dropdownMenu.destroy(); }); QUnit.test('click on an item should not change url', function (assert) { assert.expect(0); - var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items); + var dropdownMenu = createDropdownMenu(this.items); testUtils.dom.click(dropdownMenu.$('.o_dropdown_toggler_btn')); dropdownMenu.$el.click(function () { // we do not want a click to get out and change the url, for example @@ -99,10 +94,12 @@ QUnit.module('Web', { QUnit.test('options rendering', function (assert) { assert.expect(3); - this.items[0].options = [{optionId: 1, description: "First Option", groupId: 1}, {optionId: 2, description: "Second Option", groupId: 1}]; - - var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items); - // open dropdown + this.items[0].hasOptions = true; + this.items[0].options = [ + {optionId: 1, description: "First Option", groupId: 1}, + {optionId: 2, description: "Second Option", groupId: 1} + ]; + var dropdownMenu = createDropdownMenu(this.items); testUtils.dom.click(dropdownMenu.$('button:first')); assert.containsN(dropdownMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 4); // open options menu @@ -115,12 +112,16 @@ QUnit.module('Web', { dropdownMenu.destroy(); }); - QUnit.test('close menu closes also submenu', function (assert) { + QUnit.test('close menu closes also submenus', function (assert) { assert.expect(2); - this.items[0].options = [{optionId: 1, description: "First Option"}, {optionId: 2, description: "Second Option"}]; - - var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items); + this.items[0].hasOptions = true; + this.items[0].options = [ + {optionId: 1, description: "First Option"}, + {optionId: 2, description: "Second Option"} + ]; + var dropdownMenu = createDropdownMenu(this.items); + // open dropdown menu testUtils.dom.click(dropdownMenu.$('button:first')); // open options menu testUtils.dom.click(dropdownMenu.$('span.fa-caret-right')); @@ -132,74 +133,59 @@ QUnit.module('Web', { dropdownMenu.destroy(); }); - QUnit.test('click on an option should toggle options and item states properly', function (assert) { - assert.expect(22); - - this.items[0].options = [{optionId: 1, description: "First Option"}, {optionId: 2, description: "Second Option"}]; + QUnit.test('click on an option should trigger the event "item_option_clicked" with appropriate data', function (assert) { + assert.expect(16); + var self = this; var eventNumber = 0; - - var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items, { + this.items[0].hasOptions = true; + this.items[0].options = [ + {optionId: 1, description: "First Option"}, + {optionId: 2, description: "Second Option"} + ]; + var dropdownMenu = createDropdownMenu(this.items, { intercepts: { - menu_item_toggled: function (ev) { + item_option_clicked: function (ev) { eventNumber++; + assert.strictEqual(ev.data.id, 1); if (eventNumber === 1) { - assert.strictEqual(ev.data.itemId, 1); - assert.strictEqual(ev.data.isActive, true); assert.strictEqual(ev.data.optionId, 1); - } else { - assert.strictEqual(ev.data.itemId, 1); - assert.strictEqual(ev.data.isActive, false); - assert.strictEqual(ev.data.optionId, false); + self.items[0].isActive = true; + self.items[0].currentOptionId = 1; } - }, - item_option_changed: function (ev) { - if (eventNumber === 1) { - assert.strictEqual(ev.data.itemId, 1); - assert.strictEqual(ev.data.isActive, true); + if (eventNumber === 2) { + assert.strictEqual(ev.data.optionId, 2); + self.items[0].currentOptionId = 2; + } + if (eventNumber === 3) { assert.strictEqual(ev.data.optionId, 2); + self.items[0].isActive = false; + self.items[0].currentOptionId = false; } + dropdownMenu.update(self.items); }, }, }); // open dropdown menu testUtils.dom.click(dropdownMenu.$('button:first')); - // open options menu - testUtils.dom.click(dropdownMenu.$('span.fa-caret-right')); - assert.containsN(dropdownMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 7); - // Don't forget there is a hidden li.divider element at first place among children - assert.doesNotHaveClass(dropdownMenu.$('.o_menu_item:nth-child(2) > .dropdown-item'), 'selected'); - assert.doesNotHaveClass(dropdownMenu.$('.o_item_option:nth-child(2) > .dropdown-item'), 'selected'); - assert.doesNotHaveClass(dropdownMenu.$('.o_item_option:nth-child(3) > .dropdown-item'), 'selected'); - testUtils.dom.click(dropdownMenu.$('.o_item_option:first')); - assert.hasClass(dropdownMenu.$('.o_menu_item:nth-child(2) > .dropdown-item'), 'selected'); - assert.hasClass(dropdownMenu.$('.o_item_option:nth-child(2) > .dropdown-item'), 'selected'); - assert.doesNotHaveClass(dropdownMenu.$('.o_item_option:nth-child(3) > .dropdown-item'), 'selected'); - testUtils.dom.click(dropdownMenu.$('.o_item_option:nth-child(3)')); - assert.hasClass(dropdownMenu.$('.o_menu_item:nth-child(2) > .dropdown-item'), 'selected'); - assert.doesNotHaveClass(dropdownMenu.$('.o_item_option:nth-child(2) > .dropdown-item'), 'selected'); - assert.hasClass(dropdownMenu.$('.o_item_option:nth-child(3) > .dropdown-item'), 'selected'); - testUtils.dom.click(dropdownMenu.$('.o_item_option:nth-child(3)')); - assert.doesNotHaveClass(dropdownMenu.$('.o_menu_item:nth-child(2) > .dropdown-item'), 'selected'); - assert.doesNotHaveClass(dropdownMenu.$('.o_item_option:nth-child(2) > .dropdown-item'), 'selected'); - assert.doesNotHaveClass(dropdownMenu.$('.o_item_option:nth-child(3) > .dropdown-item'), 'selected'); - dropdownMenu.destroy(); - }); + assert.containsN(dropdownMenu, '.dropdown-divider, .o_menu_item', 4); + testUtils.dom.clickFirst(dropdownMenu.$('.o_menu_item')); + // click on first option + testUtils.dom.click(dropdownMenu.$('.o_item_option > .dropdown-item').eq(0)); + assert.hasClass(dropdownMenu.$('.o_menu_item > .dropdown-item').first(), 'selected'); + assert.hasClass(dropdownMenu.$('.o_item_option> .dropdown-item').eq(0),'selected'); + assert.doesNotHaveClass(dropdownMenu.$('.o_item_option > .dropdown-item').eq(1), 'selected'); + // click on second option + testUtils.dom.click($('.o_item_option > .dropdown-item').eq(1)); + assert.hasClass(dropdownMenu.$('.o_menu_item > .dropdown-item').first(), 'selected'); + assert.doesNotHaveClass(dropdownMenu.$('.o_item_option > .dropdown-item').eq(0), 'selected'); + assert.hasClass(dropdownMenu.$('.o_item_option > .dropdown-item').eq(1), 'selected'); + // click again on second option + testUtils.dom.click($('.o_item_option > .dropdown-item').eq(1)); + assert.doesNotHaveClass(dropdownMenu.$('.o_menu_item > .dropdown-item').first(), 'selected'); + assert.doesNotHaveClass(dropdownMenu.$('.o_item_option > .dropdown-item').eq(0), 'selected'); + assert.doesNotHaveClass(dropdownMenu.$('.o_item_option > .dropdown-item').eq(1), 'selected'); - QUnit.test('trash an item should be possible', function (assert) { - assert.expect(3); - - var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items, { - intercepts: { - menu_item_deleted: function (ev) { - assert.strictEqual(ev.data.itemId, 2); - }, - }, - }); - testUtils.dom.click(dropdownMenu.$('button:first')); - assert.containsN(dropdownMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 4); - testUtils.dom.click(dropdownMenu.$('span.o_trash_button')); - assert.containsN(dropdownMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 2); dropdownMenu.destroy(); }); }); diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index b4c1c5f5d3e..d8f6b34d263 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -204,7 +204,7 @@ <script type="text/javascript" src="/web/static/src/js/chrome/action_manager.js"></script> <script type="text/javascript" src="/web/static/src/js/chrome/action_manager_act_window.js"></script> <script type="text/javascript" src="/web/static/src/js/chrome/action_manager_report.js"/> - <script type="text/javascript" src="/web/static/src/js/chrome/control_panel.js"></script> + <script type="text/javascript" src="/web/static/src/js/chrome/action_mixin.js"/> <script type="text/javascript" src="/web/static/src/js/core/browser_detection.js"></script> <script type="text/javascript" src="/web/static/src/js/chrome/keyboard_navigation_mixin.js"></script> <script type="text/javascript" src="/web/static/src/js/chrome/abstract_web_client.js"></script> @@ -239,18 +239,9 @@ <script type="text/javascript" src="/web/static/src/js/chrome/user_menu.js"></script> <script type="text/javascript" src="/web/static/src/js/chrome/menu.js"></script> <script type="text/javascript" src="/web/static/src/js/chrome/apps_menu.js"></script> - <script type="text/javascript" src="/web/static/src/js/views/search/search_view.js"></script> - <script type="text/javascript" src="/web/static/src/js/views/search/search_view_mobile.js"></script> - <script type="text/javascript" src="/web/static/src/js/views/search/search_filters.js"></script> - <script type="text/javascript" src="/web/static/src/js/views/search/search_inputs.js"></script> - <script type="text/javascript" src="/web/static/src/js/views/search/favorites_menu.js"></script> - <script type="text/javascript" src="/web/static/src/js/views/search/groupby_menu.js"></script> - <script type="text/javascript" src="/web/static/src/js/views/search/time_range_menu.js"></script> - <script type="text/javascript" src="/web/static/src/js/views/search/time_range_menu_options.js"></script> - <script type="text/javascript" src="/web/static/src/js/views/search/groupby_menu_interface_mixin.js"></script> + <script type="text/javascript" src="/web/static/src/js/widgets/dropdown_menu.js"></script> <script type="text/javascript" src="/web/static/src/js/widgets/pie_chart.js"></script> - <script type="text/javascript" src="/web/static/src/js/views/search/filters_menu.js"></script> <script type="text/javascript" src="/web/static/src/js/apps.js"></script> <script type="text/javascript" src="/web/static/src/js/fields/abstract_field.js"></script> @@ -270,6 +261,26 @@ <script type="text/javascript" src="/web/static/src/js/views/basic/basic_view.js"></script> <script type="text/javascript" src="/web/static/src/js/views/basic/basic_controller.js"></script> <script type="text/javascript" src="/web/static/src/js/views/basic/basic_renderer.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/control_panel_controller.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/control_panel_model.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/control_panel_renderer.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/control_panel_view.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/mobile_control_panel_controller.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/mobile_control_panel_renderer.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_facet.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_bar.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/filter_menu.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/groupby_menu.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/time_range_menu.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/favorite_menu.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/control_panel_view_parameters.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_filters.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_filters_registry.js"></script> <script type="text/javascript" src="/web/static/src/js/views/field_manager_mixin.js"></script> <script type="text/javascript" src="/web/static/src/js/views/standalone_field_manager_mixin.js"></script> <script type="text/javascript" src="/web/static/src/js/views/view_registry.js"></script> @@ -533,6 +544,7 @@ <script type="text/javascript" src="/web/static/tests/views/abstract_controller_tests.js"></script> <script type="text/javascript" src="/web/static/tests/views/abstract_view_tests.js"></script> + <script type="text/javascript" src="/web/static/tests/views/control_panel_tests.js"></script> <script type="text/javascript" src="/web/static/tests/views/form_tests.js"></script> <script type="text/javascript" src="/web/static/tests/views/graph_tests.js"></script> <script type="text/javascript" src="/web/static/tests/views/list_tests.js"></script> @@ -543,7 +555,7 @@ <script type="text/javascript" src="/web/static/tests/views/abstract_view_banner_tests.js"></script> <script type="text/javascript" src="/web/static/tests/views/kanban_model_tests.js"></script> <script type="text/javascript" src="/web/static/tests/views/view_dialogs_tests.js"></script> - <script type="text/javascript" src="/web/static/tests/views/search_filters_menu_tests.js"></script> + <script type="text/javascript" src="/web/static/tests/views/search_filter_menu_tests.js"></script> <script type="text/javascript" src="/web/static/tests/views/search_groupby_menu_tests.js"></script> <script type="text/javascript" src="/web/static/tests/widgets/dropdown_menu_tests.js"></script> <script type="text/javascript" src="/web/static/tests/views/search_view_tests.js"></script> @@ -561,7 +573,6 @@ <script type="text/javascript" src="/web/static/tests/core/dom_tests.js"></script> <script type="text/javascript" src="/web/static/tests/chrome/action_manager_tests.js"></script> - <script type="text/javascript" src="/web/static/tests/chrome/search_tests.js"></script> <script type="text/javascript" src="/web/static/tests/chrome/pager_tests.js"></script> <script type="text/javascript" src="/web/static/tests/chrome/keyboard_navigation_mixin_tests.js"></script> <script type="text/javascript" src="/web/static/tests/chrome/user_menu_tests.js"></script> @@ -614,10 +625,10 @@ <script type="text/javascript" src="/base/static/tests/base_settings_mobile_tests.js"></script> <script type="text/javascript" src="/web/static/tests/chrome/action_manager_mobile_tests.js"></script> - <script type="text/javascript" src="/web/static/tests/chrome/search_view_mobile_tests.js"></script> <script type="text/javascript" src="/web/static/tests/views/calendar_mobile_tests.js"></script> <script type="text/javascript" src="/web/static/tests/views/kanban_mobile_tests.js"></script> <script type="text/javascript" src="/web/static/tests/views/form_mobile_tests.js"></script> + <script type="text/javascript" src="/web/static/tests/views/mobile_control_panel_tests.js"></script> <script type="text/javascript" src="/web/static/tests/fields/basic_fields_mobile_tests.js"></script> <script type="text/javascript" src="/web/static/tests/fields/relational_fields_mobile_tests.js"></script> </t> @@ -742,9 +753,6 @@ </t> <t t-set="head" t-value="head_web + (head or '')"/> <t t-set="body_classname" t-value="'o_web_client'"/> - <div class="o_main"> - <main class="o_main_content"/> - </div> </t> </template> diff --git a/addons/web_editor/static/tests/web_editor_tests.js b/addons/web_editor/static/tests/web_editor_tests.js index 12a5937988f..1995add777d 100644 --- a/addons/web_editor/static/tests/web_editor_tests.js +++ b/addons/web_editor/static/tests/web_editor_tests.js @@ -256,7 +256,7 @@ QUnit.test('html_frame does not crash when saving in edit mode (editor not loade }); testUtils.form.clickEdit(form); - testUtils.fields.editInput(form.$('input'), 'trululu'); + testUtils.fields.editInput(form.$('input.o_input.o_field_char'), 'trululu'); testUtils.form.clickSave(form); // crash without editor fully loaded assert.verifySteps(['read']); @@ -308,7 +308,7 @@ QUnit.test('html_frame saving in edit mode (editor and content fully loaded)', f }); testUtils.form.clickEdit(form); - testUtils.fields.editInput(form.$('input'), 'trululu'); + testUtils.fields.editInput(form.$('input.o_input.o_field_char'), 'trululu'); testUtils.form.clickSave(form); loadDeferred.resolve(); // simulate late loading of html frame |
