diff options
Diffstat (limited to 'addons/web/static/src/js/views')
53 files changed, 4476 insertions, 3810 deletions
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; }); |
