summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/src/js')
-rw-r--r--addons/web/static/src/js/chrome/abstract_action.js144
-rw-r--r--addons/web/static/src/js/chrome/abstract_web_client.js17
-rw-r--r--addons/web/static/src/js/chrome/action_manager.js169
-rw-r--r--addons/web/static/src/js/chrome/action_manager_act_window.js484
-rw-r--r--addons/web/static/src/js/chrome/action_mixin.js182
-rw-r--r--addons/web/static/src/js/chrome/control_panel.js286
-rw-r--r--addons/web/static/src/js/core/registry.js51
-rw-r--r--addons/web/static/src/js/core/service_mixins.js27
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js15
-rw-r--r--addons/web/static/src/js/report/client_action.js12
-rw-r--r--addons/web/static/src/js/services/core.js2
-rw-r--r--addons/web/static/src/js/services/data_manager.js23
-rw-r--r--addons/web/static/src/js/tools/debug_manager.js2
-rw-r--r--addons/web/static/src/js/views/abstract_controller.js205
-rw-r--r--addons/web/static/src/js/views/abstract_model.js37
-rw-r--r--addons/web/static/src/js/views/abstract_renderer.js12
-rw-r--r--addons/web/static/src/js/views/abstract_view.js336
-rw-r--r--addons/web/static/src/js/views/basic/basic_controller.js29
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js6
-rw-r--r--addons/web/static/src/js/views/basic/basic_renderer.js29
-rw-r--r--addons/web/static/src/js/views/basic/basic_view.js19
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_controller.js6
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_view.js2
-rw-r--r--addons/web/static/src/js/views/control_panel/control_panel_controller.js237
-rw-r--r--addons/web/static/src/js/views/control_panel/control_panel_model.js1093
-rw-r--r--addons/web/static/src/js/views/control_panel/control_panel_renderer.js387
-rw-r--r--addons/web/static/src/js/views/control_panel/control_panel_view.js238
-rw-r--r--addons/web/static/src/js/views/control_panel/mobile_control_panel_controller.js34
-rw-r--r--addons/web/static/src/js/views/control_panel/mobile_control_panel_renderer.js (renamed from addons/web/static/src/js/views/search/search_view_mobile.js)59
-rw-r--r--addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js142
-rw-r--r--addons/web/static/src/js/views/control_panel/search/control_panel_view_parameters.js (renamed from addons/web/static/src/js/views/search/time_range_menu_options.js)35
-rw-r--r--addons/web/static/src/js/views/control_panel/search/favorite_menu.js103
-rw-r--r--addons/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js8
-rw-r--r--addons/web/static/src/js/views/control_panel/search/filter_menu.js219
-rw-r--r--addons/web/static/src/js/views/control_panel/search/groupby_menu.js214
-rw-r--r--addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js160
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_bar.js225
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js485
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js37
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_facet.js129
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_filters.js (renamed from addons/web/static/src/js/views/search/search_filters.js)52
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_filters_registry.js30
-rw-r--r--addons/web/static/src/js/views/control_panel/search/time_range_menu.js106
-rw-r--r--addons/web/static/src/js/views/form/form_controller.js11
-rw-r--r--addons/web/static/src/js/views/form/form_renderer.js7
-rw-r--r--addons/web/static/src/js/views/form/form_view.js30
-rw-r--r--addons/web/static/src/js/views/graph/graph_controller.js47
-rw-r--r--addons/web/static/src/js/views/graph/graph_renderer.js2
-rw-r--r--addons/web/static/src/js/views/graph/graph_view.js3
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_controller.js4
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_view.js7
-rw-r--r--addons/web/static/src/js/views/kanban/quick_create_form_view.js19
-rw-r--r--addons/web/static/src/js/views/list/list_controller.js8
-rw-r--r--addons/web/static/src/js/views/list/list_renderer.js3
-rw-r--r--addons/web/static/src/js/views/list/list_view.js10
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_controller.js19
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_view.js8
-rw-r--r--addons/web/static/src/js/views/search/favorites_menu.js335
-rw-r--r--addons/web/static/src/js/views/search/filters_menu.js253
-rw-r--r--addons/web/static/src/js/views/search/groupby_menu.js232
-rw-r--r--addons/web/static/src/js/views/search/groupby_menu_interface_mixin.js117
-rw-r--r--addons/web/static/src/js/views/search/search_inputs.js840
-rw-r--r--addons/web/static/src/js/views/search/search_view.js1244
-rw-r--r--addons/web/static/src/js/views/search/time_range_menu.js198
-rw-r--r--addons/web/static/src/js/views/view_dialogs.js169
-rw-r--r--addons/web/static/src/js/views/view_utils.js46
-rw-r--r--addons/web/static/src/js/widgets/dropdown_menu.js192
-rw-r--r--addons/web/static/src/js/widgets/old/dropdown_menu.js236
-rw-r--r--addons/web/static/src/js/widgets/pie_chart.js8
69 files changed, 5343 insertions, 4793 deletions
diff --git a/addons/web/static/src/js/chrome/abstract_action.js b/addons/web/static/src/js/chrome/abstract_action.js
index c4755338bf0..226802ba76b 100644
--- a/addons/web/static/src/js/chrome/abstract_action.js
+++ b/addons/web/static/src/js/chrome/abstract_action.js
@@ -2,82 +2,126 @@ odoo.define('web.AbstractAction', function (require) {
"use strict";
/**
- * We define here the generic notion of Action (from the point of view of the
- * web client). In short, an action is a widget which controls the main part
- * of the screen (everything below the control panel).
- *
- * More precisely, the action manager is the component that coordinates a stack
- * of actions. Whenever the user navigates in the interface, switches views,
- * open different menus, the action manager creates/updates/destroys special
- * widgets which are actions. These actions need to answer to a standardised
- * API, which is the reason for this AbstractAction class.
- *
- * In practice, most actions are view controllers (coming from a
- * ir.action.act_window). However, some actions are 'client actions'. They
- * also need to be an AbstractAction for a better cooperation with the action
- * manager.
+ * We define here the AbstractAction widget, which implements the ActionMixin.
+ * All client actions must extend this widget.
*
* @module web.AbstractAction
*/
+var ActionMixin = require('web.ActionMixin');
+var ControlPanelView = require('web.ControlPanelView');
var Widget = require('web.Widget');
-var AbstractAction = Widget.extend({
+var AbstractAction = Widget.extend(ActionMixin, {
+ config: {
+ ControlPanelView: ControlPanelView,
+ },
/**
- * Called each time the action is attached into the DOM.
+ * If this flag is set to true, the client action will create a control
+ * panel whenever it is created.
+ *
+ * @type boolean
*/
- on_attach_callback: function () {},
+ hasControlPanel: false,
+
/**
- * Called each time the action is detached from the DOM.
+ * If true, this flag indicates that the client action should automatically
+ * fetch the <arch> of a search view (or control panel view). Note that
+ * to do that, it also needs a specific modelName.
+ *
+ * For example, the Discuss application adds the following line in its
+ * constructor:
+ *
+ * this.controlPanelParams.modelName = 'mail.message';
+ *
+ * @type boolean
*/
- on_detach_callback: function () {},
+ loadControlPanel: false,
+
/**
- * Called by the action manager when action is restored (typically, when the
- * user clicks on the action in the breadcrumb)
+ * A client action might want to use a search bar in its control panel, or
+ * it could choose not to use it.
+ *
+ * Note that it only makes sense if hasControlPanel is set to true.
*
- * @returns {Deferred|undefined}
+ * @type boolean
*/
- willRestore: function () {},
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
+ withSearchBar: false,
/**
- * In some situations, we need confirmation from the controller that the
- * current state can be destroyed without prejudice to the user. For
- * example, if the user has edited a form, maybe we should ask him if we
- * can discard all his changes when we switch to another action. In that
- * case, the action manager will call this method. If the returned
- * deferred is succesfully resolved, then we can destroy the current action,
- * otherwise, we need to stop.
+ * This parameter can be set to customize the available sub menus in the
+ * controlpanel (Filters/Group By/Favorites). This is basically a list of
+ * the sub menus that we want to use.
+ *
+ * Note that it only makes sense if hasControlPanel is set to true.
+ *
+ * For example, set ['filter', 'favorite'] to enable the Filters and
+ * Favorites menus.
*
- * @returns {Deferred} resolved if the action can be removed, rejected otherwise
+ * @type string[]
*/
- canBeRemoved: function () {
- return $.when();
- },
+ searchMenuTypes: [],
+
/**
- * This function is called when the current context (~state) of the action
- * should be known. For instance, if the action is a view controller,
- * this may be useful to reinstantiate the view in the same state.
+ * @override
+ *
+ * @param {Widget} parent
+ * @param {Object} action
+ * @param {Object} [options]
*/
- getContext: function () {
+ init: function (parent, action, options) {
+ this._super(parent);
+ this._title = action.display_name || action.name;
+ this.controlPanelParams = {
+ actionId: action.id,
+ context: action.context,
+ breadcrumbs: options && options.breadcrumbs || [],
+ title: this.getTitle(),
+ viewId: action.search_view_id && action.search_view_id[0],
+ withSearchBar: this.withSearchBar,
+ searchMenuTypes: this.searchMenuTypes,
+ };
},
/**
- * Gives the focus to the action
+ * The willStart method is actually quite complicated if the client action
+ * has a controlPanel, because it needs to prepare it.
+ *
+ * @override
*/
- giveFocus: function () {
+ willStart: function () {
+ var self = this;
+ if (this.hasControlPanel) {
+ var params = this.controlPanelParams;
+ var def;
+ if (this.loadControlPanel) {
+ def = this
+ .loadFieldView(params.modelName, params.context || {}, params.viewId, 'search')
+ .then(function (fieldsView) {
+ params.viewInfo = {
+ arch: fieldsView.arch,
+ fields: fieldsView.fields,
+ };
+ });
+ }
+ return $.when(def).then(function () {
+ var controlPanelView = new self.config.ControlPanelView(params);
+ return controlPanelView.getController(self).then(function (controlPanel) {
+ self._controlPanel = controlPanel;
+ return self._controlPanel.appendTo(document.createDocumentFragment());
+ });
+ });
+ }
+ return $.when();
},
/**
- * Renders the buttons to append, in most cases, to the control panel (in
- * the bottom left corner). When the action is rendered in a dialog, those
- * buttons might be moved to the dialog's footer.
- *
- * @param {jQuery Node} $node
+ * @override
*/
- renderButtons: function ($node) {
+ start: function () {
+ if (this._controlPanel) {
+ this._controlPanel.$el.prependTo(this.$el);
+ }
+ return this._super.apply(this, arguments);
},
});
diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js
index 80711fd24ed..ea8fdc24a18 100644
--- a/addons/web/static/src/js/chrome/abstract_web_client.js
+++ b/addons/web/static/src/js/chrome/abstract_web_client.js
@@ -60,10 +60,11 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi
},
load_filters: function (event) {
return data_manager
- .load_filters(event.data.dataset, event.data.action_id)
+ .load_filters(event.data)
.then(event.data.on_success);
},
create_filter: '_onCreateFilter',
+ delete_filter: '_onDeleteFilter',
push_state: '_onPushState',
show_effect: '_onShowEffect',
// session
@@ -218,7 +219,7 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi
this.action_manager = new ActionManager(this, session.user_context);
var fragment = document.createDocumentFragment();
return this.action_manager.appendTo(fragment).then(function () {
- dom.append(self.$('.o_main_content'), fragment, {
+ dom.append(self.$el, fragment, {
in_DOM: true,
callbacks: [{widget: self.action_manager}],
});
@@ -368,6 +369,18 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi
.then(e.data.on_success);
},
/**
+ * @private
+ * @param {OdooEvent} e
+ * @param {Object} e.data.filter the filter description
+ * @param {function} e.data.on_success called when the RPC succeeds with its
+ * returned value as argument
+ */
+ _onDeleteFilter: function (e) {
+ data_manager
+ .delete_filter(e.data.filterId)
+ .then(e.data.on_success);
+ },
+ /**
* Displays a warning in a dialog or with the notification service
*
* @private
diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js
index 18bbb6a3681..c798189c0a0 100644
--- a/addons/web/static/src/js/chrome/action_manager.js
+++ b/addons/web/static/src/js/chrome/action_manager.js
@@ -10,10 +10,8 @@ odoo.define('web.ActionManager', function (require) {
*/
var AbstractAction = require('web.AbstractAction');
-var Bus = require('web.Bus');
var concurrency = require('web.concurrency');
var Context = require('web.Context');
-var ControlPanel = require('web.ControlPanel');
var config = require('web.config');
var core = require('web.core');
var Dialog = require('web.Dialog');
@@ -24,7 +22,7 @@ var Widget = require('web.Widget');
var _t = core._t;
var ActionManager = Widget.extend({
- className: 'o_content',
+ className: 'o_action_manager',
custom_events: {
breadcrumb_clicked: '_onBreadcrumbClicked',
history_back: '_onHistoryBack',
@@ -64,17 +62,6 @@ var ActionManager = Widget.extend({
this.currentDialogController = null;
},
/**
- * @override
- */
- start: function () {
- // AAB: temporarily instantiate a unique main ControlPanel used by
- // controllers in the controllerStack
- this.controlPanel = new ControlPanel(this);
- var def = this.controlPanel.insertBefore(this.$el);
-
- return $.when(def, this._super.apply(this, arguments));
- },
- /**
* Called each time the action manager is attached into the DOM.
*/
on_attach_callback: function () {
@@ -266,14 +253,6 @@ var ActionManager = Widget.extend({
if (controller.scrollPosition) {
this.trigger_up('scrollTo', controller.scrollPosition);
}
-
- if (!controller.widget.need_control_panel) {
- this.controlPanel.do_hide();
- } else {
- this.controlPanel.update({
- breadcrumbs: this._getBreadcrumbs(),
- }, {clear: false});
- }
},
/**
* Closes the current dialog, if any. Because we listen to the 'closed'
@@ -330,19 +309,12 @@ var ActionManager = Widget.extend({
return this._executeActionInDialog(action, options);
}
+ var controller = self.controllers[action.controllerID];
return this.clearUncommittedChanges()
.then(function () {
- var controller = self.controllers[action.controllerID];
- var widget = controller.widget;
- // AAB: this will be moved to the Controller
- if (widget.need_control_panel) {
- // set the ControlPanel bus on the controller to allow it to
- // communicate its status
- widget.set_cp_bus(self.controlPanel.get_bus());
- }
return self.dp.add(self._startController(controller));
})
- .then(function (controller) {
+ .then(function () {
if (self.currentDialogController) {
self._closeDialog({ silent: true });
}
@@ -358,7 +330,7 @@ var ActionManager = Widget.extend({
}
// update the internal state and the DOM
- self._pushController(controller, options);
+ self._pushController(controller);
// toggle the fullscreen mode for actions in target='fullscreen'
self._toggleFullscreen();
@@ -387,12 +359,6 @@ var ActionManager = Widget.extend({
var self = this;
var controller = this.controllers[action.controllerID];
var widget = controller.widget;
- // AAB: this will be moved to the Controller
- if (widget.need_control_panel) {
- // set the ControlPanel bus on the controller to allow it to
- // communicate its status
- widget.set_cp_bus(new Bus());
- }
return this._startController(controller).then(function (controller) {
var prevDialogOnClose;
@@ -472,20 +438,18 @@ var ActionManager = Widget.extend({
}
var controllerID = _.uniqueId('controller_');
+
+ var index = this._getControllerStackIndex(options);
+ options.breadcrumbs = this._getBreadcrumbs(this.controllerStack.slice(0, index));
options.controllerID = controllerID;
+ var widget = new ClientAction(this, action, options);
var controller = {
actionID: action.jsID,
+ index: index,
jsID: controllerID,
- widget: new ClientAction(this, action, options),
+ title: widget.getTitle(),
+ widget: widget,
};
- // AAB: TODO: simplify this with AbstractAction (implement a getTitle
- // function that returns action.name by default, and that can be
- // overriden in client actions and view controllers)
- Object.defineProperty(controller, 'title', {
- get: function () {
- return controller.widget.get('title') || action.display_name || action.name;
- },
- });
this.controllers[controllerID] = controller;
action.controllerID = controllerID;
return this._executeAction(action, options).done(function () {
@@ -586,34 +550,52 @@ var ActionManager = Widget.extend({
return $.when();
},
/**
- * Returns a description of the current stack of controllers, used to render
- * the breadcrumbs. It is an array of Objects with keys 'title' (what to
- * display in the breadcrumbs) and 'controllerID' (the ID of the
- * corresponding controller, used to restore it when this part of the
+ * Returns a description of the controllers in the given controller stack.
+ * It is used to render the breadcrumbs. It is an array of Objects with keys
+ * 'title' (what to display in the breadcrumbs) and 'controllerID' (the ID
+ * of the corresponding controller, used to restore it when this part of the
* breadcrumbs is clicked).
- * Ignores the content of the stack of controllers if the action of the
- * last controller of the stack is flagged with 'no_breadcrumbs', indicating
- * that the breadcrumbs should not be displayed for that action.
*
* @private
+ * @param {string[]} controllerStack
* @returns {Object[]}
*/
- _getBreadcrumbs: function () {
+ _getBreadcrumbs: function (controllerStack) {
var self = this;
- var currentController = this.getCurrentController();
- var noBreadcrumbs = !currentController ||
- this.actions[currentController.actionID].context.no_breadcrumbs;
- if (noBreadcrumbs) {
- return [];
- }
- return _.map(this.controllerStack, function (controllerID) {
+ return _.map(controllerStack, function (controllerID) {
return {
- title: self.controllers[controllerID].title,
controllerID: controllerID,
+ title: self.controllers[controllerID].title,
};
});
},
/**
+ * Returns the index where a controller should be inserted in the controller
+ * stack according to the given options. By default, a controller is pushed
+ * on the top of the stack.
+ *
+ * @private
+ * @param {options} [options.clear_breadcrumbs=false] if true, insert at
+ * index 0 and remove all other controllers
+ * @param {options} [options.index=null] if given, that index is returned
+ * @param {options} [options.replace_last_action=false] if true, replace the
+ * last controller of the stack
+ * @returns {integer} index
+ */
+ _getControllerStackIndex: function (options) {
+ var index;
+ if ('index' in options) {
+ index = options.index;
+ } else if (options.clear_breadcrumbs) {
+ index = 0;
+ } else if (options.replace_last_action) {
+ index = this.controllerStack.length - 1;
+ } else {
+ index = this.controllerStack.length;
+ }
+ return index;
+ },
+ /**
* Returns an object containing information about the given controller, like
* its title, its action's id, the active_id and active_ids of the action...
*
@@ -625,7 +607,7 @@ var ActionManager = Widget.extend({
var controller = this.controllers[controllerID];
var action = this.actions[controller.actionID];
var state = {
- title: controller.title,
+ title: controller.widget.getTitle(),
};
if (action.id) {
state.action = action.id;
@@ -706,41 +688,28 @@ var ActionManager = Widget.extend({
* @param {Object} controller
* @param {string} controller.jsID
* @param {Widget} controller.widget
- * @param {Object} [options]
- * @param {Object} [options.clear_breadcrumbs=false] if true, destroys all
- * controllers from the controller stack before adding the given one
- * @param {Object} [options.replace_last_action=false] if true, replaces the
- * last controller of the controller stack by the given one
- * @param {integer} [options.index] if given, pushes the controller at that
- * position in the controller stack, and destroys the controllers with an
- * higher index
- */
- _pushController: function (controller, options) {
- options = options || {};
+ * @param {integer} controller.index the controller is pushed at that
+ * position in the controller stack and controllers with an higher index
+ * are destroyed
+ */
+ _pushController: function (controller) {
var self = this;
// detach the current controller
this._detachCurrentController();
- // empty the controller stack or replace the last controller as requested,
- // destroy the removed controllers and push the new controller to the stack
- var toDestroy;
- if (options.clear_breadcrumbs) {
- toDestroy = this.controllerStack;
- this.controllerStack = [];
- } else if (options.replace_last_action && this.controllerStack.length > 0) {
- toDestroy = [this.controllerStack.pop()];
- } else if (options.index !== undefined) {
- toDestroy = this.controllerStack.splice(options.index);
- // reject from the list of controllers to destroy the one that we are
- // currently pushing, or those linked to the same action as the one
- // linked to the controller that we are pushing
- toDestroy = _.reject(toDestroy, function (controllerID) {
- return controllerID === controller.jsID ||
- self.controllers[controllerID].actionID === controller.actionID;
- });
- }
+ // push the new controller to the stack at the given position, and
+ // destroy controllers with an higher index
+ var toDestroy = this.controllerStack.slice(controller.index);
+ // reject from the list of controllers to destroy the one that we are
+ // currently pushing, or those linked to the same action as the one
+ // linked to the controller that we are pushing
+ toDestroy = _.reject(toDestroy, function (controllerID) {
+ return controllerID === controller.jsID ||
+ self.controllers[controllerID].actionID === controller.actionID;
+ });
this._removeControllers(toDestroy);
+ this.controllerStack = this.controllerStack.slice(0, controller.index);
this.controllerStack.push(controller.jsID);
// append the new controller to the DOM
@@ -861,7 +830,7 @@ var ActionManager = Widget.extend({
return $.when(def).then(function () {
return $.when(controller.widget.do_show()).then(function () {
var index = _.indexOf(self.controllerStack, controllerID);
- self._pushController(controller, {index: index});
+ self._pushController(controller, index);
});
});
},
@@ -878,19 +847,7 @@ var ActionManager = Widget.extend({
* @returns {Deferred<Object>} resolved with the controller when it is ready
*/
_startController: function (controller) {
- var self = this;
var fragment = document.createDocumentFragment();
- // AAB: change this logic to stop using the properties mixin
- controller.widget.on("change:title", this, function () {
- if (self.getCurrentController() !== controller) {
- return;
- }
- var action = self.actions[controller.actionID];
- if (!action.flags || !action.flags.headless) {
- var breadcrumbs = self._getBreadcrumbs();
- self.controlPanel.update({breadcrumbs: breadcrumbs}, {clear: false});
- }
- });
return controller.widget.appendTo(fragment).then(function () {
return controller;
});
diff --git a/addons/web/static/src/js/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js
index e66ae801bc1..1a688ffa6b2 100644
--- a/addons/web/static/src/js/chrome/action_manager_act_window.js
+++ b/addons/web/static/src/js/chrome/action_manager_act_window.js
@@ -10,21 +10,12 @@ var ActionManager = require('web.ActionManager');
var config = require('web.config');
var Context = require('web.Context');
var core = require('web.core');
-var data = require('web.data'); // this will be removed at some point
var pyUtils = require('web.py_utils');
-var SearchView = require('web.SearchView');
var view_registry = require('web.view_registry');
-var _t = core._t;
-
ActionManager.include({
custom_events: _.extend({}, ActionManager.prototype.custom_events, {
- env_updated: '_onEnvUpdated',
execute_action: '_onExecuteAction',
- get_controller_context: '_onGetControllerContext',
- navigation_move: '_onNavigationMove',
- update_filters: '_onUpdateFilters',
- search: '_onSearch',
switch_view: '_onSwitchView',
}),
@@ -33,6 +24,26 @@ ActionManager.include({
//--------------------------------------------------------------------------
/**
+ * Override to handle the case of lazy-loaded controllers, which may be the
+ * last controller in the stack, but which should not be considered as
+ * current controller as they don't have an alive widget.
+ *
+ * Note: this function assumes that there can be at most one lazy loaded
+ * controller in the stack
+ *
+ * @override
+ */
+ getCurrentController: function () {
+ var currentController = this._super.apply(this, arguments);
+ var action = currentController && this.actions[currentController.actionID];
+ if (action && action.type === 'ir.actions.act_window' && !currentController.widget) {
+ var lastControllerID = this.controllerStack.pop();
+ currentController = this._super.apply(this, arguments);
+ this.controllerStack.push(lastControllerID);
+ }
+ return currentController;
+ },
+ /**
* Overrides to handle the case where an 'ir.actions.act_window' has to be
* loaded.
*
@@ -59,9 +70,9 @@ ActionManager.include({
currentAction.type === 'ir.actions.act_window') {
// the action to load is already the current one, so update it
this._closeDialog(true); // there may be a currently opened dialog, close it
- currentAction.env.currentId = state.id;
+ var viewOptions = {currentId: state.id};
var viewType = state.view_type || currentController.viewType;
- return this._switchController(currentAction, viewType);
+ return this._switchController(currentAction, viewType, viewOptions);
} else if (!core.action_registry.contains(state.action)) {
// the action to load isn't the current one, so execute it
var context = {};
@@ -114,59 +125,6 @@ ActionManager.include({
//--------------------------------------------------------------------------
/**
- * Instantiates a search view for a given action, and starts it so that it
- * is ready to be appended to the DOM.
- *
- * @private
- * @param {Object} action
- * @returns {Deferred} resolved with the search view when it is ready
- */
- _createSearchView: function (action) {
- // if requested, keep the searchview of the current action instead of
- // creating a new one
- if (action._keepSearchView) {
- var currentAction = this.getCurrentAction();
- if (currentAction) {
- action.searchView = currentAction.searchView;
- action.env = currentAction.env; // make those actions share the same env
- return $.when(currentAction.searchView);
- } else {
- // there is not searchview to keep, so reset the flag to false
- // to ensure that the one that will be created will be correctly
- // destroyed
- action._keepSearchView = false;
- }
- }
-
- // AAB: temporarily create a dataset, until the SearchView is refactored
- // and stops using it
- var dataset = new data.DataSetSearch(this, action.res_model, action.context, action.domain);
- if (action.res_id) {
- dataset.ids.push(action.res_id);
- dataset.index = 0;
- }
-
- // find 'search_default_*' keys in actions's context
- var searchDefaults = {};
- _.each(action.context, function (value, key) {
- var match = /^search_default_(.*)$/.exec(key);
- if (match) {
- searchDefaults[match[1]] = value;
- }
- });
- var searchView = new SearchView(this, dataset, action.searchFieldsView, {
- $buttons: $('<div>'),
- action: action,
- disable_custom_filters: action.flags.disableCustomFilters,
- search_defaults: searchDefaults,
- });
-
- return searchView.appendTo(document.createDocumentFragment()).then(function () {
- action.searchView = searchView;
- return searchView;
- });
- },
- /**
* Instantiates the controller for a given action and view type, and adds it
* to the list of controllers in the action.
*
@@ -181,6 +139,9 @@ ActionManager.include({
* @param {Object} [viewOptions] dict of options passed to the initialization
* of the controller's widget
* @param {Object} [options]
+ * @param {string} [options.controllerID=false] when the controller has
+ * previously been lazy-loaded, we want to keep its jsID when loading it
+ * @param {integer} [options.index=0] the controller's index in the stack
* @param {boolean} [options.lazy=false] set to true to differ the
* initialization of the controller's widget
* @returns {Deferred<Object>} resolved with the created controller
@@ -195,31 +156,36 @@ ActionManager.include({
return $.Deferred().reject();
}
- var controllerID = _.uniqueId('controller_');
+ options = options || {};
+ var index = options.index || 0;
+ var controllerID = options.controllerID || _.uniqueId('controller_');
var controller = {
actionID: action.jsID,
className: 'o_act_window', // used to remove the padding in dialogs
+ index: index,
jsID: controllerID,
viewType: viewType,
};
Object.defineProperty(controller, 'title', {
get: function () {
// handle the case where the widget is lazy loaded
- return controller.widget ? controller.widget.getTitle() : (action.display_name || action.name);
+ return controller.widget ?
+ controller.widget.getTitle() :
+ (action.display_name || action.name);
},
});
this.controllers[controllerID] = controller;
- if (!options || !options.lazy) {
+ if (!options.lazy) {
// build the view options from different sources
- viewOptions = _.extend({
+ var flags = action.flags || {};
+ viewOptions = _.extend({}, flags, flags[viewType], viewOptions, {
action: action,
- limit: action.limit,
- }, action.flags, action.flags[viewType], viewOptions, action.env);
- // pass the controllerID to the views as an hook for further
- // communication with trigger_up (e.g. for 'env_updated' event)
- viewOptions = _.extend(viewOptions, { controllerID: controllerID });
-
+ breadcrumbs: this._getBreadcrumbs(this.controllerStack.slice(0, index)),
+ // pass the controllerID to the views as an hook for further
+ // communication with trigger_up
+ controllerID: controllerID,
+ });
var view = new viewDescr.Widget(viewDescr.fieldsView, viewOptions);
var def = $.Deferred();
action.controllers[viewType] = def;
@@ -263,9 +229,6 @@ ActionManager.include({
// its reference is removed
controllerDef.reject();
});
- if (action.searchView && !action._keepSearchView) {
- action.searchView.destroy();
- }
},
/**
* Executes actions of type 'ir.actions.act_window'.
@@ -280,75 +243,64 @@ ActionManager.include({
*/
_executeWindowAction: function (action, options) {
var self = this;
-
- if (action.context.active_id || action.context.active_ids) {
- // we assume that when 'active_id' or 'active_ids' is used in the
- // context, we are in a 'related' action, so we disable the
- // searchview's default custom filters
- action.context.search_disable_custom_filters = true;
- }
- action.flags = this._generateActionFlags(action);
-
return this.dp.add(this._loadViews(action)).then(function (fieldsViews) {
var views = self._generateActionViews(action, fieldsViews);
action._views = action.views; // save the initial attribute
action.views = views;
- if (fieldsViews.search) {
- action.searchFieldsView = fieldsViews.search;
- }
- action.env = self._generateActionEnv(action, options);
+ action.controlPanelFieldsView = fieldsViews.search;
action.controllers = {};
- // select the first view to display, and optionally the main view
- // which will be lazyloaded
- var firstView = options.viewType && _.findWhere(views, {type: options.viewType});
- var mainView;
- if (firstView) {
- if (!firstView.multiRecord && views[0].multiRecord) {
- mainView = views[0];
+ // select the current view to display, and optionally the main view
+ // of the action which will be lazyloaded
+ var curView = options.viewType && _.findWhere(views, {type: options.viewType});
+ var lazyView;
+ if (curView) {
+ if (!curView.multiRecord && views[0].multiRecord) {
+ lazyView = views[0];
}
} else {
- firstView = views[0];
+ curView = views[0];
}
// use mobile-friendly view by default in mobile, if possible
if (config.device.isMobile) {
- if (!firstView.isMobileFriendly) {
- firstView = self._findMobileView(views, firstView.multiRecord) || firstView;
+ if (!curView.isMobileFriendly) {
+ curView = self._findMobileView(views, curView.multiRecord) || curView;
}
- if (mainView && !mainView.isMobileFriendly) {
- mainView = self._findMobileView(views, mainView.multiRecord) || mainView;
+ if (lazyView && !lazyView.isMobileFriendly) {
+ lazyView = self._findMobileView(views, lazyView.multiRecord) || lazyView;
}
}
- var def;
- if (action.flags.hasSearchView) {
- def = self._createSearchView(action).then(function (searchView) {
- // udpate domain, context and groupby in the env
- var searchData = searchView.build_search_data();
- _.extend(action.env, self._processSearchData(action, searchData));
- });
+ var lazyViewDef;
+ if (lazyView) {
+ // if the main view is lazy-loaded, its (lazy-loaded) controller is inserted
+ // into the controller stack (so that breadcrumbs can be correctly computed),
+ // so we force clear_breadcrumbs to false so that it won't be removed when the
+ // current controller will be inserted afterwards
+ options.clear_breadcrumbs = false;
+ // this controller being lazy-loaded, this call is actually sync
+ lazyViewDef = self._createViewController(action, lazyView.type, {}, {lazy: true})
+ .then(function (lazyLoadedController) {
+ self.controllerStack.push(lazyLoadedController.jsID);
+ });
}
- return $.when(def).then(function () {
- var defs = [];
- defs.push(self._createViewController(action, firstView.type));
- if (mainView) {
- defs.push(self._createViewController(action, mainView.type, {}, {lazy: true}));
- }
- return self.dp.add($.when.apply($, defs));
- }).then(function (controller, lazyLoadedController) {
- action.controllerID = controller.jsID;
- return self._executeAction(action, options).done(function () {
- if (lazyLoadedController) {
- // controller should be placed just before the current one
- var index = self.controllerStack.length - 1;
- self.controllerStack.splice(index, 0, lazyLoadedController.jsID);
- self.controlPanel.update({
- breadcrumbs: self._getBreadcrumbs(),
- }, {clear: false});
- }
- });
- }).fail(self._destroyWindowAction.bind(self, action));
+ return self.dp.add($.when(lazyViewDef))
+ .then(function () {
+ var viewOptions = {
+ controllerState: options.controllerState,
+ currentId: options.resID,
+ };
+ var curViewDef = self._createViewController(action, curView.type, viewOptions, {
+ index: self._getControllerStackIndex(options),
+ });
+ return self.dp.add(curViewDef);
+ })
+ .then(function (controller) {
+ action.controllerID = controller.jsID;
+ return self._executeAction(action, options);
+ })
+ .fail(self._destroyWindowAction.bind(self, action));
});
},
/**
@@ -368,49 +320,6 @@ ActionManager.include({
});
},
/**
- * Generates the initial environment of a given action, which is a dict
- * containing information like the model name, the domain, the context...
- * That information is shared between the controllers of the corresponding
- * action.
- *
- * @private
- * @param {Object} action
- * @param {Object} options
- * @param {integer} [options.resID]
- * @returns {Object}
- */
- _generateActionEnv: function (action, options) {
- var resID = options.resID || action.res_id;
- return {
- modelName: action.res_model,
- ids: resID ? [resID] : undefined,
- currentId: resID || undefined,
- domain: [],
- context: action.context || {},
- groupBy: [],
- };
- },
- /**
- * Generates the flags of a given action.
- *
- * @private
- * @param {Object} action
- * @returns {Object}
- */
- _generateActionFlags: function (action) {
- var popup = action.target === 'new';
- var inline = action.target === 'inline';
- var form = action.views[0][1] === 'form';
- return _.defaults({}, action.flags, {
- disableCustomFilters: action.context && action.context.search_disable_custom_filters,
- footerToButtons: popup,
- hasSearchView: !(popup && form) && !inline,
- hasSidebar: !popup && !inline,
- headless: (popup || inline) && form,
- mode: (popup || inline || action.target === 'fullscreen') && 'edit',
- });
- },
- /**
* Generate the description of the views of a given action. For each view,
* it generates a dict with information like the fieldsView, the view type,
* the Widget to use...
@@ -483,12 +392,14 @@ ActionManager.include({
* @returns {Deferred}
*/
_loadViews: function (action) {
+ var inDialog = action.target === 'new';
+ var inline = action.target === 'inline';
var options = {
action_id: action.id,
- toolbar: action.flags.hasSidebar,
+ toolbar: inDialog && inline,
};
var views = action.views.slice();
- if (action.flags.hasSearchView) {
+ if (!inline && !(inDialog && action.views[0][1] === 'form')) {
options.load_filters = true;
var searchviewID = action.search_view_id && action.search_view_id[0];
views.push([searchviewID || false, 'search']);
@@ -496,60 +407,6 @@ ActionManager.include({
return this.loadViews(action.res_model, action.context, views, options);
},
/**
- * Overrides to handle the 'keepSearchView' option. If set to true, the
- * search view of the current action will be re-used in the new action, i.e.
- * the environment (domain, context, groupby) will be shared between both
- * actions.
- *
- * @override
- */
- _preprocessAction: function (action, options) {
- this._super.apply(this, arguments);
- if (action.type === 'ir.actions.act_window' && options.keepSearchView) {
- action._keepSearchView = true;
- }
- },
- /**
- * Processes the search data sent by the search view.
- *
- * @private
- * @param {Object} action
- * @param {Object} searchData
- * @param {Object} [searchData.contexts=[]]
- * @param {Object} [searchData.domains=[]]
- * @param {Object} [searchData.groupbys=[]]
- * @returns {Object} an object with keys 'context', 'domain', 'groupBy'
- */
- _processSearchData: function (action, searchData) {
- var contexts = searchData.contexts;
- var domains = searchData.domains;
- var groupbys = searchData.groupbys;
- var action_context = action.context || {};
- var results = pyUtils.eval_domains_and_contexts({
- domains: [action.domain || []].concat(domains || []),
- contexts: [action_context].concat(contexts || []),
- group_by_seq: groupbys || [],
- eval_context: this.userContext,
- });
- var groupBy = results.group_by.length ?
- results.group_by :
- (action.context.group_by || []);
- groupBy = (typeof groupBy === 'string') ? [groupBy] : groupBy;
-
- if (results.error) {
- throw new Error(_.str.sprintf(_t("Failed to evaluate search criterions")+": \n%s",
- JSON.stringify(results.error)));
- }
-
- var context = _.omit(results.context, 'time_ranges');
-
- return {
- context: context,
- domain: results.domain,
- groupBy: groupBy,
- };
- },
- /**
* Overrides to handle the case of 'ir.actions.act_window' actions, i.e.
* destroys all controllers associated to the given action, and its search
* view.
@@ -598,8 +455,8 @@ ActionManager.include({
return this._super.apply(this, arguments);
},
/**
- * Handles the switch from a controller to another inside the same window
- * action.
+ * Handles the switch from a controller to another (either inside the same
+ * window action, or from a window action to another using the breadcrumbs).
*
* @private
* @param {Object} controller the controller to switch to
@@ -608,18 +465,55 @@ ActionManager.include({
*/
_switchController: function (action, viewType, viewOptions) {
var self = this;
+ var view = _.findWhere(action.views, {type: viewType});
+ if (!view) {
+ // can't switch to an unknown view
+ return $.Deferred().reject();
+ }
+ var currentController = this.getCurrentController();
+ var index;
+ if (currentController.actionID !== action.jsID) {
+ // the requested controller is from another action, so we went back
+ // to a previous action using the breadcrumbs
+ var controller = _.findWhere(this.controllers, {
+ actionID: action.jsID,
+ viewType: viewType,
+ });
+ index = _.indexOf(this.controllerStack, controller.jsID);
+ } else {
+ // the requested controller is from the same action as the current
+ // one, so we either
+ // 1) go one step back from a mono record view to a multi record
+ // one using the breadcrumbs
+ // 2) or we switched from a view to another using the view
+ // switcher
+ // 3) or we opened a record from a multi record view
+ if (view.multiRecord) {
+ // cases 1) and 2) (with multi record views): replace the first
+ // controller linked to the same action in the stack
+ index = _.findIndex(this.controllerStack, function (controllerID) {
+ return self.controllers[controllerID].actionID === action.jsID;
+ });
+ } else if (!_.findWhere(action.views, {type: currentController.viewType}).multiRecord) {
+ // case 2) (with mono record views): replace the last
+ // controller by the new one if they are from the same action
+ // and if they both are mono record
+ index = this.controllerStack.length - 1;
+ } else {
+ // case 3): insert the controller on the top of the controller
+ // stack
+ index = this.controllerStack.length;
+ }
+ }
- var newController = function () {
+ var newController = function (controllerID) {
+ var options = {
+ controllerID: controllerID,
+ index: index,
+ };
return self
- ._createViewController(action, viewType, viewOptions)
+ ._createViewController(action, viewType, viewOptions, options)
.then(function (controller) {
- // AAB: this will be moved to the Controller
- var widget = controller.widget;
- if (widget.need_control_panel) {
- // set the ControlPanel bus on the controller to allow it to
- // communicate its status
- widget.set_cp_bus(self.controlPanel.get_bus());
- }
return self._startController(controller);
});
};
@@ -637,15 +531,9 @@ ActionManager.include({
} else {
controllerDef = controllerDef.then(function (controller) {
if (!controller.widget) {
- // lazy loaded -> load it now
- return newController().done(function (newController) {
- // replace the old controller (without widget) by the new one
- var index = self.controllerStack.indexOf(controller.jsID);
- self.controllerStack[index] = newController.jsID;
- delete self.controllers[controller.jsID];
- });
+ // lazy loaded -> load it now (with same jsID)
+ return newController(controller.jsID);
} else {
- viewOptions = _.extend(viewOptions || {}, action.env);
return $.when(controller.widget.willRestore()).then(function () {
return controller.widget.reload(viewOptions).then(function () {
return controller;
@@ -656,22 +544,7 @@ ActionManager.include({
}
return this.dp.add(controllerDef).then(function (controller) {
- var view = _.findWhere(action.views, {type: viewType});
- var currentController = self.getCurrentController();
- var index;
- if (currentController.actionID !== action.jsID) {
- index = _.indexOf(self.controllerStack, controller.jsID);
- } else if (view.multiRecord) {
- // remove other controllers linked to the same action from the stack
- index = _.findIndex(self.controllerStack, function (controllerID) {
- return self.controllers[controllerID].actionID === action.jsID;
- });
- } else if (!_.findWhere(action.views, {type: currentController.viewType}).multiRecord) {
- // replace the last controller by the new one if they are from the
- // same action and if they both are mono record
- index = self.controllerStack.length - 1;
- }
- return self._pushController(controller, {index: index});
+ return self._pushController(controller);
});
},
@@ -680,18 +553,6 @@ ActionManager.include({
//--------------------------------------------------------------------------
/**
- * @private
- * @param {OdooEvent} ev
- * @param {string} ev.data.controllerID
- * @param {Object} ev.data.env
- */
- _onEnvUpdated: function (ev) {
- ev.stopPropagation();
- var controller = this.controllers[ev.data.controllerID];
- var action = this.actions[controller.actionID];
- _.extend(action.env, ev.data.env);
- },
- /**
* Handler for event 'execute_action', which is typically called when a
* button is clicked. The button may be of type 'object' (call a given
* method of a given model) or 'action' (execute a given action).
@@ -803,67 +664,6 @@ ActionManager.include({
});
},
/**
- * Handles a context request: provides to the caller the context of the
- * current controller.
- *
- * @private
- * @param {OdooEvent} ev
- * @param {function} ev.data.callback used to send the requested context
- */
- _onGetControllerContext: function (ev) {
- ev.stopPropagation();
- var currentController = this.getCurrentController();
- var context = currentController && currentController.widget.getContext();
- ev.data.callback(context || {});
- },
- /**
- * Called mainly from the control panel when the focus should be given to a
- * controller
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onNavigationMove : function (ev) {
- switch(ev.data.direction) {
- case 'down' :
- var currentController = this.getCurrentController().widget;
- currentController.giveFocus();
- ev.stopPropagation();
- break;
- }
- },
- /**
- * Handles a request to add/remove search view filters.
- *
- * @param {OdooEvent} ev
- * @param {string} ev.data.controllerID
- * @param {Array[Object]} [ev.data.newFilters]
- * @param {Array[Object]} [ev.data.filtersToRemove]
- * @param {function} ev.data.callback called with the added filters as arg
- */
- _onUpdateFilters: function (ev) {
- var controller = this.controllers[ev.data.controllerID];
- var action = this.actions[controller.actionID];
- var data = ev.data;
- var addedFilters = action.searchView.updateFilters(data.newFilters, data.filtersToRemove);
- data.callback(addedFilters);
- },
- /**
- * Called when there is a change in the search view, so the current action's
- * environment needs to be updated with the new domain, context and groupby.
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onSearch: function (ev) {
- ev.stopPropagation();
- // AAB: the id of the correct controller should be given in data
- var currentController = this.getCurrentController();
- var action = this.actions[currentController.actionID];
- _.extend(action.env, this._processSearchData(action, ev.data));
- currentController.widget.reload(_.extend({offset: 0}, action.env));
- },
- /**
* @private
* @param {OdooEvent} ev
* @param {string} ev.data.controllerID the id of the controller that
@@ -882,13 +682,11 @@ ActionManager.include({
// only switch to the requested view if the controller that
// triggered the request is the current controller
var action = this.actions[currentController.actionID];
- if ('res_id' in ev.data) {
- action.env.currentId = ev.data.res_id;
- }
- var options = {};
- if (viewType === 'form' && !action.env.currentId) {
- options.mode = 'edit';
- } else if (ev.data.mode) {
+ var options = {
+ controllerState: currentController.widget.exportState(),
+ currentId: ev.data.res_id,
+ };
+ if (ev.data.mode) {
options.mode = ev.data.mode;
}
this._switchController(action, viewType, options);
diff --git a/addons/web/static/src/js/chrome/action_mixin.js b/addons/web/static/src/js/chrome/action_mixin.js
new file mode 100644
index 00000000000..099244592ad
--- /dev/null
+++ b/addons/web/static/src/js/chrome/action_mixin.js
@@ -0,0 +1,182 @@
+odoo.define('web.ActionMixin', function (require) {
+"use strict";
+
+/**
+ * We define here the ActionMixin, the generic notion of action (from the point
+ * of view of the web client). In short, an action is a widget which controls
+ * the main part of the screen (everything below the navbar).
+ *
+ * More precisely, the action manager is the component that coordinates a stack
+ * of actions. Whenever the user navigates in the interface, switches views,
+ * open different menus, the action manager creates/updates/destroys special
+ * widgets which implements the ActionMixin. These actions need to answer to a
+ * standardised API, which is the reason for this mixin.
+ *
+ * In practice, most actions are view controllers (coming from an
+ * ir.action.act_window). However, some actions are 'client actions'. They
+ * also need to implement the ActionMixin for a better cooperation with the
+ * action manager.
+ *
+ * @module web.ActionMixin
+ */
+
+var core = require('web.core');
+
+var ActionMixin = {
+ template: 'Action',
+
+ /**
+ * The action mixin assumes that it is rendered with the 'Action' template.
+ * This template has a special zone ('.o_content') where the content should
+ * be added. Actions that want to automatically render a template there
+ * should define the contentTemplate key. In short, client actions should
+ * probably define a contentTemplate key, and not a template key.
+ */
+ contentTemplate: null,
+
+ /**
+ * If an action wants to use a control panel, it will be created and
+ * registered in this _controlPanel key (the widget). The way this control
+ * panel is created is up to the implementation (so, view controllers or
+ * client actions may have different needs).
+ *
+ * Note that most of the time, this key should be set by the framework, not
+ * by the code of the client action.
+ */
+ _controlPanel: null,
+
+ /**
+ * String containing the title of the client action (which may be needed to
+ * display in the breadcrumbs zone of the control panel).
+ *
+ * @see _setTitle
+ */
+ _title: '',
+
+ /**
+ * @override
+ */
+ renderElement: function () {
+ this._super.apply(this, arguments);
+ if (this.contentTemplate) {
+ var content = core.qweb.render(this.contentTemplate, {widget: this});
+ this.$('.o_content').append(content);
+ }
+ },
+ /**
+ * Called each time the action is attached into the DOM.
+ */
+ on_attach_callback: function () {},
+ /**
+ * Called each time the action is detached from the DOM.
+ */
+ on_detach_callback: function () {},
+ /**
+ * Called by the action manager when action is restored (typically, when the
+ * user clicks on the action in the breadcrumb)
+ *
+ * @returns {Deferred|undefined}
+ */
+ willRestore: function () {},
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * In some situations, we need confirmation from the controller that the
+ * current state can be destroyed without prejudice to the user. For
+ * example, if the user has edited a form, maybe we should ask him if we
+ * can discard all his changes when we switch to another action. In that
+ * case, the action manager will call this method. If the returned
+ * deferred is succesfully resolved, then we can destroy the current action,
+ * otherwise, we need to stop.
+ *
+ * @returns {Deferred} resolved if the action can be removed, rejected
+ * otherwise
+ */
+ canBeRemoved: function () {
+ return $.when();
+ },
+ /**
+ * This function is called when the current state of the action
+ * should be known. For instance, if the action is a view controller,
+ * this may be useful to reinstantiate a view in the same state.
+ * Typically the state can (and should) be encoded in a query object of
+ * the form
+ * {
+ * context: {...},
+ * groupBy: [...],
+ * domain = [...],
+ * orderedBy = [...],
+ * }
+ * where the context key can contain many information.
+ * This method is mainly called during the creation of a custom filter.
+ *
+ * @returns {Object}
+ */
+ getOwnedQueryParams: function () {
+ return {};
+ },
+ /**
+ * Returns a title that may be displayed in the breadcrumb area. For
+ * example, the name of the record (for a form view). This is actually
+ * important for the action manager: this is the way it is able to give
+ * the proper titles for other actions.
+ *
+ * @returns {string}
+ */
+ getTitle: function () {
+ return this._title;
+ },
+ /**
+ * Gives the focus to the action
+ */
+ giveFocus: function () {
+ },
+ /**
+ * Renders the buttons to append, in most cases, to the control panel (in
+ * the bottom left corner). When the action is rendered in a dialog, those
+ * buttons might be moved to the dialog's footer.
+ *
+ * @param {jQuery Node} $node
+ */
+ renderButtons: function ($node) {
+ },
+ /**
+ * This is the main method to customize the controlpanel content.
+ *
+ * @see updateContents method in ControlPanelRenderer for more info
+ *
+ * @param {Object} [status]
+ * @param {string} [status.title]
+ * @param {Object} [options]
+ * @param {boolean} [options.clear]
+ */
+ updateControlPanel: function (status, options) {
+ if (this._controlPanel) {
+ status = status || {};
+ status.title = status.title || this.getTitle();
+ this._controlPanel.updateContents(status, options || {});
+ }
+ },
+ // TODO: add hooks methods:
+ // - onRestoreHook (on_reverse_breadcrumbs)
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} title
+ */
+ _setTitle: function (title) {
+ this._title = title;
+ this.updateControlPanel({title: this._title}, {clear: false});
+ },
+};
+
+return ActionMixin;
+
+});
diff --git a/addons/web/static/src/js/chrome/control_panel.js b/addons/web/static/src/js/chrome/control_panel.js
deleted file mode 100644
index 5d0601820d0..00000000000
--- a/addons/web/static/src/js/chrome/control_panel.js
+++ /dev/null
@@ -1,286 +0,0 @@
-odoo.define('web.ControlPanelMixin', function (require) {
-"use strict";
-
-/**
- * Mixin allowing widgets to communicate with the ControlPanel. Widgets needing a
- * ControlPanel should use this mixin and call update_control_panel(cp_status) where
- * cp_status contains information for the ControlPanel to update itself.
- *
- * Note that the API is slightly awkward. Hopefully we will improve this when
- * we get the time to refactor the control panel.
- *
- * For example, here is what a typical client action would need to do to add
- * support for a control panel with some buttons::
- *
- * var ControlPanelMixin = require('web.ControlPanelMixin');
- *
- * var SomeClientAction = Widget.extend(ControlPanelMixin, {
- * ...
- * start: function () {
- * this._renderButtons();
- * this._updateControlPanel();
- * ...
- * },
- * do_show: function () {
- * ...
- * this._updateControlPanel();
- * },
- * _renderButtons: function () {
- * this.$buttons = $(QWeb.render('SomeTemplate.Buttons'));
- * this.$buttons.on('click', ...);
- * },
- * _updateControlPanel: function () {
- * this.update_control_panel({
- * cp_content: {
- * $buttons: this.$buttons,
- * },
- * });
- */
-var ControlPanelMixin = {
- need_control_panel: true,
- /**
- * @param {web.Bus} [cp_bus] Bus to communicate with the ControlPanel
- */
- set_cp_bus: function(cp_bus) {
- this.cp_bus = cp_bus;
- },
- /**
- * Triggers 'update' on the cp_bus to update the ControlPanel according to cp_status
- * @param {Object} [cp_status] see web.ControlPanel.update() for a description
- * @param {Object} [options] see web.ControlPanel.update() for a description
- */
- update_control_panel: function(cp_status, options) {
- if (this.cp_bus) {
- this.cp_bus.trigger("update", cp_status || {}, options || {});
- }
- },
-};
-
-return ControlPanelMixin;
-
-});
-
-odoo.define('web.ControlPanel', function (require) {
-"use strict";
-
-var Bus = require('web.Bus');
-var data = require('web.data');
-var Widget = require('web.Widget');
-
-var ControlPanel = Widget.extend({
- template: 'ControlPanel',
- /**
- * @param {String} [template] the QWeb template to render the ControlPanel.
- * By default, the template 'ControlPanel' will be used
- */
- init: function(parent, template) {
- this._super(parent);
- if (template) {
- this.template = template;
- }
-
- this.bus = new Bus(this);
- // the updateIndex is used to prevent concurrent updates of the control
- // panel depending on asynchronous code to be executed in the wrong order
- this.bus.updateIndex = 0;
- this.bus.on("update", this, this.update);
- },
- /**
- * Renders the control panel and creates a dictionnary of its exposed elements
- * @return {jQuery.Deferred}
- */
- start: function() {
- // Exposed jQuery nodesets
- this.nodes = {
- $buttons: this.$('.o_cp_buttons'),
- $pager: this.$('.o_cp_pager'),
- $searchview: this.$('.o_cp_searchview'),
- $searchview_buttons: this.$('.o_search_options'),
- $sidebar: this.$('.o_cp_sidebar'),
- $switch_buttons: this.$('.o_cp_switch_buttons'),
- };
-
- // Prevent the search dropdowns to close when clicking inside them
- this.$el.on('click.bs.dropdown', '.o_search_options .dropdown-menu', function (e) {
- e.stopPropagation();
- });
-
- // By default, hide the ControlPanel and remove its contents from the DOM
- this._toggle_visibility(false);
-
- return this._super();
- },
- /**
- * @return {Object} the Bus the ControlPanel is listening on
- */
- get_bus: function() {
- return this.bus;
- },
- /**
- * Updates the content and displays the ControlPanel
- * @param {Object} [status.active_view] the current active view
- * @param {Array} [status.breadcrumbs] the breadcrumbs to display (see _render_breadcrumbs() for
- * precise description)
- * @param {Object} [status.cp_content] dictionnary containing the new ControlPanel jQuery elements
- * @param {Boolean} [status.hidden] true if the ControlPanel should be hidden
- * @param {openerp.web.SearchView} [status.searchview] the searchview widget
- * @param {Boolean} [status.search_view_hidden] true if the searchview is hidden, false otherwise
- * @param {Boolean} [options.clear] set to true to clear from control panel
- * elements that are not in status.cp_content
- */
- update: function(status, options) {
- this.bus.updateIndex++;
-
- this._toggle_visibility(!status.hidden);
-
- // Don't update the ControlPanel in headless mode as the views have
- // inserted themselves the buttons where they want, so inserting them
- // again in the ControlPanel will remove them from where they should be
- if (!status.hidden) {
- options = _.defaults({}, options, {
- clear: true, // clear control panel by default
- });
- var new_cp_content = status.cp_content || {};
-
- // Render the breadcrumbs
- if (status.breadcrumbs) {
- this.$('.breadcrumb').html(this._render_breadcrumbs(status.breadcrumbs));
- }
-
- // Detach control_panel old content and attach new elements
- var toDetach = this.nodes;
- if (status.searchview && this.searchview === status.searchview) {
- // If the searchview is the same as before, don't detach it s.t.
- // we don't loose any floating content, nor the focus
- toDetach = _.omit(toDetach, '$searchview');
- new_cp_content = _.omit(new_cp_content, '$searchview');
- }
- if (options.clear) {
- this._detach_content(toDetach);
- // Show the searchview buttons area, which might have been hidden by
- // the searchview, as client actions may insert elements into it
- this.nodes.$searchview_buttons.show();
- } else {
- this._detach_content(_.pick(toDetach, _.keys(new_cp_content)));
- }
- this._attach_content(new_cp_content);
- if (options.clear || status.searchview) {
- this.searchview = status.searchview;
- }
-
- // Update the searchview and switch buttons
- if (status.searchview || options.clear) {
- this._update_search_view(status.searchview, status.search_view_hidden, status.groupable, status.enableTimeRangeMenu);
- }
- if (status.active_view_selector) {
- this._update_switch_buttons(status.active_view_selector);
- }
- }
- },
- /**
- * Private function that hides (or shows) the ControlPanel in headless (resp. non-headless) mode
- * Also detaches or attaches its contents to clean the DOM
- * @param {Boolean} [visible] true to show the control panel, false to hide it
- */
- _toggle_visibility: function(visible) {
- this.do_toggle(visible);
- if (!visible && !this.$content) {
- this.$content = this.$el.contents().detach();
- } else if (this.$content) {
- this.$content.appendTo(this.$el);
- this.$content = null;
- }
- },
- /**
- * Private function that detaches the content of the ControlPanel
- * @param {Object} [elements_to_detach] subset of this.nodes to detach
- */
- _detach_content: function(elements_to_detach) {
- _.each(elements_to_detach, function($nodeset) {
- $nodeset.contents().detach();
- });
- },
- /**
- * Private function that attaches content to the ControlPanel
- * @param {Object} [content] dictionnary of jQuery elements to attach, whose keys
- * are jQuery nodes identifiers in this.nodes
- */
- _attach_content: function(content) {
- var self = this;
- _.each(content, function($nodeset, $element) {
- if ($nodeset && self.nodes[$element]) {
- self.nodes[$element].append($nodeset);
- }
- });
- },
- /**
- * Private function that removes active class on all switch-buttons and adds
- * it to the one of the active view
- * @param {Object} [active_view_selector] the selector of the div to activate
- */
- _update_switch_buttons: function(active_view_selector) {
- _.each(this.nodes.$switch_buttons.find('button'), function(button) {
- $(button).removeClass('active');
- });
- this.$(active_view_selector).addClass('active');
- },
- /**
- * Private function that renders the breadcrumbs
- * @param {Array} [breadcrumbs] list of objects containing the following keys:
- * - action: the action to execute when clicking on this part of the breadcrumbs
- * - index: the index in the breadcrumbs (starting at 0)
- * - title: what to display in the breadcrumbs
- * @return {Array} list of breadcrumbs' li jQuery elements
- */
- _render_breadcrumbs: function (breadcrumbs) {
- var self = this;
- return breadcrumbs.map(function (bc, index) {
- return self._render_breadcrumbs_li(bc, index, breadcrumbs.length);
- });
- },
- /**
- * Private function that renders a breadcrumbs' li Jquery element
- */
- _render_breadcrumbs_li: function (bc, index, length) {
- var self = this;
- var is_last = (index === length-1);
- var li_content = bc.title && _.escape(bc.title.trim()) || data.noDisplayContent;
- var $bc = $('<li>', {class: 'breadcrumb-item'})
- .append(is_last ? li_content : $('<a>', {href: '#'}).html(li_content))
- .toggleClass('active', is_last);
- if (!is_last) {
- $bc.click(function (ev) {
- ev.preventDefault();
- self.trigger_up('breadcrumb_clicked', {controllerID: bc.controllerID});
- });
- }
- return $bc;
- },
- /**
- * Private function that updates the SearchView's visibility and extend the
- * breadcrumbs area if the SearchView is not visible
- *
- * @private
- * @param {SearchView} [searchview] the searchview Widget
- * @param {boolean} [isHidden] visibility of the searchview
- * @param {boolean} [groupable] visibility of the groupable menu (only
- * relevant if searchview is visible)
- */
- _update_search_view: function (searchview, isHidden, groupable, enableTimeRangeMenu) {
- if (searchview) {
- searchview.toggle_visibility(!isHidden);
- if (groupable !== undefined){
- searchview.groupby_menu.do_toggle(groupable);
- }
- if (enableTimeRangeMenu !== undefined){
- searchview.displayTimeRangeMenu(enableTimeRangeMenu);
- }
- }
- this.nodes.$searchview.toggle(!isHidden);
- this.$el.toggleClass('o_breadcrumb_full', !!isHidden);
- },
-});
-
-return ControlPanel;
-
-});
diff --git a/addons/web/static/src/js/core/registry.js b/addons/web/static/src/js/core/registry.js
index f925b0ea30a..c6d35229639 100644
--- a/addons/web/static/src/js/core/registry.js
+++ b/addons/web/static/src/js/core/registry.js
@@ -25,6 +25,8 @@ var Registry = Class.extend({
*/
init: function (mapping) {
this.map = Object.create(mapping || null);
+ this._scoreMapping = Object.create(null);
+ this._sortedKeys = null;
this.listeners = []; // listening callbacks on newly added items.
},
@@ -39,9 +41,12 @@ var Registry = Class.extend({
*
* @param {string} key
* @param {any} value
+ * @param {[number]} score if given, this value will be used to order keys
* @returns {Registry} can be used to chain add calls.
*/
- add: function (key, value) {
+ add: function (key, value, score) {
+ this._scoreMapping[key] = score === undefined ? key : score;
+ this._sortedKeys = null;
this.map[key] = value;
_.each(this.listeners, function (callback) {
callback(key, value);
@@ -49,7 +54,7 @@ var Registry = Class.extend({
return this;
},
/**
- * Check if the registry contains the value
+ * Check if the registry contains the key
*
* @param {string} key
* @returns {boolean}
@@ -58,18 +63,12 @@ var Registry = Class.extend({
return (key in this.map);
},
/**
- * Creates and returns a copy of the current mapping, with the provided
- * mapping argument added in (replacing existing keys if needed)
+ * Returns the content of the registry (an object mapping keys to values)
*
- * Parent and child remain linked, a new key in the parent (which is not
- * overwritten by the child) will appear in the child.
- *
- * @param {Object} [mapping={}] a mapping of keys to object-paths
+ * @returns {Object}
*/
- extend: function (mapping) {
- var child = new Registry(this.map);
- _.extend(child.map, mapping);
- return child;
+ entries: function () {
+ return Object.create(this.map);
},
/**
* Returns the value associated to the given key.
@@ -96,6 +95,23 @@ var Registry = Class.extend({
return null;
},
/**
+ * Return the list of keys in map object.
+ *
+ * The registry guarantees that the keys have a consistent order, defined by
+ * the 'score' value when the item has been added.
+ *
+ * @returns {string[]}
+ */
+ keys: function () {
+ var self = this;
+ if (!this._sortedKeys) {
+ this._sortedKeys = _.sortBy(Object.keys(this.map), function (key) {
+ return self._scoreMapping[key] || 0;
+ });
+ }
+ return this._sortedKeys;
+ },
+ /**
* Register a callback to execute when items are added to the registry.
*
* @param {function} callback function with parameters (key, value).
@@ -103,6 +119,17 @@ var Registry = Class.extend({
onAdd: function (callback) {
this.listeners.push(callback);
},
+ /**
+ * Return the list of values in map object
+ *
+ * @returns {string[]}
+ */
+ values: function () {
+ var self = this;
+ return this.keys().map(function (key) {
+ return self.map[key];
+ });
+ },
});
return Registry;
diff --git a/addons/web/static/src/js/core/service_mixins.js b/addons/web/static/src/js/core/service_mixins.js
index 9c127a1bc8c..e8088d45cb5 100644
--- a/addons/web/static/src/js/core/service_mixins.js
+++ b/addons/web/static/src/js/core/service_mixins.js
@@ -139,8 +139,8 @@ var ServicesMixin = {
promise.abort = abort.bind(def);
return promise;
},
- loadFieldView: function (dataset, view_id, view_type, options) {
- return this.loadViews(dataset.model, dataset.get_context().eval(), [[view_id, view_type]], options).then(function (result) {
+ loadFieldView: function (modelName, context, view_id, view_type, options) {
+ return this.loadViews(modelName, context, [[view_id, view_type]], options).then(function (result) {
return result[view_type];
});
},
@@ -155,11 +155,28 @@ var ServicesMixin = {
});
return def;
},
- loadFilters: function (dataset, action_id) {
+ loadFilters: function (modelName, actionId, context) {
var def = $.Deferred();
this.trigger_up('load_filters', {
- dataset: dataset,
- action_id: action_id,
+ modelName: modelName,
+ actionId: actionId,
+ context: context,
+ on_success: def.resolve.bind(def),
+ });
+ return def;
+ },
+ createFilter: function (filter) {
+ var def = $.Deferred();
+ this.trigger_up('create_filter', {
+ filter: filter,
+ on_success: def.resolve.bind(def),
+ });
+ return def;
+ },
+ deleteFilter: function (filterId) {
+ var def = $.Deferred();
+ this.trigger_up('delete_filter', {
+ filterId: filterId,
on_success: def.resolve.bind(def),
});
return def;
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index 60b0275c8c8..6d53d283f8a 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -16,7 +16,7 @@ odoo.define('web.relational_fields', function (require) {
var AbstractField = require('web.AbstractField');
var basicFields = require('web.basic_fields');
var concurrency = require('web.concurrency');
-var ControlPanel = require('web.ControlPanel');
+var ControlPanelView = require('web.ControlPanelView');
var dialogs = require('web.view_dialogs');
var core = require('web.core');
var data = require('web.data');
@@ -1030,7 +1030,14 @@ var FieldX2Many = AbstractField.extend({
}
var self = this;
var defs = [];
- this.control_panel = new ControlPanel(this, "X2ManyControlPanel");
+ var controlPanelView = new ControlPanelView({
+ template: 'X2ManyControlPanel',
+ withSearchBar: false,
+ });
+ var cpDef = controlPanelView.getController(this).then(function (controlPanel) {
+ self._controlPanel = controlPanel;
+ return self._controlPanel.prependTo(self.$el);
+ });
this.pager = new Pager(this, this.value.count, this.value.offset + 1, this.value.limit, {
single_page_hidden: true,
withAccessKey: false,
@@ -1053,9 +1060,9 @@ var FieldX2Many = AbstractField.extend({
});
this._renderButtons();
defs.push(this.pager.appendTo($('<div>'))); // start the pager
- defs.push(this.control_panel.prependTo(this.$el));
+ defs.push(cpDef);
return $.when.apply($, defs).then(function () {
- self.control_panel.update({
+ self._controlPanel.updateContents({
cp_content: {
$buttons: self.$buttons,
$pager: self.pager.$el,
diff --git a/addons/web/static/src/js/report/client_action.js b/addons/web/static/src/js/report/client_action.js
index 7041913c286..3a520e3d6b5 100644
--- a/addons/web/static/src/js/report/client_action.js
+++ b/addons/web/static/src/js/report/client_action.js
@@ -4,7 +4,6 @@ odoo.define('report.client_action', function (require) {
var AbstractAction = require('web.AbstractAction');
var config = require('web.config');
var core = require('web.core');
-var ControlPanelMixin = require('web.ControlPanelMixin');
var session = require('web.session');
var utils = require('report.utils');
@@ -17,9 +16,9 @@ var AUTHORIZED_MESSAGES = [
'report:do_action',
];
-var ReportAction = AbstractAction.extend(ControlPanelMixin, {
-
- template: 'report.client_action',
+var ReportAction = AbstractAction.extend({
+ hasControlPanel: true,
+ contentTemplate: 'report.client_action',
init: function (parent, action, options) {
this._super.apply(this, arguments);
@@ -27,7 +26,7 @@ var ReportAction = AbstractAction.extend(ControlPanelMixin, {
options = options || {};
this.action_manager = parent;
- this.title = options.display_name || options.name;
+ this._title = options.display_name || options.name;
this.edit_mode_available = false;
this.in_edit_mode = false;
@@ -42,7 +41,6 @@ var ReportAction = AbstractAction.extend(ControlPanelMixin, {
start: function () {
var self = this;
- this.set('title', this.title);
this.iframe = this.$('iframe')[0];
return $.when(this._super.apply(this, arguments), session.is_bound).then(function () {
var web_base_url = session['web.base.url'];
@@ -93,7 +91,7 @@ var ReportAction = AbstractAction.extend(ControlPanelMixin, {
},
_update_control_panel: function () {
- this.update_control_panel({
+ this.updateControlPanel({
cp_content: {
$buttons: this.$buttons,
},
diff --git a/addons/web/static/src/js/services/core.js b/addons/web/static/src/js/services/core.js
index 522d6b07302..3d6cfc4e684 100644
--- a/addons/web/static/src/js/services/core.js
+++ b/addons/web/static/src/js/services/core.js
@@ -39,8 +39,6 @@ return {
// registries
action_registry : new Registry(),
crash_registry: new Registry(),
- search_filters_registry: new Registry(),
- search_widgets_registry: new Registry(),
serviceRegistry: new Registry(),
/**
* @type {String}
diff --git a/addons/web/static/src/js/services/data_manager.js b/addons/web/static/src/js/services/data_manager.js
index bb7f25d206b..afedaa1148f 100644
--- a/addons/web/static/src/js/services/data_manager.js
+++ b/addons/web/static/src/js/services/data_manager.js
@@ -128,15 +128,20 @@ return core.Class.extend({
*
* @param {Object} [dataset] the dataset for which the filters are loaded
* @param {int} [action_id] the id of the action (optional)
+ * @param {Object} params
+ * @param {string} params.modelName
+ * @param {Object} params.context
+ * @param {integer} params.actionId
* @return {Deferred} resolved with the requested filters
*/
- load_filters: function (dataset, action_id) {
- var key = this._gen_key(dataset.model, action_id);
+ load_filters: function (params) {
+ var key = this._gen_key(params.modelName, params.actionId);
if (!this._cache.filters[key]) {
this._cache.filters[key] = rpc.query({
- args: [dataset.model, action_id],
+ args: [params.modelName, params.actionId],
kwargs: {
- context: dataset.get_context(),
+ context: params.context || {},
+ // get_context() de dataset
},
model: 'ir.filters',
method: 'get_filters',
@@ -158,26 +163,26 @@ return core.Class.extend({
model: 'ir.filters',
method: 'create_or_replace',
})
- .then(function (filter_id) {
+ .then(function (filterId) {
var key = [
filter.model_id,
filter.action_id || false,
].join(',');
self._invalidate(self._cache.filters, key);
- return filter_id;
+ return filterId;
});
},
/**
* Calls 'unlink' on 'ir_filters'.
*
- * @param {Object} [filter] the description of the filter to remove
+ * @param {integer} filterId, Id of the filter to remove
* @return {Deferred}
*/
- delete_filter: function (filter) {
+ delete_filter: function (filterId) {
var self = this;
return rpc.query({
- args: [filter.id],
+ args: [filterId],
model: 'ir.filters',
method: 'unlink',
})
diff --git a/addons/web/static/src/js/tools/debug_manager.js b/addons/web/static/src/js/tools/debug_manager.js
index 391d8613aac..a348f54acd9 100644
--- a/addons/web/static/src/js/tools/debug_manager.js
+++ b/addons/web/static/src/js/tools/debug_manager.js
@@ -345,8 +345,8 @@ DebugManager.include({
action: this._action,
can_edit: this._can_edit_views,
controller: this._controller,
+ controlPanelView: this._controller && this._controller._controlPanel,
manager: this,
- searchview: this._controller && this._controller.searchView,
view: this._controller && _.findWhere(this._action.views, {
type: this._controller.viewType,
}),
diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js
index d2d22a626d1..77584a272d6 100644
--- a/addons/web/static/src/js/views/abstract_controller.js
+++ b/addons/web/static/src/js/views/abstract_controller.js
@@ -12,18 +12,21 @@ odoo.define('web.AbstractController', function (require) {
* reading localstorage, ...) has to go through the controller.
*/
-var AbstractAction = require('web.AbstractAction');
+var ActionMixin = require('web.ActionMixin');
var ajax = require('web.ajax');
var concurrency = require('web.concurrency');
var config = require('web.config');
-var ControlPanelMixin = require('web.ControlPanelMixin');
var core = require('web.core');
+var mvc = require('web.mvc');
var QWeb = core.qweb;
-var AbstractController = AbstractAction.extend(ControlPanelMixin, {
+var AbstractController = mvc.Controller.extend(ActionMixin, {
custom_events: {
+ get_controller_query_params: '_onGetControllerQueryParams',
+ navigation_move: '_onNavigationMove',
open_record: '_onOpenRecord',
+ search: '_onSearch',
switch_view: '_onSwitchView',
},
events: {
@@ -31,50 +34,30 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
},
/**
- * @constructor
- * @param {Widget} parent
- * @param {AbstractModel} model
- * @param {AbstractRenderer} renderer
- * @param {object} params
+ * @override
* @param {string} params.modelName
* @param {string} [params.controllerID] an id to ease the communication
* with upstream components
+ * @param {ControlPanelController} [params.controlPanel]
* @param {any} [params.handle] a handle that will be given to the model (some id)
- * @param {any} params.initialState the initialState
* @param {boolean} params.isMultiRecord
* @param {Object[]} params.actionViews
* @param {string} params.viewType
- * @param {boolean} params.withControlPanel set to false to hide the
- * ControlPanel
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
- this.model = model;
- this.renderer = renderer;
+ this._controlPanel = params.controlPanel;
+ this._title = params.displayName;
this.modelName = params.modelName;
- this.handle = params.handle;
this.activeActions = params.activeActions;
this.controllerID = params.controllerID;
this.initialState = params.initialState;
this.bannerRoute = params.bannerRoute;
- // use a DropPrevious to correctly handle concurrent updates
- this.dp = new concurrency.DropPrevious();
- // those arguments are temporary, they won't be necessary as soon as the
- // ControlPanel will be handled by the View
- this.displayName = params.displayName;
this.isMultiRecord = params.isMultiRecord;
- this.searchable = params.searchable;
- this.searchView = params.searchView;
- this.searchViewHidden = params.searchViewHidden;
- this.groupable = params.groupable;
- this.enableTimeRangeMenu = params.enableTimeRangeMenu;
this.actionViews = params.actionViews;
this.viewType = params.viewType;
- this.withControlPanel = params.withControlPanel !== false;
- // override this.need_control_panel so that the ActionManager doesn't
- // update the control panel when it isn't visible (this is a temporary
- // hack that can be removed as soon as the CP'll be handled by the view)
- this.need_control_panel = this.withControlPanel;
+ // use a DropPrevious to correctly handle concurrent updates
+ this.dp = new concurrency.DropPrevious();
},
/**
* Simply renders and updates the url.
@@ -86,13 +69,13 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
this.$el.addClass('o_view_controller');
- // render the ControlPanel elements (buttons, pager, sidebar...)
- this.controlPanelElements = this._renderControlPanelElements();
+ return this._super.apply(this, arguments).then(function () {
+ if (self._controlPanel) {
+ // render the ControlPanel elements (buttons, pager, sidebar...)
+ self.controlPanelElements = self._renderControlPanelElements();
+ self._controlPanel.$el.prependTo(self.$el);
+ }
- return $.when(
- this._super.apply(this, arguments),
- this.renderer.appendTo(this.$el)
- ).then(function () {
return self._update(self.initialState);
});
},
@@ -106,14 +89,14 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
if (this.controlPanelElements && this.controlPanelElements.$switch_buttons) {
this.controlPanelElements.$switch_buttons.off();
}
- return this._super.apply(this, arguments);
+ this._super.apply(this, arguments);
},
/**
* Called each time the controller is attached into the DOM.
*/
on_attach_callback: function () {
- if (this.searchView) {
- this.searchView.on_attach_callback();
+ if (this._controlPanel) {
+ this._controlPanel.on_attach_callback();
}
this.renderer.on_attach_callback();
},
@@ -153,26 +136,18 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
return $.when();
},
/**
- * Returns any special keys that may be useful when reloading the view to
- * get the same effect. This is necessary for saving the current view in
- * the favorites. For example, a graph view might want to add a key to
- * save the current graph type.
+ * Export the state of the controller containing information that is shared
+ * between different controllers of a same action (like the current
+ * searchQuery of the controlPanel).
*
* @returns {Object}
*/
- getContext: function () {
- return {};
- },
- /**
- * Returns a title that may be displayed in the breadcrumb area. For
- * example, the name of the record.
- *
- * note: this will be moved to AbstractAction
- *
- * @returns {string}
- */
- getTitle: function () {
- return this.displayName;
+ exportState: function () {
+ var state = {};
+ if (this._controlPanel) {
+ state.cpState = this._controlPanel.exportState();
+ }
+ return state;
},
/**
* Gives the focus to the renderer
@@ -199,7 +174,16 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
* @returns {Deferred}
*/
reload: function (params) {
- return this.update(params || {});
+ params = params || {};
+ var def;
+ var controllerState = params.controllerState || {};
+ var cpState = controllerState.cpState;
+ if (this._controlPanel && cpState) {
+ def = this._controlPanel.importState(cpState).then(function (searchQuery) {
+ params = _.extend({}, params, searchQuery);
+ });
+ }
+ return $.when(def).then(this.update.bind(this, params, {}));
},
/**
* For views that require a pager, this method will be called to allow the
@@ -246,16 +230,16 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
// and this controller should no longer update the control panel.
// note that this won't be necessary as soon as each controller will have
// its own control panel
- var cpUpdateIndex = this.cp_bus && this.cp_bus.updateIndex;
+ var cpUpdateIndex = this._controlPanel && this._controlPanel.updateIndex;
return this.dp.add(def).then(function (handle) {
- if (self.cp_bus && cpUpdateIndex !== self.cp_bus.updateIndex) {
+ if (self._controlPanel && cpUpdateIndex !== self._controlPanel.updateIndex) {
return;
}
self.handle = handle || self.handle; // update handle if we reloaded
var state = self.model.get(self.handle);
var localState = self.renderer.getLocalState();
return self.dp.add(self.renderer.updateState(state, params)).then(function () {
- if (self.cp_bus && cpUpdateIndex !== self.cp_bus.updateIndex) {
+ if (self._controlPanel && cpUpdateIndex !== self._controlPanel.updateIndex) {
return;
}
self.renderer.setLocalState(localState);
@@ -327,7 +311,7 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
js.remove();
});
return $.when.apply($, defs).then(function () {
- $banner.prependTo(self.$el);
+ $banner.prependTo(self.$('> .o_content'));
self._$banner = $banner;
});
});
@@ -342,24 +326,20 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
* @returns {Object} an object containing the control panel jQuery elements
*/
_renderControlPanelElements: function () {
- var elements = {};
+ var elements = {
+ $buttons: $('<div>'),
+ $sidebar: $('<div>'),
+ $pager: $('<div>'),
+ };
- if (this.withControlPanel) {
- elements = {
- $buttons: $('<div>'),
- $sidebar: $('<div>'),
- $pager: $('<div>'),
- };
-
- this.renderButtons(elements.$buttons);
- this.renderSidebar(elements.$sidebar);
- this.renderPager(elements.$pager);
- // remove the unnecessary outer div
- elements = _.mapObject(elements, function($node) {
- return $node && $node.contents();
- });
- elements.$switch_buttons = this._renderSwitchButtons();
- }
+ this.renderButtons(elements.$buttons);
+ this.renderSidebar(elements.$sidebar);
+ this.renderPager(elements.$pager);
+ // remove the unnecessary outer div
+ elements = _.mapObject(elements, function($node) {
+ return $node && $node.contents();
+ });
+ elements.$switch_buttons = this._renderSwitchButtons();
return elements;
},
@@ -379,6 +359,7 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
var template = config.device.isMobile ? 'ControlPanel.SwitchButtons.Mobile' : 'ControlPanel.SwitchButtons';
var $switchButtons = $(QWeb.render(template, {
+ viewType: this.viewType,
views: views,
}));
// create bootstrap tooltips
@@ -392,8 +373,8 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
self.trigger_up('switch_view', {view_type: viewType});
}, 200, true));
+ // set active view's icon as view switcher button's icon in mobile
if (config.device.isMobile) {
- // set active view's icon as view switcher button's icon
var activeView = _.findWhere(views, {type: this.viewType});
$switchButtons.find('.o_switch_view_button_icon').addClass('fa fa-lg ' + activeView.icon);
}
@@ -401,6 +382,13 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
return $switchButtons;
},
/**
+ * @override
+ * @private
+ */
+ _startRenderer: function () {
+ return this.renderer.appendTo(this.$('.o_content'));
+ },
+ /**
* This method is called after each update or when the start method is
* completed.
*
@@ -413,23 +401,12 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
* @param {Object} state the state given by the model
* @returns {Deferred}
*/
- _update: function (state) {
+ _update: function () {
// AAB: update the control panel -> this will be moved elsewhere at some point
var cpContent = _.extend({}, this.controlPanelElements);
- if (this.searchView) {
- _.extend(cpContent, {
- $searchview: this.searchView.$el,
- $searchview_buttons: this.searchView.$buttons,
- });
- }
- this.update_control_panel({
- active_view_selector: '.o_cp_switch_' + this.viewType,
+ this.updateControlPanel({
cp_content: cpContent,
- hidden: !this.withControlPanel,
- searchview: this.searchView,
- search_view_hidden: !this.searchable || this.searchviewHidden,
- groupable: this.groupable,
- enableTimeRangeMenu: this.enableTimeRangeMenu,
+ title: this.getTitle(),
});
this._pushState();
@@ -479,6 +456,36 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
}
},
/**
+ * FIXME: this logic should be rethought
+ *
+ * Handles a context request: provides to the caller the state of the
+ * current controller.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {function} ev.data.callback used to send the requested state
+ */
+ _onGetControllerQueryParams: function (ev) {
+ ev.stopPropagation();
+ var state = this.getOwnedQueryParams();
+ ev.data.callback(state || {});
+ },
+ /**
+ * Called mainly from the control panel when the focus should be given to
+ * the controller
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNavigationMove : function (ev) {
+ switch(ev.data.direction) {
+ case 'down' :
+ ev.stopPropagation();
+ this.giveFocus();
+ break;
+ }
+ },
+ /**
* When an Odoo event arrives requesting a record to be opened, this method
* gets the res_id, and request a switch view in the appropriate mode
*
@@ -503,6 +510,20 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
});
},
/**
+ * Called when there is a change in the search view, so the current action's
+ * environment needs to be updated with the new domain, context and groupby.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {Array[]} ev.data.domain
+ * @param {Object} ev.data.context
+ * @param {string[]} ev.data.groupby
+ */
+ _onSearch: function (ev) {
+ ev.stopPropagation();
+ this.reload(_.extend({offset: 0}, ev.data));
+ },
+ /**
* Intercepts the 'switch_view' event to add the controllerID into the data,
* and lets the event bubble up.
*
diff --git a/addons/web/static/src/js/views/abstract_model.js b/addons/web/static/src/js/views/abstract_model.js
index 4ea6b217e65..da73ac664a3 100644
--- a/addons/web/static/src/js/views/abstract_model.js
+++ b/addons/web/static/src/js/views/abstract_model.js
@@ -14,52 +14,21 @@ odoo.define('web.AbstractModel', function (require) {
* in order to be able to notify its parent by bubbling events up.
*/
-var Class = require('web.Class');
var fieldUtils = require('web.field_utils');
-var mixins = require('web.mixins');
-var ServicesMixin = require('web.ServicesMixin');
-
-var AbstractModel = Class.extend(mixins.EventDispatcherMixin, ServicesMixin, {
- /**
- * @param {Widget} parent
- */
- init: function (parent) {
- mixins.EventDispatcherMixin.init.call(this);
- this.setParent(parent);
- },
+var mvc = require('web.mvc');
+var AbstractModel = mvc.Model.extend({
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
- * This method should return the complete state necessary for the renderer
- * to display the currently viewed data.
- *
- * @returns {*}
- */
- get: function () {
- },
- /**
- * The load method is called once in a model, when we load the data for the
- * first time. The method returns (a deferred that resolves to) some kind
- * of token/handle. The handle can then be used with the get method to
- * access a representation of the data.
- *
- * @param {Object} params
- * @param {string} params.modelName the name of the model
- * @returns {Deferred} The deferred resolves to some kind of handle
- */
- load: function (params) {
- return $.when();
- },
- /**
* When something changes, the data may need to be refetched. This is the
* job for this method: reloading (only if necessary) all the data and
* making sure that they are ready to be redisplayed.
*
* @param {Object} params
- * @returns {Deferred}
+ * @returns {Promise}
*/
reload: function (params) {
return $.when();
diff --git a/addons/web/static/src/js/views/abstract_renderer.js b/addons/web/static/src/js/views/abstract_renderer.js
index 059380f87bd..0f18263c9ee 100644
--- a/addons/web/static/src/js/views/abstract_renderer.js
+++ b/addons/web/static/src/js/views/abstract_renderer.js
@@ -7,22 +7,18 @@ odoo.define('web.AbstractRenderer', function (require) {
*
*/
-var Widget = require('web.Widget');
+var mvc = require('web.mvc');
/**
* @class AbstractRenderer
*/
-return Widget.extend({
+return mvc.Renderer.extend({
/**
- * @constructor
- * @param {Widget} parent
- * @param {any} state
- * @param {Object} params
+ * @override
* @param {string} [params.noContentHelp]
*/
init: function (parent, state, params) {
- this._super(parent);
- this.state = state;
+ this._super.apply(this, arguments);
this.arch = params.arch;
this.noContentHelp = params.noContentHelp;
},
diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js
index 024216b5619..75de2be9446 100644
--- a/addons/web/static/src/js/views/abstract_view.js
+++ b/addons/web/static/src/js/views/abstract_view.js
@@ -23,14 +23,16 @@ odoo.define('web.AbstractView', function (require) {
* in most case discarded.
*/
-var ajax = require('web.ajax');
-var Class = require('web.Class');
var AbstractModel = require('web.AbstractModel');
var AbstractRenderer = require('web.AbstractRenderer');
var AbstractController = require('web.AbstractController');
-var utils = require('web.utils');
+var ControlPanelView = require('web.ControlPanelView');
+var mvc = require('web.mvc');
+var viewUtils = require('web.viewUtils');
-var AbstractView = Class.extend({
+var Factory = mvc.Factory;
+
+var AbstractView = Factory.extend({
// name displayed in view switchers
display_name: '',
// indicates whether or not the view is mobile-friendly
@@ -40,22 +42,20 @@ var AbstractView = Class.extend({
// multi_record is used to distinguish views displaying a single record
// (e.g. FormView) from those that display several records (e.g. ListView)
multi_record: true,
- // determine if a search view should be displayed in the control panel and
- // allowed to interact with the view. Currently, the only not searchable
- // views are the form view and the diagram view.
- searchable: true,
// viewType is the type of the view, like 'form', 'kanban', 'list'...
viewType: undefined,
- // if searchable, this flag determines if the search view will display a
- // groupby menu or not. This is useful for the views which do not support
- // grouping data.
- groupable: true,
- enableTimeRangeMenu: false,
- config: {
+ // determines if a search bar is available
+ withSearchBar: true,
+ // determines the search menus available and their orders
+ searchMenuTypes: ['filter', 'groupBy', 'favorite'],
+ // determines if a control panel should be instantiated
+ withControlPanel: true,
+ // determines the MVC components to use
+ config: _.extend({}, Factory.prototype.config, {
Model: AbstractModel,
Renderer: AbstractRenderer,
Controller: AbstractController,
- },
+ }),
/**
* The constructor function is supposed to set 3 variables: rendererParams,
@@ -65,33 +65,32 @@ var AbstractView = Class.extend({
* @constructs AbstractView
*
* @param {Object} viewInfo
- * @param {Object} viewInfo.arch
+ * @param {Object|string} viewInfo.arch
* @param {Object} viewInfo.fields
* @param {Object} viewInfo.fieldsInfo
* @param {Object} params
- * @param {string} params.modelName The actual model name
- * @param {Object} params.context
- * @param {number} [params.count]
+ * @param {string} [params.modelName]
+ * @param {Object} [params.action={}]
+ * @param {Object} [params.context={}]
* @param {string} [params.controllerID]
- * @param {string[]} params.domain
- * @param {string[][]} params.timeRange
- * @param {string[][]} params.comparisonTimeRange
- * @param {string} params.timeRangeDescription
- * @param {string} params.comparisonTimeRangeDescription
- * @param {boolean} params.compare
- * @param {string[]} params.groupBy
+ * @param {number} [params.count]
* @param {number} [params.currentId]
- * @param {boolean} params.isEmbedded
+ * @param {string} [params.controllerState]
+ * @param {string} [params.displayName]
+ * @param {Array[]} [params.domain=[]]
* @param {number[]} [params.ids]
- * @param {boolean} [params.withControlPanel]
- * @param {boolean} [params.action.flags.headless]
- * @param {string} [params.action.display_name]
- * @param {string} [params.action.name]
- * @param {string} [params.action.help]
- * @param {string} [params.action.jsID]
- * @param {boolean} [params.action.views]
+ * @param {boolean} [params.isEmbedded=false]
+ * @param {Object} [params.searchQuery={}]
+ * @param {Object} [params.searchQuery.context={}]
+ * @param {Array[]} [params.searchQuery.domain=[]]
+ * @param {string[]} [params.searchQuery.groupBy=[]]
+ * @param {Object} [params.userContext={}]
+ * @param {boolean} [params.withControlPanel=true]
*/
init: function (viewInfo, params) {
+ var action = params.action || {};
+ params = _.defaults(params, this._extractParamsFromAction(action));
+
// in general, the fieldsView has to be processed by the View (e.g. the
// arch is a string that needs to be parsed) ; the only exception is for
// inline form views inside form views, as they are processed alongside
@@ -103,89 +102,51 @@ var AbstractView = Class.extend({
} else {
this.fieldsView = viewInfo;
}
- this.fields = this.fieldsView.viewFields;
this.arch = this.fieldsView.arch;
- // the boolean parameter 'isEmbedded' determines if the view should be considered
- // as a subview. For now this is only used by the graph controller that appends a
- // 'Group By' button beside the 'Measures' button when the graph view is embedded.
+ this.fields = this.fieldsView.viewFields;
+ this.userContext = params.userContext || {};
+ this.withControlPanel = params.withControlPanel;
+
+ // the boolean parameter 'isEmbedded' determines if the view should be
+ // considered as a subview. For now this is only used by the graph
+ // controller that appends a 'Group By' button beside the 'Measures'
+ // button when the graph view is embedded.
var isEmbedded = params.isEmbedded || false;
this.rendererParams = {
arch: this.arch,
isEmbedded: isEmbedded,
- noContentHelp: params.action && params.action.help,
+ noContentHelp: params.noContentHelp,
};
- var timeRangeMenuData = params.context.timeRangeMenuData;
- var timeRange = [];
- var comparisonTimeRange = [];
- var compare = false;
- var timeRangeDescription = "";
- var comparisonTimeRangeDescription = "";
- if (this.enableTimeRangeMenu && timeRangeMenuData) {
- timeRange = timeRangeMenuData.timeRange;
- comparisonTimeRange = timeRangeMenuData.comparisonTimeRange;
- compare = comparisonTimeRange.length > 0;
- timeRangeDescription = timeRangeMenuData.timeRangeDescription;
- comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription;
- this.rendererParams.timeRangeDescription = timeRangeDescription;
- this.rendererParams.comparisonTimeRangeDescription = comparisonTimeRangeDescription;
- }
-
this.controllerParams = {
- modelName: params.modelName,
+ actionViews: params.actionViews,
activeActions: {
edit: this.arch.attrs.edit ? JSON.parse(this.arch.attrs.edit) : true,
create: this.arch.attrs.create ? JSON.parse(this.arch.attrs.create) : true,
delete: this.arch.attrs.delete ? JSON.parse(this.arch.attrs.delete) : true,
duplicate: this.arch.attrs.duplicate ? JSON.parse(this.arch.attrs.duplicate) : true,
},
- groupable: this.groupable,
- enableTimeRangeMenu: this.enableTimeRangeMenu,
- isEmbedded: isEmbedded,
- controllerID: params.controllerID,
bannerRoute: this.arch.attrs.banner_route,
+ controllerID: params.controllerID,
+ displayName: params.displayName,
+ isEmbedded: isEmbedded,
+ isMultiRecord: this.multi_record,
+ modelName: params.modelName,
+ viewType: this.viewType,
};
- // AAB: these params won't be necessary as soon as the ControlPanel will
- // be instantiated by the View
- this.controllerParams.displayName = params.action && (params.action.display_name || params.action.name);
- this.controllerParams.isMultiRecord = this.multi_record;
- this.controllerParams.searchable = this.searchable;
- this.controllerParams.searchView = params.action && params.action.searchView;
- this.controllerParams.searchViewHidden = this.searchview_hidden; // AAB: use searchable instead where it is used?
- this.controllerParams.actionViews = params.action ? params.action.views : [];
- this.controllerParams.viewType = this.viewType;
- this.controllerParams.withControlPanel = true;
- if (params.action && params.action.flags) {
- this.controllerParams.withControlPanel = !params.action.flags.headless;
- } else if ('withControlPanel' in params) {
- this.controllerParams.withControlPanel = params.withControlPanel;
- }
-
- var groupBy = params.groupBy;
- if (typeof groupBy === 'string') {
- groupBy = [groupBy];
- }
+ var controllerState = params.controllerState || {};
+ var currentId = controllerState.currentId || params.currentId;
this.loadParams = {
context: params.context,
count: params.count || ((this.controllerParams.ids !== undefined) &&
this.controllerParams.ids.length) || 0,
domain: params.domain,
- timeRange: timeRange,
- timeRangeDescription: timeRangeDescription,
- comparisonTimeRange: comparisonTimeRange,
- comparisonTimeRangeDescription: comparisonTimeRangeDescription,
- compare: compare,
- groupedBy: groupBy,
modelName: params.modelName,
- res_id: params.currentId,
- res_ids: params.ids,
- orderedBy: params.context ? params.context.orderedBy : [],
+ res_id: currentId,
+ res_ids: controllerState.resIds || params.ids || (currentId ? [currentId] : undefined),
};
- if (params.modelName) {
- this.loadParams.modelName = params.modelName;
- }
// default_order is like:
// 'name,id desc'
// but we need it like:
@@ -197,8 +158,23 @@ var AbstractView = Class.extend({
return {name: order[0], asc: order[1] !== 'desc'};
});
}
+ if (params.searchQuery) {
+ this._updateMVCParams(params.searchQuery);
+ }
- this.userContext = params.userContext;
+ this.controlPanelParams = {
+ action: action,
+ activateDefaultFavorite: params.activateDefaultFavorite,
+ breadcrumbs: params.breadcrumbs,
+ context: this.loadParams.context,
+ domain: this.loadParams.domain,
+ modelName: params.modelName,
+ searchMenuTypes: params.searchMenuTypes,
+ state: controllerState.cpState,
+ viewInfo: params.controlPanelFieldsView,
+ withBreadcrumbs: params.withBreadcrumbs,
+ withSearchBar: params.withSearchBar,
+ };
},
//--------------------------------------------------------------------------
@@ -206,69 +182,48 @@ var AbstractView = Class.extend({
//--------------------------------------------------------------------------
/**
- * Main method of the view class. Create a controller, and make sure that
- * data and libraries are loaded.
- *
- * There is a unusual thing going in this method with parents: we create
- * renderer/model with parent as parent, then we have to reassign them at
- * the end to make sure that we have the proper relationships. This is
- * necessary to solve the problem that the controller need the model and the
- * renderer to be instantiated, but the model need a parent to be able to
- * load itself, and the renderer needs the data in its constructor.
- *
- * @param {Widget} parent The parent of the resulting Controller (most
- * likely an action manager)
- * @returns {Deferred} The deferred resolves to a controller
+ * @override
*/
getController: function (parent) {
var self = this;
- // check if a model already exists, as if not, one will be created and
- // we'll have to set the controller as its parent
- var alreadyHasModel = !!this.model;
- return $.when(this._loadData(parent), ajax.loadLibs(this)).then(function () {
- var state = self.model.get(arguments[0]);
- var renderer = self.getRenderer(parent, state);
- var Controller = self.Controller || self.config.Controller;
- var controllerParams = _.extend({
- initialState: state,
- }, self.controllerParams);
- var controller = new Controller(parent, self.model, renderer, controllerParams);
- renderer.setParent(controller);
-
- if (!alreadyHasModel) {
- // if we have a model, it already has a parent. Otherwise, we
- // set the controller, so the rpcs from the model actually work
- self.model.setParent(controller);
+ var def;
+ if (this.withControlPanel) {
+ def = this._createControlPanel(parent);
+ }
+ var _super = this._super.bind(this);
+ return $.when(def).then(function (controlPanel) {
+ if (controlPanel) {
+ var searchQuery = controlPanel.getSearchQuery();
+ self._updateMVCParams(searchQuery);
}
- return controller;
+ // get the parent of the model if it already exists, as _super will
+ // set the new controller as parent, which we don't want
+ var modelParent = self.model && self.model.getParent();
+ return _super(parent).done(function (controller) {
+ if (controlPanel) {
+ controlPanel.setParent(controller);
+ }
+ if (modelParent) {
+ // if we already add a model, restore its parent
+ self.model.setParent(modelParent);
+ }
+ });
+
});
},
/**
- * Returns the view model or create an instance of it if none
+ * Ensures that only one instance of AbstractModel is created
*
- * @param {Widget} parent the parent of the model, if it has to be created
- * @return {Object} instance of the view model
+ * @override
*/
- getModel: function (parent) {
+ getModel: function () {
if (!this.model) {
- var Model = this.config.Model;
- this.model = new Model(parent);
+ this.model = this._super.apply(this, arguments);
}
return this.model;
},
/**
- * Returns the a new view renderer instance
- *
- * @param {Widget} parent the parent of the model, if it has to be created
- * @param {Object} state the information related to the rendered view
- * @return {Object} instance of the view renderer
- */
- getRenderer: function (parent, state) {
- var Renderer = this.config.Renderer;
- return new Renderer(parent, state, this.rendererParams);
- },
- /**
- * this is useful to customize the actual class to use before calling
+ * This is useful to customize the actual class to use before calling
* createView.
*
* @param {Controller} Controller
@@ -282,16 +237,61 @@ var AbstractView = Class.extend({
//--------------------------------------------------------------------------
/**
- * Load initial data from the model
+ * Instantiates and starts a ControlPanelController.
*
* @private
- * @param {Widget} parent the parent of the model
- * @returns {Deferred<*>} a deferred that resolves to whatever the model
- * decide to return
+ * @param {Widget} parent
+ * @returns {ControlPanelController}
*/
- _loadData: function (parent) {
- var model = this.getModel(parent);
- return model.load(this.loadParams);
+ _createControlPanel: function (parent) {
+ var self = this;
+ var controlPanelView = new ControlPanelView(this.controlPanelParams);
+ return controlPanelView.getController(parent).then(function (controlPanel) {
+ self.controllerParams.controlPanel = controlPanel;
+ return controlPanel.appendTo(document.createDocumentFragment()).then(function () {
+ return controlPanel;
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {Object} [action]
+ * @param {Object} [action.context || {}]
+ * @param {boolean} [action.context.no_breadcrumbs=false]
+ * @param {integer} [action.context.active_id]
+ * @param {integer[]} [action.context.active_ids]
+ * @param {Object} [action.controlPanelFieldsView]
+ * @param {string} [action.display_name]
+ * @param {Array[]} [action.domain=[]]
+ * @param {string} [action.help]
+ * @param {integer} [action.id]
+ * @param {integer} [action.limit]
+ * @param {string} [action.name]
+ * @param {string} [action.res_model]
+ * @param {string} [action.target]
+ * @returns {Object}
+ */
+ _extractParamsFromAction: function (action) {
+ action = action || {};
+ var context = action.context || {};
+ var inline = action.target === 'inline';
+ return {
+ actionId: action.id || false,
+ actionViews: action.views || [],
+ activateDefaultFavorite: !context.active_id && !context.active_ids,
+ context: action.context || {},
+ controlPanelFieldsView: action.controlPanelFieldsView,
+ currentId: action.res_id ? action.res_id : undefined, // load returns 0
+ displayName: action.display_name || action.name,
+ domain: action.domain || [],
+ limit: action.limit,
+ modelName: action.res_model,
+ noContentHelp: action.help,
+ searchMenuTypes: inline ? [] : this.searchMenuTypes,
+ withBreadcrumbs: 'no_breadcrumbs' in context ? !context.no_breadcrumbs : true,
+ withControlPanel: this.withControlPanel,
+ withSearchBar: inline ? false : this.withSearchBar,
+ };
},
/**
* Processes a fieldsView. In particular, parses its arch.
@@ -303,12 +303,44 @@ var AbstractView = Class.extend({
*/
_processFieldsView: function (fieldsView) {
var fv = _.extend({}, fieldsView);
- var doc = $.parseXML(fv.arch).documentElement;
- var stripWhitespaces = doc.nodeName.toLowerCase() !== 'kanban';
- fv.arch = utils.xml_to_json(doc, stripWhitespaces);
+ fv.arch = viewUtils.parseArch(fv.arch);
fv.viewFields = _.defaults({}, fv.viewFields, fv.fields);
return fv;
},
+ /**
+ * Hook to update the renderer, controller and load params with the result
+ * of a search (i.e. a context, a domain and a groupBy).
+ *
+ * @private
+ * @param {Object} searchQuery
+ * @param {Object} searchQuery.context
+ * @param {Object} [searchQuery.context.timeRangeMenuData={}]
+ * @param {Array[]} [searchQuery.context.timeRangeMenuData.comparisonTimeRange=[]]
+ * @param {string} [searchQuery.context.timeRangeMenuData.comparisonTimeRangeDescription='']
+ * @param {string} [searchQuery.context.timeRangeMenuData.timeRangeDescription='']
+ * @param {Array[]} [searchQuery.context.timeRangeMenuData.timeRange=[]]
+ * @param {Array[]} searchQuery.domain
+ * @param {string[]} searchQuery.groupBy
+ */
+ _updateMVCParams: function (searchQuery) {
+ var timeRangeMenuData = searchQuery.context.timeRangeMenuData || {};
+ var comparisonTimeRange = timeRangeMenuData.comparisonTimeRange || [];
+ var comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription || '';
+ var timeRangeDescription = timeRangeMenuData.timeRangeDescription || '';
+ this.loadParams = _.extend(this.loadParams, {
+ compare: comparisonTimeRange.length > 0,
+ comparisonTimeRange: comparisonTimeRange,
+ comparisonTimeRangeDescription: comparisonTimeRangeDescription,
+ context: searchQuery.context,
+ domain: searchQuery.domain,
+ groupedBy: searchQuery.groupBy,
+ timeRange: timeRangeMenuData.timeRange || [],
+ timeRangeDescription: timeRangeMenuData.timeRangeDescription || '',
+ });
+ this.loadParams.orderedBy = searchQuery.orderedBy ? searchQuery.orderedBy : this.loadParams.orderedBy;
+ this.rendererParams.timeRangeDescription = timeRangeDescription;
+ this.rendererParams.comparisonTimeRangeDescription = comparisonTimeRangeDescription;
+ },
});
return AbstractView;
diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js
index 183885b9fb8..7f578da077d 100644
--- a/addons/web/static/src/js/views/basic/basic_controller.js
+++ b/addons/web/static/src/js/views/basic/basic_controller.js
@@ -36,8 +36,8 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
this.confirmOnDelete = params.confirmOnDelete;
this.hasButtons = params.hasButtons;
FieldManagerMixin.init.call(this, this.model);
- this.handle = params.initialState.id;
this.mode = params.mode || 'readonly';
+ this.handle = this.initialState.id;
},
/**
* @override
@@ -201,6 +201,18 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
self._updatePager();
});
},
+ /**
+ * @override
+ */
+ reload: function (params) {
+ if (params && params.controllerState) {
+ if (params.controllerState.currentId) {
+ params.currentId = params.controllerState.currentId;
+ }
+ params.ids = params.controllerState.resIds;
+ }
+ return this._super.apply(this, arguments);
+ },
//--------------------------------------------------------------------------
// Private
@@ -369,6 +381,20 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
}
},
/**
+ * Override to add the current record ID (currentId) and the list of ids
+ * (resIds) in the current dataPoint to the exported state.
+ *
+ * @override
+ */
+ exportState: function () {
+ var state = this._super.apply(this, arguments);
+ var env = this.model.get(this.handle, {env: true});
+ return _.extend(state, {
+ currentId: env.currentId,
+ resIds: env.ids,
+ });
+ },
+ /**
* Returns the new sidebar env
*
* @private
@@ -487,7 +513,6 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
var sidebarEnv = this._getSidebarEnv();
this.sidebar.updateEnv(sidebarEnv);
}
- this.trigger_up('env_updated', {controllerID: this.controllerID, env: env});
},
/**
* Helper method, to make sure the information displayed by the pager is up
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
index 598f88637d8..8266b1dee42 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -2053,14 +2053,12 @@ var BasicModel = AbstractModel.extend({
// We don't do it now because it's not an important case.
// However, we can for sure increment by 1 if we are on the last page.
-
var handleFieldValue = computedList.data[index].data[handleField];
if (position === 'top') {
handleFieldValue--;
} else if (list.count <= list.offset + list.limit - (list.tempLimitIncrement || 0)) {
handleFieldValue++;
}
-
return {
field: handleField,
value: handleFieldValue,
@@ -4383,7 +4381,9 @@ var BasicModel = AbstractModel.extend({
if (options.context !== undefined) {
element.context = options.context;
- element.orderedBy = options.context.orderedBy || element.orderedBy;
+ }
+ if (options.orderedBy !== undefined) {
+ element.orderedBy = options.orderedBy || element.orderedBy;
}
if (options.domain !== undefined) {
element.domain = options.domain;
diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js
index 8db200183ef..27821081f96 100644
--- a/addons/web/static/src/js/views/basic/basic_renderer.js
+++ b/addons/web/static/src/js/views/basic/basic_renderer.js
@@ -518,35 +518,6 @@ var BasicRenderer = AbstractRenderer.extend({
});
},
/**
- * Renders a button according to a given node element.
- *
- * @private
- * @param {Object} node
- * @param {Object} [options]
- * @param {string} [options.extraClass]
- * @param {boolean} [options.textAsTitle=false]
- * @returns {jQuery}
- */
- _renderButtonFromNode: function (node, options) {
- var btnOptions = {
- attrs: _.omit(node.attrs, 'icon', 'string', 'type', 'attrs', 'modifiers', 'options'),
- icon: node.attrs.icon,
- };
- if (options && options.extraClass) {
- var classes = btnOptions.attrs.class ? btnOptions.attrs.class.split(' ') : [];
- btnOptions.attrs.class = _.uniq(classes.concat(options.extraClass.split(' '))).join(' ');
- }
- var str = (node.attrs.string || '').replace(/_/g, '');
- if (str) {
- if (options && options.textAsTitle) {
- btnOptions.attrs.title = str;
- } else {
- btnOptions.text = str;
- }
- }
- return dom.renderButton(btnOptions);
- },
- /**
* Instantiates the appropriate AbstractField specialization for the given
* node and prepares its rendering and addition to the DOM. Indeed, the
* rendering of the widget will be started and the associated deferred will
diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js
index eb3efe7001d..d5d6cfa18d5 100644
--- a/addons/web/static/src/js/views/basic/basic_view.js
+++ b/addons/web/static/src/js/views/basic/basic_view.js
@@ -51,7 +51,6 @@ var BasicView = AbstractView.extend({
this.loadParams.fieldsInfo = this.fieldsInfo;
this.loadParams.fields = this.fields;
- this.loadParams.context = params.context || {};
this.loadParams.limit = parseInt(this.arch.attrs.limit, 10) || params.limit;
this.loadParams.viewType = this.viewType;
this.loadParams.parentID = params.parentID;
@@ -96,19 +95,17 @@ var BasicView = AbstractView.extend({
* @private
* @returns {Deferred}
*/
- _loadData: function () {
+ _loadData: function (model) {
if (this.recordID) {
- var self = this;
-
// Add the fieldsInfo of the current view to the given recordID,
// as it will be shared between two views, and it must be able to
// handle changes on fields that are only on this view.
- this.model.addFieldsInfo(this.recordID, {
+ model.addFieldsInfo(this.recordID, {
fields: this.fields,
fieldsInfo: this.fieldsInfo,
});
- var record = this.model.get(this.recordID);
+ var record = model.get(this.recordID);
var viewType = this.viewType;
var viewFields = Object.keys(record.fieldsInfo[viewType]);
var fieldNames = _.difference(viewFields, Object.keys(record.data));
@@ -168,14 +165,14 @@ var BasicView = AbstractView.extend({
// onchange RPCs), that we haven't been able to process earlier
// (because those fields were unknow at that time). So we ask
// the model to process them.
- def = this.model.applyRawChanges(record.id, viewType).then(function () {
- if (self.model.isNew(record.id)) {
- return self.model.applyDefaultValues(record.id, {}, {
+ def = model.applyRawChanges(record.id, viewType).then(function () {
+ if (model.isNew(record.id)) {
+ return model.applyDefaultValues(record.id, {}, {
fieldNames: fieldNames,
viewType: viewType,
});
} else {
- return self.model.reload(record.id, {
+ return model.reload(record.id, {
fieldNames: fieldNames,
keepChanges: true,
viewType: viewType,
@@ -184,7 +181,7 @@ var BasicView = AbstractView.extend({
});
}
return $.when(def).then(function () {
- return record.id;
+ return model.get(record.id);
});
}
return this._super.apply(this, arguments);
diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js
index b699879472a..0934ae462b3 100644
--- a/addons/web/static/src/js/views/calendar/calendar_controller.js
+++ b/addons/web/static/src/js/views/calendar/calendar_controller.js
@@ -75,7 +75,7 @@ var CalendarController = AbstractController.extend({
* @returns {string}
*/
getTitle: function () {
- return this.get('title');
+ return this._title;
},
/**
* Render the buttons according to the CalendarView.buttons template and
@@ -156,7 +156,7 @@ var CalendarController = AbstractController.extend({
* @returns {Deferred}
*/
_updateRecord: function (record) {
- return this.model.updateRecord(record).then(this.reload.bind(this));
+ return this.model.updateRecord(record).then(this.reload.bind(this, {}));
},
//--------------------------------------------------------------------------
@@ -430,7 +430,7 @@ var CalendarController = AbstractController.extend({
this.$buttons.find('.active').removeClass('active');
this.$buttons.find('.o_calendar_button_' + this.mode).addClass('active');
}
- this.set({title: this.displayName + ' (' + event.data.title + ')'});
+ this._setTitle(this.displayName + ' (' + event.data.title + ')');
},
});
diff --git a/addons/web/static/src/js/views/calendar/calendar_view.js b/addons/web/static/src/js/views/calendar/calendar_view.js
index 20d358c517c..90db648ffd3 100644
--- a/addons/web/static/src/js/views/calendar/calendar_view.js
+++ b/addons/web/static/src/js/views/calendar/calendar_view.js
@@ -29,7 +29,7 @@ var CalendarView = AbstractView.extend({
Renderer: CalendarRenderer,
},
viewType: 'calendar',
- groupable: false,
+ searchMenuTypes: ['filter', 'favorite'],
/**
* @override
diff --git a/addons/web/static/src/js/views/control_panel/control_panel_controller.js b/addons/web/static/src/js/views/control_panel/control_panel_controller.js
new file mode 100644
index 00000000000..991cee949e8
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/control_panel_controller.js
@@ -0,0 +1,237 @@
+odoo.define('web.ControlPanelController', function (require) {
+"use strict";
+
+var mvc = require('web.mvc');
+
+var ControlPanelController = mvc.Controller.extend({
+ className: 'o_cp_controller',
+ custom_events: {
+ facet_removed: '_onFacetRemoved',
+ get_search_query: '_onGetSearchQuery',
+ item_option_clicked: '_onItemOptionClicked',
+ item_trashed: '_onItemTrashed',
+ menu_item_clicked: '_onMenuItemClicked',
+ new_favorite: '_onNewFavorite',
+ new_filters: '_onNewFilters',
+ new_groupBy: '_onNewGroupBy',
+ activate_time_range: '_onActivateTimeRange',
+ autocompletion_filter: '_onAutoCompletionFilter',
+ },
+
+ /**
+ * @override
+ * @param {Object} params
+ * @param {string} params.modelName
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+
+ this.modelName = params.modelName;
+ },
+ /**
+ * Called when the control panel is inserted into the DOM.
+ */
+ on_attach_callback: function () {
+ this.renderer.on_attach_callback();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see ControlPanelModel (exportState)
+ * @returns {Object}
+ */
+ exportState: function () {
+ return this.model.exportState();
+ },
+ /**
+ * Compute the search related values that will be used to fetch data.
+ *
+ * @returns {Object} object with keys 'context', 'domain', 'groupBy'
+ */
+ getSearchQuery: function () {
+ return this.model.getQuery();
+ },
+ /**
+ * @param {Object} state a ControlPanelModel state
+ * @returns {Deferred<Object>} the result of `getSearchState`
+ */
+ importState: function (state) {
+ var defs = [];
+ this.model.importState(state);
+ defs.push(this.getSearchQuery());
+ defs.push(this.renderer.updateState(this.model.get()));
+ return $.when(defs).then(function (defsResults) {
+ return defsResults[0];
+ });
+ },
+ /**
+ * Called at each switch view. This is required until the control panel is
+ * shared between controllers of an action.
+ *
+ * @param {string} controllerID
+ */
+ setControllerID: function (controllerID) {
+ this.controllerID = controllerID;
+ },
+ /**
+ * Update the content and displays the ControlPanel.
+ *
+ * @see ControlPanelRenderer (updateContents)
+ * @param {Object} status
+ * @param {Object} [options]
+ */
+ updateContents: function (status, options) {
+ this.renderer.updateContents(status, options);
+ },
+ /**
+ * Update the domain of the search view by adding and/or removing filters.
+ *
+ * @todo: the way it is done could be improved, but the actual state of the
+ * searchview doesn't allow to do much better.
+
+ * @param {Object[]} newFilters list of filters to add, described by
+ * objects with keys domain (the domain as an Array), description (the text
+ * to display in the facet) and type with value 'filter'.
+ * @param {string[]} filtersToRemove list of filter ids to remove
+ * (previously added ones)
+ * @returns {string[]} list of added filters (to pass as filtersToRemove
+ * for a further call to this function)
+ */
+ updateFilters: function (newFilters, filtersToRemove) {
+ var newFilterIDS = this.model.createNewFilters(newFilters);
+ this.model.deactivateFilters(filtersToRemove);
+ this._reportNewQueryAndRender();
+ return newFilterIDS;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {jQuery}
+ */
+ _getSubMenus: function () {
+ return this.renderer.$subMenus;
+ },
+ /**
+ * @private
+ * @returns {Deferred}
+ */
+ _reportNewQueryAndRender: function () {
+ this.trigger_up('search', this.model.getQuery());
+ var state = this.model.get();
+ return this.renderer.updateState(state);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onActivateTimeRange: function (ev) {
+ ev.stopPropagation();
+ this.model.activateTimeRange(
+ ev.data.id,
+ ev.data.timeRangeId,
+ ev.data.comparisonTimeRangeId
+ );
+ this._reportNewQueryAndRender();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onAutoCompletionFilter: function (ev) {
+ ev.stopPropagation();
+ this.model.toggleAutoCompletionFilter(ev.data);
+ this._reportNewQueryAndRender();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onFacetRemoved: function (ev) {
+ ev.stopPropagation();
+ var group = ev.data.group;
+ if (!group) {
+ group = this.renderer.getLastFacet();
+ }
+ this.model.deactivateGroup(group.id);
+ this._reportNewQueryAndRender();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onGetSearchQuery: function (ev) {
+ ev.stopPropagation();
+ var query = this.getSearchQuery();
+ ev.data.callback(query);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onItemOptionClicked: function (ev) {
+ ev.stopPropagation();
+ this.model.toggleFilterWithOptions(ev.data.id, ev.data.optionId);
+ this._reportNewQueryAndRender();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onItemTrashed: function (ev) {
+ ev.stopPropagation();
+ var def = this.model.deleteFilterEverywhere(ev.data.id);
+ def.then(this._reportNewQueryAndRender.bind(this));
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onMenuItemClicked: function (ev) {
+ ev.stopPropagation();
+ this.model.toggleFilter(ev.data.id);
+ this._reportNewQueryAndRender();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNewFavorite: function (ev) {
+ ev.stopPropagation();
+ var def = this.model.createNewFavorite(ev.data);
+ def.then(this._reportNewQueryAndRender.bind(this));
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNewFilters: function (ev) {
+ ev.stopPropagation();
+ this.model.createNewFilters(ev.data.filters);
+ this._reportNewQueryAndRender();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNewGroupBy: function (ev) {
+ ev.stopPropagation();
+ this.model.createNewGroupBy(ev.data);
+ this._reportNewQueryAndRender();
+ },
+});
+
+return ControlPanelController;
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/control_panel_model.js b/addons/web/static/src/js/views/control_panel/control_panel_model.js
new file mode 100644
index 00000000000..9353022eacf
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/control_panel_model.js
@@ -0,0 +1,1093 @@
+odoo.define('web.ControlPanelModel', function (require) {
+"use strict";
+
+var controlPanelViewParameters = require('web.controlPanelViewParameters');
+var core = require('web.core');
+var Domain = require('web.Domain');
+var mvc = require('web.mvc');
+var pyUtils = require('web.py_utils');
+var searchBarAutocompleteRegistry = require('web.search_bar_autocomplete_sources_registry');
+var session = require('web.session');
+
+var _t = core._t;
+
+var DEFAULT_TIMERANGE = controlPanelViewParameters.DEFAULT_TIMERANGE;
+var TIME_RANGE_OPTIONS = controlPanelViewParameters.TIME_RANGE_OPTIONS;
+var COMPARISON_TIME_RANGE_OPTIONS = controlPanelViewParameters.COMPARISON_TIME_RANGE_OPTIONS;
+
+var ControlPanelModel = mvc.Model.extend({
+ /**
+ * @override
+ * @param {string} [params.actionId]
+ * @param {string} [params.context={}]
+ * @param {string} [params.domain=[]]
+ * @param {string} [params.field={}]
+ * @param {string} [params.modelName]
+ */
+ init: function (parent, params) {
+ this._super.apply(this, arguments);
+
+ // Tricks to avoid losing information on filter descriptions in control panel model configuration
+ TIME_RANGE_OPTIONS = TIME_RANGE_OPTIONS.map(function (option) {
+ return _.extend(option, {description: option.description.toString()});
+ });
+ COMPARISON_TIME_RANGE_OPTIONS = COMPARISON_TIME_RANGE_OPTIONS.map(function (option) {
+ return _.extend(option, {description: option.description.toString()});
+ });
+
+
+ this.modelName = params.modelName;
+ // info on fields of model this.modelName
+ this.fields = params.fields || {};
+
+ // info on current action
+ this.actionId = params.actionId;
+ this.actionContext = params.context || {};
+ this.actionDomain = params.domain || [];
+
+ // triple determining a control panel model configuration
+ this.filters = {};
+ this.groups = {};
+ this.query = [];
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Activate a given filter of type 'timeRange' with a timeRangeId
+ * and optionaly a comparsionTimeRangeId
+ *
+ * @param {string} filterId
+ * @param {string} timeRangeId
+ * @param {string} [comparisonTimeRangeId]
+ */
+ activateTimeRange: function (filterId, timeRangeId, comparisonTimeRangeId) {
+ var filter = this.filters[filterId];
+ filter.timeRangeId = timeRangeId || filter.defaultTimeRangeId;
+ filter.comparisonTimeRangeId = comparisonTimeRangeId;
+ var group = this.groups[filter.groupId];
+ var groupActive = group.activeFilterIds.length;
+ if (groupActive) {
+ group.activeFilterIds = [filterId];
+ } else {
+ this.toggleFilter(filterId);
+ }
+ },
+ /**
+ * Ccreate an ir_filter server side. If the operation is successful, a new
+ * filter of type 'favorite' is created and activated.
+ *
+ * @param {Object} newFavorite
+ */
+ createNewFavorite: function (newFavorite) {
+ return this._saveQuery(_.pick(
+ newFavorite,
+ ['description', 'isDefault', 'isShared', 'type']
+ )).then(function () {
+ newFavorite.on_success();
+ });
+ },
+ /**
+ * Create new filters of type 'filter' with same new groupId and groupNumber.
+ * They are activated.
+ *
+ * @param {Object[]} newFilters
+ * @returns {string[]} filterIds, ids of the newly created filters
+ */
+ createNewFilters: function (newFilters) {
+ var self = this;
+ var filterIds = [];
+ var groupNumber = this._generateNewGroupNumber();
+ this._createGroupOfFilters(newFilters);
+ newFilters.forEach(function (filter) {
+ filter.groupNumber = groupNumber;
+ self.toggleFilter(filter.id);
+ filterIds.push(filter.id);
+ });
+ return filterIds;
+ },
+ /**
+ * Create a new groupBy with the groupId shared by all filters of type 'groupBy'
+ * but a new groupNumber
+ * It is activated.
+ *
+ * @param {Object} newGroupBy
+ */
+ createNewGroupBy: function (newGroupBy) {
+ var id = _.uniqueId('__filter__');
+ newGroupBy.id = id;
+ newGroupBy.groupId = this._getGroupIdOfType('groupBy');
+ newGroupBy.groupNumber = this._generateNewGroupNumber();
+ this.filters[id] = newGroupBy;
+ if (_.contains(['date', 'datetime'], newGroupBy.fieldType)) {
+ this.toggleFilterWithOptions(newGroupBy.id);
+ } else {
+ this.toggleFilter(newGroupBy.id);
+ }
+ },
+ /**
+ * Ensure that the filters determined by the given filterIds are
+ * deactivated (if one or many of them are already deactivated, nothing bad happens)
+ *
+ * @param {string[]} filterIds
+ */
+ deactivateFilters: function (filterIds) {
+ var self = this;
+ filterIds.forEach(function (filterId) {
+ var filter = self.filters[filterId];
+ var group = self.groups[filter.groupId];
+ if (_.contains(group.activeFilterIds, filterId)) {
+ self.toggleFilter(filterId);
+ }
+ });
+ },
+ /**
+ * Deactivate all filters in a given group with given id.
+ *
+ * @param {string} groupId
+ */
+ deactivateGroup: function (groupId) {
+ var self = this;
+ var group = this.groups[groupId];
+ _.each(group.activeFilterIds, function (filterId) {
+ var filter = self.filters[filterId];
+ // TODO: put this logic in toggleFilter 'field' type
+ if (filter.autoCompleteValues) {
+ filter.autoCompleteValues = [];
+ }
+ });
+ // TODO: use toggleFilter here
+ group.activeFilterIds = [];
+ this.query.splice(this.query.indexOf(groupId), 1);
+ },
+ /**
+ * Delete a filter of type 'favorite' with given filterId server side and in control panel model.
+ * Of course this forces the filter to be removed from the search query.
+ *
+ * @param {string} filterId
+ */
+ deleteFilterEverywhere: function (filterId) {
+ var self = this;
+ var filter = this.filters[filterId];
+ var def = this.deleteFilter(filter.serverSideId).then(function () {
+ var activeFavoriteId = self.groups[filter.groupId].activeFilterIds[0];
+ var isActive = activeFavoriteId === filterId;
+ if (isActive) {
+ self.toggleFilter(filterId);
+ }
+ delete self.filters[filterId];
+ });
+ return def;
+ },
+ /**
+ * Return the state of the control panel (the filters, groups and the
+ * current query). This state can then be used in an other control panel
+ * model (with same key modelName) via the importState method.
+ *
+ * @returns {Object}
+ */
+ exportState: function () {
+ return {
+ filters: this.filters,
+ groups: this.groups,
+ query: this.query,
+ };
+ },
+ /**
+ * @override
+ *
+ * @returns {Object}
+ */
+ get: function () {
+ var self = this;
+ // we maintain a unique source activeFilterIds that contain information
+ // on active filters. But the renderer can have more information since
+ // it does not modifies filters activity.
+ // We thus give a different structure to renderer that may contain duplicated
+ // information.
+ // Note that filters are filters of filter type only, groupbys are groupbys,...!
+ var filterFields = [];
+ var filters = [];
+ var groupBys = [];
+ var timeRanges = [];
+ var favorites = [];
+ Object.keys(this.filters).forEach(function (filterId) {
+ var filter = _.extend({}, self.filters[filterId]);
+ var group = self.groups[filter.groupId];
+ filter.isActive = group.activeFilterIds.indexOf(filterId) !== -1;
+ if (filter.type === 'field') {
+ filterFields.push(filter);
+ }
+ if (filter.type === 'filter') {
+ filters.push(filter);
+ }
+ if (filter.type === 'groupBy') {
+ groupBys.push(filter);
+ }
+ if (filter.type === 'favorite') {
+ favorites.push(filter);
+ }
+ if (filter.type === 'timeRange') {
+ timeRanges.push(filter);
+ }
+ });
+ var facets = [];
+ // resolve active filters for facets
+ this.query.forEach(function (groupID) {
+ var group = self.groups[groupID];
+ var facet = _.extend({}, group);
+ facet.filters = facet.activeFilterIds.map(function (filterID) {
+ return self.filters[filterID];
+ });
+ facets.push(facet);
+ });
+ favorites = _.sortBy(favorites, 'groupNumber');
+ return {
+ facets: facets,
+ filterFields: filterFields,
+ filters: filters,
+ groupBys: groupBys,
+ timeRanges: timeRanges,
+ favorites: favorites,
+ groups: this.groups,
+ query: _.extend([], this.query),
+ fields: this.fields,
+ };
+ },
+ /**
+ * @returns {Object} An object called search query with keys domain, groupBy,
+ * context, orderedBy.
+ */
+ getQuery: function () {
+ var userContext = session.user_context;
+ var context = _.extend(
+ pyUtils.eval('contexts', this._getQueryContext(), userContext),
+ this._getTimeRangeMenuData(true)
+ );
+ var domain = Domain.prototype.stringToArray(this._getDomain(), userContext);
+ // this must be done because pyUtils.eval does not know that it needs to evaluate domains within contexts
+ if (context.timeRangeMenuData) {
+ if (typeof context.timeRangeMenuData.timeRange === 'string') {
+ context.timeRangeMenuData.timeRange = pyUtils.eval('domain', context.timeRangeMenuData.timeRange);
+ }
+ if (typeof context.timeRangeMenuData.comparisonTimeRange === 'string') {
+ context.timeRangeMenuData.comparisonTimeRange = pyUtils.eval('domain', context.timeRangeMenuData.comparisonTimeRange);
+ }
+ }
+ var action_context = this.actionContext;
+ var results = pyUtils.eval_domains_and_contexts({
+ domains: [this.actionDomain].concat([domain] || []),
+ contexts: [action_context].concat(context || []),
+ eval_context: session.user_context,
+ });
+ if (results.error) {
+ throw new Error(_.str.sprintf(_t("Failed to evaluate search criterions")+": \n%s",
+ JSON.stringify(results.error)));
+ }
+
+ var groupBys = this._getGroupBy();
+ var groupBy = groupBys.length ?
+ groupBys :
+ (this.actionContext.group_by || []);
+ groupBy = (typeof groupBy === 'string') ? [groupBy] : groupBy;
+
+ context = _.omit(results.context, 'time_ranges');
+
+ return {
+ context: context,
+ domain: results.domain,
+ groupBy: groupBy,
+ orderedBy: this._getOrderedBy(),
+ };
+ },
+ /**
+ * Set filters, groups, and query keys according to the given state.
+ *
+ * @param {Object} state
+ */
+ importState: function (state) {
+ this.filters = state.filters;
+ this.groups = state.groups;
+ this.query = state.query;
+ },
+ /**
+ * The load method is the place where the favorites are also loaded and
+ * the model state is first established.
+ * The computation of the active filters at initialization is accomplished
+ * here.
+ *
+ * @param {Object} params
+ * @param {boolean} [params.activateDefaultFavorite=false]
+ * @param {Array[]} [params.groups=[]]
+ * @param {Object} [params.initialState]
+ * @param {string[]} [params.searchMenuTypes=[]]
+ * @param {Object} [params.timeRanges]
+ * @param {boolean} [params.withSearchBar]
+ * @returns {Deferred}
+ */
+ load: function (params) {
+ var self = this;
+ this.searchMenuTypes = params.searchMenuTypes || [];
+ this.activateDefaultFavorite = params.activateDefaultFavorite;
+
+ if (!params.withSearchBar && params.searchMenuTypes.length === 0) {
+ // The model state will remain as set in init method
+ // and the info comming from arch put in params.groups (if any)
+ // will be lost. This is not a problem because the state
+ // won't be use elsewhere.
+ return $.when();
+ }
+ if (params.initialState) {
+ this.importState(params.initialState);
+ return $.when();
+ } else {
+ var groups = params.groups || [];
+ groups.forEach(function (group) {
+ self._createGroupOfFilters(group);
+ });
+ if (this._getGroupIdOfType('groupBy') === undefined) {
+ this._createEmptyGroup('groupBy');
+ }
+ this._createGroupOfTimeRanges();
+ return $.when.apply($, self._loadSearchDefaults()).then(function () {
+ return self._loadFavorites().then(function () {
+ if (self.query.length === 0) {
+ self._activateDefaultFilters();
+ self._activateDefaultTimeRanges(params.timeRanges);
+ }
+ });
+ });
+ }
+ },
+ /**
+ * Toggle a filter with given id in a way appropriate to its type.
+ *
+ * @param {Object} params
+ * @param {string} params.filterId
+ * @param {Object} params.autoCompleteValues
+ */
+ toggleAutoCompletionFilter: function (params) {
+ var filter = this.filters[params.filterId];
+ if (filter.type === 'field') {
+ filter.autoCompleteValues = params.autoCompleteValues;
+ // the autocompletion filter is dynamic
+ filter.domain = this._getAutoCompletionFilterDomain(filter);
+ // active the filter
+ var group = this.groups[filter.groupId];
+ if (!group.activeFilterIds.includes(filter.id)) {
+ group.activeFilterIds.push(filter.id);
+ this.query.push(group.id);
+ }
+ } else {
+ if (filter.hasOptions) {
+ this.toggleFilterWithOptions(filter.id);
+ } else {
+ this.toggleFilter(filter.id);
+ }
+ }
+ },
+ /**
+ * Toggle a filter throught the modification of this.groups and potentially
+ * of this.query and this.filters.
+ *
+ * @param {string} filterId
+ */
+ toggleFilter: function (filterId) {
+ var self = this;
+ var filter = this.filters[filterId];
+ var group = this.groups[filter.groupId];
+ var index = group.activeFilterIds.indexOf(filterId);
+ var initiaLength = group.activeFilterIds.length;
+ if (index === -1) {
+ // we need to deactivate all groups when activating a favorite
+ if (filter.type === 'favorite') {
+ this.query.forEach(function (groupId) {
+ self.groups[groupId].activeFilterIds = [];
+ });
+ this.query = [];
+ }
+ group.activeFilterIds.push(filterId);
+ // if initiaLength is 0, the group was not active.
+ if (filter.type === 'favorite' || initiaLength === 0) {
+ this.query.push(group.id);
+ }
+ } else {
+ if (filter.type === 'field' && filter.autoCompleteValues) {
+ filter.autoCompleteValues = [];
+ }
+ group.activeFilterIds.splice(index, 1);
+ // if initiaLength is 1, the group is now inactive.
+ if (initiaLength === 1) {
+ this.query.splice(this.query.indexOf(group.id), 1);
+ }
+ }
+ },
+ /**
+ * Used to toggle a given filter(Id) that has options with a given option(Id).
+ *
+ * @param {string} filterId
+ * @param {string} [optionId]
+ */
+ toggleFilterWithOptions: function (filterId, optionId) {
+ var filter = this.filters[filterId];
+ var group = this.groups[filter.groupId];
+ var alreadyActive = group.activeFilterIds.indexOf(filterId) !== -1;
+ if (alreadyActive) {
+ if (filter.currentOptionId === optionId) {
+ this.toggleFilter(filterId);
+ filter.currentOptionId = false;
+ } else {
+ filter.currentOptionId = optionId || filter.defaultOptionId;
+ }
+ } else {
+ this.toggleFilter(filterId);
+ filter.currentOptionId = optionId || filter.defaultOptionId;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Activate all filters (but favorites) with key isDefault=true
+ *
+ * @private
+ */
+ _activateDefaultFilters: function () {
+ var self = this;
+ Object.keys(this.filters).forEach(function (filterId) {
+ var filter = self.filters[filterId];
+ // if we are here, this means there is no favorite with isDefault set to true
+ if (filter.isDefault && filter.type !== 'favorite') {
+ if (filter.hasOptions) {
+ self.toggleFilterWithOptions(filter.id);
+ } else {
+ self.toggleFilter(filter.id);
+ }
+ }});
+ },
+ /**
+ * If defaultTimeRanges param is provided, activate the filter of type
+ * 'timeRange' it determines with the correct options.
+ *
+ * @private
+ * @param {Object} [defaultTimeRanges]
+ * @param {string} [defaultTimeRanges.field], field of type 'date' or 'datetime'
+ * @param {string} [defaultTimeRanges.range], to choose among the following:
+ * 'last_7_days', 'last_30_days', 'last_365_days', 'today', 'this_week',
+ * 'this_month', 'this_quarter', 'this_year', 'yesterday', 'last_week',
+ * 'last_month', 'last_quarter', 'last_year'
+ * @param {'previous_period'|'previous_year'} [defaultTimeRanges.comparisonRange]
+ */
+ _activateDefaultTimeRanges: function (defaultTimeRanges) {
+ var self = this;
+ if (defaultTimeRanges) {
+ var filterId = Object.keys(this.filters).find(function (filterId) {
+ var filter = self.filters[filterId];
+ return filter.type === 'timeRange' && filter.fieldName === defaultTimeRanges.field;
+ });
+ if (filterId) {
+ this.activateTimeRange(
+ filterId,
+ defaultTimeRanges.range,
+ defaultTimeRanges.comparisonRange
+ );
+ }
+ }
+ },
+ /**
+ * Create a new filter of type 'favorite' and toggle it.
+ * It belongs to the unique group of favorites.
+ *
+ * @private
+ * @param {Object} favorite
+ */
+ _addNewFavorite: function (favorite) {
+ var id = _.uniqueId('__filter__');
+ favorite.id = id;
+ favorite.groupId = this._getGroupIdOfType('favorite');
+ this.filters[id] = favorite;
+ this.toggleFilter(favorite.id);
+ },
+ /**
+ * Add a new empty group to this.groups of a specified type.
+ *
+ * @private
+ * @param {string} type
+ */
+ _createEmptyGroup: function (type) {
+ var id = _.uniqueId('__group__');
+ this.groups[id] = {
+ id: id,
+ type: type,
+ activeFilterIds: [],
+ };
+ },
+ /**
+ * Using a list of 'prefilters', create a new group in this.groups and a new
+ * filter for each prefilter. The new filters are put in the new group.
+ *
+ * @private
+ * @param {Object[]} group, list of 'prefilters'
+ */
+ _createGroupOfFilters: function (group) {
+ var self= this;
+ var type;
+ var groupId = _.uniqueId('__group__');
+ group.forEach(function (filter) {
+ var id = _.uniqueId('__filter__');
+ filter.id = id;
+ filter.groupId = groupId;
+ type = filter.type;
+ self.filters[id] = filter;
+ });
+ this.groups[groupId] = {
+ id: groupId,
+ type: type,
+ activeFilterIds: [],
+ };
+ },
+ /**
+ * Add a group of type 'timeRange' in this.groups and generate a filter
+ * of the same type for each suitable field in this.fields. The new filters
+ * are put in the new group.
+ *
+ * @private
+ */
+ _createGroupOfTimeRanges: function () {
+ var self = this;
+ var timeRanges = [];
+ Object.keys(this.fields).forEach(function (fieldName) {
+ var field = self.fields[fieldName];
+ var fieldType = field.type;
+ if (_.contains(['date', 'datetime'], fieldType) && field.sortable) {
+ timeRanges.push({
+ type: 'timeRange',
+ description: field.string,
+ fieldName : fieldName,
+ fieldType: fieldType,
+ timeRangeId: false,
+ comparisonTimeRangeId: false,
+ defaultTimeRangeId: DEFAULT_TIMERANGE,
+ timeRangeOptions: TIME_RANGE_OPTIONS,
+ comparisonTimeRangeOptions: COMPARISON_TIME_RANGE_OPTIONS
+ });
+ }
+ });
+ if (timeRanges.length) {
+ this._createGroupOfFilters(timeRanges);
+ } else {
+ // create empty timeRange group
+ this._createEmptyGroup('timeRange');
+ }
+ },
+ /**
+ * Create a new groupNumber not already used elsewhere.
+ * Group numbers are used to separate graphically groups of items in the
+ * search menus (filter menu, groupBy menu,...).
+ *
+ * @private
+ * @returns {number} groupNumber
+ */
+ _generateNewGroupNumber: function () {
+ var self = this;
+ var groupNumber = 1 + Object.keys(this.filters).reduce(
+ function (max, filterId) {
+ var filter = self.filters[filterId];
+ if (filter.groupNumber) {
+ max = Math.max(filter.groupNumber, max);
+ }
+ return max;
+ },
+ 1
+ );
+ return groupNumber;
+ },
+ /**
+ * @private
+ * @param {Object} filter
+ * @returns {string} domain
+ */
+ _getAutoCompletionFilterDomain: function (filter) {
+ var domain = "";
+ var field = this.fields[filter.attrs.name];
+ // TODO: should not do that, the domain logic should be put somewhere else
+ var Obj = searchBarAutocompleteRegistry.getAny([filter.attrs.widget, field.type]);
+ if (Obj) {
+ var obj = new (Obj) (this, filter, field, this.actionContext);
+ domain = obj.getDomain(filter.autoCompleteValues);
+ }
+ return domain;
+ },
+ /**
+ * Return the string representation of a domain created by combining
+ * appropriately (with an 'AND') the domains coming from the active groups.
+ *
+ * @private
+ * @returns {string} the string representation of a domain
+ */
+ _getDomain: function () {
+ var self = this;
+ var domains = this.query.map(function (groupId) {
+ var group = self.groups[groupId];
+ return self._getGroupDomain(group);
+ });
+ return pyUtils.assembleDomains(domains, 'AND');
+ },
+ /**
+ * Return the context of the provided filter.
+ *
+ * @private
+ * @param {Object} filter
+ * @returns {Object} context
+ */
+ _getFilterContext: function (filter) {
+ var context = {};
+ if (filter.type === 'favorite') {
+ _.extend(context, filter.context);
+ }
+ // the following code aims to restore this:
+ // https://github.com/odoo/odoo/blob/master/addons/web/static/src/js/views/search/search_inputs.js#L498
+ // this is required for the helpdesk tour to pass
+ // this seems weird to only do that for m2o fields, but a test fails if
+ // we do it for other fields (my guess being that the test should simply
+ // be adapted)
+ if (filter.type === 'field' && filter.isDefault) {
+ if (this.fields[filter.attrs.name].type === 'many2one') {
+ var value = filter.defaultValue;
+ // the following if required to make the main_flow_tour pass (see
+ // https://github.com/odoo/odoo/blob/master/addons/web/static/src/js/views/search/search_inputs.js#L461)
+ if (_.isArray(filter.defaultValue)) {
+ value = filter.defaultValue[0];
+ }
+ context['default_' + filter.attrs.name] = value;
+ }
+ }
+ return context;
+ },
+ /**
+ * Compute (if possible) the domain of the provided filter.
+ *
+ * @private
+ * @param {Object} filter
+ * @returns {string|undefined} domain, string representation of a domain
+ */
+ _getFilterDomain: function (filter) {
+ var domain;
+ if (filter.type === 'filter') {
+ domain = filter.domain;
+ if (filter.domain === undefined) {
+ domain = Domain.prototype.constructDomain(
+ filter.fieldName,
+ filter.currentOptionId,
+ filter.fieldType
+ );
+ }
+ }
+ if (filter.type === 'favorite') {
+ domain = filter.domain;
+ }
+ if (filter.type === 'field') {
+ domain = filter.domain;
+ }
+ return domain;
+ },
+ /**
+ * Compute the groupBys (if possible) of the provided filter.
+ *
+ * @private
+ * @param {Object} filter
+ * @returns {string[]|undefined} groupBys
+ */
+ _getFilterGroupBys: function (filter) {
+ var groupBys;
+ if (filter.type === 'groupBy') {
+ var groupBy = filter.fieldName;
+ if (filter.currentOptionId) {
+ groupBy = groupBy + ':' + filter.currentOptionId;
+ }
+ groupBys = [groupBy];
+ }
+ if (filter.type === 'favorite') {
+ groupBys = filter.groupBys;
+ }
+ return groupBys;
+ },
+ /**
+ * Return the concatenation of groupBys comming from the active filters.
+ * The array this.query encoding the order in which the groups have been
+ * activated, the results respect the appropriate logic: the groupBys
+ * coming from an active favorite (if any) come first, then come the
+ * groupBys comming from the active filters of type 'groupBy'.
+ *
+ * @private
+ * @returns {string[]} groupBys
+ */
+ _getGroupBy: function () {
+ var self = this;
+ var groupBys = this.query.reduce(
+ function (acc, groupId) {
+ var group = self.groups[groupId];
+ return acc.concat(self._getGroupGroupBys(group));
+ },
+ []
+ );
+ return groupBys;
+ },
+ /**
+ * Return the list of the contexts of the filters acitve in the given
+ * group.
+ *
+ * @private
+ * @param {Object} group
+ * @returns {Object[]}
+ */
+ _getGroupContexts: function (group) {
+ var self = this;
+ var contexts = group.activeFilterIds.map(function (filterId) {
+ var filter = self.filters[filterId];
+ return self._getFilterContext(filter);
+ });
+ return _.compact(contexts);
+ },
+ /**
+ * Return the string representation of a domain created by combining
+ * appropriately (with an 'OR') the domains coming from the filters
+ * active in the given group.
+ *
+ * @private
+ * @param {Object} group
+ * @returns {string} string representation of a domain
+ */
+ _getGroupDomain: function (group) {
+ var self = this;
+ var domains = group.activeFilterIds.map(function (filterId) {
+ var filter = self.filters[filterId];
+ return self._getFilterDomain(filter);
+ });
+ return pyUtils.assembleDomains(_.compact(domains), 'OR');
+ },
+ /**
+ * Return the groupBys coming form the filtes active in the given group.
+ *
+ * @private
+ * @param {Object} group
+ * @returns {string[]}
+ */
+ _getGroupGroupBys: function (group) {
+ var self = this;
+ var groupBys = group.activeFilterIds.reduce(
+ function (acc, filterId) {
+ var filter = self.filters[filterId];
+ acc = acc.concat(self._getFilterGroupBys(filter));
+ return acc;
+ },
+ []
+ );
+ return _.compact(groupBys);
+ },
+ /**
+ * Return the id of the group with the provided type.
+ *
+ * @private
+ * @param {'groupBy'|'favorite'|'timeRange'} type
+ * @returns {string}
+ */
+ _getGroupIdOfType: function (type) {
+ var self = this;
+ return Object.keys(this.groups).find(function (groupId) {
+ var group = self.groups[groupId];
+ return group.type === type;
+ });
+ },
+ /**
+ * Used to get the key orderedBy of a favorite.
+ *
+ * @private
+ * @returns {Object[]} orderedBy
+ */
+ _getOrderedBy: function () {
+ var orderedBy;
+ var id = this._getGroupIdOfType('favorite');
+ if (this.query.indexOf(id) !== -1) {
+ // if we are here, this means that the group of favorite is
+ // active and activeFilterIds is a list of length one.
+ var group = this.groups[id];
+ var activeFavoriteId = group.activeFilterIds[0];
+ var favorite = this.filters[activeFavoriteId];
+ orderedBy = favorite.orderedBy;
+ }
+ return orderedBy;
+ },
+ /**
+ * Return the list of the contexts of active filters.
+ *
+ * @private
+ * @returns {Object[]}
+ */
+ _getQueryContext: function () {
+ var self = this;
+ var contexts = this.query.reduce(
+ function (acc, groupId) {
+ var group = self.groups[groupId];
+ acc = acc.concat(self._getGroupContexts(group));
+ return acc;
+ },
+ []
+ );
+ return _.compact(contexts);
+ },
+ /**
+ * Return an empty object or an object with a key timeRangeMenuData
+ * containing info on time ranges and their descriptions if a filter of type
+ * 'timeRange' is activated (only one can be).
+ * The key timeRange and comparisonTimeRange will be string or array
+ * representation of domains according to the value of evaluation:
+ * array if evaluation is true, string if false.
+ *
+ * @private
+ * @param {boolean} [evaluation=false]
+ * @returns {Object}
+ */
+ _getTimeRangeMenuData: function (evaluation) {
+ var context = {};
+ // groupOfTimeRanges can be undefined in case with withSearchBar is false
+ var groupOfTimeRanges = this.groups[this._getGroupIdOfType('timeRange')];
+ if (groupOfTimeRanges && groupOfTimeRanges.activeFilterIds.length) {
+ var filter = this.filters[groupOfTimeRanges.activeFilterIds[0]];
+
+ var comparisonTimeRange = "[]";
+ var comparisonTimeRangeDescription;
+
+ var timeRange = Domain.prototype.constructDomain(
+ filter.fieldName,
+ filter.timeRangeId,
+ filter.fieldType
+ );
+ var timeRangeDescription = filter.timeRangeOptions.find(function (option) {
+ return option.optionId === filter.timeRangeId;
+ }).description.toString();
+ if (filter.comparisonTimeRangeId) {
+ comparisonTimeRange = Domain.prototype.constructDomain(
+ filter.fieldName,
+ filter.timeRangeId,
+ filter.fieldType,
+ null,
+ filter.comparisonTimeRangeId
+ );
+ comparisonTimeRangeDescription = filter.comparisonTimeRangeOptions.find(function (comparisonOption) {
+ return comparisonOption.optionId === filter.comparisonTimeRangeId;
+ }).description.toString();
+ }
+ if (evaluation) {
+ timeRange = Domain.prototype.stringToArray(timeRange);
+ comparisonTimeRange = Domain.prototype.stringToArray(comparisonTimeRange);
+ }
+ context = {
+ timeRangeMenuData: {
+ timeRange: timeRange,
+ timeRangeDescription: timeRangeDescription,
+ comparisonTimeRange: comparisonTimeRange,
+ comparisonTimeRangeDescription: comparisonTimeRangeDescription,
+ }
+ };
+ }
+ return context;
+ },
+ /**
+ * Load custom filters in db, then create a group of type 'favorite' and a
+ * filter of type 'favorite' for each loaded custom filters.
+ * The new filters are put in the new group.
+ * Finally, if there exists (a necessarily unique) default favorite, it is activated
+ * if this.activateDefaultFavorite is true.
+ *
+ * @private
+ * @returns {Deferred}
+ */
+ _loadFavorites: function () {
+ var self = this;
+ var def = this.loadFilters(this.modelName,this.actionId).then(function (favorites) {
+ if (favorites.length) {
+ favorites = favorites.map(function (favorite) {
+ var userId = favorite.user_id ? favorite.user_id[0] : false;
+ var groupNumber = userId ? 1 : 2;
+ var context = pyUtils.eval('context', favorite.context, session.user_context);
+ var groupBys = [];
+ if (context.group_by) {
+ groupBys = context.group_by;
+ delete context.group_by;
+ }
+ var sort = JSON.parse(favorite.sort);
+ var orderedBy = sort.map(function (order) {
+ var orderTerms = order.split(' ');
+ return {
+ name: orderTerms[0],
+ asc: orderTerms.length === 2 && orderTerms[1] === 'asc',
+ };
+ });
+ return {
+ type: 'favorite',
+ description: favorite.name,
+ isRemovable: true,
+ groupNumber: groupNumber,
+ isDefault: favorite.is_default,
+ domain: favorite.domain,
+ groupBys: groupBys,
+ // we want to keep strings as long as possible
+ context: favorite.context,
+ orderedBy: orderedBy,
+ userId: userId,
+ serverSideId: favorite.id,
+ };
+ });
+ self._createGroupOfFilters(favorites);
+ if (self.activateDefaultFavorite) {
+ var defaultFavoriteId = Object.keys(self.filters).find(function (filterId) {
+ var filter = self.filters[filterId];
+ return filter.type === 'favorite' && filter.isDefault;
+ });
+ if (defaultFavoriteId) {
+ self.toggleFilter(defaultFavoriteId);
+ }
+ }
+ } else {
+ self._createEmptyGroup('favorite');
+ }
+ });
+ return def;
+ },
+ /**
+ * Load search defaults and set the `domain` key on filter (of type `field`).
+ * Some search defaults need to fetch data (like m2o for example) so this
+ * is asynchronous.
+ *
+ * @private
+ * @returns {Deferred[]}
+ */
+ _loadSearchDefaults: function () {
+ var self = this;
+ var defs = [];
+ _.each(this.filters, function (filter) {
+ if (filter.type === 'field' && filter.isDefault) {
+ var def;
+ var field = self.fields[filter.attrs.name];
+ var value = filter.defaultValue;
+ if (field.type === 'many2one') {
+ if (value instanceof Array) {
+ // M2O search fields do not currently handle multiple default values
+ // there are many cases of {search_default_$m2ofield: [id]}, need
+ // to handle this as if it were a single value.
+ value = value[0];
+ }
+ def = self._rpc({
+ model: field.relation,
+ method: 'name_get',
+ args: [value],
+ context: self.actionContext,
+ }).then(function (result) {
+ var autocompleteValue = {
+ label: result[0][1],
+ value: value,
+ };
+ filter.autoCompleteValues.push(autocompleteValue);
+ filter.domain = self._getAutoCompletionFilterDomain(filter);
+ });
+ } else {
+ var autocompleteValue;
+ if (field.type === 'selection') {
+ var match = _.find(field.selection, function (sel) {
+ return sel[0] === value;
+ });
+ autocompleteValue = {
+ label: match[1],
+ value: match[0],
+ };
+ } else {
+ autocompleteValue = {
+ label: String(value),
+ value: value,
+ };
+ }
+ filter.autoCompleteValues.push(autocompleteValue);
+ filter.domain = self._getAutoCompletionFilterDomain(filter);
+ }
+ if (def) {
+ defs.push(def);
+ }
+ }
+ });
+ return defs;
+ },
+ /**
+ * Compute the search Query and save it as an ir.filter in db.
+ * No evaluation of domains is done in order to keep them dynamic.
+ * If the operatio is successful, a new filter of type 'favorite' is
+ * created and activated.
+ *
+ * @private
+ * @param {Object} favorite
+ * @returns {Deferred}
+ */
+ _saveQuery: function (favorite) {
+ var self = this;
+ var userContext = session.user_context;
+ var controllerQueryParams;
+ this.trigger_up('get_controller_query_params', {
+ callback: function (state) {
+ controllerQueryParams = state;
+ },
+ });
+ var queryContext = this._getQueryContext();
+ var timeRangeMenuInfo = this._getTimeRangeMenuData(false);
+ var context = pyUtils.eval(
+ 'contexts',
+ [userContext, controllerQueryParams.context, timeRangeMenuInfo].concat(queryContext)
+ );
+ context = _.omit(context, Object.keys(userContext));
+ var groupBys = this._getGroupBy();
+ if (groupBys.length) {
+ context.group_by = groupBys;
+ }
+ var domain = this._getDomain();
+ var userId = favorite.isShared ? false : session.uid;
+ var orderedBy = this._getOrderedBy() || [];
+ if (controllerQueryParams.orderedBy) {
+ orderedBy = controllerQueryParams.orderedBy;
+ }
+ var sort = orderedBy.map(function (order) {
+ return order.name + ((order.asc === false) ? " desc" : "");
+ });
+
+ var irFilter = {
+ name: favorite.description,
+ context: context,
+ domain: domain,
+ is_default: favorite.isDefault,
+ user_id: userId,
+ model_id: this.modelName,
+ action_id: this.actionId,
+ sort: JSON.stringify(sort),
+ };
+ return this.createFilter(irFilter).then(function (serverSideId) {
+ // we don't want the groupBys to be located in the context in control panel model
+ delete context.group_by;
+ favorite.isRemovable = true;
+ favorite.groupNumber = userId ? 1 : 2;
+ favorite.context = context;
+ favorite.groupBys = groupBys;
+ favorite.domain = domain;
+ favorite.orderedBy = orderedBy;
+ // not sure keys are usefull
+ favorite.userId = userId;
+ favorite.serverSideId = serverSideId;
+ self._addNewFavorite(favorite);
+ });
+ },
+});
+
+return ControlPanelModel;
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/control_panel_renderer.js b/addons/web/static/src/js/views/control_panel/control_panel_renderer.js
new file mode 100644
index 00000000000..eea7fdae430
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/control_panel_renderer.js
@@ -0,0 +1,387 @@
+odoo.define('web.ControlPanelRenderer', function (require) {
+"use strict";
+
+var config = require('web.config');
+var data = require('web.data');
+var FavoriteMenu = require('web.FavoriteMenu');
+var FilterMenu = require('web.FilterMenu');
+var GroupByMenu = require('web.GroupByMenu');
+var mvc = require('web.mvc');
+var SearchBar = require('web.SearchBar');
+var TimeRangeMenu = require('web.TimeRangeMenu');
+
+var Renderer = mvc.Renderer;
+
+var ControlPanelRenderer = Renderer.extend({
+ template: 'ControlPanel',
+ custom_events: {
+ get_action_info: '_onGetActionInfo',
+ },
+ events: _.extend({}, Renderer.prototype.events, {
+ 'click.bs.dropdown .o_search_options .dropdown-menu': '_onDropdownClicked',
+ 'click .o_searchview_more': '_onMore',
+ }),
+
+ /**
+ * @override
+ * @param {Object} [params.action] current action if any
+ * @param {Object} [params.context]
+ * @param {Object[]} [params.breadcrumbs=[]] list of breadcrumbs elements
+ * @param {boolean} [params.withBreadcrumbs=false] if false, breadcrumbs
+ * won't be rendered
+ * @param {boolean} [params.withSearchBar=false] if false, no search bar
+ * is rendered
+ * @param {string[]} [params.searchMenuTypes=[]] determines the search menus
+ * that are displayed.
+ * @param {String} [params.template] the QWeb template to render the
+ * ControlPanel. By default, the template 'ControlPanel' will be used.
+ * @param {string} [params.title=''] the title visible in control panel
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+ this._breadcrumbs = params.breadcrumbs || [];
+ this._title = params.title || '';
+ this.withBreadcrumbs = params.withBreadcrumbs;
+ this.withSearchBar = params.withSearchBar;
+ if (params.template) {
+ this.template = params.template;
+ }
+ this.context = params.context;
+
+ this.$subMenus = null;
+ this.action = params.action;
+ this.displaySearchMenu = true;
+ this.menusSetup = false;
+ this.searchMenuTypes = params.searchMenuTypes || [];
+ this.subMenus = {};
+ },
+ /**
+ * Render the control panel and create a dictionnary of its exposed elements.
+ *
+ * @override
+ */
+ start: function () {
+ var self = this;
+
+ // exposed jQuery nodesets
+ this.nodes = {
+ $buttons: this.$('.o_cp_buttons'),
+ $pager: this.$('.o_cp_pager'),
+ $sidebar: this.$('.o_cp_sidebar'),
+ $switch_buttons: this.$('.o_cp_switch_buttons'),
+ };
+
+ // if we don't use the default search bar and buttons, we expose the
+ // corresponding areas for custom content
+ if (!this.withSearchBar) {
+ this.nodes.$searchview = this.$('.o_cp_searchview');
+ }
+ if (this.searchMenuTypes.length === 0) {
+ this.nodes.$searchview_buttons = this.$('.o_search_options');
+ }
+
+ if (this.withBreadcrumbs) {
+ this._renderBreadcrumbs();
+ }
+
+ var superDef = this._super.apply(this, arguments);
+ var searchDef = this._renderSearch();
+ return $.when(superDef, searchDef).then(function () {
+ self._setSearchMenusVisibility();
+ });
+ },
+ /**
+ * @override
+ */
+ on_attach_callback: function () {
+ this._focusSearchInput();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {Object}
+ */
+ getLastFacet: function () {
+ return this.state.facets.slice(-1)[0];
+ },
+ /**
+ * This function is called when actions call 'updateControlPanel' with
+ * custom contents to insert in the exposed areas.
+ *
+ * @param {Object} status
+ * @param {Object} [status.cp_content] dictionnary containing the jQuery
+ * elements to insert in the exposed areas
+ * @param {string} [status.breadcrumbs] the breadcrumbs to display before
+ * the current controller
+ * @param {string} [status.title] the title of the current controller, to
+ * display at the end of the breadcrumbs
+ * @param {Object} [options]
+ * @param {Boolean} [options.clear=true] set to false to keep control panel
+ * elements that are not in status.cp_content (useful for partial updates)
+ */
+ updateContents: function (status, options) {
+ var new_cp_content = status.cp_content || {};
+ var clear = 'clear' in (options || {}) ? options.clear : true;
+
+ if (this.withBreadcrumbs) {
+ this._breadcrumbs = status.breadcrumbs || this._breadcrumbs;
+ this._title = status.title || this._title;
+ this._renderBreadcrumbs();
+ }
+
+ if (clear) {
+ this._detachContent(this.nodes);
+ } else {
+ this._detachContent(_.pick(this.nodes, _.keys(new_cp_content)));
+ }
+ this._attachContent(new_cp_content);
+ },
+ /**
+ * Update the state of the renderer state. It retriggers a full rerendering.
+ *
+ * @param {Object} state
+ * @returns {Deferred}
+ */
+ updateState: function (state) {
+ this.state = state;
+ return this._renderSearch();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} content dictionnary of jQuery elements to attach, whose
+ * keys are jQuery nodes identifiers in this.nodes
+ */
+ _attachContent: function (content) {
+ for (var $element in content) {
+ var $nodeset = content[$element];
+ if ($nodeset && this.nodes[$element]) {
+ this.nodes[$element].append($nodeset);
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {Object} content subset of this.nodes to detach
+ */
+ _detachContent: function (content) {
+ for (var $element in content) {
+ content[$element].contents().detach();
+ }
+ },
+ /**
+ * @private
+ */
+ _focusSearchInput: function () {
+ if (this.withSearchBar && !config.device.isMobile) {
+ // in mobile mode, we would rather not focus manually the
+ // input, because it opens up the integrated keyboard, which is
+ // not what you expect when you just selected a filter.
+ this.searchBar.focus();
+ }
+ },
+ /**
+ * @private
+ * @param {string} menuType
+ * @returns {Objects[]} menuItems
+ */
+ _getMenuItems: function (menuType) {
+ var menuItems;
+ if (menuType === 'filter') {
+ menuItems = this.state.filters;
+ }
+ if (menuType === 'groupBy') {
+ menuItems = this.state.groupBys;
+ }
+ if (menuType === 'timeRange') {
+ menuItems = this.state.timeRanges;
+ }
+ if (menuType === 'favorite') {
+ menuItems = this.state.favorites;
+ }
+ return menuItems;
+ },
+ /**
+ * @private
+ * @returns {jQueryElement}
+ */
+ _getSubMenusPlace: function () {
+ return $('<div>').appendTo(this.$('.o_search_options'));
+ },
+ /**
+ * @private
+ */
+ _renderBreadcrumbs: function () {
+ var self = this;
+ var breadcrumbsDescriptors = this._breadcrumbs.concat({title: this._title});
+ var breadcrumbs = breadcrumbsDescriptors.map(function (bc, index) {
+ return self._renderBreadcrumbsItem(bc, index, breadcrumbsDescriptors.length);
+ });
+ this.$('.breadcrumb').html(breadcrumbs);
+ },
+ /**
+ * Render a breadcrumbs' li jQuery element.
+ *
+ * @private
+ * @param {Object} bc
+ * @param {string} bc.title
+ * @param {string} bc.controllerID
+ * @param {integer} index
+ * @param {integer} length
+ * @returns {jQueryElement} $bc
+ */
+ _renderBreadcrumbsItem: function (bc, index, length) {
+ var self = this;
+ var is_last = (index === length-1);
+ var li_content = bc.title && _.escape(bc.title.trim()) || data.noDisplayContent;
+ var $bc = $('<li>', {class: 'breadcrumb-item'})
+ .append(is_last ? li_content : $('<a>', {href: '#'}).html(li_content))
+ .toggleClass('active', is_last);
+ if (!is_last) {
+ $bc.click(function (ev) {
+ ev.preventDefault();
+ self.trigger_up('breadcrumb_clicked', {controllerID: bc.controllerID});
+ });
+ }
+ return $bc;
+ },
+ /**
+ * Renderer the search bar and the search menus
+ *
+ * @private
+ * @returns {Deferred}
+ */
+ _renderSearch: function () {
+ var defs = [];
+ if (this.menusSetup) {
+ this._updateMenus();
+ } else {
+ this.menusSetup = true;
+ defs = defs.concat(this._setupMenus());
+ }
+ if (this.withSearchBar) {
+ defs.push(this._renderSearchBar());
+ }
+ return $.when(this, defs).then(this._focusSearchInput.bind(this));
+ },
+ /**
+ * @private
+ * @returns {Deferred}
+ */
+ _renderSearchBar: function () {
+ // TODO: might need a reload instead of a destroy/instantiate
+ var oldSearchBar = this.searchBar;
+ this.searchBar = new SearchBar(this, {
+ context: this.context,
+ facets: this.state.facets,
+ fields: this.state.fields,
+ filters: this.state.filters,
+ filterFields: this.state.filterFields,
+ groupBys: this.state.groupBys,
+ });
+ return this.searchBar.appendTo(this.$('.o_searchview')).then(function () {
+ if (oldSearchBar) {
+ oldSearchBar.destroy();
+ }
+ });
+ },
+ /**
+ * Hide or show the search menus according to this.displaySearchMenu.
+ *
+ * @private
+ */
+ _setSearchMenusVisibility: function () {
+ this.$('.o_searchview_more')
+ .toggleClass('fa-search-plus', !this.displaySearchMenu)
+ .toggleClass('fa-search-minus', this.displaySearchMenu);
+ this.$('.o_search_options')
+ .toggleClass('o_hidden', !this.displaySearchMenu);
+ },
+ /**
+ * Create a new menu of the given type and append it to this.$subMenus.
+ * This menu is also added to this.subMenus.
+ *
+ * @private
+ * @param {string} menuType
+ * @returns {Deferred}
+ */
+ _setupMenu: function (menuType) {
+ var Menu;
+ var menu;
+ if (menuType === 'filter') {
+ Menu = FilterMenu;
+ }
+ if (menuType === 'groupBy') {
+ Menu = GroupByMenu;
+ }
+ if (menuType === 'timeRange') {
+ Menu = TimeRangeMenu;
+ }
+ if (menuType === 'favorite') {
+ Menu = FavoriteMenu;
+ }
+ if (_.contains(['filter', 'groupBy', 'timeRange'], menuType)) {
+ menu = new Menu(this, this._getMenuItems(menuType), this.state.fields);
+ }
+ if (menuType === 'favorite') {
+ menu = new Menu(this, this._getMenuItems(menuType), this.action);
+ }
+ this.subMenus[menuType] = menu;
+ return menu.appendTo(this.$subMenus);
+ },
+ /**
+ * Instantiate the search menu determined by this.searchMenuTypes.
+ *
+ * @private
+ * @returns {Deferred[]}
+ */
+ _setupMenus: function () {
+ this.$subMenus = this._getSubMenusPlace();
+ return this.searchMenuTypes.map(this._setupMenu.bind(this));
+ },
+ /**
+ * Update the search menus.
+ *
+ * @private
+ */
+ _updateMenus: function () {
+ var self = this;
+ this.searchMenuTypes.forEach(function (menuType) {
+ self.subMenus[menuType].update(self._getMenuItems(menuType));
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Prevent the search dropdowns from closing when clicking inside them.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onDropdownClicked: function (ev) {
+ ev.stopPropagation();
+ },
+ /**
+ * Toggle the search menus visibility.
+ *
+ * @private
+ */
+ _onMore: function () {
+ this.displaySearchMenu = !this.displaySearchMenu;
+ this._setSearchMenusVisibility();
+ },
+});
+
+return ControlPanelRenderer;
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/control_panel_view.js b/addons/web/static/src/js/views/control_panel/control_panel_view.js
new file mode 100644
index 00000000000..3ba7afb2a60
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/control_panel_view.js
@@ -0,0 +1,238 @@
+odoo.define('web.ControlPanelView', function (require) {
+"use strict";
+
+var ControlPanelController = require('web.ControlPanelController');
+var ControlPanelModel = require('web.ControlPanelModel');
+var ControlPanelRenderer = require('web.ControlPanelRenderer');
+var controlPanelViewParameters = require('web.controlPanelViewParameters');
+var mvc = require('web.mvc');
+var pyUtils = require('web.py_utils');
+var viewUtils = require('web.viewUtils');
+
+var DEFAULT_INTERVAL = controlPanelViewParameters.DEFAULT_INTERVAL;
+var DEFAULT_PERIOD = controlPanelViewParameters.DEFAULT_PERIOD;
+var INTERVAL_OPTIONS = controlPanelViewParameters.INTERVAL_OPTIONS;
+var PERIOD_OPTIONS = controlPanelViewParameters.PERIOD_OPTIONS;
+
+var Factory = mvc.Factory;
+
+var ControlPanelView = Factory.extend({
+ config: _.extend({}, Factory.prototype.config, {
+ Controller: ControlPanelController,
+ Model: ControlPanelModel,
+ Renderer: ControlPanelRenderer,
+ }),
+
+ /**
+ * @override
+ * @param {Object} [params={}]
+ * @param {Object} [params.action={}]
+ * @param {Object} [params.context={}]
+ * @param {string} [params.domain=[]]
+ * @param {string} [params.modelName]
+ * @param {string[]} [params.searchMenuTypes=[]]
+ * determines search menus displayed.
+ * @param {Object} [params.state] used to determine the control panel model
+ * essential content at load. For instance, state can be the state of an
+ * other control panel model that we want to use.
+ * @param {string} [params.template] the QWeb template to render
+ * @param {Object} [params.viewInfo={arch: '<search/>', fields: {}}] a
+ * search fieldsview
+ * @param {string} [params.viewInfo.arch]
+ * @param {boolean} [params.withBreadcrumbs=true] if set to false,
+ * breadcrumbs won't be rendered
+ * @param {boolean} [params.withSearchBar=true] if set to false, no default
+ * search bar will be rendered
+ * @param {boolean} [activateDefaultFavorite=false] determine if the default
+ * custom filters can be activated
+ */
+ init: function (params) {
+ var self = this;
+ this._super();
+ params = params || {};
+ var viewInfo = params.viewInfo || {arch: '<search/>', fields: {}};
+ var context = _.extend({}, params.context);
+ var domain = params.domain || [];
+ var action = params.action || {};
+
+ this.searchDefaults = {};
+ Object.keys(context).forEach(function (key) {
+ var match = /^search_default_(.*)$/.exec(key);
+ if (match) {
+ self.searchDefaults[match[1]] = context[key];
+ delete context[key];
+ }
+ });
+
+ this.arch = viewUtils.parseArch(viewInfo.arch);
+ this.fields = viewInfo.fields;
+
+ this.controllerParams.modelName = params.modelName;
+
+ this.modelParams.context = context;
+ this.modelParams.domain = domain;
+ this.modelParams.modelName = params.modelName;
+ this.modelParams.actionId = action.id;
+ this.modelParams.fields = this.fields;
+
+ this.rendererParams.action = action;
+ this.rendererParams.breadcrumbs = params.breadcrumbs;
+ this.rendererParams.context = context;
+ this.rendererParams.searchMenuTypes = params.searchMenuTypes || [];
+ this.rendererParams.template = params.template;
+ this.rendererParams.withBreadcrumbs = params.withBreadcrumbs !== false;
+ this.rendererParams.withSearchBar = 'withSearchBar' in params ? params.withSearchBar : true;
+
+ this.loadParams.withSearchBar = 'withSearchBar' in params ? params.withSearchBar : true;
+ this.loadParams.searchMenuTypes = params.searchMenuTypes || [];
+ this.loadParams.activateDefaultFavorite = params.activateDefaultFavorite;
+ if (this.loadParams.withSearchBar) {
+ if (params.state) {
+ this.loadParams.initialState = params.state;
+ } else {
+ // groups are determined in _parseSearchArch
+ this.loadParams.groups = [];
+ this.loadParams.timeRanges = context.time_ranges;
+ this._parseSearchArch(this.arch);
+ }
+ }
+
+ PERIOD_OPTIONS = PERIOD_OPTIONS.map(function (option) {
+ return _.extend(option, {description: option.description.toString()});
+ });
+ INTERVAL_OPTIONS = INTERVAL_OPTIONS.map(function (option) {
+ return _.extend(option, {description: option.description.toString()});
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} child parsed arch node
+ * @returns {Object}
+ */
+ _evalArchChild: function (child) {
+ if (child.attrs.context) {
+ try {
+ var context = pyUtils.eval('context', child.attrs.context);
+ if (context.group_by) {
+ // let us extract basic data since we just evaluated context
+ // and use a correct tag!
+ child.tag = 'groupBy';
+ child.attrs.fieldName = context.group_by.split(':')[0];
+ child.attrs.defaultInterval = context.group_by.split(':')[1];
+ }
+ } catch (e) {}
+ }
+ return child;
+ },
+ /**
+ * @private
+ * @param {Object} filter
+ * @param {Object} attrs
+ */
+ _extractAttributes: function (filter, attrs) {
+ filter.isDefault = this.searchDefaults[attrs.name] ? true : false;
+ filter.description = attrs.string ||
+ attrs.help ||
+ attrs.name ||
+ attrs.domain ||
+ 'Ω';
+ if (filter.type === 'filter') {
+ filter.domain = attrs.domain;
+ if (attrs.date) {
+ filter.fieldName = attrs.date;
+ filter.fieldType = this.fields[attrs.date].type;
+ // we should be able to declare list of options per date filter
+ // (request of POs) (same remark for groupbys)
+ filter.hasOptions = true;
+ filter.options = PERIOD_OPTIONS;
+ filter.defaultOptionId = attrs.default_period ||
+ DEFAULT_PERIOD;
+ filter.currentOptionId = false;
+ }
+ } else if (filter.type === 'groupBy') {
+ filter.fieldName = attrs.fieldName;
+ filter.fieldType = this.fields[attrs.fieldName].type;
+ if (_.contains(['date', 'datetime'], filter.fieldType)) {
+ filter.hasOptions = true;
+ filter.options = INTERVAL_OPTIONS;
+ filter.defaultOptionId = attrs.defaultInterval ||
+ DEFAULT_INTERVAL;
+ filter.currentOptionId = false;
+ }
+ } else if (filter.type === 'field') {
+ var field = this.fields[attrs.name];
+ filter.attrs = attrs;
+ filter.autoCompleteValues = [];
+ if (filter.isDefault) {
+ // on field, default can be used with a value
+ filter.defaultValue = this.searchDefaults[filter.attrs.name];
+ }
+ if (!attrs.string) {
+ attrs.string = field.string;
+ }
+ }
+ },
+ /**
+ * Parse the arch of a 'search' view.
+ *
+ * @private
+ * @param {Object} arch arch with root node <search>
+ */
+ _parseSearchArch: function (arch) {
+ var self = this;
+ var preFilters = _.flatten(arch.children.map(function (child) {
+ return child.tag !== 'group' ?
+ self._evalArchChild(child) :
+ child.children.map(self._evalArchChild);
+ }));
+ preFilters.push({tag: 'separator'});
+
+ var filter;
+ var currentTag;
+ var currentGroup = [];
+ var groupOfGroupBys = [];
+ var groupNumber = 1;
+
+ _.each(preFilters, function (preFilter) {
+ if (preFilter.tag !== currentTag || _.contains(['separator', 'field'], preFilter.tag)) {
+ if (currentGroup.length) {
+ if (currentTag === 'groupBy') {
+ groupOfGroupBys = groupOfGroupBys.concat(currentGroup);
+ } else {
+ self.loadParams.groups.push(currentGroup);
+ }
+ }
+ currentTag = preFilter.tag;
+ currentGroup = [];
+ groupNumber++;
+ }
+ if (preFilter.tag !== 'separator') {
+ filter = {
+ type: preFilter.tag,
+ // we need to codify here what we want to keep from attrs
+ // and how, for now I put everything.
+ // In some sence, some filter are active (totally determined, given)
+ // and others are passive (require input(s) to become determined)
+ // What is the right place to process the attrs?
+ };
+ if (filter.type === 'filter' || filter.type === 'groupBy') {
+ filter.groupNumber = groupNumber;
+ }
+ self._extractAttributes(filter, preFilter.attrs);
+ currentGroup.push(filter);
+ }
+ });
+ if (groupOfGroupBys.length) {
+ this.loadParams.groups.push(groupOfGroupBys);
+ }
+ },
+});
+
+return ControlPanelView;
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/mobile_control_panel_controller.js b/addons/web/static/src/js/views/control_panel/mobile_control_panel_controller.js
new file mode 100644
index 00000000000..92537c4d8e0
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/mobile_control_panel_controller.js
@@ -0,0 +1,34 @@
+ odoo.define('web.MobileControlPanelController', function (require) {
+"use strict";
+
+var config = require('web.config');
+var ControlPanelController = require('web.ControlPanelController');
+
+if (!config.device.isMobile) {
+ return;
+}
+
+ControlPanelController.include({
+ custom_events:_.extend({}, ControlPanelController.prototype.custom_events, {
+ 'search_bar_cleared': '_onSearchBarCleared',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * There is a 'Clear' button in the mobile control panel view.
+ *
+ * @private
+ */
+ _onSearchBarCleared: function () {
+ var self = this;
+ this.model.get().query.forEach(function (groupId) {
+ self.model.deactivateGroup(groupId);
+ });
+ this._reportNewQueryAndRender();
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/views/search/search_view_mobile.js b/addons/web/static/src/js/views/control_panel/mobile_control_panel_renderer.js
index ea7fe33ad81..23852abcf53 100644
--- a/addons/web/static/src/js/views/search/search_view_mobile.js
+++ b/addons/web/static/src/js/views/control_panel/mobile_control_panel_renderer.js
@@ -1,16 +1,16 @@
- odoo.define('web.SearchViewMobile', function (require) {
+ odoo.define('web.MobileControlPanelRenderer', function (require) {
"use strict";
var config = require('web.config');
-var SearchView = require('web.SearchView');
+var ControlPanelRenderer = require('web.ControlPanelRenderer');
if (!config.device.isMobile) {
return;
}
-SearchView.include({
- template:'SearchViewMobile',
- events:_.extend({}, SearchView.prototype.events, {
+ControlPanelRenderer.include({
+ template:'MobileControlPanel',
+ events:_.extend({}, ControlPanelRenderer.prototype.events, {
'click .o_mobile_search_close, .o_mobile_search_show_result, .o_enable_searchview': '_toggleMobileSearchView',
'click': '_onOpenMobileSearchView',
'click .o_mobile_search_clear_facets': '_onEmptyAll',
@@ -19,42 +19,46 @@ SearchView.include({
}),
//--------------------------------------------------------------------------
- // Public
+ // Private
//--------------------------------------------------------------------------
/**
+ * Overridden to correctly place submenu inside the mobile section.
+ *
+ * @private
* @override
*/
- renderFacets: function () {
- this._super.apply(this, arguments);
- this.$('.o_mobile_search_clear_facets')
- .toggleClass('o_hidden', !this.query.length);
+ _getSubMenusPlace: function () {
+ return this.$('.o_mobile_search_filter');
},
/**
+ * @private
* @override
*/
- toggle_visibility: function (is_visible) {
- // Do not do anything, toggling visibility of searchview is handled
- // explicitly for mobile
+ _render: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self.$('.o_mobile_search_clear_facets')
+ .toggleClass('o_hidden', !self.state.query.length);
+ });
},
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
/**
+ * @private
* @override
*/
- _getButtonsElement: function () {
- return this.$('.o_mobile_search_filter');
+ _renderSearchBar: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self.searchBar.$el.detach().insertAfter(self.$('.o_mobile_search_header'));
+ });
},
/**
- * Toggle mobile search view screen
+ * Toggles mobile search view screen.
*
* @private
*/
_toggleMobileSearchView: function () {
- this.$('.o_enable_searchview').toggleClass('btn-secondary', !!this.query.length);
+ this.$('.o_enable_searchview').toggleClass('btn-secondary', !!this.state.query.length);
this.$('.o_mobile_search').toggleClass('o_hidden');
},
@@ -63,7 +67,7 @@ SearchView.include({
//--------------------------------------------------------------------------
/**
- * Toggle the little arrow in main buttons
+ * Toggles the little arrow in main buttons.
*
* @private
* @param {BootstrapEvent} ev
@@ -72,16 +76,15 @@ SearchView.include({
$(ev.currentTarget).find('.fa-chevron-right').toggleClass('fa-chevron-down');
},
/**
- * Clears all filters from the search view
+ * Clears all filters from the search view.
*
* @private
- * @param {MouseEvent} event
*/
_onEmptyAll: function () {
- this.query.reset();
+ this.trigger_up('search_bar_cleared');
},
/**
- * Open the mobile search view screen
+ * Opens the mobile search view screen.
*
* @private
* @param {MouseEvent} ev
@@ -93,4 +96,4 @@ SearchView.include({
},
});
-}); \ No newline at end of file
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js b/addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js
new file mode 100644
index 00000000000..0a2498c69df
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js
@@ -0,0 +1,142 @@
+odoo.define('web.AddNewFavoriteMenu', function (require) {
+"use strict";
+
+var core = require('web.core');
+var favoritesSubmenusRegistry = require('web.favorites_submenus_registry');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+
+var AddNewFavoriteMenu = Widget.extend({
+ template: 'AddNewFavoriteMenu',
+ events: _.extend({}, Widget.prototype.events, {
+ 'click .o_save_favorite': '_onSaveFavoriteClick',
+ 'click .o_add_favorite.o_menu_header': '_onMenuHeaderClick',
+ 'keyup .o_save_name input': '_onKeyUp',
+ }),
+ /**
+ * @override
+ * @param {Object} params
+ * @param {Object} params.action
+ * @param {Object} params.favorites
+ */
+ init: function (parent, params) {
+ this._super(parent);
+ this.favorites = params.favorites;
+ this.action = params.action;
+ this.isOpen = false;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this._render();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Close the menu and re-render the widget.
+ */
+ closeMenu: function () {
+ this.isOpen = false;
+ this._render();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _render: function () {
+ this.renderElement();
+ if (this.isOpen) {
+ var $input = this.$('.o_favorite_name input.o_input');
+ $input.val(this.action.name);
+ $input.focus();
+ }
+ },
+ /**
+ * @private
+ */
+ _saveFavorite: function () {
+ var self = this;
+ var $inputs = this.$('input');
+ var description = $inputs[0].value;
+ var isDefault = $inputs[1].checked;
+ var isShared = $inputs[2].checked;
+
+ if (!description.length){
+ this.do_warn(_t("Error"), _t("A name for your favorite is required."));
+ $inputs[0].focus();
+ return;
+ }
+ var descriptionAlreadyExists = !!this.favorites.find(function (favorite) {
+ return favorite.description === description;
+ });
+ if (descriptionAlreadyExists) {
+ this.do_warn(_t("Error"), _t("Filter with same name already exists."));
+ $inputs[0].focus();
+ return;
+ }
+ this.trigger_up('new_favorite', {
+ type: 'favorite',
+ description: description,
+ isDefault: isDefault,
+ isShared: isShared,
+ on_success: function () {self.generatorMenuIsOpen = false;},
+ });
+ this.closeMenu();
+ },
+ /**
+ * Hide and display the submenu which allows adding custom filters.
+ *
+ * @private
+ */
+ _toggleMenu: function () {
+ this.isOpen = !this.isOpen;
+ this._render();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onKeyUp: function (ev) {
+ if (ev.which === $.ui.keyCode.ENTER) {
+ this._saveFavorite();
+ }
+ },
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onMenuHeaderClick: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this._toggleMenu();
+ },
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onSaveFavoriteClick: function (ev) {
+ ev.stopPropagation();
+ this._saveFavorite();
+ },
+});
+
+favoritesSubmenusRegistry.add('add_new_favorite_menu', AddNewFavoriteMenu, 0);
+
+return AddNewFavoriteMenu;
+
+});
diff --git a/addons/web/static/src/js/views/search/time_range_menu_options.js b/addons/web/static/src/js/views/control_panel/search/control_panel_view_parameters.js
index 9c042935d0e..171737b66fb 100644
--- a/addons/web/static/src/js/views/search/time_range_menu_options.js
+++ b/addons/web/static/src/js/views/control_panel/search/control_panel_view_parameters.js
@@ -1,10 +1,13 @@
-odoo.define('web.TimeRangeMenuOptions', function (require) {
+odoo.define('web.controlPanelViewParameters', function (require) {
"use strict";
var core = require('web.core');
+
var _lt = core._lt;
-var PeriodOptions = [
+// for FilterMenu
+var DEFAULT_PERIOD = 'this_month';
+var PERIOD_OPTIONS = [
{description: _lt('Last 7 Days'), optionId: 'last_7_days', groupId: 1},
{description: _lt('Last 30 Days'), optionId: 'last_30_days', groupId: 1},
{description: _lt('Last 365 Days'), optionId: 'last_365_days', groupId: 1},
@@ -20,14 +23,34 @@ var PeriodOptions = [
{description: _lt('Last Year'), optionId: 'last_year', groupId: 3},
];
-var ComparisonOptions = [
+// for GroupBy menu
+var GROUPABLE_TYPES = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime'];
+var DEFAULT_INTERVAL = 'month';
+var INTERVAL_OPTIONS = [
+ {description: _lt('Day'), optionId: 'day', groupId: 1},
+ {description: _lt('Week'), optionId: 'week', groupId: 1},
+ {description: _lt('Month'), optionId: 'month', groupId: 1},
+ {description: _lt('Quarter'), optionId: 'quarter', groupId: 1},
+ {description: _lt('Year'), optionId: 'year', groupId: 1},
+ ];
+
+// for TimeRangeMenu
+var DEFAULT_TIMERANGE = DEFAULT_PERIOD;
+var TIME_RANGE_OPTIONS = PERIOD_OPTIONS;
+var COMPARISON_TIME_RANGE_OPTIONS = [
{description: _lt('Previous Period'), optionId: 'previous_period'},
{description: _lt('Previous Year'), optionId: 'previous_year'}
];
return {
- PeriodOptions: PeriodOptions,
- ComparisonOptions: ComparisonOptions,
+ COMPARISON_TIME_RANGE_OPTIONS: COMPARISON_TIME_RANGE_OPTIONS,
+ DEFAULT_INTERVAL: DEFAULT_INTERVAL,
+ DEFAULT_PERIOD: DEFAULT_PERIOD,
+ DEFAULT_TIMERANGE: DEFAULT_TIMERANGE,
+ GROUPABLE_TYPES: GROUPABLE_TYPES,
+ INTERVAL_OPTIONS: INTERVAL_OPTIONS,
+ PERIOD_OPTIONS: PERIOD_OPTIONS,
+ TIME_RANGE_OPTIONS: TIME_RANGE_OPTIONS,
};
-});
+}); \ No newline at end of file
diff --git a/addons/web/static/src/js/views/control_panel/search/favorite_menu.js b/addons/web/static/src/js/views/control_panel/search/favorite_menu.js
new file mode 100644
index 00000000000..8b9ae4d44d6
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/favorite_menu.js
@@ -0,0 +1,103 @@
+odoo.define('web.FavoriteMenu', function (require) {
+"use strict";
+
+var config = require('web.config');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var DropdownMenu = require('web.DropdownMenu');
+var favorites_submenus_registry = require('web.favorites_submenus_registry');
+
+var _t = core._t;
+
+var FavoriteMenu = DropdownMenu.extend({
+ /**
+ * @override
+ * @param {Object} action
+ */
+ init: function (parent, favorites, action) {
+ this._super(parent, favorites);
+ this.action = action;
+ this.isMobile = config.device.isMobile;
+ this.dropdownCategory = 'favorite';
+ this.dropdownTitle = _t('Favorites');
+ this.dropdownIcon = 'fa fa-star';
+ this.dropdownSymbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : false;
+ this.dropdownStyle.mainButton.class = 'o_favorites_menu_button ' +
+ this.dropdownStyle.mainButton.class;
+
+ },
+ /**
+ * Render the template used to register a new favorite and append it
+ * to the basic dropdown menu.
+ *
+ * @override
+ */
+ start: function () {
+ var self = this;
+ var params = {
+ favorites: this.items,
+ action: this.action,
+ };
+ this.$menu = this.$('.o_dropdown_menu');
+ this.$menu.addClass('o_favorites_menu');
+ this.subMenus = [];
+ favorites_submenus_registry.values().forEach(function (SubMenu) {
+ var subMenu = new SubMenu(self, params);
+ subMenu.appendTo(self.$menu);
+ self.subMenus.push(subMenu);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _closeSubMenus: function () {
+ _.invoke(this.subMenus, 'closeMenu');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _onBootstrapClose: function () {
+ this._super.apply(this, arguments);
+ this._closeSubMenus();
+ },
+ /**
+ * @override
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onTrashButtonClick: function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ var self = this;
+ var id = $(event.currentTarget).data('id');
+ var favorite = this.items.find(function (favorite) {
+ return favorite.id === id;
+ });
+ var globalWarning = _t("This filter is global and will be removed for everybody if you continue.");
+ var warning = _t("Are you sure that you want to remove this filter?");
+ var message = favorite.userId ? warning : globalWarning;
+
+ Dialog.confirm(self, message, {
+ title: _t("Warning"),
+ confirm_callback: function () {
+ self.trigger_up('item_trashed', {id: id});
+ },
+ });
+
+ },
+});
+
+return FavoriteMenu;
+
+}); \ No newline at end of file
diff --git a/addons/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js b/addons/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js
new file mode 100644
index 00000000000..b05f8dfe268
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js
@@ -0,0 +1,8 @@
+odoo.define('web.favorites_submenus_registry', function (require) {
+"use strict";
+
+var Registry = require('web.Registry');
+
+return new Registry();
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/filter_menu.js b/addons/web/static/src/js/views/control_panel/search/filter_menu.js
new file mode 100644
index 00000000000..70a242dd0f8
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/filter_menu.js
@@ -0,0 +1,219 @@
+odoo.define('web.FilterMenu', function (require) {
+"use strict";
+
+var config = require('web.config');
+var core = require('web.core');
+var Domain = require('web.Domain');
+var DropdownMenu = require('web.DropdownMenu');
+var search_filters = require('web.search_filters');
+var time = require('web.time');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+var FilterMenu = DropdownMenu.extend({
+ custom_events: {
+ confirm_proposition: '_onConfirmProposition',
+ remove_proposition: '_onRemoveProposition',
+ },
+ events: _.extend({}, DropdownMenu.prototype.events, {
+ 'click .o_add_custom_filter': '_onAddCustomFilterClick',
+ 'click .o_add_condition': '_onAddCondition',
+ 'click .o_apply_filter': '_onApplyClick',
+ }),
+ /**
+ * @override
+ * @param {Object} fields
+ */
+ init: function (parent, filters, fields) {
+ this._super(parent, filters);
+
+ // determines where the filter menu is displayed and its style
+ this.isMobile = config.device.isMobile;
+ // determines when the 'Add custom filter' submenu is open
+ this.generatorMenuIsOpen = false;
+ this.propositions = [];
+ this.fields = _.pick(fields, function (field, name) {
+ return field.selectable !== false && name !== 'id';
+ });
+ this.fields.id = {string: 'ID', type: 'id', searchable: true};
+ this.dropdownCategory = 'filter';
+ this.dropdownTitle = _t('Filters');
+ this.dropdownIcon = 'fa fa-filter';
+ this.dropdownSymbol = this.isMobile ?
+ 'fa fa-chevron-right float-right mt4' :
+ false;
+ this.dropdownStyle.mainButton.class = 'o_filters_menu_button ' +
+ this.dropdownStyle.mainButton.class;
+ },
+ /**
+ * Render the template used to add a new custom filter and append it
+ * to the basic dropdown menu.
+ *
+ * @override
+ */
+ start: function () {
+ this.$menu = this.$('.o_dropdown_menu');
+ this.$menu.addClass('o_filters_menu');
+ this._renderGeneratorMenu();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a proposition inside the custom filter edition menu.
+ *
+ * @private
+ * @returns {$.Deferred}
+ */
+ _appendProposition: function () {
+ // make modern sear_filters code!!! It works but...
+ var prop = new search_filters.ExtendedSearchProposition(this, this.fields);
+ this.propositions.push(prop);
+ this.$('.o_apply_filter').prop('disabled', false);
+ prop.insertBefore(this.$addFilterMenu);
+ },
+ /**
+ * Confirm a filter proposition, creates it and add it to the menu.
+ *
+ * @private
+ */
+ _commitSearch: function () {
+ var filters = _.invoke(this.propositions, 'get_filter').map(function (preFilter) {
+ return {
+ type: 'filter',
+ description: preFilter.attrs.string,
+ domain: Domain.prototype.arrayToString(preFilter.attrs.domain),
+ };
+ });
+ // TO DO intercepts 'new_filters' and decide what to do whith filters
+ // rewrite web.search_filters?
+ this.trigger_up('new_filters', {filters: filters});
+ _.invoke(this.propositions, 'destroy');
+ this.propositions = [];
+ this._toggleCustomFilterMenu();
+ },
+ /**
+ * @private
+ */
+ _renderGeneratorMenu: function () {
+ this.$el.find('.o_generator_menu').remove();
+ if (!this.generatorMenuIsOpen) {
+ _.invoke(this.propositions, 'destroy');
+ this.propositions = [];
+ }
+ var $generatorMenu = QWeb.render('FilterMenuGenerator', {widget: this});
+ this.$menu.append($generatorMenu);
+ this.$addFilterMenu = this.$menu.find('.o_add_filter_menu');
+ if (this.generatorMenuIsOpen && !this.propositions.length) {
+ this._appendProposition();
+ }
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderMenuItems: function () {
+ var self= this;
+ this._super.apply(this, arguments);
+ // the following code adds tooltip on date options in order
+ // to alert the user of the meaning of intervals
+ var $options = this.$('.o_item_option');
+ $options.each(function () {
+ var $option = $(this);
+ $option.tooltip({
+ delay: { show: 500, hide: 0 },
+ title: function () {
+ var itemId = $option.attr('data-item_id');
+ var optionId = $option.attr('data-option_id');
+ var fieldName = _.findWhere(self.items, {id: itemId}).fieldName;
+ var domain = Domain.prototype.constructDomain(fieldName, optionId, 'date', true);
+ var evaluatedDomain = Domain.prototype.stringToArray(domain);
+ var dateFormat = time.getLangDateFormat();
+ var dateStart = moment(evaluatedDomain[1][2], "YYYY-MM-DD", 'en').format(dateFormat);
+ var dateEnd = moment(evaluatedDomain[2][2], "YYYY-MM-DD", 'en').format(dateFormat);
+ if (optionId === 'today' || optionId === 'yesterday') {
+ return dateStart;
+ }
+ return _.str.sprintf(_t('From %s To %s'), dateStart, dateEnd);
+ }
+ });
+ });
+ },
+ /**
+ * Hide and display the submenu which allows adding custom filters.
+ *
+ * @private
+ */
+ _toggleCustomFilterMenu: function () {
+ this.generatorMenuIsOpen = !this.generatorMenuIsOpen;
+ this._renderGeneratorMenu();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAddCondition: function (ev) {
+ ev.stopPropagation();
+ this._appendProposition();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAddCustomFilterClick: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this._toggleCustomFilterMenu();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onApplyClick: function (ev) {
+ ev.stopPropagation();
+ this._commitSearch();
+ },
+ /**
+ * @override
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onBootstrapClose: function () {
+ this._super.apply(this, arguments);
+ this.generatorMenuIsOpen = false;
+ this._renderGeneratorMenu();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onConfirmProposition: function (ev) {
+ ev.stopPropagation();
+ this._commitSearch();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onRemoveProposition: function (ev) {
+ ev.stopPropagation();
+ this.propositions = _.without(this.propositions, ev.target);
+ if (!this.propositions.length) {
+ this.$('.o_apply_filter').prop('disabled', true);
+ }
+ ev.target.destroy();
+ },
+});
+
+return FilterMenu;
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/groupby_menu.js b/addons/web/static/src/js/views/control_panel/search/groupby_menu.js
new file mode 100644
index 00000000000..b67bf9ee75d
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/groupby_menu.js
@@ -0,0 +1,214 @@
+odoo.define('web.GroupByMenu', function (require) {
+"use strict";
+
+var config = require('web.config');
+var controlPanelViewParameters = require('web.controlPanelViewParameters');
+var core = require('web.core');
+var DropdownMenu = require('web.DropdownMenu');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+var DEFAULT_INTERVAL = controlPanelViewParameters.DEFAULT_INTERVAL;
+var GROUPABLE_TYPES = controlPanelViewParameters.GROUPABLE_TYPES;
+var INTERVAL_OPTIONS = controlPanelViewParameters.INTERVAL_OPTIONS;
+
+var GroupByMenu = DropdownMenu.extend({
+ events: _.extend({}, DropdownMenu.prototype.events, {
+ 'click .o_add_custom_group': '_onAddCustomGroupClick',
+ 'click button.o_apply_group': '_onButtonApplyClick',
+ 'click .o_group_selector': '_onGroupSelectorClick',
+ }),
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {Object[]} groupBys list of groupBys (type IGroupBy below)
+ * interface IGroupBy {
+ * itemId: string; unique id associated with the groupby
+ * fieldName: string; field name without interval!
+ * description: string; label printed on screen
+ * groupId: string;
+ * isActive: boolean; determines if the groupby is considered active
+ * hasOptions: boolean; true when the groupBy has fieldType 'date' or 'datetime' (optional)
+ * options: array of objects with 'optionId' and 'description' keys; (optional)
+ * defaultOptionId: string refers to an optionId that should be activated by default
+ * currentOptionId: string refers to an optionId that is activated if item is active (optional)
+ * }
+ * @param {Object} fields 'field_get' of a model: mapping from field name
+ * to an object with its properties
+ * @param {Object} options
+ * @param {string} options.headerStyle conditions the style of the main button
+ */
+ init: function (parent, groupBys, fields, options) {
+ var self = this;
+ this._super(parent, groupBys);
+ this.fields = fields;
+ // determines when the 'Add custom groupby' submenu is open
+ this.generatorMenuIsOpen = false;
+ // determines list of options used by groupBys of type 'date'
+ this.groupableFields = [];
+ _.each(fields, function (field, name) {
+ if (field.sortable && _.contains(GROUPABLE_TYPES, field.type)) {
+ self.groupableFields.push(_.extend({}, field, {
+ name: name,
+ }));
+ }
+ });
+ this.groupableFields = _.sortBy(this.groupableFields, 'string');
+ // determines the list of field names that can be added to the menu
+ // via the 'Add Custom Groupby' menu
+ this.presentedFields = this._setPresentedFields(groupBys);
+
+ // determines where the filter menu is displayed and partly its style
+ this.isMobile = config.device.isMobile;
+
+ this.dropdownCategory = 'groupby';
+ this.dropdownTitle = _t('Group By');
+ this.dropdownIcon = 'fa fa-bars';
+ this.dropdownSymbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : false;
+ // the default style of the groupby menu can be changed here using the options key 'headerStyle'
+ if (options && options.headerStyle === 'primary') {
+ this.dropdownStyle = {
+ el: {class: 'btn-group o_group_by_menu o_dropdown', attrs: {'role': 'group'}},
+ mainButton: {class: 'btn btn-primary dropdown-toggle'},
+ };
+ }
+ INTERVAL_OPTIONS = INTERVAL_OPTIONS.map(function (option) {
+ return _.extend(option, {description: option.description.toString()});
+ });
+ },
+
+ /**
+ * Render the template used to add a new custom groupby and append it
+ * to the basic dropdown menu.
+ *
+ * @override
+ */
+ start: function () {
+ this.$menu = this.$('.o_dropdown_menu');
+ this.$menu.addClass('o_group_by_menu');
+ this._renderGeneratorMenu();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @param {Object[]} groupBys
+ */
+ update: function (groupBys) {
+ this._super.apply(this, arguments);
+ this.presentedFields = this._setPresentedFields(groupBys);
+ this._renderGeneratorMenu();
+ },
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Method called via the 'Add Custom Groupby' menu to create a new groupby
+ * and add it (activated) to the menu. In case the field is of type date, it
+ * is activated using the option determined by the parameter
+ * 'DEFAULT_INTERVAL'.
+ *
+ * @private
+ * @param {string} fieldName
+ */
+ _addGroupby: function (fieldName) {
+ var field = this.presentedFields.find(function (field) {
+ return field.name === fieldName;
+ });
+ var groupBy = {
+ type: 'groupBy',
+ description: field.string,
+ fieldName: fieldName,
+ fieldType: field.type,
+ };
+ if (_.contains(['date', 'datetime'], field.type)) {
+ groupBy.hasOptions = true;
+ groupBy.options = INTERVAL_OPTIONS;
+ groupBy.defaultOptionId = DEFAULT_INTERVAL;
+ groupBy.currentOptionId = false;
+ }
+ this.trigger_up('new_groupBy', groupBy);
+ },
+ /**
+ * @private
+ */
+ _renderGeneratorMenu: function () {
+ this.$el.find('.o_generator_menu').remove();
+ var $generatorMenu = QWeb.render('GroupByMenuGenerator', {widget: this});
+ this.$menu.append($generatorMenu);
+ this.$addCustomGroup = this.$menu.find('.o_add_custom_group');
+ this.$groupSelector = this.$menu.find('.o_group_selector');
+ },
+ /**
+ * @private
+ */
+ _setPresentedFields: function (groupBys) {
+ return this.groupableFields.filter(function (field) {
+ var groupByFields = _.pluck(groupBys, 'fieldName');
+ return !_.contains(groupByFields, field.name);
+ });
+ },
+ /**
+ * @private
+ */
+ _toggleCustomGroupMenu: function () {
+ this.generatorMenuIsOpen = !this.generatorMenuIsOpen;
+ this._renderGeneratorMenu();
+ if (this.generatorMenuIsOpen) {
+ this.$groupSelector.focus();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Toggle the 'Add Custom Group'.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAddCustomGroupClick: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this._toggleCustomGroupMenu();
+ },
+ /*
+ * @override
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onBootstrapClose: function () {
+ this._super.apply(this, arguments);
+ this.generatorMenuIsOpen = false;
+ this._renderGeneratorMenu();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onButtonApplyClick: function (ev) {
+ ev.stopPropagation();
+ var fieldName = this.$groupSelector.val();
+ this.generatorMenuIsOpen = false;
+ this._addGroupby(fieldName);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onGroupSelectorClick: function (ev) {
+ ev.stopPropagation();
+ },
+});
+
+return GroupByMenu;
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js b/addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js
new file mode 100644
index 00000000000..56d43ee6708
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js
@@ -0,0 +1,160 @@
+odoo.define('web.GroupByMenuMixin', function (require) {
+"use strict";
+
+var GroupByMenu = require('web.GroupByMenu');
+var controlPanelViewParameters = require('web.controlPanelViewParameters');
+
+/**
+ * The aim of this mixin is to facilitate the interaction between
+ * a view controller and a dropdown menu with its control panel
+ * TO DO: the pivot subview has two types of groupbys so that it will not
+ * understand the current implementation of this mixin
+ *
+ * @mixin
+ * @name GroupByMenuMixin
+ */
+var GroupByMenuMixin = {
+ init: function () {
+ this.custom_events = _.extend({}, this.custom_events, {
+ menu_item_clicked: '_onMenuItemClicked',
+ item_option_clicked: '_onItemOptionClicked',
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Instantiate a widget GroupByMenu and incorporate it to the control panel
+ * of an embedded view. This is used for instance in the dashboard view in
+ * enterprise where there is no GroupBy menu in the search view because it
+ * would not make sense to have one at the global level.
+ * This function is called by renderButtons when the parameter
+ * 'this.isEmbedded' is set to true.
+ *
+ * private
+ * @param {jQuery} $node
+ * @param {Object} groupableFields
+ */
+ _addGroupByMenu: function ($node, groupableFields) {
+ this.sortedFieldNames = _.sortBy(Object.keys(groupableFields), function (fieldName) {
+ return groupableFields[fieldName].string;
+ });
+ this.groupableFields = groupableFields;
+ var groupBys = this._getGroupBys(this.model.get().groupedBy);
+ this.groupByMenu = new GroupByMenu(this, groupBys, this.groupableFields);
+ this.groupByMenu.insertAfter($node.find('div:first'));
+ },
+ /**
+ * This method puts the active groupBys in a convenient form.
+ *
+ * @private
+ * @param {string[]} activeGroupBys
+ * returns {string[]} groupBysNormalized
+ */
+ _normalizeActiveGroupBys: function (activeGroupBys) {
+ var groupBysNormalized = activeGroupBys.map(function (groupBy) {
+ return {fieldName: groupBy.split(':')[0], interval: groupBy.split(':')[1] || false};
+ });
+ return groupBysNormalized;
+ },
+ /**
+ * This method has to be implemented by the view controller that needs to
+ * interpret the click in an appropriate manner.
+ *
+ * @private
+ * @param {string[]} groupBys
+ */
+ _setGroupby: function (groupBys) {},
+ /**
+ * Return the list of groupBys in a form suitable for the groupByMenu. We do
+ * this each time because we want to be synchronized with the view model.
+ *
+ * @private
+ * @param {string[]} activeGroupBys
+ * returns {Object[]} groupBys
+ */
+ _getGroupBys: function (activeGroupBys) {
+ var self = this;
+ var groupBys = [];
+ var groupBysNormalized = this._normalizeActiveGroupBys(activeGroupBys);
+ this.sortedFieldNames.forEach(function (fieldName) {
+ var field = self.groupableFields[fieldName];
+ var groupByActivity = _.findWhere(groupBysNormalized, {fieldName: fieldName});
+ var groupBy = {
+ id: fieldName,
+ isActive: groupByActivity ? true : false,
+ description: field.string,
+ };
+ if (_.contains(['date', 'datetime'], field.type)) {
+ groupBy.hasOptions = true;
+ groupBy.options = controlPanelViewParameters.INTERVAL_OPTIONS;
+ groupBy.currentOptionId = groupByActivity && groupByActivity.interval ?
+ groupByActivity.interval :
+ false;
+ }
+ groupBys.push(groupBy);
+ });
+ return groupBys;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onItemOptionClicked: function (ev) {
+ var fieldName = ev.data.id;
+ var optionId = ev.data.optionId;
+ var activeGroupBys = this.model.get().groupedBy;
+ var currentOptionId = activeGroupBys.reduce(
+ function (optionId, groupby) {
+ if (groupby.split(':')[0] === fieldName){
+ optionId = groupby.split(':')[1] || controlPanelViewParameters.DEFAULT_INTERVAL;
+ }
+ return optionId;
+ },
+ false
+ );
+ var groupByFieldNames = _.map(activeGroupBys, function (groupby) {
+ return groupby.split(':')[0];
+ });
+ var indexOfGroupby = groupByFieldNames.indexOf(fieldName);
+ if (indexOfGroupby === -1) {
+ activeGroupBys.push(fieldName + ':' + optionId);
+ } else if (currentOptionId === optionId) {
+ activeGroupBys.splice(indexOfGroupby, 1);
+ } else {
+ activeGroupBys.splice(indexOfGroupby, 1, fieldName + ':' + optionId);
+ }
+ this._setGroupby(activeGroupBys);
+ this.groupByMenu.update(this._getGroupBys(activeGroupBys));
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onMenuItemClicked: function (ev) {
+ var fieldName = ev.data.id;
+ var activeGroupBys = this.model.get().groupedBy;
+ var groupByFieldNames = _.map(activeGroupBys, function (groupby) {
+ return groupby.split(':')[0];
+ });
+ var indexOfGroupby = groupByFieldNames.indexOf(fieldName);
+ if (indexOfGroupby === -1) {
+ activeGroupBys.push(fieldName);
+ } else {
+ activeGroupBys.splice(indexOfGroupby, 1);
+ }
+ this._setGroupby(activeGroupBys);
+ this.groupByMenu.update(this._getGroupBys(activeGroupBys));
+ },
+};
+
+return GroupByMenuMixin;
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/search_bar.js b/addons/web/static/src/js/views/control_panel/search/search_bar.js
new file mode 100644
index 00000000000..f9fa15fe67d
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/search_bar.js
@@ -0,0 +1,225 @@
+odoo.define('web.SearchBar', function (require) {
+"use strict";
+
+var AutoComplete = require('web.AutoComplete');
+var searchBarAutocompleteRegistry = require('web.search_bar_autocomplete_sources_registry');
+var SearchFacet = require('web.SearchFacet');
+var Widget = require('web.Widget');
+
+var SearchBar = Widget.extend({
+ template: 'SearchView.SearchBar',
+ events: _.extend({}, Widget.prototype.events, {
+ 'keydown': '_onKeydown',
+ }),
+ /**
+ * @override
+ * @param {Object} [params]
+ * @param {Object} [params.context]
+ * @param {Object[]} [params.facets]
+ * @param {Object} [params.fields]
+ * @param {Object[]} [params.filterFields]
+ * @param {Object[]} [params.filters]
+ * @param {Object[]} [params.groupBys]
+ */
+ init: function (parent, params) {
+ this._super.apply(this, arguments);
+
+ this.context = params.context;
+
+ this.facets = params.facets;
+ this.fields = params.fields;
+ this.filters = params.filters;
+ this.filterFields = params.filterFields;
+ this.groupBys = params.groupBys;
+
+ this.autoCompleteSources = [];
+ this.searchFacets = [];
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$input = this.$('input');
+ var self = this;
+ var defs = [this._super.apply(this, arguments)];
+ _.each(this.facets, function (facet) {
+ defs.push(self._renderFacet(facet));
+ });
+ defs.push(this._setupAutoCompletion());
+ return $.when.apply($, defs);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Focus the searchbar.
+ */
+ focus: function () {
+ this.$input.focus();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _focusFollowing: function () {
+ var focusedIndex = this._getFocusedFacetIndex();
+ var $toFocus;
+ if (focusedIndex === this.searchFacets.length - 1) {
+ $toFocus = this.$input;
+ } else {
+ $toFocus = this.searchFacets.length && this.searchFacets[focusedIndex + 1].$el;
+ }
+
+ if ($toFocus.length) {
+ $toFocus.focus();
+ }
+ },
+ /**
+ * @private
+ */
+ _focusPreceding: function () {
+ var focusedIndex = this._getFocusedFacetIndex();
+ var $toFocus;
+ if (focusedIndex === -1) {
+ $toFocus = this.searchFacets.length && _.last(this.searchFacets).$el;
+ } else if (focusedIndex === 0) {
+ $toFocus = this.$input;
+ } else {
+ $toFocus = this.searchFacets.length && this.searchFacets[focusedIndex - 1].$el;
+ }
+
+ if ($toFocus.length) {
+ $toFocus.focus();
+ }
+ },
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _getFocusedFacetIndex: function () {
+ return _.findIndex(this.searchFacets, function (searchFacet) {
+ return searchFacet.$el[0] === document.activeElement;
+ });
+ },
+ /**
+ * Provide auto-completion result for req.term.
+ *
+ * @private
+ * @param {Object} req request to complete
+ * @param {String} req.term searched term to complete
+ * @param {Function} callback
+ */
+ _getAutoCompleteSources: function (req, callback) {
+ var defs = _.invoke(this.autoCompleteSources, 'getAutocompletionValues', req.term);
+ $.when.apply($, defs).then(function () {
+ callback(_(arguments).chain()
+ .compact()
+ .flatten(true)
+ .value());
+ });
+ },
+ /**
+ * @private
+ * @param {Object} facet
+ * @returns {Deferred}
+ */
+ _renderFacet: function (facet) {
+ var searchFacet = new SearchFacet(this, facet);
+ this.searchFacets.push(searchFacet);
+ return searchFacet.insertBefore(this.$('input'));
+ },
+ /**
+ * @private
+ * @returns {Deferred}
+ */
+ _setupAutoCompletion: function () {
+ var self = this;
+ this._setupAutoCompletionWidgets();
+ this.autoComplete = new AutoComplete(this, {
+ source: this._getAutoCompleteSources.bind(this),
+ select: this._onAutoCompleteSelected.bind(this),
+ get_search_string: function () {
+ return self.$input.val().trim();
+ },
+ });
+ return this.autoComplete.appendTo(this.$el);
+ },
+ /**
+ * @private
+ */
+ _setupAutoCompletionWidgets: function () {
+ var self = this;
+ var registry = searchBarAutocompleteRegistry;
+ _.each(this.filterFields, function (filter) {
+ var field = self.fields[filter.attrs.name];
+ var Obj = registry.getAny([filter.attrs.widget, field.type]);
+ if (Obj) {
+ self.autoCompleteSources.push(new (Obj) (self, filter, field, self.context));
+ }
+ });
+ _.each(this.filters, function (filter) {
+ self.autoCompleteSources.push(new (registry.get('filter'))(self, filter));
+ });
+ _.each(this.groupBys, function (filter) {
+ self.autoCompleteSources.push(new (registry.get('groupby'))(self, filter));
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} e
+ * @param {Object} ui
+ * @param {Object} ui.item selected completion item
+ */
+ _onAutoCompleteSelected: function (e, ui) {
+ e.preventDefault();
+ var filter = ui.item.facet.filter;
+ if (filter.type === 'field') {
+ var values = filter.autoCompleteValues;
+ values.push(ui.item.facet.values[0]);
+ this.trigger_up('autocompletion_filter', {
+ filterId: filter.id,
+ autoCompleteValues: values,
+ });
+ } else {
+ this.trigger_up('autocompletion_filter', {
+ filterId: filter.id,
+ });
+ }
+ },
+ /**
+ * @private
+ * @param {KeyEvent} e
+ */
+ _onKeydown: function (e) {
+ switch(e.which) {
+ case $.ui.keyCode.LEFT:
+ this._focusPreceding();
+ e.preventDefault();
+ break;
+ case $.ui.keyCode.RIGHT:
+ this._focusFollowing();
+ e.preventDefault();
+ break;
+ case $.ui.keyCode.BACKSPACE:
+ if (this.$input.val() === '') {
+ this.trigger_up('facet_removed');
+ }
+ break;
+ }
+ },
+});
+
+return SearchBar;
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js b/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js
new file mode 100644
index 00000000000..f1ac0ee0f2e
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js
@@ -0,0 +1,485 @@
+odoo.define('web.SearchBarAutoCompleteSources', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+var core = require('web.core');
+var Domain = require('web.Domain');
+var field_utils = require('web.field_utils');
+var mixins = require('web.mixins');
+var pyUtils = require('web.py_utils');
+var ServicesMixin = require('web.ServicesMixin');
+var time = require('web.time');
+
+var _t = core._t;
+var _lt = core._lt;
+
+var FilterInterface = Class.extend(mixins.EventDispatcherMixin, {
+ completion_label: _lt("%s"),
+ /**
+ * @override
+ * @param {Object} filter
+ */
+ init: function (parent, filter) {
+ mixins.EventDispatcherMixin.init.call(this);
+ this.setParent(parent);
+
+ this.filter = filter;
+ },
+ /**
+ * Fetch auto-completion values for the widget.
+ *
+ * The completion values should be an array of objects with keys facet and
+ * label. They will be used by search_bar in @_onAutoCompleteSelected.
+ *
+ * @param {string} value value to getAutocompletionValues
+ * @returns {Deferred<null|Array>}
+ */
+ getAutocompletionValues: function (value) {
+ var result;
+ value = value.toLowerCase();
+ if (fuzzy.test(value, this.filter.description)) {
+ result = [{
+ label: _.str.sprintf(this.completion_label.toString(),
+ _.escape(this.filter.description)),
+ facet: {
+ filter: this.filter,
+ },
+ }];
+ }
+ return $.when(result);
+ },
+});
+
+var Filter = FilterInterface.extend({
+ completion_label: _lt("Filter on: %s"),
+});
+
+var GroupBy = FilterInterface.extend({
+ completion_label: _lt("Group by: %s"),
+});
+
+var Field = FilterInterface.extend(ServicesMixin, {
+ default_operator: '=',
+ /**
+ * @override
+ * @param {Object} field
+ * @param {Object} filter
+ * @param {Object} context needed for extra rpc (i.e. m2o)
+ */
+ init: function (parent, filter, field, context) {
+ this._super.apply(this, arguments);
+
+ this.field = field;
+ this.filter = filter;
+ this.attrs = _.extend({}, field, filter.attrs);
+ this.context = context;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getAutocompletionValues: function (value) {
+ return $.when([{
+ label: this._getAutocompletionLabel(value),
+ facet: this._getFacetValue(value),
+ }]);
+ },
+ /**
+ * TODO: the domain logic (getDomain, operator, etc.) should not be put here
+ * (this has nothing to do with the autocomplete sources).
+ *
+ * This method is used by the ControlPanelModel when setting the
+ * `filter_domain`.
+ *
+ * @param {Object[]} values
+ * @returns {string}
+ */
+ getDomain: function (values) {
+ if (!values.length) { return; }
+
+ var valueToDomain;
+ var self = this;
+ var domain = this.attrs.filter_domain;
+ if (domain) {
+ valueToDomain = function (facetValue) {
+ return Domain.prototype.stringToArray(
+ domain,
+ {
+ // these are the values that can be used in search view
+ // fields `filter_domain` attribute
+ self: self._valueFrom(facetValue),
+ raw_value: facetValue.value,
+ }
+ );
+ };
+ } else {
+ valueToDomain = function (facetValue) {
+ return self._makeDomain(
+ self.attrs.name,
+ self.attrs.operator || self.default_operator,
+ facetValue
+ );
+ };
+ }
+ var domains = values.map(valueToDomain);
+
+ domains = domains.map(Domain.prototype.arrayToString);
+ return pyUtils.assembleDomains(domains, 'OR');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {any} value
+ * @returns {string}
+ */
+ _getAutocompletionLabel: function (value) {
+ return _.str.sprintf(_.str.escapeHTML(
+ _t("Search %(field)s for: %(value)s")), {
+ field: '<em>' + _.escape(this.attrs.string) + '</em>',
+ value: '<strong>' + _.escape(value) + '</strong>'});
+ },
+ /**
+ * @private
+ * @param {any} value
+ * @returns {Object}
+ */
+ _getFacetValue: function (value) {
+ return {
+ filter: this.filter,
+ values: [{label: value, value: value}],
+ };
+ },
+ /**
+ * Function creating the returned domain for the field, override this
+ * methods in children if you only need to customize the field's domain
+ * without more complex alterations or tests (and without the need to
+ * change override the handling of filter_domain)
+ *
+ * @private
+ * @param {String} name the field's name
+ * @param {String} operator the field's operator (either attribute-specified or default operator for the field
+ * @param {Number|String} facet parsed value for the field
+ * @returns {Array<Array>} domain to include in the resulting search
+ */
+ _makeDomain: function (name, operator, facet) {
+ return [[name, operator, this._valueFrom(facet)]];
+ },
+ /**
+ * @private
+ * @param {Object} facetValue
+ * @param {any} facetValue.value
+ * @returns {any}
+ */
+ _valueFrom: function (facetValue) {
+ return facetValue.value;
+ },
+});
+
+var CharField = Field.extend({
+ default_operator: 'ilike',
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getAutocompletionValues: function (value) {
+ if (_.isEmpty(value)) { return $.when(null); }
+ return this._super.apply(this, arguments);
+ },
+});
+
+var NumberField = Field.extend({
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getAutocompletionValues: function (value) {
+ var val = this.parse(value);
+ if (isNaN(val)) { return $.when(); }
+ return this._super.apply(this, arguments);
+ },
+});
+
+var IntegerField = NumberField.extend({
+ error_message: _t("not a valid integer"),
+ parse: function (value) {
+ try {
+ return field_utils.parse.integer(value);
+ } catch (e) {
+ return NaN;
+ }
+ },
+});
+
+var FloatField = NumberField.extend({
+ error_message: _t("not a valid number"),
+ parse: function (value) {
+ try {
+ return field_utils.parse.float(value);
+ } catch (e) {
+ return NaN;
+ }
+ },
+});
+
+var SelectionField = Field.extend({
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getAutocompletionValues: function (value) {
+ var self = this;
+ var results = _(this.attrs.selection).chain()
+ .filter(function (sel) {
+ var selValue = sel[0], label = sel[1];
+ if (selValue === undefined || !label) { return false; }
+ return label.toLowerCase().indexOf(value.toLowerCase()) !== -1;
+ })
+ .map(function (sel) {
+ return {
+ label: _.escape(sel[1]),
+ indent: true,
+ facet: self._getFacetValue(sel)
+ };
+ }).value();
+ if (_.isEmpty(results)) { return $.when(null); }
+ return $.when.call(null, [{
+ label: _.escape(this.attrs.string)
+ }].concat(results));
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {any} value
+ * @returns {Object}
+ */
+ _getFacetValue: function (value) {
+ return {
+ filter: this.filter,
+ values: [{label: value[1], value: value[0]}],
+ };
+ },
+});
+
+var BooleanField = SelectionField.extend({
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ this.attrs.selection = [
+ [true, _t("Yes")],
+ [false, _t("No")]
+ ];
+ },
+});
+
+var DateField = Field.extend({
+ /**
+ * @override
+ */
+ getAutocompletionValues: function (value) {
+ // Make sure the value has a correct format before the creation of the moment object. See
+ // issue https://github.com/moment/moment/issues/1407
+ var t, v;
+ try {
+ t = (this.attrs && this.attrs.type === 'datetime') ? 'datetime' : 'date';
+ v = field_utils.parse[t](value, {type: t}, {timezone: true});
+ } catch (e) {
+ return $.when(null);
+ }
+
+ var m = moment(v, t === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD');
+ if (!m.isValid()) { return $.when(null); }
+ var dateString = field_utils.format[t](m, {type: t});
+ var label = this._getAutocompletionLabel(dateString);
+ var facet = this._getFacetValue(dateString, m.toDate());
+ return $.when([{
+ label: label,
+ facet: facet,
+ }]);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _getAutocompletionLabel: function (value) {
+ return _.str.sprintf(_.str.escapeHTML(
+ _t("Search %(field)s at: %(value)s")), {
+ field: '<em>' + _.escape(this.attrs.string) + '</em>',
+ value: '<strong>' + value + '</strong>'});
+ },
+ /**
+ * @override
+ * @param {any} rawValue
+ */
+ _getFacetValue: function (value, rawValue) {
+ var facet = this._super.apply(this, arguments);
+ facet.values[0].value = rawValue;
+ return facet;
+ },
+ /**
+ * @override
+ */
+ _valueFrom: function (facetValue) {
+ return time.date_to_str(facetValue.value);
+ },
+});
+
+var DateTimeField = DateField.extend({
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _valueFrom: function (facetValue) {
+ return time.datetime_to_str(facetValue.value);
+ },
+});
+
+var ManyToOneField = CharField.extend({
+ default_operator: {},
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getAutocompletionValues: function (value) {
+ if (_.isEmpty(value)) { return $.when(null); }
+ var label = this._getAutocompletionLabel(value);
+ return $.when([{
+ label: label,
+ facet: this._getFacetValue(value),
+ expand: this._expand.bind(this),
+ }]);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _getExpandedFacetValue: function (value) {
+ return {
+ filter: this.filter,
+ values: [{label: value[1], value: value[0]}],
+ };
+ },
+ /**
+ * @override
+ */
+ _getFacetValue: function (value) {
+ return {
+ filter: this.filter,
+ values: [{label: value, value: value, operator: 'ilike'}],
+ };
+ },
+ /**
+ * @override
+ */
+ _expand: function (value) {
+ var self = this;
+ var args = this.attrs.domain;
+ if (typeof args === 'string') {
+ try {
+ args = Domain.prototype.stringToArray(args);
+ } catch(e) {
+ args = [];
+ }
+ }
+ return this._rpc({
+ model: this.attrs.relation,
+ method: 'name_search',
+ kwargs: {
+ name: value,
+ args: args,
+ limit: 8,
+ context: this.context,
+ },
+ })
+ .then(function (results) {
+ if (_.isEmpty(results)) { return null; }
+ return _(results).map(function (result) {
+ return {
+ label: _.escape(result[1]),
+ facet: self._getExpandedFacetValue(result)
+ };
+ });
+ });
+ },
+ /**
+ * @override
+ */
+ _makeDomain: function (name, operator, facetValue) {
+ operator = facetValue.operator || operator;
+
+ switch(operator){
+ case this.default_operator:
+ return [[name, '=', facetValue.value]];
+ case 'ilike':
+ return [[name, 'ilike', facetValue.value]];
+ case 'child_of':
+ return [[name, 'child_of', facetValue.value]];
+ }
+ return this._super(name, operator, facetValue);
+ },
+ /**
+ * @override
+ */
+ _valueFrom: function (facetValue) {
+ return facetValue.label;
+ },
+});
+
+return {
+ BooleanField: BooleanField,
+ CharField: CharField,
+ DateField: DateField,
+ DateTimeField: DateTimeField,
+ Field: Field,
+ Filter: Filter,
+ FloatField: FloatField,
+ GroupBy: GroupBy,
+ IntegerField: IntegerField,
+ ManyToOneField: ManyToOneField,
+ NumberField: NumberField,
+ SelectionField: SelectionField,
+};
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js b/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js
new file mode 100644
index 00000000000..fd5d1052b0b
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js
@@ -0,0 +1,37 @@
+odoo.define('web.search_bar_autocomplete_sources_registry', function (require) {
+"use strict";
+
+var Registry = require('web.Registry');
+
+return new Registry();
+
+});
+
+odoo.define('web._search_bar_autocomplete_sources_registry', function(require) {
+"use strict";
+
+var registry = require('web.search_bar_autocomplete_sources_registry');
+var SearchBarAutoCompleteSources = require('web.SearchBarAutoCompleteSources');
+
+// fields
+registry
+ .add('char', SearchBarAutoCompleteSources.CharField)
+ .add('text', SearchBarAutoCompleteSources.CharField)
+ .add('html', SearchBarAutoCompleteSources.CharField)
+ .add('boolean', SearchBarAutoCompleteSources.BooleanField)
+ .add('integer', SearchBarAutoCompleteSources.IntegerField)
+ .add('id', SearchBarAutoCompleteSources.IntegerField)
+ .add('float', SearchBarAutoCompleteSources.FloatField)
+ .add('monetary', SearchBarAutoCompleteSources.FloatField)
+ .add('selection', SearchBarAutoCompleteSources.SelectionField)
+ .add('datetime', SearchBarAutoCompleteSources.DateTimeField)
+ .add('date', SearchBarAutoCompleteSources.DateField)
+ .add('many2one', SearchBarAutoCompleteSources.ManyToOneField)
+ .add('many2many', SearchBarAutoCompleteSources.CharField)
+ .add('one2many', SearchBarAutoCompleteSources.CharField);
+
+// others
+registry.add('filter', SearchBarAutoCompleteSources.Filter);
+registry.add('groupby', SearchBarAutoCompleteSources.GroupBy);
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/search_facet.js b/addons/web/static/src/js/views/control_panel/search/search_facet.js
new file mode 100644
index 00000000000..ca9a7ad6225
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/search_facet.js
@@ -0,0 +1,129 @@
+odoo.define('web.SearchFacet', function (require) {
+"use strict";
+
+var core = require('web.core');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+
+var SearchFacet = Widget.extend({
+ template: 'SearchView.SearchFacet',
+ events: _.extend({}, Widget.prototype.events, {
+ 'click .o_facet_remove': '_onFacetRemove',
+ 'keydown': '_onKeydown',
+ }),
+ /**
+ * @override
+ * @param {Object} facet
+ */
+ init: function (parent, facet) {
+ this._super.apply(this, arguments);
+
+ var self = this;
+ this.facet = facet;
+ this.facetValues = _.map(this.facet.filters, function (filter) {
+ return self._getFilterDescription(filter);
+ });
+ this.separator = this._getSeparator();
+ this.icon = this._getIcon();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the correct description according to filter.
+ *
+ * @private
+ * @returns {string}
+ */
+ _getFilterDescription: function (filter) {
+ if (filter.type === 'field') {
+ var values = _.pluck(filter.autoCompleteValues, 'label');
+ return values.join(_t(' or '));
+ }
+ var description = filter.description;
+ if (filter.hasOptions) {
+ var optionValue =_.findWhere(filter.options, {
+ optionId: filter.currentOptionId,
+ });
+ description += ': ' + optionValue.description;
+ }
+ if (filter.type === 'timeRange') {
+ var timeRangeValue =_.findWhere(filter.timeRangeOptions, {
+ optionId: filter.timeRangeId,
+ });
+ description += ': ' + timeRangeValue.description;
+ if (filter.comparisonTimeRangeId) {
+ var comparisonTimeRangeValue =_.findWhere(filter.comparisonTimeRangeOptions, {
+ optionId: filter.comparisonTimeRangeId,
+ });
+ description += ' / ' + comparisonTimeRangeValue.description;
+ }
+ }
+ return description;
+ },
+ /**
+ * Get the correct icon according to facet type.
+ *
+ * @private
+ * @returns {string}
+ */
+ _getIcon: function () {
+ var icon;
+ if (this.facet.type === 'filter') {
+ icon = 'fa-filter';
+ } else if (this.facet.type === 'groupBy') {
+ icon = 'fa-bars';
+ } else if (this.facet.type === 'favorite') {
+ icon = 'fa-star';
+ } else if (this.facet.type === 'timeRange') {
+ icon = 'fa-calendar';
+ }
+ return icon;
+ },
+ /**
+ * Get the correct separator according to facet type.
+ *
+ * @private
+ * @returns {string}
+ */
+ _getSeparator: function () {
+ var separator;
+ if (this.facet.type === 'filter') {
+ separator = _t('or');
+ } else if (this.facet.type === 'field') {
+ separator = _t('or');
+ } else if (this.facet.type === 'groupBy') {
+ separator = '>';
+ }
+ return separator;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onFacetRemove: function () {
+ this.trigger_up('facet_removed', {group: this.facet});
+ },
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown: function (ev) {
+ switch (ev.which) {
+ case $.ui.keyCode.BACKSPACE:
+ this.trigger_up('facet_removed', {group: this.facet});
+ break;
+ }
+ },
+});
+
+return SearchFacet;
+
+});
diff --git a/addons/web/static/src/js/views/search/search_filters.js b/addons/web/static/src/js/views/control_panel/search/search_filters.js
index 4455a7b9bdf..6ff84dce9c7 100644
--- a/addons/web/static/src/js/views/search/search_filters.js
+++ b/addons/web/static/src/js/views/control_panel/search/search_filters.js
@@ -4,12 +4,13 @@ odoo.define('web.search_filters', function (require) {
var core = require('web.core');
var datepicker = require('web.datepicker');
var field_utils = require('web.field_utils');
+var search_filters_registry = require('web.search_filters_registry');
var Widget = require('web.Widget');
var _t = core._t;
var _lt = core._lt;
-var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.ExtendedSearchProposition# */{
+var ExtendedSearchProposition = Widget.extend({
template: 'SearchView.extended_search.proposition',
events: {
'change .o_searchview_extended_prop_field': 'changed',
@@ -25,10 +26,7 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
},
},
/**
- * @constructs instance.web.search.ExtendedSearchProposition
- * @extends instance.web.Widget
- *
- * @param parent
+ * @override
* @param fields
*/
init: function (parent, fields) {
@@ -46,8 +44,8 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
},
changed: function () {
var nval = this.$(".o_searchview_extended_prop_field").val();
- if(this.attrs.selected === null || this.attrs.selected === undefined || nval != this.attrs.selected.name) {
- this.select_field(_.detect(this.fields, function (x) {return x.name == nval;}));
+ if(this.attrs.selected === null || this.attrs.selected === undefined || nval !== this.attrs.selected.name) {
+ this.select_field(_.detect(this.fields, function (x) {return x.name === nval;}));
}
},
operator_changed: function (e) {
@@ -71,7 +69,7 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
}
var type = field.type;
- var Field = core.search_filters_registry.getAny([type, "char"]);
+ var Field = search_filters_registry.getAny([type, "char"]);
this.value = new Field(this, field);
_.each(this.value.operators, function (operator) {
@@ -84,8 +82,9 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
},
get_filter: function () {
- if (this.attrs.selected === null || this.attrs.selected === undefined)
+ if (this.attrs.selected === null || this.attrs.selected === undefined) {
return null;
+ }
var field = this.attrs.selected,
op_select = this.$('.o_searchview_extended_prop_op')[0],
operator = op_select.options[op_select.selectedIndex];
@@ -172,6 +171,15 @@ var Char = Field.extend({
{value: "∃", text: _lt("is set")},
{value: "∄", text: _lt("is not set")}
],
+ get_domain: function (field, operator) {
+ switch (operator.value) {
+ case '∃': return [[field.name, '!=', false]];
+ case '∄': return [[field.name, '=', false]];
+ // does not work in all cases
+ // default: return "[('" + field.name + "', '" + operator.value + "', '" + this.get_value() + "')]";
+ default: return [[field.name, operator.value, this.get_value()]];
+ }
+ },
get_value: function () {
return this.$el.val();
}
@@ -358,23 +366,17 @@ var Boolean = Field.extend({
}
});
-core.search_filters_registry
- .add('char', Char)
- .add('text', Char)
- .add('one2many', Char)
- .add('many2one', Char)
- .add('many2many', Char)
- .add('datetime', DateTime)
- .add('date', Date)
- .add('integer', Integer)
- .add('float', Float)
- .add('monetary', Float)
- .add('boolean', Boolean)
- .add('selection', Selection)
- .add('id', Id);
-
return {
- ExtendedSearchProposition: ExtendedSearchProposition
+ Boolean: Boolean,
+ Char: Char,
+ Date: Date,
+ DateTime: DateTime,
+ ExtendedSearchProposition: ExtendedSearchProposition,
+ Field: Field,
+ Float: Float,
+ Id: Id,
+ Integer: Integer,
+ Selection: Selection,
};
});
diff --git a/addons/web/static/src/js/views/control_panel/search/search_filters_registry.js b/addons/web/static/src/js/views/control_panel/search/search_filters_registry.js
new file mode 100644
index 00000000000..d6cbb9be2d0
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/search_filters_registry.js
@@ -0,0 +1,30 @@
+odoo.define('web.search_filters_registry', function (require) {
+"use strict";
+
+var Registry = require('web.Registry');
+
+return new Registry();
+
+});
+
+odoo.define('web._search_filters_registry', function(require) {
+"use strict";
+
+var registry = require('web.search_filters_registry');
+var search_filters = require('web.search_filters');
+
+registry
+ .add('boolean', search_filters.Boolean)
+ .add('char', search_filters.Char)
+ .add('date', search_filters.Date)
+ .add('datetime', search_filters.DateTime)
+ .add('float', search_filters.Float)
+ .add('id', search_filters.Id)
+ .add('integer', search_filters.Integer)
+ .add('many2many', search_filters.Char)
+ .add('many2one', search_filters.Char)
+ .add('monetary', search_filters.Float)
+ .add('one2many', search_filters.Char)
+ .add('text', search_filters.Char)
+ .add('selection', search_filters.Selection);
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/time_range_menu.js b/addons/web/static/src/js/views/control_panel/search/time_range_menu.js
new file mode 100644
index 00000000000..8b6c6552c47
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/time_range_menu.js
@@ -0,0 +1,106 @@
+odoo.define('web.TimeRangeMenu', function (require) {
+"use strict";
+
+var config = require('web.config');
+var controlPanelViewParameters = require('web.controlPanelViewParameters');
+var Widget = require('web.Widget');
+
+var COMPARISON_TIME_RANGE_OPTIONS = controlPanelViewParameters.COMPARISON_TIME_RANGE_OPTIONS;
+var PERIOD_OPTIONS = controlPanelViewParameters.PERIOD_OPTIONS;
+
+var TimeRangeMenu = Widget.extend({
+ template: 'web.TimeRangeMenu',
+ events: {
+ 'click .o_apply_range': '_onApplyButtonClick',
+ 'click .o_comparison_checkbox': '_onCheckBoxClick',
+ },
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {Object[]} timeRanges
+ *
+ */
+ init: function (parent, timeRanges) {
+ this._super.apply(this, arguments);
+ // determine header style
+ this.isMobile = config.device.isMobile;
+ this.symbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : 'caret';
+ // fixed parameters
+ this.periodOptions = PERIOD_OPTIONS;
+ this.comparisonTimeRangeOptions = COMPARISON_TIME_RANGE_OPTIONS;
+ this.periodGroups = _.uniq(PERIOD_OPTIONS.map(function (option) {
+ return option.groupId;
+ }));
+ // variable parameters
+ this.timeRanges = timeRanges;
+ this.configuration = {
+ comparisonIsSelected: false,
+ comparisonTimeRangeId: false,
+ id: false,
+ timeRangeId: false,
+ };
+ this._configure();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {Object[]} timeRanges
+ */
+ update: function (timeRanges) {
+ this.timeRanges = timeRanges;
+ this._configure();
+ this.renderElement();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _configure: function () {
+ this.configuration = this.timeRanges.find(function (timeRange) {
+ return timeRange.isActive;
+ }) || this.configuration;
+ this.configuration.comparisonIsSelected = !!this.configuration.comparisonTimeRangeId;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onApplyButtonClick: function () {
+ var id = this.$('.o_date_field_selector').val();
+ var timeRangeId = this.$('.o_time_range_selector').val();
+ var comparisonTimeRangeId = false;
+ if (this.configuration.comparisonIsSelected) {
+ comparisonTimeRangeId = this.$('.o_comparison_time_range_selector').val();
+ }
+ this.trigger_up('activate_time_range', {
+ id: id,
+ timeRangeId: timeRangeId,
+ comparisonTimeRangeId: comparisonTimeRangeId
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onCheckBoxClick: function (ev) {
+ ev.stopPropagation();
+ this.configuration.comparisonIsSelected = this.$('.o_comparison_checkbox').prop('checked');
+ this.$('.o_comparison_time_range_selector').toggleClass('o_hidden');
+ this.$el.addClass('open');
+ }
+});
+
+return TimeRangeMenu;
+
+});
diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js
index 9c354be6765..5feefce716f 100644
--- a/addons/web/static/src/js/views/form/form_controller.js
+++ b/addons/web/static/src/js/views/form/form_controller.js
@@ -132,7 +132,7 @@ var FormController = BasicController.extend({
* @param {jQueryElement} $node
*/
renderButtons: function ($node) {
- var $footer = this.footerToButtons ? this.$('footer') : null;
+ var $footer = this.footerToButtons ? this.renderer.$('footer') : null;
var mustRenderFooterButtons = $footer && $footer.length;
if (!this.defaultButtons && !mustRenderFooterButtons) {
return;
@@ -219,7 +219,7 @@ var FormController = BasicController.extend({
var self = this;
return this._super.apply(this, arguments).then(function (changedFields) {
// the title could have been changed
- self.set('title', self.getTitle());
+ self._setTitle(self.getTitle());
self._updateEnv();
if (_t.database.multi_lang && changedFields.length) {
@@ -250,6 +250,9 @@ var FormController = BasicController.extend({
* @override
*/
update: function (params, options) {
+ if ('currentId' in params && !params.currentId) {
+ this.mode = 'edit'; // if there is no record, we are in 'edit' mode
+ }
params = _.extend({viewType: 'form', mode: this.mode}, params);
return this._super(params, options);
},
@@ -395,7 +398,7 @@ var FormController = BasicController.extend({
*/
_update: function () {
var title = this.getTitle();
- this.set('title', title);
+ this._setTitle(title);
this._updateButtons();
this._updateSidebar();
return this._super.apply(this, arguments).then(this.autofocus.bind(this));
@@ -406,7 +409,7 @@ var FormController = BasicController.extend({
_updateButtons: function () {
if (this.$buttons) {
if (this.footerToButtons) {
- var $footer = this.$('footer');
+ var $footer = this.renderer.$('footer');
if ($footer.length) {
this.$buttons.empty().append($footer);
}
diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js
index 3dd92818276..52bbc044d1f 100644
--- a/addons/web/static/src/js/views/form/form_renderer.js
+++ b/addons/web/static/src/js/views/form/form_renderer.js
@@ -5,6 +5,7 @@ var BasicRenderer = require('web.BasicRenderer');
var config = require('web.config');
var core = require('web.core');
var dom = require('web.dom');
+var viewUtils = require('web.viewUtils');
var _t = core._t;
var qweb = core.qweb;
@@ -439,7 +440,7 @@ var FormRenderer = BasicRenderer.extend({
* @returns {jQueryElement}
*/
_renderHeaderButton: function (node) {
- var $button = this._renderButtonFromNode(node);
+ var $button = viewUtils.renderButtonFromNode(node);
// Current API of odoo for rendering buttons is "if classes are given
// use those on top of the 'btn' and 'btn-{size}' classes, otherwise act
@@ -639,7 +640,7 @@ var FormRenderer = BasicRenderer.extend({
* @returns {jQueryElement}
*/
_renderStatButton: function (node) {
- var $button = this._renderButtonFromNode(node, {
+ var $button = viewUtils.renderButtonFromNode(node, {
extraClass: 'oe_stat_button',
});
$button.append(_.map(node.children, this._renderNode.bind(this)));
@@ -685,7 +686,7 @@ var FormRenderer = BasicRenderer.extend({
* @returns {jQueryElement}
*/
_renderTagButton: function (node) {
- var $button = this._renderButtonFromNode(node);
+ var $button = viewUtils.renderButtonFromNode(node);
$button.append(_.map(node.children, this._renderNode.bind(this)));
this._addOnClickAction($button, node);
this._handleAttributes($button, node);
diff --git a/addons/web/static/src/js/views/form/form_view.js b/addons/web/static/src/js/views/form/form_view.js
index 01474a01180..d49f2b005fa 100644
--- a/addons/web/static/src/js/views/form/form_view.js
+++ b/addons/web/static/src/js/views/form/form_view.js
@@ -18,7 +18,8 @@ var FormView = BasicView.extend({
display_name: _lt('Form'),
icon: 'fa-edit',
multi_record: false,
- searchable: false,
+ withSearchBar: false,
+ searchMenuTypes: [],
jsLibs: [],
viewType: 'form',
/**
@@ -27,17 +28,14 @@ var FormView = BasicView.extend({
init: function (viewInfo, params) {
this._super.apply(this, arguments);
- var mode = params.mode || params.context.form_view_initial_mode ||
- (params.currentId ? 'readonly' : 'edit');
+ var mode = params.mode || (params.currentId ? 'readonly' : 'edit');
this.loadParams.type = 'record';
this.controllerParams.disableAutofocus = params.disable_autofocus;
this.controllerParams.hasSidebar = params.hasSidebar;
this.controllerParams.toolbarActions = viewInfo.toolbar;
this.controllerParams.footerToButtons = params.footerToButtons;
- if ('action' in params && 'flags' in params.action) {
- this.controllerParams.footerToButtons = params.action.flags.footerToButtons;
- }
+
var defaultButtons = 'default_buttons' in params ? params.default_buttons : true;
this.controllerParams.defaultButtons = defaultButtons;
this.controllerParams.mode = mode;
@@ -64,6 +62,26 @@ var FormView = BasicView.extend({
//--------------------------------------------------------------------------
/**
+ * @override
+ * @param {string} [action.target]
+ */
+ _extractParamsFromAction: function (action) {
+ var params = this._super.apply(this, arguments);
+ var inDialog = action.target === 'new';
+ var inline = action.target === 'inline';
+ var fullscreen = action.target === 'fullscreen';
+ params.withControlPanel = !(inDialog || inline);
+ params.footerToButtons = inDialog;
+ params.hasSearchView = inDialog ? false : params.hasSearchView;
+ params.searchMenuTypes = inDialog ? [] : params.searchMenuTypes;
+ if (inDialog || inline || fullscreen) {
+ params.mode = 'edit';
+ } else if (action.context && action.context.form_view_initial_mode) {
+ params.mode = action.context.form_view_initial_mode;
+ }
+ return params;
+ },
+ /**
* Loads the subviews for x2many fields when they are not inline
*
* @private
diff --git a/addons/web/static/src/js/views/graph/graph_controller.js b/addons/web/static/src/js/views/graph/graph_controller.js
index 27bd59e476d..15f7fbeaa35 100644
--- a/addons/web/static/src/js/views/graph/graph_controller.js
+++ b/addons/web/static/src/js/views/graph/graph_controller.js
@@ -6,13 +6,11 @@ odoo.define('web.GraphController', function (require) {
var core = require('web.core');
var AbstractController = require('web.AbstractController');
-var GroupByMenuInterfaceMixin = require('web.GroupByMenuInterfaceMixin');
+var GroupByMenuMixin = require('web.GroupByMenuMixin');
var qweb = core.qweb;
-var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{
- className: 'o_graph',
-
+var GraphController = AbstractController.extend(GroupByMenuMixin,{
/**
* @override
* @param {Widget} parent
@@ -24,19 +22,24 @@ var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{
* @param {string[]} params.groupableFields,
*/
init: function (parent, model, renderer, params) {
- GroupByMenuInterfaceMixin.init.call(this);
+ GroupByMenuMixin.init.call(this);
this._super.apply(this, arguments);
this.measures = params.measures;
// this parameter condition the appearance of a 'Group By'
// button in the control panel owned by the graph view.
this.isEmbedded = params.isEmbedded;
+
// this parameter determines what is the list of fields
// that may be used within the groupby menu available when
// the view is embedded
this.groupableFields = params.groupableFields;
- // extends custom_events key. This allow the controller
- // to listen to information comming from the groupby menu.
- // this is used when the view is embedded.
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$el.addClass('o_graph_controller');
+ return this._super.apply(this, arguments);
},
/**
* @todo check if this can be removed (mostly duplicate with
@@ -62,18 +65,20 @@ var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{
* @override
* @returns {Object}
*/
- getContext: function () {
+ getOwnedQueryParams: function () {
var state = this.model.get();
return {
- graph_measure: state.measure,
- graph_mode: state.mode,
- graph_groupbys: state.groupedBy,
- // this parameter is not used anywher for now
- // the idea would be to seperate intervals from
- // fieldnames in groupbys. This could be done
- // in graph view only or everywhere but this is
- // a big refactoring.
- graph_intervalMapping: state.intervalMapping,
+ context: {
+ graph_measure: state.measure,
+ graph_mode: state.mode,
+ graph_groupbys: state.groupedBy,
+ // this parameter is not used anywher for now
+ // the idea would be to seperate intervals from
+ // fieldnames in groupbys. This could be done
+ // in graph view only or everywhere but this is
+ // a big refactoring.
+ graph_intervalMapping: state.intervalMapping,
+ }
};
},
/**
@@ -110,10 +115,10 @@ var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{
* override
*
* @private
- * @param {string[]} groupbys
+ * @param {string[]} groupBys
*/
- _setGroupby: function (groupbys) {
- this.update({groupBy: groupbys});
+ _setGroupby: function (groupBys) {
+ this.update({groupBy: groupBys});
},
/**
diff --git a/addons/web/static/src/js/views/graph/graph_renderer.js b/addons/web/static/src/js/views/graph/graph_renderer.js
index e68a4446ba5..fe4efab1b20 100644
--- a/addons/web/static/src/js/views/graph/graph_renderer.js
+++ b/addons/web/static/src/js/views/graph/graph_renderer.js
@@ -23,7 +23,7 @@ var CHART_TYPES = ['pie', 'bar', 'line'];
var MAX_LEGEND_LENGTH = 25 * (Math.max(1, config.device.size_class));
return AbstractRenderer.extend({
- className: "o_graph_container",
+ className: "o_graph_renderer",
/**
* @override
* @param {Widget} parent
diff --git a/addons/web/static/src/js/views/graph/graph_view.js b/addons/web/static/src/js/views/graph/graph_view.js
index a55da16c6da..4567daf0ead 100644
--- a/addons/web/static/src/js/views/graph/graph_view.js
+++ b/addons/web/static/src/js/views/graph/graph_view.js
@@ -36,7 +36,8 @@ var GraphView = AbstractView.extend({
Renderer: GraphRenderer,
},
viewType: 'graph',
- enableTimeRangeMenu: 'true',
+ searchMenuTypes: ['filter', 'groupBy', 'timeRange', 'favorite'],
+
/**
* @override
*/
diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js
index 25fb88c7bd4..2383a0ac730 100644
--- a/addons/web/static/src/js/views/kanban/kanban_controller.js
+++ b/addons/web/static/src/js/views/kanban/kanban_controller.js
@@ -238,7 +238,7 @@ var KanbanController = BasicController.extend({
self.renderer.updateColumn(db_id, data);
});
});
- }).fail(this.reload.bind(this));
+ }).fail(this.reload.bind(this, {}));
},
/**
* @private
@@ -279,7 +279,7 @@ var KanbanController = BasicController.extend({
} else if (this.on_create && this.on_create !== 'quick_create') {
// Execute the given action
this.do_action(this.on_create, {
- on_close: this.reload.bind(this),
+ on_close: this.reload.bind(this, {}),
additional_context: state.context,
});
} else {
diff --git a/addons/web/static/src/js/views/kanban/kanban_view.js b/addons/web/static/src/js/views/kanban/kanban_view.js
index 523f0ef1c57..9f589ffe2a7 100644
--- a/addons/web/static/src/js/views/kanban/kanban_view.js
+++ b/addons/web/static/src/js/views/kanban/kanban_view.js
@@ -30,13 +30,11 @@ var KanbanView = BasicView.extend({
*/
init: function (viewInfo, params) {
this._super.apply(this, arguments);
-
this.loadParams.limit = this.loadParams.limit || 40;
// in mobile, columns are lazy-loaded, so set 'openGroupByDefault' to
// false so that they will won't be loaded by the initial load
this.loadParams.openGroupByDefault = config.device.isMobile ? false : true;
this.loadParams.type = 'list';
- this.loadParams.groupBy = this.arch.attrs.default_group_by ? [this.arch.attrs.default_group_by] : (params.groupBy || []);
var progressBar;
utils.traverse(this.arch, function (n) {
var isProgressBar = (n.tag === 'progressbar');
@@ -106,6 +104,11 @@ var KanbanView = BasicView.extend({
}
return true;
},
+ _updateMVCParams: function () {
+ this._super.apply(this, arguments);
+ var defaultGroupBy = this.arch.attrs.default_group_by;
+ this.loadParams.groupBy = defaultGroupBy ? [defaultGroupBy] : (this.loadParams.groupedBy || []);
+ },
});
return KanbanView;
diff --git a/addons/web/static/src/js/views/kanban/quick_create_form_view.js b/addons/web/static/src/js/views/kanban/quick_create_form_view.js
index 71bd0073f84..af769d62095 100644
--- a/addons/web/static/src/js/views/kanban/quick_create_form_view.js
+++ b/addons/web/static/src/js/views/kanban/quick_create_form_view.js
@@ -56,10 +56,6 @@ var QuickCreateFormModel = BasicModel.extend({
});
var QuickCreateFormController = FormController.extend({
- custom_events: _.extend({}, FormController.prototype.custom_events, {
- env_updated: '_onEnvUpdated',
- }),
-
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
@@ -83,23 +79,10 @@ var QuickCreateFormController = FormController.extend({
getChanges: function () {
return this.model.getChanges(this.handle);
},
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Stops the propagation of the 'env_updated' event to prevent interferences
- * with the kanban controller.
- *
- * @private
- */
- _onEnvUpdated: function (ev) {
- ev.stopPropagation();
- },
});
var QuickCreateFormView = FormView.extend({
+ withControlPanel: false,
config: _.extend({}, FormView.prototype.config, {
Model: QuickCreateFormModel,
Renderer: QuickCreateFormRenderer,
diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js
index ab3adbf5922..fd5429d7c3e 100644
--- a/addons/web/static/src/js/views/list/list_controller.js
+++ b/addons/web/static/src/js/views/list/list_controller.js
@@ -87,10 +87,10 @@ var ListController = BasicController.extend({
/*
* @override
*/
- getContext: function () {
- var context = this._super.apply(this, arguments);
- context.orderedBy = this.model.get(this.handle, {raw: true}).orderedBy || [];
- return context;
+ getOwnedQueryParams: function () {
+ var state = this._super.apply(this, arguments);
+ var orderedBy = this.model.get(this.handle, {raw: true}).orderedBy || [];
+ return _.extend({}, state, {orderedBy: orderedBy});
},
/**
* Returns the list of currently selected res_ids (with the check boxes on
diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js
index 87542170dd4..ee081aa3c5b 100644
--- a/addons/web/static/src/js/views/list/list_renderer.js
+++ b/addons/web/static/src/js/views/list/list_renderer.js
@@ -8,6 +8,7 @@ var dom = require('web.dom');
var field_utils = require('web.field_utils');
var Pager = require('web.Pager');
var utils = require('web.utils');
+var viewUtils = require('web.viewUtils');
var _t = core._t;
@@ -329,7 +330,7 @@ var ListRenderer = BasicRenderer.extend({
*/
_renderButton: function (record, node) {
var self = this;
- var $button = this._renderButtonFromNode(node, {
+ var $button = viewUtils.renderButtonFromNode(node, {
extraClass: node.attrs.icon ? 'o_icon_button' : undefined,
textAsTitle: !!node.attrs.icon,
});
diff --git a/addons/web/static/src/js/views/list/list_view.js b/addons/web/static/src/js/views/list/list_view.js
index bc80017743b..291cb8de8a0 100644
--- a/addons/web/static/src/js/views/list/list_view.js
+++ b/addons/web/static/src/js/views/list/list_view.js
@@ -42,7 +42,6 @@ var ListView = BasicView.extend({
this.controllerParams.editable = this.arch.attrs.editable;
this.controllerParams.hasSidebar = params.hasSidebar;
this.controllerParams.toolbarActions = viewInfo.toolbar;
- this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf;
this.controllerParams.mode = mode;
this.controllerParams.selectedRecords = selectedRecords;
@@ -55,6 +54,15 @@ var ListView = BasicView.extend({
this.loadParams.limit = this.loadParams.limit || 80;
this.loadParams.type = 'list';
},
+
+ //--------------------------------------------------------------------------
+ // Private
+ //---
+
+ _updateMVCParams: function () {
+ this._super.apply(this, arguments);
+ this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf;
+ },
});
return ListView;
diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js
index 9dfe5dc11b5..a4833b40d81 100644
--- a/addons/web/static/src/js/views/pivot/pivot_controller.js
+++ b/addons/web/static/src/js/views/pivot/pivot_controller.js
@@ -22,7 +22,7 @@ var _t = core._t;
var QWeb = core.qweb;
var PivotController = AbstractController.extend({
- template: 'PivotView',
+ contentTemplate: 'PivotView',
events: {
'click .o_pivot_header_cell_opened': '_onOpenHeaderClick',
'click .o_pivot_header_cell_closed': '_onClosedHeaderClick',
@@ -83,12 +83,14 @@ var PivotController = AbstractController.extend({
* @override method from AbstractController
* @returns {Object}
*/
- getContext: function () {
+ getOwnedQueryParams: function () {
var state = this.model.get();
return {
- pivot_measures: state.measures,
- pivot_column_groupby: state.colGroupBys,
- pivot_row_groupby: state.rowGroupBys,
+ context: {
+ pivot_measures: state.measures,
+ pivot_column_groupby: state.colGroupBys,
+ pivot_row_groupby: state.rowGroupBys,
+ }
};
},
/**
@@ -172,6 +174,13 @@ var PivotController = AbstractController.extend({
.addClass('show');
},
/**
+ * @override
+ * @private
+ */
+ _startRenderer: function () {
+ return this.renderer.appendTo(this.$('.o_pivot'));
+ },
+ /**
* @private
*/
_update: function () {
diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js
index d85076f8271..38d8d80a9d7 100644
--- a/addons/web/static/src/js/views/pivot/pivot_view.js
+++ b/addons/web/static/src/js/views/pivot/pivot_view.js
@@ -28,7 +28,8 @@ var PivotView = AbstractView.extend({
Renderer: PivotRenderer,
},
viewType: 'pivot',
- enableTimeRangeMenu: 'true',
+ searchMenuTypes: ['filter', 'groupBy', 'timeRange', 'favorite'],
+
/**
* @override
* @param {Object} params
@@ -117,10 +118,9 @@ var PivotView = AbstractView.extend({
this.controllerParams.groupableFields = groupableFields;
// retrieve form and list view ids from the action to open those views
// when a data cell of the pivot view is clicked
-
this.controllerParams.views = [
- _findView(params.action && params.action.views, 'list'),
- _findView(params.action && params.action.views, 'form'),
+ _findView(params.actionViews, 'list'),
+ _findView(params.actionViews, 'form'),
];
function _findView(views, viewType) {
var view = _.find(views, function (view) {
diff --git a/addons/web/static/src/js/views/search/favorites_menu.js b/addons/web/static/src/js/views/search/favorites_menu.js
deleted file mode 100644
index 46949e901a3..00000000000
--- a/addons/web/static/src/js/views/search/favorites_menu.js
+++ /dev/null
@@ -1,335 +0,0 @@
-odoo.define('web.FavoriteMenu', function (require) {
-"use strict";
-
-var config = require('web.config');
-var core = require('web.core');
-var data_manager = require('web.data_manager');
-var Domain = require('web.Domain');
-var pyUtils = require('web.py_utils');
-var session = require('web.session');
-var Widget = require('web.Widget');
-
-var _t = core._t;
-
-return Widget.extend({
- template: 'SearchView.FavoriteMenu',
- events: {
- 'click .dropdown-item': function (ev) {
- ev.preventDefault();
- },
- 'click .o_save_search': function (ev) {
- ev.preventDefault();
- ev.stopPropagation();
- this.toggle_save_menu();
- },
- 'click .o_save_name button': 'save_favorite',
- 'hidden.bs.dropdown': '_closeMenus',
- 'keyup .o_save_name input': function (ev) {
- if (ev.which === $.ui.keyCode.ENTER) {
- this.save_favorite();
- }
- },
- },
- init: function (parent, query, target_model, action, filters) {
- this._super.apply(this,arguments);
- this.searchview = parent;
- this.query = query;
- this.target_model = target_model;
- this.action = action;
- this.action_id = action.id;
- this.filters = {};
- this.isMobile = config.device.isMobile;
- _.each(filters, this.add_filter.bind(this));
- },
- start: function () {
- var self = this;
- this.$filters = {};
- this.$save_search = this.$('.o_save_search');
- this.$save_name = this.$('.o_save_name');
- this.$inputs = this.$save_name.find('input');
- this.$user_divider = this.$('.dropdown-divider.user_filter');
- this.$shared_divider = this.$('.dropdown-divider.shared_filter');
- this.$inputs.eq(0).val(this.searchview.get_title());
- var $shared_filter = this.$inputs.eq(1),
- $default_filter = this.$inputs.eq(2);
- $shared_filter.click(function () {$default_filter.prop('checked', false);});
- $default_filter.click(function () {$shared_filter.prop('checked', false);});
-
- this.query
- .on('remove', function (facet) {
- if (facet.get('is_custom_filter')) {
- self.clear_selection();
- }
- })
- .on('reset', this.proxy('clear_selection'));
-
- _.each(this.filters, this.append_filter.bind(this));
-
- return this._super();
- },
- toggle_save_menu: function (is_open) {
- this.$save_search
- .toggleClass('o_closed_menu', !(_.isUndefined(is_open)) ? !is_open : undefined)
- .toggleClass('o_open_menu', is_open);
- this.$save_name.toggle(is_open);
- if (this.$save_search.hasClass('o_open_menu')) {
- this.$save_name.find('input').first().focus();
- }
- },
- _closeMenus: function () {
- this.toggle_save_menu(false);
- },
- save_favorite: function () {
- var self = this,
- filter_name = this.$inputs[0].value,
- default_filter = this.$inputs[1].checked,
- shared_filter = this.$inputs[2].checked;
- if (!filter_name.length){
- this.do_warn(_t("Error"), _t("Filter name is required."));
- this.$inputs.first().focus();
- return;
- }
- if (_.chain(this.filters)
- .pluck('name')
- .contains(filter_name).value()) {
- this.do_warn(_t("Error"), _t("Filter with same name already exists."));
- this.$inputs.first().focus();
- return;
- }
- var user_context = this.getSession().user_context;
- // get search params with domains not evaluated
- var search = this.searchview.build_search_data(true);
- var domain = pyUtils.assembleDomains(search.domains, 'AND');
- var controllerContext;
- this.trigger_up('get_controller_context', {
- callback: function (ctx) {
- if (ctx && ctx.orderedBy) {
- var sort = _.map(ctx.orderedBy, function (obj) {
- var order = obj.asc ? 'ASC' : 'DESC';
- return obj.name + ' ' + order;
- });
- self.searchview.dataset.set_sort(sort);
- }
- controllerContext = _.omit(ctx, ['orderedBy']);
- },
- });
-
- var results = pyUtils.eval_domains_and_contexts({
- domains: [],
- contexts: [user_context].concat(search.contexts.concat(controllerContext || [])),
- group_by_seq: search.groupbys || [],
- });
- if (!_.isEmpty(results.group_by)) {
- results.context.group_by = results.group_by;
- }
- // Don't save user_context keys in the custom filter, otherwise end
- // up with e.g. wrong uid or lang stored *and used in subsequent
- // reqs*
- var ctx = results.context;
- _(_.keys(session.user_context)).each(function (key) {
- delete ctx[key];
- });
- var filter = {
- name: filter_name,
- user_id: shared_filter ? false : session.uid,
- model_id: this.target_model,
- context: results.context,
- domain: domain,
- sort: JSON.stringify(this.searchview.dataset._sort),
- is_default: default_filter,
- action_id: this.action_id,
- };
- return this._createFilter(filter).then(function (id) {
- filter.id = id;
- self.toggle_save_menu(false);
- self.$save_name.find('input').val('').prop('checked', false);
- self.add_filter(filter);
- self.append_filter(filter);
- self.toggle_filter(filter, true);
- });
- },
- get_default_filter: function () {
- var personal_filter = _.find(this.filters, function (filter) {
- return filter.user_id && filter.is_default;
- });
- if (personal_filter) {
- return personal_filter;
- }
- return _.find(this.filters, function (filter) {
- return !filter.user_id && filter.is_default;
- });
- },
- /**
- * Generates a mapping key (in the filters and $filter mappings) for the
- * filter descriptor object provided (as returned by ``get_filters``).
- *
- * The mapping key is guaranteed to be unique for a given (user_id, name)
- * pair.
- *
- * @param {Object} filter
- * @param {String} filter.name
- * @param {Number|Pair<Number, String>} [filter.user_id]
- * @return {String} mapping key corresponding to the filter
- */
- key_for: function (filter) {
- var user_id = filter.user_id,
- action_id = filter.action_id,
- uid = (user_id instanceof Array) ? user_id[0] : user_id,
- act_id = (action_id instanceof Array) ? action_id[0] : action_id;
- return _.str.sprintf('(%s)(%s)%s', uid, act_id, filter.name);
- },
- /**
- * Generates a :js:class:`~instance.web.search.Facet` descriptor from a
- * filter descriptor
- *
- * @param {Object} filter
- * @param {String} filter.name
- * @param {Object} [filter.context]
- * @param {Array} [filter.domain]
- * @return {Object}
- */
- facet_for: function (filter) {
- var self = this;
- return {
- category: _t("Custom Filter"),
- icon: 'fa-star',
- field: {
- get_context: function () {
- var sortParsed = JSON.parse(filter.sort || "[]");
- var orderedBy = [];
- _.each(sortParsed, function (sort) {
- orderedBy.push({
- name: sort[0] === '-' ? sort.slice(1) : sort,
- asc: sort[0] === '-' ? false : true,
- });
- });
-
- return _.defaults({}, filter.context, {orderedBy : orderedBy});
- },
- get_groupby: function () { return [filter.context]; },
- // facet is not used
- get_domain: function (facet, noEvaluation) {
- noEvaluation = noEvaluation || false;
- var userContext = self.getSession().user_context;
- var domain = pyUtils.assembleDomains([filter.domain]);
- if (!noEvaluation) {
- return Domain.prototype.stringToArray(domain, userContext);
- }
- return domain;
- }
- },
- _id: filter.id,
- is_custom_filter: true,
- values: [{label: filter.name, value: null}]
- };
- },
- clear_selection: function () {
- this.$('.selected').removeClass('selected');
- },
- /**
- * Adds a filter description to the filters dict
- * @param {Object} [filter] the filter description
- */
- add_filter: function (filter) {
- this.filters[this.key_for(filter)] = filter;
- },
- /**
- * Creates a $filter JQuery node, adds it to the $filters dict and appends it to the filter menu
- * @param {Object} [filter] the filter description
- */
- append_filter: function (filter) {
- var self = this;
- var key = this.key_for(filter);
-
- if (filter.user_id) {
- this.$user_divider.show();
- } else {
- this.$shared_divider.show();
- }
- if (!(key in this.$filters)) {
- var $filter = $('<div>', {class: 'position-relative'})
- .addClass(filter.user_id ? 'o-searchview-custom-private'
- : 'o-searchview-custom-public')
- .append($('<a>', {href: '#', class: 'dropdown-item'}).text(filter.name))
- .append($('<span>', {
- class: 'fa fa-trash-o o-remove-filter',
- on: {
- click: function (event) {
- event.stopImmediatePropagation();
- self.remove_filter(filter, $filter, key);
- },
- },
- }))
- .insertBefore(filter.user_id ? this.$user_divider : this.$shared_divider);
- this.$filters[key] = $filter;
- }
- this.$filters[key].unbind('click').click(function () {
- self.toggle_filter(filter);
- });
- },
- toggle_filter: function (filter, preventSearch) {
- var current = this.query.find(function (facet) {
- return facet.get('_id') === filter.id;
- });
- if (current) {
- this.query.remove(current);
- this.$filters[this.key_for(filter)].find('.dropdown-item').removeClass('selected');
- return;
- }
- this.query.reset([this.facet_for(filter)], {
- preventSearch: preventSearch || false});
-
- // Load sort settings on view
- if (!_.isUndefined(filter.sort)){
- var sort_items = JSON.parse(filter.sort);
- this.searchview.dataset.set_sort(sort_items);
- }
-
- this.$filters[this.key_for(filter)].find('.dropdown-item').addClass('selected');
- },
- remove_filter: function (filter, $filter, key) {
- var self = this;
- var global_warning = _t("This filter is global and will be removed for everybody if you continue."),
- warning = _t("Are you sure that you want to remove this filter?");
- if (!confirm(filter.user_id ? warning : global_warning)) {
- return;
- }
- return data_manager
- .delete_filter(filter)
- .done(function () {
- $filter.remove();
- delete self.$filters[key];
- delete self.filters[key];
- var has_user_filter = _.find(self.filters, function(filter) { return filter.user_id; });
- var has_shared_filer = _.find(self.filters, function(filter) { return !filter.user_id; });
- if (!has_user_filter) {
- self.$user_divider.hide();
- }
- if (!has_shared_filer) {
- self.$shared_divider.hide();
- }
- });
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Creates a new search view filter.
- *
- * @private
- * @param {Object} filter the filter description
- * @returns {$.Deferred} resolved with the RPC's result when it succeeds
- */
- _createFilter: function (filter) {
- var def = $.Deferred();
- this.trigger_up('create_filter', {
- filter: filter,
- on_success: def.resolve.bind(def),
- });
- return def;
- },
-});
-
-});
diff --git a/addons/web/static/src/js/views/search/filters_menu.js b/addons/web/static/src/js/views/search/filters_menu.js
deleted file mode 100644
index f61bbff8045..00000000000
--- a/addons/web/static/src/js/views/search/filters_menu.js
+++ /dev/null
@@ -1,253 +0,0 @@
-odoo.define('web.FiltersMenu', function (require) {
-"use strict";
-
-var config = require('web.config');
-var core = require('web.core');
-var Domain = require('web.Domain');
-var DropdownMenu = require('web.DropdownMenu');
-var search_filters = require('web.search_filters');
-var time = require('web.time');
-var TimeRangeMenuOptions = require('web.TimeRangeMenuOptions');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-var PERIOD_OPTIONS = TimeRangeMenuOptions.PeriodOptions;
-
-var DEFAULT_PERIOD = 'this_month';
-
-var FiltersMenu = DropdownMenu.extend({
- custom_events: {
- remove_proposition: '_onRemoveProposition',
- confirm_proposition: '_onConfirmProposition',
- },
- events: _.extend({}, DropdownMenu.prototype.events, {
- 'click .o_add_custom_filter': '_onAddCustomFilterClick',
- 'click .o_add_condition': '_appendProposition',
- 'click .o_apply_filter': '_onApplyClick',
- ':hover .o_item_option': '_onOptionHover',
- }),
-
- /**
- * @override
- * @param {Widget} parent
- * @param {Object[]} filters list of filters (type IFilter below)
- * interface IFilter {
- * itemId: string; unique id associated with the filter
- * domain: string;
- * isPeriod: boolean
- * fieldName: string; fieldName used to generate a domain in case isDate is true (and domain empty)
- * description: string; label printed on screen
- * groupId: string;
- * isActive: boolean; (optional) determines if the filter is considered active
- * isOpen: boolean; (optional) in case there are options the submenu presenting the options
- * is opened or closed according to isOpen
- * isRemovable: boolean; (optional) can be removed from menu
- * options: array of objects with 'optionId' and 'description' keys; (optional)
- * defaultOptionId: string refers to an optionId (optional)
- * currentOptionId: string refers to an optionId that is activated if item is active (optional)
- * }
- * @param {Object} fields
- */
- init: function (parent, filters, fields) {
- // determines where the filter menu is displayed and its style
- this.isMobile = config.device.isMobile;
- // determines list of options used by filter of type 'date'
- this.periodOptions = PERIOD_OPTIONS;
- this.defaultOptionId = DEFAULT_PERIOD;
- // determines when the 'Add custom filter' submenu is open
- this.generatorMenuIsOpen = false;
- this.propositions = [];
- this.fields = _.pick(fields, function (field, name) {
- return field.selectable !== false && name !== 'id';
- });
- this.fields.id = {string: 'ID', type: 'id', searchable: true};
- var dropdownHeader = {
- category: 'filterCategory',
- title: _t('Filters'),
- icon: 'fa fa-filter',
- symbol: this.isMobile ? 'fa fa-chevron-right float-right mt4' : false,
- };
- this._super(parent, dropdownHeader, filters, this.fields);
- },
-
- /**
- * render the template used to add a new custom filter and append it
- * to the basic dropdown menu
- *
- * @private
- */
- start: function () {
- this.$menu = this.$('.o_dropdown_menu');
- this.$menu.addClass('o_filters_menu');
- var generatorMenu = QWeb.render('FiltersMenuGenerator', {widget: this});
- this.$menu.append(generatorMenu);
- this.$addCustomFilter = this.$menu.find('.o_add_custom_filter');
- this.$addFilterMenu = this.$menu.find('.o_add_filter_menu');
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Add a proposition inside the custom filter edition menu
- *
- * @private
- * @returns {$.Deferred}
- */
- _appendProposition: function () {
- var prop = new search_filters.ExtendedSearchProposition(this, this.fields);
- this.propositions.push(prop);
- this.$('.o_apply_filter').prop('disabled', false);
- return prop.insertBefore(this.$addFilterMenu);
- },
- /**
- * Confirm a filter selection, creates it and add it to the menu
- *
- * @private
- */
- _commitSearch: function () {
- var self = this;
- var filters = _.invoke(this.propositions, 'get_filter');
- _.each(filters, function (filter) {
- filter.attrs.domain = Domain.prototype.arrayToString(filter.attrs.domain);
- });
- var groupId = _.uniqueId('__group__');
- var data = [];
- filters.forEach(function (filter) {
- var filterName = _.uniqueId('__filter__');
- var filterItem = {
- itemId: filterName,
- description: filter.attrs.string,
- groupId: groupId,
- isActive: true,
- };
- self._prepareItem(filterItem);
- data.push({
- itemId: filterName,
- groupId: groupId,
- filter: filter,
- });
- self.items.push(filterItem);
- });
- this._renderMenuItems();
- this.trigger_up('new_filters', data);
- _.invoke(this.propositions, 'destroy');
- this.propositions = [];
- this._toggleCustomFilterMenu();
- },
- /**
- * override
- *
- * @private
- * @param {Object} item
- */
- _prepareItem: function (item) {
- if (item.isPeriod) {
- item.options = this.periodOptions;
- }
- this._super.apply(this, arguments);
- },
- /**
- * override
- *
- * @private
- */
- _renderMenuItems: function () {
- var self= this;
- this._super.apply(this, arguments);
- // the following code adds tooltip on date options in order
- // to alert the user of the meaning of intervals
- var $options = this.$('.o_filters_menu .o_item_option');
- $options.each(function () {
- var $option = $(this);
- $option.tooltip({
- delay: { show: 500, hide: 0 },
- title: function () {
- var itemId = $option.attr('data-item_id');
- var optionId = $option.attr('data-option_id');
- var fieldName = _.findWhere(self.items, {itemId: itemId}).fieldName;
- var domain = Domain.prototype.constructDomain(fieldName, optionId, 'date', true);
- var evaluatedDomain = Domain.prototype.stringToArray(domain);
- var dateFormat = time.getLangDateFormat();
- var dateStart = moment(evaluatedDomain[1][2], "YYYY-MM-DD", 'en').format(dateFormat);
- var dateEnd = moment(evaluatedDomain[2][2], "YYYY-MM-DD", 'en').format(dateFormat);
- if (optionId === 'today' || optionId === 'yesterday') {
- return dateStart;
- }
- return _.str.sprintf(_t('From %s To %s'), dateStart, dateEnd);
- }
- });
- });
- },
- /**
- * Hide and display the sub menu which allows adding custom filters
- *
- * @private
- */
- _toggleCustomFilterMenu: function () {
- var self = this;
- this.generatorMenuIsOpen = !this.generatorMenuIsOpen;
- var def;
- if (this.generatorMenuIsOpen && !this.propositions.length) {
- def = this._appendProposition();
- }
- if (!this.generatorMenuIsOpen) {
- _.invoke(this.propositions, 'destroy');
- this.propositions = [];
- }
- $.when(def).then(function () {
- self.$addCustomFilter
- .toggleClass('o_closed_menu', !self.generatorMenuIsOpen)
- .toggleClass('o_open_menu', self.generatorMenuIsOpen);
- self.$('.o_add_filter_menu').toggle();
- });
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onAddCustomFilterClick: function (event) {
- event.preventDefault();
- event.stopPropagation();
- this._toggleCustomFilterMenu();
- },
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onApplyClick: function (event) {
- event.stopPropagation();
- this._commitSearch();
- },
- /**
- * @private
- * @param {OdooEvent} event
- */
- _onConfirmProposition: function (event) {
- event.stopPropagation();
- this._commitSearch();
- },
- /**
- * @private
- * @param {OdooEvent} event
- */
- _onRemoveProposition: function (event) {
- event.stopPropagation();
- this.propositions = _.without(this.propositions, event.target);
- if (!this.propositions.length) {
- this.$('.o_apply_filter').prop('disabled', true);
- }
- event.target.destroy();
- },
-});
-
-return FiltersMenu;
-
-});
diff --git a/addons/web/static/src/js/views/search/groupby_menu.js b/addons/web/static/src/js/views/search/groupby_menu.js
deleted file mode 100644
index 207455c1a47..00000000000
--- a/addons/web/static/src/js/views/search/groupby_menu.js
+++ /dev/null
@@ -1,232 +0,0 @@
-odoo.define('web.GroupByMenu', function (require) {
-"use strict";
-
-var config = require('web.config');
-var core = require('web.core');
-var DropdownMenu = require('web.DropdownMenu');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-var GROUPABLE_TYPES = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime'];
-
-var DEFAULT_INTERVAL = 'month';
-
-var GroupByMenu = DropdownMenu.extend({
- events: _.extend({}, DropdownMenu.prototype.events,
- {
- 'click .o_add_custom_group': '_onAddCustomGroupClick',
- 'click button.o_apply_group': '_onButtonApplyClick',
- 'click .o_group_selector': '_onGroupSelectorClick',
- }),
-
- /**
- * @override
- * @param {Widget} parent
- * @param {Object[]} groupbys list of groupbys (type IGroupBy below)
- * interface IGroupBy {
- * itemId: string; unique id associated with the groupby
- * fieldName: string; field name without interval!
- * description: string; label printed on screen
- * groupId: string;
- * isDate: boolean;
- * isActive: boolean; (optional) determines if the groupby is considered active
- * isOpen: boolean; (optional) in case there are options the submenu presenting the options
- * is opened or closed according to isOpen
- * isRemovable: boolean; (optional) can be removed from menu
- * options: array of objects with 'optionId' and 'description' keys; (optional)
- * currentOptionId: string refers to an optionId that is activated if item is active (optional)
- * }
- * @param {Object} fields 'field_get' of a model: mapping from field name
- * to an object with its properties
- * @param {Object} options
- * @param {string} options.headerStyle conditions the style of the main button
- */
- init: function (parent, groupbys, fields, options) {
- var self = this;
- this.fields = fields;
- // determines when the 'Add custom groupby' submenu is open
- this.generatorMenuIsOpen = false;
- // determines list of options used by groupbys of type 'date'
- this.intervalOptions = [
- {description: 'Day', optionId: 'day', groupId: 1},
- {description: 'Week', optionId: 'week', groupId: 1},
- {description: 'Month', optionId: 'month', groupId: 1},
- {description: 'Quarter', optionId: 'quarter', groupId: 1},
- {description: 'Year', optionId: 'year', groupId: 1},
- ];
- this.defaultOptionId = DEFAULT_INTERVAL;
- this.groupableFields = [];
- _.each(fields, function (field, name) {
- if (field.sortable && _.contains(GROUPABLE_TYPES, field.type)) {
- self.groupableFields.push(_.extend({}, field, {
- name: name,
- isDate: _.contains(['date', 'datetime'], field.type),
- }));
- }
- });
- this.groupableFields = _.sortBy(this.groupableFields, 'string');
- _.each(groupbys, this._prepareItem.bind(this));
- // determines the list of field names that can be added to the menu
- // via the 'Add Custom Groupby' menu
- this.presentedFields = _.filter(this.groupableFields, function (field) {
- var groupByFields = _.pluck(groupbys, 'fieldName');
- return !_.contains(groupByFields, field.name);
- });
- // determines where the filter menu is displayed and partly its style
- this.isMobile = config.device.isMobile;
- // the default style of the groupby menu can be changed here using the options key 'headerStyle'
- var style;
- if (options && options.headerStyle === 'primary') {
- style = {
- el: {class: 'btn-group o_graph_groupbys_menu o_dropdown', attrs: {'role': 'group'}},
- mainButton: {class: 'btn btn-primary dropdown-toggle'},
- };
- }
- var dropdownHeader = {
- category: 'groupByCategory',
- title: _t('Group By'),
- icon: 'fa fa-bars',
- symbol: this.isMobile ? 'fa fa-chevron-right float-right mt4' : false,
- style: style,
- };
- this._super(parent, dropdownHeader, groupbys, options);
- },
-
- /**
- * render the template used to add a new custom groupby and append it
- * to the basic dropdown menu
- *
- * @private
- */
- start: function () {
- this.$menu = this.$('.o_dropdown_menu');
- this.$menu.addClass('o_group_by_menu');
- var $generatorMenu = QWeb.render('GroupbyMenuGenerator', {widget: this});
- this.$menu.append($generatorMenu);
- this.$addCustomGroup = this.$menu.find('.o_add_custom_group');
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * method called via the 'Add Custom Groupby' menu to create
- * a new groupby and add it (activated) to the menu
- * In case the field is of type date, it is activated using
- * the option determined by the parameter 'DEFAULT_INTERVAL'
- *
- * @private
- * @param {string} fieldName
- */
- _addGroupby: function (fieldName) {
- var field = _.findWhere(this.groupableFields, {name: fieldName});
- var groupbyName = _.uniqueId('__groupby__');
- var groupby = {
- itemId: groupbyName,
- description: field.string,
- fieldName: fieldName,
- groupId: _.uniqueId('__group__'),
- isActive: true,
- };
- var eventData = _.clone(groupby);
- this._prepareItem(groupby);
- if (groupby.hasOptions) {
- groupby.defaultOptionId = DEFAULT_INTERVAL;
- groupby.currentOptionId = DEFAULT_INTERVAL;
- groupby.isDate = true;
- eventData.optionId = groupby.currentOptionId;
- eventData.isDate = true;
- }
- this.items.push(groupby);
- var fieldIndex = this.presentedFields.indexOf(field);
- this.presentedFields.splice(fieldIndex, 1);
- this._renderGeneratorMenu();
- this._renderMenuItems();
- this.trigger_up('new_groupby', eventData);
- },
- /**
- * override
- *
- * @private
- * @param {Object} item
- */
- _prepareItem: function (item) {
- if (_.contains(['date', 'datetime'], this.fields[item.fieldName].type)) {
- item.options = this.intervalOptions;
- }
- // super has to be called here because we need to add options to groupby
- // before to call it since some keys in a groupby are initialized using
- // the keys 'options' and 'defaultOptionId'
- this._super.apply(this, arguments);
- },
- /**
- * @private
- */
- _renderGeneratorMenu: function () {
- this.$el.find('.o_generator_menu').remove();
- var $generatorMenu = QWeb.render('GroupbyMenuGenerator', {widget: this});
- this.$menu.append($generatorMenu);
- this.$addCustomGroup = this.$menu.find('.o_add_custom_group');
- this.$groupSelector = this.$menu.find('.o_group_selector');
- },
- /**
- * @private
- */
- _toggleCustomGroupMenu: function () {
- this.generatorMenuIsOpen = !this.generatorMenuIsOpen;
- this._renderGeneratorMenu();
- if (this.generatorMenuIsOpen) {
- this.$groupSelector.focus();
- }
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * toggle the 'Add Custom Group'
- *
- * @private
- * @param {MouseEvent} event
- */
- _onAddCustomGroupClick: function (event) {
- event.preventDefault();
- event.stopPropagation();
- this._toggleCustomGroupMenu();
- },
- /*
- * override
- *
- * @private
- * @param {jQueryEvent} event
- */
- _onBootstrapClose: function () {
- this._super.apply(this, arguments);
- this.generatorMenuIsOpen = false;
- this._renderGeneratorMenu();
- },
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onButtonApplyClick: function (event) {
- event.stopPropagation();
- var fieldName = this.$groupSelector.val();
- this._addGroupby(fieldName);
- this._toggleCustomGroupMenu();
- },
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onGroupSelectorClick: function (event) {
- event.stopPropagation();
- },
-});
-
-return GroupByMenu;
-
-});
diff --git a/addons/web/static/src/js/views/search/groupby_menu_interface_mixin.js b/addons/web/static/src/js/views/search/groupby_menu_interface_mixin.js
deleted file mode 100644
index d782eb99015..00000000000
--- a/addons/web/static/src/js/views/search/groupby_menu_interface_mixin.js
+++ /dev/null
@@ -1,117 +0,0 @@
-odoo.define('web.GroupByMenuInterfaceMixin', function (require) {
-"use strict";
-
-var GroupByMenu = require('web.GroupByMenu');
-
-/**
- * The aim of this mixin is to facilitate the interaction between
- * a view controller and a dropdown menu with its control panel
- * TO DO: the pivot subview has two types of groupbys so that it will not
- * understand the current implementation of this mixin
- *
- * @mixin
- * @name GroupByMenuInterfaceMixin
- */
-var GroupByMenuInterfaceMixin = {
-
- init: function () {
- this.custom_events = _.extend({}, this.custom_events, {
- menu_item_toggled: '_onMenuItemToggled',
- item_option_changed : '_onItemOptionChanged',});
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * this function instantiate a widget GroupByMenu and
- * incorporate it to the control panel of the graph view.
- * This is used for instance in the dashboard view in enterprise
- * where there is no Group by menu in the search view because it
- * would not make sense to have one at the global level.
- * this function is called by renderButtons when the parameter
- * 'this.isEmbedded' is set to true.
- *
- * private
- * @param {jQuery} $node
- */
- _addGroupByMenu: function ($node, groupableFields) {
- var groupbys = [];
- var activeGroupbys = [];
- _.each(this.model.get().groupedBy, function (groupby) {
- activeGroupbys.push({fieldName: groupby.split(':')[0], interval: groupby.split(':')[1] || false});
- });
- var groupId = '__group__1';
- _.each(groupableFields, function (field, fieldName) {
- var groupby = _.findWhere(activeGroupbys, {fieldName: fieldName});
- groupbys.push({
- isActive: groupby ? true : false,
- description: field.string,
- itemId: fieldName,
- fieldName: fieldName,
- groupId: groupId,
- defaultOptionId: groupby ? groupby.interval : false,
- });
- });
- groupbys = _.sortBy(groupbys, 'description');
- var groupByMenu = new GroupByMenu(this, groupbys, groupableFields, {headerStyle: 'primary'});
- groupByMenu.insertAfter($node.find('div:first'));
- },
-
- /**
- * This method has to be implemented by the view controller
- * that needs to interpret the click in an appropriate
- * manner
- *
- * @private
- * @param {string[]} groupbys
- */
- _setGroupby: function (groupbys) {
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onItemOptionChanged: function (ev) {
- var fieldName = ev.data.itemId;
- var interval = ev.data.optionId;
- var groupbys = this.model.get().groupedBy;
- var groupbysNormalized = _.map(groupbys, function (groupby) {
- return groupby.split(':')[0];
- });
- var indexOfGroupby = groupbysNormalized.indexOf(fieldName);
- groupbys.splice(indexOfGroupby, 1, fieldName + ':' + interval);
- this._setGroupby(groupbys);
- },
- /**
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onMenuItemToggled: function (ev) {
- var fieldName = ev.data.itemId;
- var interval = ev.data.optionId;
- var groupbys = this.model.get().groupedBy;
- var groupbysNormalized = _.map(groupbys, function (groupby) {
- return groupby.split(':')[0];
- });
- var indexOfGroupby = groupbysNormalized.indexOf(fieldName);
- if (indexOfGroupby === -1) {
- groupbys.push(fieldName + (interval ? (':' + interval) : ''));
- } else {
- groupbys.splice(indexOfGroupby, 1);
- }
- this._setGroupby(groupbys);
- },
-};
-
-return GroupByMenuInterfaceMixin;
-
-}); \ No newline at end of file
diff --git a/addons/web/static/src/js/views/search/search_inputs.js b/addons/web/static/src/js/views/search/search_inputs.js
deleted file mode 100644
index 43fbea08f9c..00000000000
--- a/addons/web/static/src/js/views/search/search_inputs.js
+++ /dev/null
@@ -1,840 +0,0 @@
-odoo.define('web.search_inputs', function (require) {
-"use strict";
-
-var Context = require('web.Context');
-var core = require('web.core');
-var Domain = require('web.Domain');
-var field_utils = require('web.field_utils');
-var pyUtils = require('web.py_utils');
-var time = require('web.time');
-var utils = require('web.utils');
-var Widget = require('web.Widget');
-
-var _t = core._t;
-var _lt = core._lt;
-
-var Input = Widget.extend( /** @lends instance.web.search.Input# */{
- /**
- * @constructs instance.web.search.Input
- * @extends instance.web.Widget
- *
- * @param parent
- */
- init: function (parent) {
- this._super(parent);
- this.searchview = parent;
- this.load_attrs({});
- },
- /**
- * Fetch auto-completion values for the widget.
- *
- * The completion values should be an array of objects with keys category,
- * label, value prefixed with an object with keys type=section and label
- *
- * @param {String} value value to complete
- * @returns {jQuery.Deferred<null|Array>}
- */
- complete: function (value) {
- return $.when(null);
- },
- /**
- * Returns a Facet instance for the provided defaults if they apply to
- * this widget, or null if they don't.
- *
- * This default implementation will try calling
- * :js:func:`instance.web.search.Input#facet_for` if the widget's name
- * matches the input key
- *
- * @param {Object} defaults
- * @returns {jQuery.Deferred<null|Object>}
- */
- facet_for_defaults: function (defaults) {
- if (!this.attrs ||
- !(this.attrs.name in defaults && defaults[this.attrs.name])) {
- return $.when(null);
- }
- return this.facet_for(defaults[this.attrs.name]);
- },
- get_context: function () {
- throw new Error(
- "get_context not implemented for widget " + this.attrs.type);
- },
- get_groupby: function () {
- throw new Error(
- "get_groupby not implemented for widget " + this.attrs.type);
- },
- get_domain: function () {
- throw new Error(
- "get_domain not implemented for widget " + this.attrs.type);
- },
- load_attrs: function (attrs) {
- if (!_.isObject(attrs.modifiers)) {
- attrs.modifiers = attrs.modifiers ? JSON.parse(attrs.modifiers) : {};
- }
- this.attrs = attrs;
- },
- /**
- * Returns whether the input is "visible". The default behavior is to
- * query the ``modifiers.invisible`` flag on the input's description or
- * view node.
- *
- * @returns {Boolean}
- */
- visible: function () {
- return !this.attrs.modifiers.invisible;
- },
-});
-
-var Field = Input.extend( /** @lends instance.web.search.Field# */ {
- template: 'SearchView.field',
- default_operator: '=',
- /**
- * @constructs instance.web.search.Field
- * @extends instance.web.search.Input
- *
- * @param view_section
- * @param field
- * @param parent
- */
- init: function (view_section, field, parent) {
- this._super(parent);
- this.load_attrs(_.extend({}, field, view_section.attrs));
- },
- facet_for: function (value) {
- return $.when({
- field: this,
- category: this.attrs.string || this.attrs.name,
- values: [{label: String(value), value: value}]
- });
- },
- value_from: function (facetValue) {
- return facetValue.get('value');
- },
- get_context: function (facet) {
- var self = this;
- // A field needs a context to send when active
- var context = this.attrs.context;
- if (_.isEmpty(context) || !facet.values.length) {
- return;
- }
- var contexts = facet.values.map(function (facetValue) {
- return new Context(context)
- .set_eval_context({self: self.value_from(facetValue)});
- });
-
- if (contexts.length === 1) { return contexts[0]; }
-
- return _.extend(new Context(), {
- __contexts: contexts
- });
- },
- get_groupby: function () {},
- /**
- * Function creating the returned domain for the field, override this
- * methods in children if you only need to customize the field's domain
- * without more complex alterations or tests (and without the need to
- * change override the handling of filter_domain)
- *
- * @param {String} name the field's name
- * @param {String} operator the field's operator (either attribute-specified or default operator for the field
- * @param {Number|String} facet parsed value for the field
- * @returns {Array<Array>} domain to include in the resulting search
- */
- make_domain: function (name, operator, facet) {
- return [[name, operator, this.value_from(facet)]];
- },
- get_domain: function (facet, noDomainEvaluation) {
- if (!facet.values.length) { return; }
-
- var value_to_domain;
- var self = this;
- var domain = this.attrs.filter_domain;
- if (domain) {
- value_to_domain = function (facetValue) {
- return Domain.prototype.stringToArray(
- domain,
- {self: self.value_from(facetValue), raw_value: facetValue.attributes.value}
- );
- };
- } else {
- value_to_domain = function (facetValue) {
- return self.make_domain(
- self.attrs.name,
- self.attrs.operator || self.default_operator,
- facetValue
- );
- };
- }
- var domains = facet.values.map(value_to_domain);
-
- // here 'noDomainEvaluation' means 'string representation'
- if (noDomainEvaluation) {
- domains = domains.map(Domain.prototype.arrayToString);
- return pyUtils.assembleDomains(domains, 'OR');
- }
- if (domains.length === 1) {
- return domains[0];
- }
- _.each(domains, Domain.prototype.normalizeArray);
- var ors = _.times(domains.length - 1, _.constant("|"));
- return ors.concat.apply(ors, domains);
- }
-});
-
-/**
- * Implementation of the ``char`` OpenERP field type:
- *
- * * Default operator is ``ilike`` rather than ``=``
- *
- * * The Javascript and the HTML values are identical (strings)
- *
- * @class
- * @extends instance.web.search.Field
- */
-var CharField = Field.extend( /** @lends instance.web.search.CharField# */ {
- default_operator: 'ilike',
- complete: function (value) {
- if (_.isEmpty(value)) { return $.when(null); }
- var label = _.str.sprintf(_.str.escapeHTML(
- _t("Search %(field)s for: %(value)s")), {
- field: '<em>' + _.escape(this.attrs.string) + '</em>',
- value: '<strong>' + _.escape(value) + '</strong>'});
- return $.when([{
- label: label,
- facet: {
- category: this.attrs.string,
- field: this,
- values: [{label: value, value: value}]
- }
- }]);
- }
-});
-
-var NumberField = Field.extend(/** @lends instance.web.search.NumberField# */{
- complete: function (value) {
- var val = this.parse(value);
- if (isNaN(val)) { return $.when(); }
- var label = _.str.sprintf(
- _t("Search %(field)s for: %(value)s"), {
- field: '<em>' + _.escape(this.attrs.string) + '</em>',
- value: '<strong>' + _.escape(value) + '</strong>'});
- return $.when([{
- label: label,
- facet: {
- category: this.attrs.string,
- field: this,
- values: [{label: value, value: val}]
- }
- }]);
- },
-});
-
-/**
- * @class
- * @extends instance.web.search.NumberField
- */
-var IntegerField = NumberField.extend(/** @lends instance.web.search.IntegerField# */{
- error_message: _t("not a valid integer"),
- parse: function (value) {
- try {
- return field_utils.parse.integer(value);
- } catch (e) {
- return NaN;
- }
- }
-});
-
-/**
- * @class
- * @extends instance.web.search.NumberField
- */
-var FloatField = NumberField.extend(/** @lends instance.web.search.FloatField# */{
- error_message: _t("not a valid number"),
- parse: function (value) {
- try {
- return field_utils.parse.float(value);
- } catch (e) {
- return NaN;
- }
- }
-});
-
-/**
- * Utility function for m2o & selection fields taking a selection/name_get pair
- * (value, name) and converting it to a Facet descriptor
- *
- * @param {instance.web.search.Field} field holder field
- * @param {Array} pair pair value to convert
- */
-function facet_from(field, pair) {
- return {
- field: field,
- category: field.attrs.string,
- values: [{label: pair[1], value: pair[0]}]
- };
-}
-
-/**
- * @class
- * @extends instance.web.search.Field
- */
-var SelectionField = Field.extend(/** @lends instance.web.search.SelectionField# */{
- // This implementation is a basic <select> field, but it may have to be
- // altered to be more in line with the GTK client, which uses a combo box
- // (~ jquery.autocomplete):
- // * If an option was selected in the list, behave as currently
- // * If something which is not in the list was entered (via the text input),
- // the default domain should become (`ilike` string_value) but **any
- // ``context`` or ``filter_domain`` becomes falsy, idem if ``@operator``
- // is specified. So at least get_domain needs to be quite a bit
- // overridden (if there's no @value and there is no filter_domain and
- // there is no @operator, return [[name, 'ilike', str_val]]
- template: 'SearchView.field.selection',
- init: function () {
- this._super.apply(this, arguments);
- // prepend empty option if there is no empty option in the selection list
- this.prepend_empty = !_(this.attrs.selection).detect(function (item) {
- return !item[1];
- });
- },
- complete: function (needle) {
- var self = this;
- var results = _(this.attrs.selection).chain()
- .filter(function (sel) {
- var value = sel[0], label = sel[1];
- if (value === undefined || !label) { return false; }
- return label.toLowerCase().indexOf(needle.toLowerCase()) !== -1;
- })
- .map(function (sel) {
- return {
- label: _.escape(sel[1]),
- indent: true,
- facet: facet_from(self, sel)
- };
- }).value();
- if (_.isEmpty(results)) { return $.when(null); }
- return $.when.call(null, [{
- label: _.escape(this.attrs.string)
- }].concat(results));
- },
- facet_for: function (value) {
- var match = _(this.attrs.selection).detect(function (sel) {
- return sel[0] === value;
- });
- if (!match) { return $.when(null); }
- return $.when(facet_from(this, match));
- }
-});
-
-var BooleanField = SelectionField.extend(/** @lends instance.web.search.BooleanField# */{
- /**
- * @constructs instance.web.search.BooleanField
- * @extends instance.web.search.BooleanField
- */
- init: function () {
- this._super.apply(this, arguments);
- this.attrs.selection = [
- [true, _t("Yes")],
- [false, _t("No")]
- ];
- }
-});
-
-/**
- * @class
- * @extends instance.web.search.DateField
- */
-var DateField = Field.extend(/** @lends instance.web.search.DateField# */{
- value_from: function (facetValue) {
- var serverFormat = (this.attrs && this.attrs.type === 'datetime') ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD';
- return facetValue.get('valueAsMoment').clone().format(serverFormat);
- },
- complete: function (needle) {
- // Make sure the needle has a correct format before the creation of the moment object. See
- // issue https://github.com/moment/moment/issues/1407
- var t, v;
- try {
- t = (this.attrs && this.attrs.type === 'datetime') ? 'datetime' : 'date';
- v = field_utils.parse[t](needle, {type: t}, {timezone: true});
- } catch (e) {
- return $.when(null);
- }
-
- var m = moment(v, t === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD');
- if (!m.isValid()) { return $.when(null); }
- var date_string = field_utils.format[t](m, {type: t});
- var label = _.str.sprintf(_.str.escapeHTML(
- _t("Search %(field)s at: %(value)s")), {
- field: '<em>' + _.escape(this.attrs.string) + '</em>',
- value: '<strong>' + date_string + '</strong>'});
- return $.when([{
- label: label,
- facet: {
- category: this.attrs.string,
- field: this,
- values: [{label: date_string, value: m.toDate(), valueAsMoment: m.clone()}]
- }
- }]);
- }
-});
-
-/**
- * Implementation of the ``datetime`` openerp field type:
- *
- * * Uses the same widget as the ``date`` field type (a simple date)
- *
- * * Builds a slighly more complex, it's a datetime range (includes time)
- * spanning the whole day selected by the date widget
- *
- * @class
- * @extends instance.web.DateField
- */
-var DateTimeField = DateField;
-
-var ManyToOneField = CharField.extend({
- default_operator: {},
- init: function (view_section, field, parent) {
- this._super(view_section, field, parent);
- this.searchview = parent;
- },
-
- complete: function (value) {
- if (_.isEmpty(value)) { return $.when(null); }
- var label = _.str.sprintf(_.str.escapeHTML(
- _t("Search %(field)s for: %(value)s")), {
- field: '<em>' + _.escape(this.attrs.string) + '</em>',
- value: '<strong>' + _.escape(value) + '</strong>'});
- return $.when([{
- label: label,
- facet: {
- category: this.attrs.string,
- field: this,
- values: [{label: value, value: value, operator: 'ilike'}]
- },
- expand: this.expand.bind(this),
- }]);
- },
-
- expand: function (needle) {
- var self = this;
- // FIXME: "concurrent" searches (multiple requests, mis-ordered responses)
- var context = pyUtils.eval(
- 'contexts', [this.searchview.dataset.get_context()]);
- var args = this.attrs.domain;
- if (typeof args === 'string') {
- try {
- args = Domain.prototype.stringToArray(args);
- } catch(e) {
- args = [];
- }
- }
- return this._rpc({
- model: this.attrs.relation,
- method: 'name_search',
- kwargs: {
- name: needle,
- args: args,
- limit: 8,
- context: context
- },
- })
- .then(function (results) {
- if (_.isEmpty(results)) { return null; }
- return _(results).map(function (result) {
- return {
- label: _.escape(result[1]),
- facet: facet_from(self, result)
- };
- });
- });
- },
- facet_for: function (value) {
- var self = this;
- if (value instanceof Array) {
- if (value.length === 2 && _.isString(value[1])) {
- return $.when(facet_from(this, value));
- }
- utils.assert(value.length <= 1,
- _t("M2O search fields do not currently handle multiple default values"));
- // there are many cases of {search_default_$m2ofield: [id]}, need
- // to handle this as if it were a single value.
- value = value[0];
- }
- var context = pyUtils.eval('contexts', [this.searchview.dataset.get_context()]);
-
- return this._rpc({
- model: this.attrs.relation,
- method: 'name_get',
- args: [value],
- context: context,
- })
- .then(function (names) {
- if (_(names).isEmpty()) { return null; }
- return facet_from(self, names[0]);
- });
- },
- value_from: function (facetValue) {
- return facetValue.get('label');
- },
- make_domain: function (name, operator, facetValue) {
- operator = facetValue.get('operator') || operator;
-
- switch(operator){
- case this.default_operator:
- return [[name, '=', facetValue.get('value')]];
- case 'ilike':
- return [[name, 'ilike', facetValue.get('value')]];
- case 'child_of':
- return [[name, 'child_of', facetValue.get('value')]];
- }
- return this._super(name, operator, facetValue);
- },
- get_context: function (facet) {
- var values = facet.values;
- if (_.isEmpty(this.attrs.context) && values.length === 1) {
- var c = {};
- var v = values.at(0);
- if (v.get('operator') !== 'ilike') {
- c['default_' + this.attrs.name] = v.get('value');
- }
- return c;
- }
- return this._super(facet);
- }
-});
-
-var FilterGroup = Input.extend(/** @lends instance.web.search.FilterGroup# */{
- template: 'SearchView.filters',
- events: {
- click: 'toggle_filter',
- },
-
- icon: "fa-filter",
- completion_label: _lt("Filter on: %s"),
-
- /**
- * Inclusive group of filters, creates a continuous "button" with clickable
- * sections (the normal display for filters is to be a self-contained button)
- *
- * @constructs instance.web.search.FilterGroup
- * @extends instance.web.search.Input
- *
- * @param {Array<instance.web.search.Filter>} filters elements of the group
- * @param {instance.web.SearchView} parent parent in which the filters are contained
- * @param {Object} intervalMapping, a key is a field name and the corresponding value
- * is the current interval used
- * (necessarily the field is of type 'date' or 'datetime')
- */
- init: function (filters, parent, intervalMapping, periodMapping) {
- // If all filters are group_by and we're not initializing a GroupbyGroup,
- // create a GroupbyGroup instead of the current FilterGroup
- if (!(this instanceof GroupbyGroup) &&
- _(filters).all(function (f) {
- if (!f.attrs.context) { return false; }
- var c = pyUtils.eval('context', f.attrs.context);
- return !_.isEmpty(c.group_by);})) {
- return new GroupbyGroup(filters, parent, intervalMapping);
- }
- this._super(parent);
- this.filters = filters;
- this.searchview = parent;
- this.intervalMapping = intervalMapping;
- this.periodMapping = periodMapping || [];
- this.searchview.query.on('add remove change reset', this.proxy('search_change'));
- },
- start: function () {
- return $.when(null);
- },
- /**
- * Handles change of the search query: any of the group's filter which is
- * in the search query should be visually checked in the drawer
- */
- search_change: function () {
- var self = this;
- var $filters = this.$el ? this.$el.removeClass('selected') : $();
- var facet = this.searchview.query.find(_.bind(this.match_facet, this));
- if (!facet) { return; }
- facet.values.each(function (v) {
- var i = _(self.filters).indexOf(v.get('value'));
- if (i === -1) { return; }
- $filters.filter(function () {
- return Number($(this).data('index')) === i;
- }).addClass('selected');
- });
- },
- /**
- * Matches the group to a facet, in order to find if the group is
- * represented in the current search query
- */
- match_facet: function (facet) {
- return facet.get('field') === this;
- },
- make_facet: function (values) {
- return {
- cat: 'filterCategory',
- category: _t("Filter"),
- icon: this.icon,
- separator: _t(" or "),
- values: values,
- field: this
- };
- },
- make_value: function (filter) {
- return {
- label: filter.attrs.string || filter.attrs.help || filter.attrs.name,
- value: filter
- };
- },
- facet_for_defaults: function (defaults) {
- var self = this;
- var fs = _(this.filters).chain()
- .filter(function (f) {
- return f.attrs && f.attrs.name && !!defaults[f.attrs.name];
- }).map(function (f) {
- return self.make_value(f);
- }).value();
- if (_.isEmpty(fs)) { return $.when(null); }
- return $.when(this.make_facet(fs));
- },
- /**
- * Fetches contexts for all enabled filters in the group
- *
- * @param {openerp.web.search.Facet} facet
- * @return {*} combined contexts of the enabled filters in this group
- */
- get_context: function (facet) {
- var contexts = facet.values.chain()
- .map(function (f) { return f.get('value').attrs.context; })
- .without('{}')
- .reject(_.isEmpty)
- .value();
-
- if (!contexts.length) { return; }
- if (contexts.length === 1) { return contexts[0]; }
- return _.extend(new Context(), {
- __contexts: contexts
- });
- },
- /**
- * Fetches group_by sequence for all enabled filters in the group
- *
- * @param {VS.model.SearchFacet} facet
- * @return {Array} enabled filters in this group
- */
- get_groupby: function (facet) {
- var self = this;
- return facet.values.chain()
- .map(function (f) {
- var fieldName = f.get('value').attrs.fieldName;
- var defaultInterval = f.get('value').attrs.defaultInterval;
- var couple = _.findWhere(self.intervalMapping, {groupby: f.get('value')});
- var interval = couple ? couple.interval: undefined;
- var context = _.clone(f.get('value').attrs.context);
- if (context && interval) {
- if (defaultInterval) {
- context = context.replace(defaultInterval, interval);
- } else if (fieldName){
- context = context.replace(fieldName, fieldName + ':' + interval);
- }
- }
- return context;
- })
- .without('{}')
- .reject(_.isEmpty)
- .value();
- },
- /**
- * Handles domains-fetching for all the filters within it: groups them.
- *
- * @param {VS.model.SearchFacet} facet
- * @param {boolean} noEvaluation determines if the domains are evaluated or not.
- By default, domains are evaluated.
- * @return {*} combined domains of the enabled filters in this group
- */
- get_domain: function (facet, noEvaluation) {
- noEvaluation = noEvaluation || false;
- var self = this;
- var userContext = this.getSession().user_context;
- var domains = facet.values.chain()
- .map(function (f) {
- var filter = f.get('value');
- var attributes = filter.attrs;
- var domain = attributes.domain;
- var fieldName = attributes.date;
- if (!domain && fieldName) {
- var couple = _.findWhere(self.periodMapping, {filter: filter});
- var period = couple ? couple.period: 'this_month';
- var type = couple ? couple.type: 'date';
- domain = Domain.prototype.constructDomain(fieldName, period, type);
- }
- return domain;
- })
- .without('[]')
- .reject(_.isEmpty)
- .map(function (domain) {
- domain = domain.replace(/%%/g, '%');
- return domain;
- })
- .value();
-
- if (!domains.length) {
- return;
- }
- // domains belong the same groups. We need to make a union.
- var domain = pyUtils.assembleDomains(domains, 'OR');
- if (!noEvaluation) {
- return Domain.prototype.stringToArray(domain, userContext);
- }
- return domain;
- },
- toggle_filter: function (e) {
- e.preventDefault();
- e.stopPropagation();
- this.toggle(this.filters[Number($(e.currentTarget).data('index'))]);
- },
- toggle: function (filter, options) {
- this.searchview.query.toggle(this.make_facet([this.make_value(filter)]), options);
- },
- is_visible: function () {
- return _.some(this.filters, function (filter) {
- return !filter.attrs.invisible;
- });
- },
- complete: function (item) {
- var self = this;
- item = item.toLowerCase();
- var facet_values = _(this.filters).chain()
- .filter(function (filter) { return filter.visible(); })
- .filter(function (filter) {
- var at = {
- string: filter.attrs.string || '',
- help: filter.attrs.help || '',
- name: filter.attrs.name || ''
- };
- var include = _.str.include;
- return include(at.string.toLowerCase(), item)
- || include(at.help.toLowerCase(), item)
- || include(at.name.toLowerCase(), item);
- })
- .map(this.make_value)
- .value();
- if (_(facet_values).isEmpty()) { return $.when(null); }
- return $.when(_.map(facet_values, function (facet_value) {
- return {
- label: _.str.sprintf(self.completion_label.toString(),
- _.escape(facet_value.label)),
- facet: self.make_facet([facet_value])
- };
- }));
- },
- /*
- * @param {Object} intervalMapping
- */
- updateIntervalMapping: function (intervalMapping) {
- this.intervalMapping = intervalMapping;
- },
- /*
- * @param {Object} periodMapping
- */
- updatePeriodMapping: function (periodMapping) {
- this.intervalMapping = periodMapping;
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
-});
-
-var GroupbyGroup = FilterGroup.extend({
- icon: 'fa-bars',
- completion_label: _lt("Group by: %s"),
- init: function (filters, parent) {
- this._super.apply(this, arguments);
- this.searchview = parent;
- // Not flanders: facet unicity is handled through the
- // (category, field) pair of facet attributes. This is all well and
- // good for regular filter groups where a group matches a facet, but for
- // groupby we want a single facet. So cheat: add an attribute on the
- // view which proxies to the first GroupbyGroup, so it can be used
- // for every GroupbyGroup and still provides the various methods needed
- // by the search view. Use weirdo name to avoid risks of conflicts
- if (!this.searchview._s_groupby) {
- this.searchview._s_groupby = {
- help: "See GroupbyGroup#init",
- get_context: this.proxy('get_context'),
- get_domain: this.proxy('get_domain'),
- get_groupby: this.proxy('get_groupby')
- };
- }
- },
- match_facet: function (facet) {
- return facet.get('field') === this.searchview._s_groupby;
- },
- make_facet: function (values) {
- return {
- cat: 'groupByCategory',
- category: _t("Group By"),
- icon: this.icon,
- separator: " > ",
- values: values,
- field: this.searchview._s_groupby
- };
- }
-});
-
-var Filter = Input.extend(/** @lends instance.web.search.Filter# */{
- template: 'SearchView.filter',
- /**
- * Implementation of the OpenERP filters (button with a context and/or
- * a domain sent as-is to the search view)
- *
- * Filters are only attributes holder, the actual work (compositing
- * domains and contexts, converting between facets and filters) is
- * performed by the filter group.
- *
- * @constructs instance.web.search.Filter
- * @extends instance.web.search.Input
- *
- * @param node
- * @param parent
- */
- init: function (node, parent) {
- this._super(parent);
- this.load_attrs(node.attrs);
- },
- facet_for: function () { return $.when(null); },
- get_context: function () { },
- get_domain: function () { },
-});
-
-/**
- * Registry of search fields, called by :js:class:`instance.web.SearchView` to
- * find and instantiate its field widgets.
- */
-
-core.search_widgets_registry
- .add('char', CharField)
- .add('text', CharField)
- .add('html', CharField)
- .add('boolean', BooleanField)
- .add('integer', IntegerField)
- .add('id', IntegerField)
- .add('float', FloatField)
- .add('monetary', FloatField)
- .add('selection', SelectionField)
- .add('datetime', DateTimeField)
- .add('date', DateField)
- .add('many2one', ManyToOneField)
- .add('many2many', CharField)
- .add('one2many', CharField);
-
-
-return {
- FilterGroup: FilterGroup,
- Filter: Filter,
- Field: Field,
- GroupbyGroup: GroupbyGroup,
-};
-
-});
diff --git a/addons/web/static/src/js/views/search/search_view.js b/addons/web/static/src/js/views/search/search_view.js
deleted file mode 100644
index 69a45649793..00000000000
--- a/addons/web/static/src/js/views/search/search_view.js
+++ /dev/null
@@ -1,1244 +0,0 @@
-odoo.define('web.SearchView', function (require) {
-"use strict";
-
-var AutoComplete = require('web.AutoComplete');
-var config = require('web.config');
-var core = require('web.core');
-var Domain = require('web.Domain');
-var FavoriteMenu = require('web.FavoriteMenu');
-var FiltersMenu = require('web.FiltersMenu');
-var GroupByMenu = require('web.GroupByMenu');
-var pyUtils = require('web.py_utils');
-var search_inputs = require('web.search_inputs');
-var TimeRangeMenu = require('web.TimeRangeMenu');
-var TimeRangeMenuOptions = require('web.TimeRangeMenuOptions');
-var utils = require('web.utils');
-var Widget = require('web.Widget');
-
-var _t = core._t;
-var ComparisonOptions = TimeRangeMenuOptions.ComparisonOptions;
-var PeriodOptions = TimeRangeMenuOptions.PeriodOptions;
-
-var Backbone = window.Backbone;
-
-var FacetValue = Backbone.Model.extend({});
-
-var FacetValues = Backbone.Collection.extend({
- model: FacetValue
-});
-
-var DEFAULT_INTERVAL = 'month';
-var DEFAULT_PERIOD = 'this_month';
-
-var Facet = Backbone.Model.extend({
- initialize: function (attrs) {
- var values = attrs.values;
- delete attrs.values;
-
- Backbone.Model.prototype.initialize.apply(this, arguments);
-
- this.values = new FacetValues(values || []);
- this.values.on('add remove change reset', function (_, options) {
- this.trigger('change', this, options);
- }, this);
- },
- get: function (key) {
- if (key !== 'values') {
- return Backbone.Model.prototype.get.call(this, key);
- }
- return this.values.toJSON();
- },
- set: function (key, value) {
- if (key !== 'values') {
- return Backbone.Model.prototype.set.call(this, key, value);
- }
- this.values.reset(value);
- },
- toJSON: function () {
- var out = {};
- var attrs = this.attributes;
- for(var att in attrs) {
- if (!attrs.hasOwnProperty(att) || att === 'field') {
- continue;
- }
- out[att] = attrs[att];
- }
- out.values = this.values.toJSON();
- return out;
- }
-});
-
-var SearchQuery = Backbone.Collection.extend({
- model: Facet,
- initialize: function () {
- Backbone.Collection.prototype.initialize.apply(
- this, arguments);
- this.on('change', function (facet) {
- if(!facet.values.isEmpty()) { return; }
-
- this.remove(facet, {silent: true});
- }, this);
- },
- add: function (values, options) {
- options = options || {};
-
- if (!values) {
- values = [];
- } else if (!(values instanceof Array)) {
- values = [values];
- }
-
- _(values).each(function (value) {
- var model = this._prepareModel(value, options);
- var previous = this.detect(function (facet) {
- return facet.get('category') === model.get('category') &&
- facet.get('field') === model.get('field');
- });
- if (previous) {
- previous.values.add(model.get('values'), _.omit(options, 'at', 'merge'));
- return;
- }
- Backbone.Collection.prototype.add.call(this, model, options);
- }, this);
- // warning: in backbone 1.0+ add is supposed to return the added models,
- // but here toggle may delegate to add and return its value directly.
- // return value of neither seems actually used but should be tested
- // before change, probably
- return this;
- },
- toggle: function (value, options) {
- options = options || {};
-
- var facet = this.detect(function (facet) {
- return facet.get('category') === value.category
- && facet.get('field') === value.field;
- });
- if (!facet) {
- return this.add(value, options);
- }
-
- var changed = false;
- _(value.values).each(function (val) {
- var already_value = facet.values.detect(function (v) {
- return v.get('value') === val.value
- && v.get('label') === val.label;
- });
- // toggle value
- if (already_value) {
- facet.values.remove(already_value, {silent: true});
- } else {
- facet.values.add(val, {silent: true});
- }
- changed = true;
- });
- // "Commit" changes to values array as a single call, so observers of
- // change event don't get misled by intermediate incomplete toggling
- // states
- facet.trigger('change', facet);
- return this;
- }
-});
-
-var InputView = Widget.extend({
- template: 'SearchView.InputView',
- events: {
- focus: function () { this.trigger('focused', this); },
- blur: function () { this.$el.val(''); this.trigger('blurred', this); },
- keydown: 'onKeydown',
- },
- onKeydown: function (e) {
- switch (e.which) {
- case $.ui.keyCode.BACKSPACE:
- if(this.$el.val() === '') {
- var preceding = this.getParent().siblingSubview(this, -1);
- if (preceding && (preceding instanceof FacetView)) {
- preceding.model.destroy();
- }
- }
- break;
-
- case $.ui.keyCode.LEFT: // Stop propagation to parent if not at beginning of input value
- if(this.el.selectionStart > 0) {
- e.stopPropagation();
- }
- break;
-
- case $.ui.keyCode.RIGHT: // Stop propagation to parent if not at end of input value
- if(this.el.selectionStart < this.$el.val().length) {
- e.stopPropagation();
- }
- break;
- }
- }
-});
-
-var FacetView = Widget.extend({
- template: 'SearchView.FacetView',
- events: {
- 'focus': function () { this.trigger('focused', this); },
- 'blur': function () {
- this.trigger('blurred', this); },
- 'click': function (e) {
- if ($(e.target).hasClass('o_facet_remove')) {
- this.model.destroy();
- return false;
- }
- this.$el.focus();
- e.stopPropagation();
- },
- 'keydown': function (e) {
- var keys = $.ui.keyCode;
- switch (e.which) {
- case keys.BACKSPACE:
- case keys.DELETE:
- this.model.destroy();
- return false;
- }
- }
- },
- /*
- * @param {Widget} parent
- * @param {Object} model
- * @param {Object} intervalMapping, a key is a field name and the corresponding value
- * is the current interval used
- * (necessarily the field is of type 'date' or 'datetime')
- * @param {Object} periodMapping, a key is a field name and the corresponding value
- * is the current period used
- * (necessarily the field is of type 'date' or 'datetime')
- */
- init: function (parent, model, intervalMapping, periodMapping) {
- this._super(parent);
- this.model = model;
- this.intervalMapping = intervalMapping;
- this.periodMapping = periodMapping;
- this.model.on('change', this.model_changed, this);
- },
- destroy: function () {
- this.model.off('change', this.model_changed, this);
- this._super();
- },
- start: function () {
- var self = this;
- var $e = this.$('.o_facet_values').last();
- return $.when(this._super()).then(function () {
- return $.when.apply(null, self.model.values.map(function (value, index) {
- if (index > 0) {
- $('<span/>', {html: self.model.get('separator') || _t(" or ")}).addClass('o_facet_values_sep').appendTo($e);
- }
- var couple;
- var option;
- if (value.attributes.value && value.attributes.value.attrs) {
- if (value.attributes.value.attrs.isPeriod) {
- couple = _.findWhere(self.periodMapping, {filter: value.attributes.value});
- option = couple ? couple.period : value.attributes.value.attrs.default_period;
- }
- if (value.attributes.value.attrs.isDate) {
- couple = _.findWhere(self.intervalMapping, {groupby: value.attributes.value});
- option = couple ?
- couple.interval :
- (value.attributes.value.attrs.defaultInterval || DEFAULT_INTERVAL);
- }
- }
- return new FacetValueView(self, value, option).appendTo($e);
- }));
- });
- },
- model_changed: function () {
- this.$el.text(this.$el.text() + '*');
- }
-});
-
-var FacetValueView = Widget.extend({
- template: 'SearchView.FacetView.Value',
- /*
- * @param {Widget} parent
- * @param {Object} model
- * @param {Object} option (optional) is used in case the facet value
- * corresponds to a groupby with an associated 'date'
- * 'datetime' field.
- * @param {Object} comparison (optional) is used in case the facet value
- * comes from the time range menu and comparison is active
- */
- init: function (parent, model, option) {
- this._super(parent);
- this.model = model;
-
- var optionDescription = _.extend({}, {
- day: 'Day',
- week: 'Week',
- month: 'Month',
- quarter: 'Quarter',
- year: 'Year',
- }, {
- today: 'Today',
- this_week: 'This Week',
- this_month: 'This Month',
- this_quarter: 'This Quarter',
- this_year: 'This Year',
- yesterday: 'Yesterday',
- last_week: 'Last Week',
- last_month: 'Last Month',
- last_quarter: 'Last Quarter',
- last_year: 'Last Year',
- last_7_days: 'Last 7 Days',
- last_30_days: 'Last 30 Days',
- last_365_days: 'Last 365 Days',
- });
- if (option) {
- var optionLabel = optionDescription[option];
- this.optionLabel = _t(optionLabel);
- }
- this.model.on('change', this.model_changed, this);
- },
- destroy: function () {
- this.model.off('change', this.model_changed, this);
- this._super();
- },
- model_changed: function () {
- this.$el.text(this.$el.text() + '*');
- }
-});
-
-var SearchView = Widget.extend({
- events: {
- 'click .o_searchview_more': function (e) {
- $(e.target).toggleClass('fa-search-plus fa-search-minus');
- var visibleSearchMenu = this.call('local_storage', 'getItem', 'visible_search_menu');
- this.call('local_storage', 'setItem', 'visible_search_menu', visibleSearchMenu !== 'true');
- this.toggle_buttons();
- },
- 'keydown .o_searchview_input, .o_searchview_facet': function (e) {
- switch(e.which) {
- case $.ui.keyCode.LEFT:
- this.focusPreceding(e.target);
- e.preventDefault();
- break;
- case $.ui.keyCode.RIGHT:
- if(!this.autocomplete.is_expandable()) {
- this.focusFollowing(e.target);
- }
- e.preventDefault();
- break;
- case $.ui.keyCode.DOWN:
- if (!this.autocomplete.is_expanded()) {
- e.preventDefault();
- this.trigger_up('navigation_move', {direction: 'down'});
- break;
- }
- }
- },
- },
- custom_events: {
- menu_item_toggled: '_onItemToggled',
- item_option_changed: '_onItemOptionChanged',
- new_groupby: '_onNewGroupby',
- new_filters: '_onNewFilters',
- time_range_modified: '_onTimeRangeModified',
- time_range_removed: '_onTimeRangeRemoved',
- },
- defaults: _.extend({}, Widget.prototype.defaults, {
- hidden: false,
- disable_custom_filters: false,
- disable_groupby: false,
- disable_favorites: false,
- disable_filters: false,
- disableTimeRangeMenu: true,
- }),
- template: "SearchView",
-
- /**
- * @constructs SearchView
- * @extends View
- *
- * @param parent
- * @param dataset
- * @param fvg
- * @param {Object} [options]
- * @param {Boolean} [options.hidden=false] hide the search view
- * @param {Boolean} [options.disable_custom_filters=false] do not load custom filters from ir.filters
- */
- init: function (parent, dataset, fvg, options) {
- this._super.apply(this, arguments);
- this.options = options;
- this.dataset = dataset;
- this.fields_view = this._processFieldsView(_.clone(fvg));
-
- this.fields = this.fields_view.fields;
- this.query = undefined;
- this.title = this.options.action && this.options.action.name;
- this.action = this.options.action || {};
- this.search_fields = [];
-
- this.hasFavorites = false;
-
- this.noDateFields = true;
- var field;
- for (var key in this.fields) {
- field = this.fields[key];
- if (_.contains(['date', 'datetime'], field.type) && field.sortable) {
- this.noDateFields = false;
- break;
- }
- }
- this.activeItemIds = {
- groupByCategory: [],
- filterCategory: []
- };
- this.groupsMapping = [];
- this.groupbysMapping = [];
- this.filtersMapping = [];
- this.intervalMapping = [];
- this.periodMapping = [];
-
- this.filters = [];
- this.groupbys = [];
- this.timeRanges = options.action && options.action.context ?
- options.action.context.time_ranges : undefined;
- var visibleSearchMenu = this.call('local_storage', 'getItem', 'visible_search_menu');
- this.visible_filters = (visibleSearchMenu !== 'false');
- this.input_subviews = []; // for user input in searchbar
- this.search_defaults = this.options.search_defaults || {};
- this.headless = this.options.hidden && _.isEmpty(this.search_defaults);
- this.$buttons = this.options.$buttons;
-
- this.filters_menu = undefined;
- this.groupby_menu = undefined;
- this.favorite_menu = undefined;
- },
- willStart: function () {
- var self = this;
- var def;
- if (!this.options.disable_favorites) {
- def = this.loadFilters(this.dataset, this.action.id).then(function (filters) {
- self.favorite_filters = filters;
- });
- }
- return $.when(this._super(), def);
- },
- start: function () {
- var self= this;
- if (this.headless) {
- this.do_hide();
- }
- this.toggle_visibility(false);
- this.setup_global_completion();
- this.query = new SearchQuery()
- .on('add change reset remove', this.proxy('do_search'))
- .on('change', this.proxy('renderChangedFacets'))
- .on('add reset remove', this.proxy('renderFacets'));
- this.$('.o_searchview_more')
- .toggleClass('fa-search-minus', this.visible_filters)
- .toggleClass('fa-search-plus', !this.visible_filters);
- var def;
- this.prepare_search_inputs();
- var $buttons = this._getButtonsElement();
- if ($buttons) {
- if (!this.options.disable_favorites) {
- this.favorite_menu = new FavoriteMenu(this, this.query, this.dataset.model, this.action, this.favorite_filters);
- def = this.favorite_menu.appendTo($buttons);
- }
- }
- return $.when(def)
- .then(this.set_default_filters.bind(this))
- .then(function () {
- var menu_defs = [];
- self.timeRangeMenu = self._createTimeRangeMenu();
- menu_defs.push(self.timeRangeMenu.prependTo($buttons));
- self.timeRangeMenu.do_hide();
- self.displayedTimeRangeMenu = self.options.disableTimeRangeMenu !== undefined &&
- !self.options.disableTimeRangeMenu;
- self.displayTimeRangeMenu(self.displayedTimeRangeMenu);
- if (!self.options.disable_groupby) {
- self.groupby_menu = self._createGroupByMenu();
- menu_defs.push(self.groupby_menu.prependTo($buttons));
- }
- if (!self.options.disable_filters) {
- self.filters_menu = self._createFiltersMenu();
- menu_defs.push(self.filters_menu.prependTo($buttons));
- }
- return $.when.apply($, menu_defs);
- });
- },
- /*
- *
- * @param {boolean}
- */
- displayTimeRangeMenu: function (b) {
- if (!b || this.noDateFields) {
- this.timeRangeMenu.do_hide();
- } else {
- this.timeRangeMenu.do_show();
- }
- },
- on_attach_callback: function () {
- this._focusInput();
- },
- get_title: function () {
- return this.title;
- },
- set_default_filters: function () {
- var self = this,
- default_custom_filter = this.$buttons && this.favorite_menu && this.favorite_menu.get_default_filter();
- if (!self.options.disable_custom_filters && default_custom_filter) {
- this.hasFavorites = true;
- return this.favorite_menu.toggle_filter(default_custom_filter, true);
- }
- if (!_.isEmpty(this.search_defaults) || this.timeRanges) {
- var inputs = this.search_fields.concat(this.filters, this.groupbys);
- var search_defaults = _.invoke(inputs, 'facet_for_defaults', this.search_defaults);
- var defaultTimeRange = this._searchDefaultTimeRange();
- search_defaults.push(defaultTimeRange);
- return $.when.apply(null, search_defaults).then(function () {
- var facets = _.compact(arguments);
- self.query.reset(facets, {preventSearch: true});
- });
- }
- this.query.reset([], {preventSearch: true});
- return $.when();
- },
- /**
- * Performs the search view collection of widget data.
- *
- * If the collection went well (all fields are valid), then triggers
- * :js:func:`instance.web.SearchView.on_search`.
- *
- * If at least one field failed its validation, triggers
- * :js:func:`instance.web.SearchView.on_invalid` instead.
- *
- * @param [_query]
- * @param {Object} [options]
- */
- do_search: function (_query, options) {
- if (options && options.preventSearch) {
- return;
- }
- var search = this.build_search_data();
- this.trigger_up('search', search);
- },
- /**
- * @param {boolean} noDomainEvaluation determines if domain are evaluated or not.
- * By default, domains are evaluated.
- *
- * Extract search data from the view's facets.
- *
- * Result is an object with 3 (own) properties:
- *
- * domains
- * Array of domains
- * contexts
- * Array of contexts
- * groupbys
- * Array of domains, in groupby order rather than view order
- *
- * @return {Object}
- */
- build_search_data: function (noDomainEvaluation) {
- var domains = [], contexts = [], groupbys = [];
- noDomainEvaluation = noDomainEvaluation || false;
- this.query.each(function (facet) {
- // field is actually a FilterGroup!
- var field = facet.get('field');
- var domain = field.get_domain(facet, noDomainEvaluation);
- if (domain) {
- domains.push(domain);
- }
- var context = field.get_context(facet, noDomainEvaluation);
- if (context) {
- contexts.push(context);
- }
- var group_by = field.get_groupby(facet);
- if (group_by) {
- groupbys.push.apply(groupbys, group_by);
- }
- });
- var intervalMappingNormalized = {};
- _.each(this.intervalMapping, function (couple) {
- var fieldName = couple.groupby.fieldName;
- var interval = couple.interval;
- intervalMappingNormalized[fieldName] = interval;
- });
- return {
- domains: domains,
- contexts: contexts,
- groupbys: groupbys,
- intervalMapping: intervalMappingNormalized,
- };
- },
- toggle_visibility: function (is_visible) {
- this.do_toggle(!this.headless && is_visible);
- if (this.$buttons) {
- this.$buttons.toggle(!this.headless && is_visible && this.visible_filters);
- }
- this._focusInput();
- },
- /**
- * puts the focus on the search input
- */
- _focusInput: function () {
- if (!config.device.touch && config.device.size_class >= config.device.SIZES.MD) {
- this.$('input').focus();
- }
- },
- toggle_buttons: function (is_visible) {
- this.visible_filters = is_visible || !this.visible_filters;
- if (this.$buttons) {
- this.$buttons.toggle(this.visible_filters);
- }
- },
- /**
- * Sets up search view's view-wide auto-completion widget
- */
- setup_global_completion: function () {
- var self = this;
- this.autocomplete = new AutoComplete(this, {
- source: this.proxy('complete_global_search'),
- select: this.proxy('select_completion'),
- get_search_string: function () {
- return self.$('.o_searchview_input').val().trim();
- },
- });
- this.autocomplete.appendTo(this.$('.o_searchview_input_container'));
- },
- /**
- * Provide auto-completion result for req.term (an array to `resp`)
- *
- * @param {Object} req request to complete
- * @param {String} req.term searched term to complete
- * @param {Function} resp response callback
- */
- complete_global_search: function (req, resp) {
- var inputs = this.search_fields.concat(this.filters, this.groupbys);
- $.when.apply(null, _(inputs).chain()
- .filter(function (input) { return input.visible(); })
- .invoke('complete', req.term)
- .value()).then(function () {
- resp(_(arguments).chain()
- .compact()
- .flatten(true)
- .value());
- });
- },
- /**
- * Action to perform in case of selection: create a facet (model)
- * and add it to the search collection
- *
- * @param {Object} e selection event, preventDefault to avoid setting value on object
- * @param {Object} ui selection information
- * @param {Object} ui.item selected completion item
- */
- select_completion: function (e, ui) {
- var facet = ui.item.facet;
- e.preventDefault();
- if(facet && facet.values && facet.values.length && String(facet.values[0].value).trim() !== "") {
- this.query.add(facet);
- } else {
- this.query.trigger('add');
- }
- },
- subviewForRoot: function (subview_root) {
- return _(this.input_subviews).detect(function (subview) {
- return subview.$el[0] === subview_root;
- });
- },
- siblingSubview: function (subview, direction, wrap_around) {
- var index = _(this.input_subviews).indexOf(subview) + direction;
- if (wrap_around && index < 0) {
- index = this.input_subviews.length - 1;
- } else if (wrap_around && index >= this.input_subviews.length) {
- index = 0;
- }
- return this.input_subviews[index];
- },
- focusPreceding: function (subview_root) {
- return this.siblingSubview(
- this.subviewForRoot(subview_root), -1, true)
- .$el.focus();
- },
- focusFollowing: function (subview_root) {
- return this.siblingSubview(
- this.subviewForRoot(subview_root), +1, true)
- .$el.focus();
- },
- /**
- */
- renderFacets: function () {
- var self = this;
- var started = [];
- _.invoke(this.input_subviews, 'destroy');
- this.input_subviews = [];
-
- var activeItemIds = {
- groupByCategory: [],
- filterCategory: [],
- };
- var timeRangeMenuIsActive;
- this.query.each(function (facet) {
- var values = facet.get('values');
- if (facet.attributes.cat === "groupByCategory") {
- activeItemIds.groupByCategory = activeItemIds.groupByCategory.concat(
- _.uniq(
- values.reduce(
- function (acc, value) {
- var groupby = value.value;
- var description = _.findWhere(self.groupbysMapping, {groupby: groupby});
- if (description) {
- acc.push(description.groupbyId);
- }
- return acc;
- },
- []
- )
- )
- );
- }
- if (facet.attributes.cat === "filterCategory") {
- activeItemIds.filterCategory = activeItemIds.filterCategory.concat(
- _.uniq(
- values.reduce(
- function (acc, value) {
- var filter = value.value;
- var description = _.findWhere(self.filtersMapping, {filter: filter});
- if (description) {
- acc.push(description.filterId);
- }
- return acc;
- },
- []
- )
- )
- );
- }
- if (facet.attributes.cat === "timeRangeCategory") {
- timeRangeMenuIsActive = true;
- }
- var f = new FacetView(this, facet, this.intervalMapping, this.periodMapping);
- started.push(f.appendTo(self.$('.o_searchview_input_container')));
- self.input_subviews.push(f);
- }, this);
-
- var i = new InputView(this);
- started.push(i.appendTo(self.$('.o_searchview_input_container')));
- self.input_subviews.push(i);
- _.each(this.input_subviews, function (childView) {
- childView.on('focused', self, self.proxy('childFocused'));
- childView.on('blurred', self, self.proxy('childBlurred'));
- });
-
- $.when.apply(null, started).then(function () {
- if (!config.device.isMobile) {
- // in mobile mode, we would rathor not focusing manually the
- // input, because it opens up the integrated keyboard, which is
- // not what you expect when you just selected a filter.
- _.last(self.input_subviews).$el.focus();
- }
- if (self.groupby_menu) {
- self.groupby_menu.updateItemsStatus(activeItemIds.groupByCategory);
- }
- if (self.filters_menu) {
- self.filters_menu.updateItemsStatus(activeItemIds.filterCategory); }
- if (self.displayedTimeRangeMenu && !timeRangeMenuIsActive) {
- self.timeRangeMenu.deactivate();
- }
- });
- },
- childFocused: function () {
- this.$el.addClass('active');
- },
- childBlurred: function () {
- this.$el.val('').removeClass('active').trigger('blur');
- this.autocomplete.close();
- },
- /**
- * Call the renderFacets method with the correct arguments.
- * This is due to the fact that change events are called with two arguments
- * (model, options) while add, reset and remove events are called with
- * (collection, model, options) as arguments
- */
- renderChangedFacets: function (model, options) {
- this.renderFacets(undefined, model, options);
- },
- // it should parse the arch field of the view, instantiate the corresponding
- // filters/fields, and put them in the correct variables:
- // * this.search_fields is a list of all the fields,
- // * this.filters: groups of filters
- // * this.group_by: group_bys
- prepare_search_inputs: function () {
- var self = this,
- arch = this.fields_view.arch;
-
- var filters = [].concat.apply([], _.map(arch.children, function (item) {
- return item.tag !== 'group' ? eval_item(item) : item.children.map(eval_item);
- }));
- function eval_item (item) {
- var category = 'filters';
- if (item.attrs.context) {
- try {
- var context = pyUtils.eval('context', item.attrs.context);
- if (context.group_by) {
- category = 'group_by';
- item.attrs.fieldName = context.group_by.split(':')[0];
- item.attrs.isDate = _.contains(['date', 'datetime'], self.fields[item.attrs.fieldName].type);
- item.attrs.defaultInterval = context.group_by.split(':')[1];
- }
- } catch (e) {}
- }
- if (item.attrs.date) {
- item.attrs.default_period = item.attrs.default_period || DEFAULT_PERIOD;
- item.attrs.type = self.fields[item.attrs.date].type;
- }
- item.attrs.isPeriod = !!item.attrs.date;
- return {
- item: item,
- category: category,
- };
- }
- var current_group = [],
- current_category = 'filters',
- categories = {filters: this.filters, group_by: this.groupbys, timeRanges: this.timeRanges};
-
- _.each(filters.concat({category:'filters', item: 'separator'}), function (filter) {
- if (filter.item.tag === 'filter' && filter.category === current_category) {
- return current_group.push(new search_inputs.Filter(filter.item, self));
- }
- if (current_group.length) {
- var group = new search_inputs.FilterGroup(current_group, self, self.intervalMapping, self.periodMapping);
- categories[current_category].push(group);
- current_group = [];
- }
- if (filter.item.tag === 'field') {
- var attrs = filter.item.attrs;
- var field = self.fields_view.fields[attrs.name];
-
- // M2O combined with selection widget is pointless and broken in search views,
- // but has been used in the past for unsupported hacks -> ignore it
- if (field.type === "many2one" && attrs.widget === "selection") {
- attrs.widget = undefined;
- }
- var Obj = core.search_widgets_registry.getAny([attrs.widget, field.type]);
- if (Obj) {
- self.search_fields.push(new (Obj) (filter.item, field, self));
- }
- }
- if (filter.item.tag === 'filter') {
- current_group.push(new search_inputs.Filter(filter.item, self));
- }
- current_category = filter.category;
- });
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Updates the domain of the search view by adding and/or removing filters.
- *
- * @todo: the way it is done could be improved, but the actual state of the
- * searchview doesn't allow to do much better.
-
- * @param {Array<Object>} newFilters list of filters to add, described by
- * objects with keys domain (the domain as an Array), and help (the text
- * to display in the facet)
- * @param {Array<Object>} filtersToRemove list of filters to remove
- * (previously added ones)
- * @returns {Array<Object>} list of added filters (to pass as filtersToRemove
- * for a further call to this function)
- */
- updateFilters: function (newFilters, filtersToRemove) {
- var self = this;
- var addedFilters = _.map(newFilters, function (filter) {
- var domain = filter.domain;
- if (domain instanceof Array) {
- domain = Domain.prototype.arrayToString(domain);
- }
- filter = {
- attrs: {domain: domain, help: filter.help},
- };
- var filterWidget = new search_inputs.Filter(filter);
- var filterGroup = new search_inputs.FilterGroup([filterWidget], self, self.intervalMapping, self.periodMapping);
- var facet = filterGroup.make_facet([filterGroup.make_value(filter)]);
- self.query.add([facet], {silent: true});
-
- return _.last(self.query.models);
- });
-
- _.each(filtersToRemove, function (filter) {
- self.query.remove(filter, {silent: true});
- });
-
- this.query.trigger('reset');
-
- return addedFilters;
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
-
- /**
- * Will return $element where Filters, Group By and Favorite buttons are
- * going to be pushed. This method is overriden by the mobile search view
- * to add these buttons somewhere else in the dom.
- *
- * @private
- * @returns {jQueryElement}
- */
- _getButtonsElement: function () {
- return this.$buttons;
- },
- /**
- * Create a groupby menu. Note that this method has a side effect: it
- * builds a mapping from a filter name to a 'search filter'.
- *
- * @private
- * @returns {Widget} the processed fieldsView
- */
- _createFiltersMenu: function () {
- var self = this;
- var filters = [];
-
- this.filters.forEach(function (group) {
- var groupId = _.uniqueId('__group__');
- group.filters.forEach(function (filter) {
- if (!filter.attrs.invisible) {
- var filterId = _.uniqueId('__filter__');
- var isPeriod = filter.attrs.isPeriod;
- var defaultPeriod = filter.attrs.default_period;
- var isActive = !self.hasFavorites && !!self.search_defaults[filter.attrs.name];
- filters.push({
- itemId: filterId,
- description: filter.attrs.string || filter.attrs.help ||
- filter.attrs.name || filter.attrs.domain || 'Ω',
- domain: filter.attrs.domain,
- fieldName: filter.attrs.date,
- isPeriod: isPeriod,
- defaultOptionId: defaultPeriod,
- isActive: isActive,
- groupId: groupId,
- });
- self.filtersMapping.push({filterId: filterId, filter: filter, groupId: groupId});
- if (isPeriod) {
- self.periodMapping.push({filter: filter, period: defaultPeriod, type: filter.attrs.type});
- }
- }
- });
- self.groupsMapping.push({groupId: groupId, group: group, category: 'Filters'});
- });
-
- return new FiltersMenu(self, filters, self.fields);
- },
-
- /**
- * Create a groupby menu. Note that this method has a side effect: it
- * builds a mapping from a filter name to a 'search filter'.
- *
- * @private
- * @returns {Widget} the processed fieldsView
- */
- _createGroupByMenu: function () {
- var self = this;
- var groupbys = [];
-
- this.groupbys.forEach(function (group) {
- var groupId = _.uniqueId('__group__');
- group.filters.forEach(function (groupby) {
- if (!groupby.attrs.invisible) {
- var groupbyId = _.uniqueId('__groupby__');
- var fieldName = groupby.attrs.fieldName;
- var isDate = groupby.attrs.isDate;
- var defaultInterval = groupby.attrs.defaultInterval || DEFAULT_INTERVAL;
- var isActive = !self.hasFavorites && !!self.search_defaults[groupby.attrs.name];
- groupbys.push({
- itemId: groupbyId,
- description: groupby.attrs.string || groupby.attrs.help || groupby.attrs.name
- || groupby.attrs.fieldName || 'Ω',
- isDate: isDate,
- fieldName: fieldName,
- defaultOptionId: defaultInterval,
- isActive: isActive,
- groupId: groupId,
- });
- if (isDate) {
- self.intervalMapping.push({groupby: groupby, interval: defaultInterval});
- }
- self.groupbysMapping.push({groupbyId: groupbyId, groupby: groupby, groupId: groupId});
- }
- });
- self.groupsMapping.push({groupId: groupId, group: group, category: 'Group By'});
- group.updateIntervalMapping(self.intervalMapping);
- });
- return new GroupByMenu(this, groupbys, this.fields);
- },
- /**
- * Create a time range menu.
- *
- * @private
- * @returns {Widget} the range menu
- */
- _createTimeRangeMenu: function () {
- return new TimeRangeMenu(this, this.fields, this.timeRanges);
- },
- /**
- * Processes a fieldsView in place. In particular, parses its arch.
- *
- * @todo: this function is also defined in AbstractView ; this code
- * duplication could be removed once the SearchView will be rewritten.
- * @private
- * @param {Object} fv
- * @param {string} fv.arch
- * @returns {Object} the processed fieldsView
- */
- _processFieldsView: function (fv) {
- var doc = $.parseXML(fv.arch).documentElement;
- fv.arch = utils.xml_to_json(doc, true);
- return fv;
- },
- /**
- * @returns {Deferred}
- */
- _searchDefaultTimeRange: function () {
- if (this.timeRanges) {
- var timeRange = "[]";
- var timeRangeDescription;
- var comparisonTimeRange = "[]";
- var comparisonTimeRangeDescription;
-
- var dateField = {
- name: this.timeRanges.field,
- type: this.fields[this.timeRanges.field].type,
- description: this.fields[this.timeRanges.field].string,
- };
-
- timeRange = Domain.prototype.constructDomain(
- dateField.name,
- this.timeRanges.range,
- dateField.type
- );
- timeRangeDescription = _.findWhere(
- PeriodOptions,
- {optionId: this.timeRanges.range}
- ).description;
-
- if (this.timeRanges.comparison_range) {
- comparisonTimeRange = Domain.prototype.constructDomain(
- dateField.name,
- this.timeRanges.range,
- dateField.type,
- null,
- this.timeRanges.comparison_range
- );
- comparisonTimeRangeDescription = _.findWhere(
- ComparisonOptions,
- {optionId: this.timeRanges.comparison_range}
- ).description;
- }
-
- return $.when({
- cat: 'timeRangeCategory',
- category: _t("Time Range"),
- icon: 'fa fa-calendar',
- field: {
- get_context: function (facet, noDomainEvaluation) {
- if (!noDomainEvaluation) {
- timeRange = Domain.prototype.stringToArray(timeRange);
- comparisonTimeRange = Domain.prototype.stringToArray(comparisonTimeRange);
- }
- return {
- timeRangeMenuData: {
- timeRange: timeRange,
- timeRangeDescription: timeRangeDescription,
- comparisonTimeRange: comparisonTimeRange,
- comparisonTimeRangeDescription: comparisonTimeRangeDescription,
- }
- };
- },
- get_groupby: function () {},
- get_domain: function () {}
- },
- isRange: true,
- values: [{
- label: dateField.description + ': ' + timeRangeDescription +
- (
- comparisonTimeRangeDescription ?
- (' / ' + comparisonTimeRangeDescription) :
- ''
- ),
- value: null,
- }],
- });
- } else {
- return $.when();
- }
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- *
- * this function is called in response to an event 'on_item_toggled'.
- * this kind of event is triggered by the filters menu or the groupby menu
- * when a user has clicked on a item (a filter or a groupby).
- * The query is modified accordingly to the new state (active or not) of that item
- *
- * @private
- * @param {OdooEvent} event
- */
- _onItemToggled: function (event) {
- var group;
- if (event.data.category === 'groupByCategory') {
- var groupby = _.findWhere(this.groupbysMapping, {groupbyId: event.data.itemId}).groupby;
- group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group;
- if (event.data.optionId) {
- var interval = event.data.optionId;
- _.findWhere(this.intervalMapping, {groupby: groupby}).interval = interval;
- group.updateIntervalMapping(this.intervalMapping);
- }
- group.toggle(groupby);
- }
- if (event.data.category === 'filterCategory') {
- var filter = _.findWhere(this.filtersMapping, {filterId: event.data.itemId}).filter;
- group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group;
- if (event.data.optionId) {
- var period = event.data.optionId;
- _.findWhere(this.periodMapping, {filter: filter}).period = period;
- group.updatePeriodMapping(this.periodMapping);
- }
- group.toggle(filter);
- }
- },
- /**
- * this function is called when a new groupby has been added to the groupby menu
- * via the 'Add Custom Groupby' submenu. The query is modified with the new groupby
- * added to it as active. The communication betwenn the groupby menu and the search view
- * is maintained by properly updating the mappings.
- * @private
- * @param {OdooEvent} event
- */
- _onNewGroupby: function (event) {
- var isDate = event.data.isDate;
- var attrs = {
- context:"{'group_by':'" + event.data.fieldName + "''}",
- name: event.data.description,
- fieldName: event.data.fieldName,
- isDate: isDate,
- };
- var groupby = new search_inputs.Filter({attrs: attrs}, this);
- if (event.data.optionId) {
- var interval = event.data.optionId;
- this.intervalMapping.push({groupby: groupby, interval: interval});
- }
- var group = new search_inputs.FilterGroup([groupby], this, this.intervalMapping, this.periodMapping);
- this.groupbysMapping.push({
- groupbyId: event.data.itemId,
- groupby: groupby,
- groupId: event.data.groupId,
- });
- this.groupsMapping.push({
- groupId: event.data.groupId,
- group: group,
- category: 'Group By',
- });
- group.toggle(groupby);
- },
- /**
- * this function is called when a new filter has been added to the filters menu
- * via the 'Add Custom Filter' submenu. The query is modified with the new filter
- * added to it as active. The communication betwenn the filters menu and the search view
- * is maintained by properly updating the mappings.
- * @private
- * @param {OdooEvent} event
- */
- _onNewFilters: function (event) {
- var self= this;
- var filter;
- var filters = [];
- var groupId;
-
- _.each(event.data, function (filterDescription) {
- filter = new search_inputs.Filter(filterDescription.filter, this);
- filters.push(filter);
- self.filtersMapping.push({
- filterId: filterDescription.itemId,
- filter: filter,
- groupId: filterDescription.groupId,
- });
- // filters belong to the same group
- if (!groupId) {
- groupId = filterDescription.groupId;
- }
- });
- var group = new search_inputs.FilterGroup(filters, this, this.intervalMapping, this.periodMapping);
- filters.forEach(function (filter) {
- group.toggle(filter, {silent: true});
- });
- this.groupsMapping.push({
- groupId: groupId,
- group: group,
- category: 'Filters',
- });
- this.query.trigger('reset');
- },
- /**
- * this function is called when the option related to an item (filter or groupby) has been
- * changed by the user. The query is modified appropriately.
- *
- * @private
- * @param {OdooEvent} event
- */
- _onItemOptionChanged: function (event) {
- var group;
- if (event.data.category === 'groupByCategory') {
- var groupby = _.findWhere(this.groupbysMapping, {groupbyId: event.data.itemId}).groupby;
- var interval = event.data.optionId;
- _.findWhere(this.intervalMapping, {groupby: groupby}).interval = interval;
- group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group;
- group.updateIntervalMapping(this.intervalMapping);
- this.query.trigger('reset');
- }
- if (event.data.category === 'filterCategory') {
- var filter = _.findWhere(this.filtersMapping, {filterId: event.data.itemId}).filter;
- var period = event.data.optionId;
- _.findWhere(this.periodMapping, {filter: filter}).period = period;
- group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group;
- group.updatePeriodMapping(this.periodMapping);
- this.query.trigger('reset');
- }
- },
- /*
- * @private
- * @param {JQueryEvent} event
- */
- _onTimeRangeModified: function () {
- var facet = this.timeRangeMenu.facetFor();
- var current = this.query.find(function (facet) {
- return facet.get('cat') === 'timeRangeCategory';
- });
- if (current) {
- this.query.remove(current, {silent: true});
- }
- this.query.add(facet);
- },
- /*
- * @private
- */
- _onTimeRangeRemoved: function () {
- var current = this.query.find(function (facet) {
- return facet.get('cat') === 'timeRangeCategory';
- });
- if (current) {
- this.query.remove(current);
- }
- },
-});
-
-_.extend(SearchView, {
- SearchQuery: SearchQuery,
- Facet: Facet,
-});
-
-return SearchView;
-
-});
diff --git a/addons/web/static/src/js/views/search/time_range_menu.js b/addons/web/static/src/js/views/search/time_range_menu.js
deleted file mode 100644
index 0378c132aab..00000000000
--- a/addons/web/static/src/js/views/search/time_range_menu.js
+++ /dev/null
@@ -1,198 +0,0 @@
-odoo.define('web.TimeRangeMenu', function (require) {
-"use strict";
-
-var config = require('web.config');
-var core = require('web.core');
-var Domain = require('web.Domain');
-var TimeRangeMenuOptions = require('web.TimeRangeMenuOptions');
-var Widget = require('web.Widget');
-
-var _t = core._t;
-var ComparisonOptions = TimeRangeMenuOptions.ComparisonOptions;
-var PeriodOptions = TimeRangeMenuOptions.PeriodOptions;
-
-var TimeRangeMenu = Widget.extend({
- template: 'web.TimeRangeMenu',
- events: {
- 'click .o_apply_range': '_onApplyButtonClick',
- 'click .o_comparison_checkbox': '_onCheckBoxClick',
- },
-
- /**
- * override
- * @param {Widget} parent
- * @param {Object} fields
- * @param {Object} configuration
- *
- */
- init: function(parent, fields, configuration) {
- var self = this;
- this.isMobile = config.device.isMobile;
- this.symbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : 'caret';
- this._super(parent);
- this.dateFields = [];
- _.each(fields, function (field, name) {
- if (field.sortable && _.contains(['date', 'datetime'], field.type)) {
- self.dateFields.push(_.extend({}, field, {
- name: name,
- }));
- }
- });
- this.periodOptions = PeriodOptions;
- this.periodGroups = PeriodOptions.reduce(
- function (acc, option) {
- if (!_.contains(acc, option.groupId)) {
- acc.push(option.groupId);
- }
- return acc;
- },
- []
- );
-
- this.comparisonOptions = ComparisonOptions;
-
- // Following steps determine initial configuration
- this.isActive = false;
- this.timeRangeId = undefined;
- this.comparisonIsSelected = false;
- this.comparisonTimeRangeId = undefined;
- this.dateField = {};
- if (configuration && configuration.field && configuration.range) {
- this.isActive = true;
- var dateField = _.findWhere(this.dateFields, {name: configuration.field});
- this.dateField = {
- name: dateField.name,
- description: dateField.string,
- type: dateField.type,
- };
- this.timeRangeId = configuration.range;
- if (configuration.comparison_range) {
- this.comparisonIsSelected = true;
- this.comparisonTimeRangeId = configuration.comparison_range;
- }
- }
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- deactivate: function () {
- this.isActive = false;
- this.comparisonIsSelected = false;
- this.renderElement();
- },
- /**
- * Generates a :js:class:`~instance.web.search.Facet` descriptor from a
- * filter descriptor
- *
- * @returns {Object}
- */
- facetFor: function () {
- var fieldDescription;
- var timeRange = "[]";
- var timeRangeDescription;
- var comparisonTimeRange = "[]";
- var comparisonTimeRangeDescription;
-
- if (this.isActive) {
- fieldDescription = this.dateField.description;
- if (this.timeRangeId !== 'custom') {
- timeRange = Domain.prototype.constructDomain(
- this.dateField.name,
- this.timeRangeId,
- this.dateField.type
- );
- timeRangeDescription = _.findWhere(
- this.periodOptions,
- {optionId: this.timeRangeId}
- ).description;
- }
- if (this.comparisonIsSelected) {
- comparisonTimeRange = Domain.prototype.constructDomain(
- this.dateField.name,
- this.timeRangeId,
- this.dateField.type,
- null,
- this.comparisonTimeRangeId
- );
- comparisonTimeRangeDescription = _.findWhere(
- this.comparisonOptions,
- {optionId: this.comparisonTimeRangeId}
- ).description;
- }
- }
-
- return {
- cat: 'timeRangeCategory',
- category: _t("Time Range"),
- icon: 'fa fa-calendar',
- field: {
- get_context: function (facet, noDomainEvaluation) {
- if (!noDomainEvaluation) {
- timeRange = Domain.prototype.stringToArray(timeRange);
- comparisonTimeRange = Domain.prototype.stringToArray(comparisonTimeRange);
- }
- return {
- timeRangeMenuData: {
- timeRange: timeRange,
- timeRangeDescription: timeRangeDescription,
- comparisonTimeRange: comparisonTimeRange,
- comparisonTimeRangeDescription: comparisonTimeRangeDescription,
- }
- };
- },
- get_groupby: function () {},
- get_domain: function () {}
- },
- isRange: true,
- values: [{
- label: fieldDescription + ': ' + timeRangeDescription +
- (
- comparisonTimeRangeDescription ?
- (' / ' + comparisonTimeRangeDescription) :
- ''
- ),
- value: null,
- }],
- };
- },
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _onApplyButtonClick: function () {
- this.isActive = true;
- var dateFieldName = this.$('.o_date_field_selector').val();
- this.timeRangeId = this.$('.o_time_range_selector').val();
- if (this.comparisonIsSelected) {
- this.comparisonTimeRangeId = this.$('.o_comparison_time_range_selector').val();
- }
- this.dateField = {
- name: dateFieldName,
- type: _.findWhere(this.dateFields, {name: dateFieldName}).type,
- description: _.findWhere(this.dateFields, {name: dateFieldName}).string,
- };
-
- this.renderElement();
- this.trigger_up('time_range_modified');
- },
- /**
- * @private
- *
- * @param {JQueryEvent} ev
- */
- _onCheckBoxClick: function (ev) {
- ev.stopPropagation();
- this.comparisonIsSelected = this.$('.o_comparison_checkbox').prop('checked');
- this.$('.o_comparison_time_range_selector').toggleClass('o_hidden');
- this.$el.addClass('open');
- }
-});
-
-return TimeRangeMenu;
-
-});
diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js
index ee11afbc3bd..fb9cd5afa0c 100644
--- a/addons/web/static/src/js/views/view_dialogs.js
+++ b/addons/web/static/src/js/views/view_dialogs.js
@@ -3,13 +3,10 @@ odoo.define('web.view_dialogs', function (require) {
var config = require('web.config');
var core = require('web.core');
-var data = require('web.data');
var Dialog = require('web.Dialog');
var dom = require('web.dom');
var ListController = require('web.ListController');
var ListView = require('web.ListView');
-var pyUtils = require('web.py_utils');
-var SearchView = require('web.SearchView');
var view_registry = require('web.view_registry');
var _t = core._t;
@@ -21,9 +18,6 @@ var _t = core._t;
var ViewDialog = Dialog.extend({
custom_events: _.extend({}, Dialog.prototype.custom_events, {
push_state: '_onPushState',
- env_updated: function (event) {
- event.stopPropagation();
- },
}),
/**
* @constructor
@@ -45,10 +39,6 @@ var ViewDialog = Dialog.extend({
this.domain = options.domain || [];
this.context = options.context || {};
this.options = _.extend(this.options || {}, options || {});
-
- // FIXME: remove this once a dataset won't be necessary anymore to interact
- // with data_manager and instantiate views
- this.dataset = new data.DataSet(this, this.res_model, this.context);
},
//--------------------------------------------------------------------------
@@ -182,7 +172,7 @@ var FormViewDialog = ViewDialog.extend({
if (this.options.fields_view) {
fields_view_def = $.when(this.options.fields_view);
} else {
- fields_view_def = this.loadFieldView(this.dataset, this.options.view_id, 'form');
+ fields_view_def = this.loadFieldView(this.res_model, this.context, this.options.view_id, 'form');
}
fields_view_def.then(function (viewInfo) {
@@ -271,17 +261,23 @@ var FormViewDialog = ViewDialog.extend({
});
var SelectCreateListController = ListController.extend({
- // Override the ListView to handle the custom events 'open_record' (triggered when clicking on a
- // row of the list) such that it triggers up 'select_record' with its res_id.
- custom_events: _.extend({}, ListController.prototype.custom_events, {
- open_record: function (event) {
- var selectedRecord = this.model.get(event.data.id);
- this.trigger_up('select_record', {
- id: selectedRecord.res_id,
- display_name: selectedRecord.data.display_name,
- });
- },
- }),
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override to select the clicked record instead of opening it
+ *
+ * @override
+ * @private
+ */
+ _onOpenRecord: function (ev) {
+ var selectedRecord = this.model.get(ev.data.id);
+ this.trigger_up('select_record', {
+ id: selectedRecord.res_id,
+ display_name: selectedRecord.data.display_name,
+ });
+ },
});
/**
@@ -299,13 +295,6 @@ var SelectCreateDialog = ViewDialog.extend({
event.stopPropagation();
this.$footer.find(".o_select_button").prop('disabled', !event.data.selection.length);
},
- search: function (event) {
- event.stopPropagation(); // prevent this event from bubbling up to the action manager
- var d = event.data;
- var searchData = this._process_search_data(d.domains, d.contexts, d.groupbys);
- this.list_controller.reload(searchData);
- },
- get_controller_context: '_onGetControllerContext',
}),
/**
@@ -320,7 +309,7 @@ var SelectCreateDialog = ViewDialog.extend({
this._super.apply(this, arguments);
_.defaults(this.options, { initial_view: 'search' });
this.on_selected = this.options.on_selected || (function () {});
- this.initial_ids = this.options.initial_ids;
+ this.initialIDs = this.options.initial_ids;
},
open: function () {
@@ -328,26 +317,13 @@ var SelectCreateDialog = ViewDialog.extend({
return this.create_edit_record();
}
var self = this;
- var user_context = this.getSession().user_context;
-
var _super = this._super.bind(this);
- var context = pyUtils.eval_domains_and_contexts({
- domains: [],
- contexts: [user_context, this.context]
- }).context;
- var search_defaults = {};
- _.each(context, function (value_, key) {
- var match = /^search_default_(.*)$/.exec(key);
- if (match) {
- search_defaults[match[1]] = value_;
- }
- });
- this.loadViews(this.dataset.model, this.dataset.get_context().eval(), [[false, 'list'], [false, 'search']], {})
- .then(this.setup.bind(this, search_defaults))
+ this.loadViews(this.res_model, this.context, [[false, 'list'], [false, 'search']], {})
+ .then(this.setup.bind(this))
.then(function (fragment) {
self.opened().then(function () {
dom.append(self.$el, fragment, {
- callbacks: [{widget: self.list_controller}],
+ callbacks: [{widget: self.listController}],
in_DOM: true,
});
self.set_buttons(self.__buttons);
@@ -357,65 +333,50 @@ var SelectCreateDialog = ViewDialog.extend({
return this;
},
- setup: function (search_defaults, fields_views) {
+ setup: function (fieldsViews) {
var self = this;
var fragment = document.createDocumentFragment();
- var searchDef = $.Deferred();
-
- // Set the dialog's header and its search view
- var $header = $('<div/>').addClass('o_modal_header').appendTo(fragment);
- var $pager = $('<div/>').addClass('o_pager').appendTo($header);
- var options = {
- $buttons: $('<div/>').addClass('o_search_options').appendTo($header),
- search_defaults: search_defaults,
- };
- var searchview = new SearchView(this, this.dataset, fields_views.search, options);
- searchview.prependTo($header).done(function () {
- var d = searchview.build_search_data();
- if (self.initial_ids) {
- d.domains.push([["id", "in", self.initial_ids]]);
- self.initial_ids = undefined;
- }
- var searchData = self._process_search_data(d.domains, d.contexts, d.groupbys);
- searchDef.resolve(searchData);
- });
-
- return $.when(searchDef).then(function (searchResult) {
- // Set the list view
- var listView = new ListView(fields_views.list, _.extend({
- context: searchResult.context,
- domain: searchResult.domain,
- groupBy: searchResult.groupBy,
- modelName: self.dataset.model,
- hasSelectors: !self.options.disable_multiple_selection,
- readonly: true,
- }, self.options.list_view_options));
- listView.setController(SelectCreateListController);
- return listView.getController(self);
- }).then(function (controller) {
- self.list_controller = controller;
- // Set the dialog's buttons
+ var domain = this.domain;
+ if (this.initialIDs) {
+ domain = domain.concat([['id', 'in', this.initialIDs]]);
+ }
+ var listView = new ListView(fieldsViews.list, _.extend({
+ action: {
+ controlPanelFieldsView: fieldsViews.search,
+ },
+ action_buttons: false,
+ context: this.context,
+ domain: domain,
+ hasSelectors: !this.options.disable_multiple_selection,
+ modelName: this.res_model,
+ readonly: true,
+ withBreadcrumbs: false,
+ }, this.options.list_view_options));
+ listView.setController(SelectCreateListController);
+ return listView.getController(this).then(function (controller) {
+ self.listController = controller;
+ // render the footer buttons
self.__buttons = [{
text: _t("Cancel"),
- classes: "btn-secondary o_form_button_cancel",
+ classes: 'btn-secondary o_form_button_cancel',
close: true,
}];
if (!self.options.no_create) {
self.__buttons.unshift({
text: _t("Create"),
- classes: "btn-primary",
+ classes: 'btn-primary',
click: self.create_edit_record.bind(self)
});
}
if (!self.options.disable_multiple_selection) {
self.__buttons.unshift({
text: _t("Select"),
- classes: "btn-primary o_select_button",
+ classes: 'btn-primary o_select_button',
disabled: true,
close: true,
click: function () {
- var records = self.list_controller.getSelectedRecords();
+ var records = self.listController.getSelectedRecords();
var values = _.map(records, function (record) {
return {
id: record.res_id,
@@ -426,28 +387,11 @@ var SelectCreateDialog = ViewDialog.extend({
},
});
}
- return self.list_controller.appendTo(fragment);
+ return self.listController.appendTo(fragment);
}).then(function () {
- searchview.toggle_visibility(true);
- self.list_controller.do_show();
- self.list_controller.renderPager($pager);
return fragment;
});
},
- _process_search_data: function (domains, contexts, groupbys) {
- var results = pyUtils.eval_domains_and_contexts({
- domains: [this.domain].concat(domains),
- contexts: [this.context].concat(contexts),
- group_by_seq: groupbys || [],
- eval_context: this.getSession().user_context,
- });
- var context = _.omit(results.context, function (value, key) { return key.indexOf('search_default_') === 0; });
- return {
- context: context,
- domain: results.domain,
- groupBy: results.group_by,
- };
- },
create_edit_record: function () {
var self = this;
var dialog = new FormViewDialog(this, _.extend({}, this.options, {
@@ -469,23 +413,6 @@ var SelectCreateDialog = ViewDialog.extend({
this.trigger_up('form_dialog_discarded');
return true;
},
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Handles a context request: provides to the caller the context of the
- * list controller.
- *
- * @private
- * @param {OdooEvent} ev
- * @param {function} ev.data.callback used to send the requested context
- */
- _onGetControllerContext: function (ev) {
- ev.stopPropagation();
- var context = this.list_controller && this.list_controller.getContext();
- ev.data.callback(context || {});
- }
});
return {
diff --git a/addons/web/static/src/js/views/view_utils.js b/addons/web/static/src/js/views/view_utils.js
index 57ae9547d2f..94485c1401a 100644
--- a/addons/web/static/src/js/views/view_utils.js
+++ b/addons/web/static/src/js/views/view_utils.js
@@ -1,7 +1,10 @@
-odoo.define('web.viewUtils', function () {
+odoo.define('web.viewUtils', function (require) {
"use strict";
-var utils = {
+var dom = require('web.dom');
+var utils = require('web.utils');
+
+var viewUtils = {
/**
* Returns the value of a group dataPoint, i.e. the value of the groupBy
* field for the records in that group.
@@ -40,8 +43,45 @@ var utils = {
}
return true;
},
+ /**
+ * @param {string} arch view arch
+ * @returns {Object} parsed arch
+ */
+ parseArch: function (arch) {
+ var doc = $.parseXML(arch).documentElement;
+ var stripWhitespaces = doc.nodeName.toLowerCase() !== 'kanban';
+ return utils.xml_to_json(doc, stripWhitespaces);
+ },
+ /**
+ * Renders a button according to a given arch node element.
+ *
+ * @param {Object} node
+ * @param {Object} [options]
+ * @param {string} [options.extraClass]
+ * @param {boolean} [options.textAsTitle=false]
+ * @returns {jQuery}
+ */
+ renderButtonFromNode: function (node, options) {
+ var btnOptions = {
+ attrs: _.omit(node.attrs, 'icon', 'string', 'type', 'attrs', 'modifiers', 'options'),
+ icon: node.attrs.icon,
+ };
+ if (options && options.extraClass) {
+ var classes = btnOptions.attrs.class ? btnOptions.attrs.class.split(' ') : [];
+ btnOptions.attrs.class = _.uniq(classes.concat(options.extraClass.split(' '))).join(' ');
+ }
+ var str = (node.attrs.string || '').replace(/_/g, '');
+ if (str) {
+ if (options && options.textAsTitle) {
+ btnOptions.attrs.title = str;
+ } else {
+ btnOptions.text = str;
+ }
+ }
+ return dom.renderButton(btnOptions);
+ },
};
-return utils;
+return viewUtils;
});
diff --git a/addons/web/static/src/js/widgets/dropdown_menu.js b/addons/web/static/src/js/widgets/dropdown_menu.js
index e92c714d838..11873718576 100644
--- a/addons/web/static/src/js/widgets/dropdown_menu.js
+++ b/addons/web/static/src/js/widgets/dropdown_menu.js
@@ -6,81 +6,48 @@ var Widget = require('web.Widget');
var QWeb = core.qweb;
-
-
var DropdownMenu = Widget.extend({
template: 'web.DropdownMenu',
+
events: {
'click .o_menu_item': '_onItemClick',
'click .o_item_option': '_onOptionClick',
- 'click span.o_trash_button': '_onTrashClick',
+ 'click span.o_trash_button': '_onTrashButtonClick',
'hidden.bs.dropdown': '_onBootstrapClose',
'click .dropdown-item-text': '_onDropDownItemTextClick',
},
- /**
- * override
- *
- * @param {Widget} parent
- * @param {Object} dropdownHeader object used to customize the dropdown menu.
- * @param {String} dropdownHeader.title
- * @param {String} dropdownHeader.icon
- * @param {String} dropdownHeader.symbol
- * @param {String} dropdownHeader.category descripbes the type of items
- * @param {String} dropdownHeader.style the button style
- * @param {Object[]} items list of menu items
- *
- * Menu items:
- *
- * * itemId: string; (optional) unique id associated with the item
- * * description: string; label printed on screen
- * * groupId: string;
- * * isActive: boolean; (optional) determines if the item is considered active
- * * isOpen: boolean; (optional) in case there are options the submenu presenting the options is opened or closed according to isOpen
- * * isRemovable: boolean; (optional) can be removed from menu options: array of objects with 'optionId' and 'description' keys; (optional)
- * * currentOptionId: string refers to an optionId that is activated if item is active (optional)
- */
- init: function (parent, dropdownHeader, items) {
+ init: function (parent, items) {
this._super(parent);
- this.dropdownCategory = dropdownHeader.category;
- this.dropdownTitle = dropdownHeader.title;
- this.dropdownIcon = dropdownHeader.icon;
- this.dropdownSymbol = dropdownHeader.symbol || false;
+ // should be specified
+ this.dropdownCategory = null;
+ this.dropdownTitle = null;
+ this.dropdownIcon = null;
+ this.dropdownSymbol = false;
// this parameter fixes the menu style. By default,
// the style used is the one used in the search view
- this.dropdownStyle = dropdownHeader.style || {
+ this.dropdownStyle = {
el: {class: 'btn-group o_dropdown', attrs: {}},
- mainButton: {class: 'o_dropdown_toggler_btn btn btn-secondary dropdown-toggle' + (this.dropdownSymbol ? ' o-no-caret' : '')},
- };
+ mainButton: {
+ class: 'o_dropdown_toggler_btn btn btn-secondary ' +
+ 'dropdown-toggle ' +
+ (this.dropdownSymbol ? 'o-no-caret' : '')
+ },
+ };
this.items = items;
- _.each(this.items, this._prepareItem.bind(this));
- },
- /**
- * override
- */
- start: function () {
- this.$menu = this.$('.o_dropdown_menu');
+ this.openItems = {};
},
+
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
- * @param {string[]} activeItemIds
+ * @param {Object} items
*/
- updateItemsStatus: function (activeItemIds) {
- _.each(this.items, function (item) {
- if (!_.contains(activeItemIds, item.itemId)) {
- item.isActive = false;
- item.currentOptionId = false;
- } else {
- item.isActive = true;
- item.currentOptionId = item.hasOptions ?
- (item.currentOptionId || item.defaultOptionId) :
- false;
- }
- });
+ update: function (items) {
+ this.items = items;
this._renderMenuItems();
},
@@ -90,97 +57,11 @@ var DropdownMenu = Widget.extend({
/**
* @private
- * @param {string} itemId
- */
- _removeItem: function (itemId) {
- var item, itemIndex;
- item = _.findWhere(this.items, {itemId: itemId});
- itemIndex = this.items.indexOf(item);
- this.items.splice(itemIndex, 1);
- this._renderMenuItems();
- var eventData = {
- category: this.dropdownCategory,
- itemId: itemId,
- groupId: item.groupId,
- };
- this.trigger_up('menu_item_deleted', eventData);
- },
- /**
- * @private
*/
_renderMenuItems: function () {
var newMenuItems = QWeb.render('DropdownMenu.MenuItems', {widget: this});
this.$el.find('.o_menu_item, .dropdown-divider[data-removable="1"]').remove();
- this.$('.o_dropdown_menu').prepend($(newMenuItems));
- },
- /**
- * this function computes some default values for some of the item keys
- * according to values passed at initialization
- *
- * @private
- * @param {Object} item
- */
- _prepareItem: function (item) {
- item.itemId = item.itemId || _.uniqueId('__item__');
- item.isOpen = item.isOpen || false;
- item.isRemovable = item.isRemovable || false;
- if (item.options && item.options.length !== 0) {
- item.options = _.sortBy(item.options, 'groupId');
- item.hasOptions = true;
- item.defaultOptionId = item.defaultOptionId || this.defaultOptionId;
- item.currentOptionId = item.isActive && item.defaultOptionId ? item.defaultOptionId : false;
- }
- },
- /**
- * @private
- * @param {string} itemId
- */
- _toggleMenuItem: function (itemId) {
- var item = _.findWhere(this.items, {itemId: itemId});
- if (!item.hasOptions) {
- item.isActive = !item.isActive;
- var eventData = {
- category: this.dropdownCategory,
- itemId: itemId,
- isActive: item.isActive,
- groupId: item.groupId,
- };
- this._renderMenuItems();
- this.trigger_up('menu_item_toggled', eventData);
- }
- if (item.hasOptions) {
- item.isOpen = !item.isOpen;
- this._renderMenuItems();
- }
- },
- /**
- * @private
- * @param {string} itemId
- * @param {string} optionId
- */
- _toggleItemOption: function (itemId, optionId) {
- var item = _.findWhere(this.items, {itemId: itemId});
- var initialState = item.isActive;
- if (item.currentOptionId === optionId) {
- item.isActive = false;
- item.currentOptionId = false;
- } else {
- item.isActive = true;
- item.currentOptionId = optionId;
- }
- var eventData = {
- category: this.dropdownCategory,
- itemId: itemId,
- groupId: item.groupId,
- isActive: item.isActive,
- optionId: item.currentOptionId,
- };
- this._renderMenuItems();
- if (item.isActive !== initialState) {
- this.trigger_up('menu_item_toggled', eventData);
- } else {
- this.trigger_up('item_option_changed', eventData);
- }
+ this.$('.o_dropdown_menu').prepend(newMenuItems);
},
//--------------------------------------------------------------------------
@@ -195,9 +76,7 @@ var DropdownMenu = Widget.extend({
* @param {jQueryEvent} event
*/
_onBootstrapClose: function () {
- _.each(this.items, function (item) {
- item.isOpen = false;
- });
+ this.openItems = {};
this._renderMenuItems();
},
/**
@@ -216,8 +95,16 @@ var DropdownMenu = Widget.extend({
_onItemClick: function (event) {
event.preventDefault();
event.stopPropagation();
- var itemId = $(event.currentTarget).data('id');
- this._toggleMenuItem(itemId);
+ var id = $(event.currentTarget).data('id');
+ var item = this.items.find(function (item) {
+ return item.id === id;
+ });
+ if (item.hasOptions) {
+ this.openItems[id] = !this.openItems[id];
+ this._renderMenuItems();
+ } else {
+ this.trigger_up('menu_item_clicked', {id: id});
+ }
},
/**
* @private
@@ -227,20 +114,19 @@ var DropdownMenu = Widget.extend({
event.preventDefault();
event.stopPropagation();
var optionId = $(event.currentTarget).data('option_id');
- var itemId = $(event.currentTarget).data('item_id');
- this._toggleItemOption(itemId, optionId);
+ var id = $(event.currentTarget).data('item_id');
+ this.trigger_up('item_option_clicked', {id: id, optionId: optionId});
},
/**
* @private
* @param {MouseEvent} event
+ *
+ * To implement in child
*/
- _onTrashClick: function (event) {
- event.stopPropagation();
- var itemId = $(event.currentTarget).data('id');
- this._removeItem(itemId);
- },
+ _onTrashButtonClick: function (event) {
+ },
});
return DropdownMenu;
-});
+}); \ No newline at end of file
diff --git a/addons/web/static/src/js/widgets/old/dropdown_menu.js b/addons/web/static/src/js/widgets/old/dropdown_menu.js
new file mode 100644
index 00000000000..429fdd03067
--- /dev/null
+++ b/addons/web/static/src/js/widgets/old/dropdown_menu.js
@@ -0,0 +1,236 @@
+odoo.define('web.OldDropdownMenu', function (require) {
+"use strict";
+
+var core = require('web.core');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+
+
+
+var OldDropdownMenu = Widget.extend({
+ template: 'web.OldDropdownMenu',
+ events: {
+ 'click .o_menu_item': '_onItemClick',
+ 'click .o_item_option': '_onOptionClick',
+ 'click span.o_trash_button': '_onTrashClick',
+ 'hidden.bs.dropdown': '_onBootstrapClose',
+ },
+
+ /**
+ * override
+ *
+ * @param {Widget} parent
+ * @param {Object} dropdownHeader object used to customize the dropdown menu.
+ * @param {String} dropdownHeader.title
+ * @param {String} dropdownHeader.icon
+ * @param {String} dropdownHeader.symbol
+ * @param {String} dropdownHeader.category descripbes the type of items
+ * @param {String} dropdownHeader.style the button style
+ * @param {Object[]} items list of menu items
+ *
+ * Menu items:
+ *
+ * * itemId: string; (optional) unique id associated with the item
+ * * description: string; label printed on screen
+ * * groupId: string;
+ * * isActive: boolean; (optional) determines if the item is considered active
+ * * isOpen: boolean; (optional) in case there are options the submenu presenting the options is opened or closed according to isOpen
+ * * isRemovable: boolean; (optional) can be removed from menu options: array of objects with 'optionId' and 'description' keys; (optional)
+ * * currentOptionId: string refers to an optionId that is activated if item is active (optional)
+ */
+ init: function (parent, dropdownHeader, items) {
+ this._super(parent);
+ this.dropdownCategory = dropdownHeader.category;
+ this.dropdownTitle = dropdownHeader.title;
+ this.dropdownIcon = dropdownHeader.icon;
+ this.dropdownSymbol = dropdownHeader.symbol || false;
+ // this parameter fixes the menu style. By default,
+ // the style used is the one used in the search view
+ this.dropdownStyle = dropdownHeader.style || {
+ el: {class: 'btn-group o_dropdown', attrs: {}},
+ mainButton: {class: 'o_dropdown_toggler_btn btn btn-secondary dropdown-toggle' + (this.dropdownSymbol ? ' o-no-caret' : '')},
+ };
+ this.items = items;
+ _.each(this.items, this._prepareItem.bind(this));
+ },
+ /**
+ * override
+ */
+ start: function () {
+ this.$menu = this.$('.o_dropdown_menu');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {string[]} activeItemIds
+ */
+ updateItemsStatus: function (activeItemIds) {
+ _.each(this.items, function (item) {
+ if (!_.contains(activeItemIds, item.itemId)) {
+ item.isActive = false;
+ item.currentOptionId = false;
+ } else {
+ item.isActive = true;
+ item.currentOptionId = item.hasOptions ?
+ (item.currentOptionId || item.defaultOptionId) :
+ false;
+ }
+ });
+ this._renderMenuItems();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} itemId
+ */
+ _removeItem: function (itemId) {
+ var item, itemIndex;
+ item = _.findWhere(this.items, {itemId: itemId});
+ itemIndex = this.items.indexOf(item);
+ this.items.splice(itemIndex, 1);
+ this._renderMenuItems();
+ var eventData = {
+ category: this.dropdownCategory,
+ itemId: itemId,
+ groupId: item.groupId,
+ };
+ this.trigger_up('menu_item_deleted', eventData);
+ },
+ /**
+ * @private
+ */
+ _renderMenuItems: function () {
+ var newMenuItems = QWeb.render('OldDropdownMenu.MenuItems', {widget: this});
+ this.$el.find('.o_menu_item, .dropdown-divider[data-removable="1"]').remove();
+ this.$('.o_dropdown_menu').prepend($(newMenuItems));
+ },
+ /**
+ * this function computes some default values for some of the item keys
+ * according to values passed at initialization
+ *
+ * @private
+ * @param {Object} item
+ */
+ _prepareItem: function (item) {
+ item.itemId = item.itemId || _.uniqueId('__item__');
+ item.isOpen = item.isOpen || false;
+ item.isRemovable = item.isRemovable || false;
+ if (item.options && item.options.length !== 0) {
+ item.options = _.sortBy(item.options, 'groupId');
+ item.hasOptions = true;
+ item.defaultOptionId = item.defaultOptionId || this.defaultOptionId;
+ item.currentOptionId = item.isActive && item.defaultOptionId ? item.defaultOptionId : false;
+ }
+ },
+ /**
+ * @private
+ * @param {string} itemId
+ */
+ _toggleMenuItem: function (itemId) {
+ var item = _.findWhere(this.items, {itemId: itemId});
+ if (!item.hasOptions) {
+ item.isActive = !item.isActive;
+ var eventData = {
+ category: this.dropdownCategory,
+ itemId: itemId,
+ isActive: item.isActive,
+ groupId: item.groupId,
+ };
+ this._renderMenuItems();
+ this.trigger_up('menu_item_toggled', eventData);
+ }
+ if (item.hasOptions) {
+ item.isOpen = !item.isOpen;
+ this._renderMenuItems();
+ }
+ },
+ /**
+ * @private
+ * @param {string} itemId
+ * @param {string} optionId
+ */
+ _toggleItemOption: function (itemId, optionId) {
+ var item = _.findWhere(this.items, {itemId: itemId});
+ var initialState = item.isActive;
+ if (item.currentOptionId === optionId) {
+ item.isActive = false;
+ item.currentOptionId = false;
+ } else {
+ item.isActive = true;
+ item.currentOptionId = optionId;
+ }
+ var eventData = {
+ category: this.dropdownCategory,
+ itemId: itemId,
+ groupId: item.groupId,
+ isActive: item.isActive,
+ optionId: item.currentOptionId,
+ };
+ this._renderMenuItems();
+ if (item.isActive !== initialState) {
+ this.trigger_up('menu_item_toggled', eventData);
+ } else {
+ this.trigger_up('item_option_changed', eventData);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method is called when Bootstrap trigger a 'close' event (for example
+ * when the user clicks outside the dropdown menu).
+ *
+ * @private
+ * @param {jQueryEvent} event
+ */
+ _onBootstrapClose: function () {
+ _.each(this.items, function (item) {
+ item.isOpen = false;
+ });
+ this._renderMenuItems();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onItemClick: function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ var itemId = $(event.currentTarget).data('id');
+ this._toggleMenuItem(itemId);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onOptionClick: function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ var optionId = $(event.currentTarget).data('option_id');
+ var itemId = $(event.currentTarget).data('item_id');
+ this._toggleItemOption(itemId, optionId);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onTrashClick: function (event) {
+ event.stopPropagation();
+ var itemId = $(event.currentTarget).data('id');
+ this._removeItem(itemId);
+ },
+});
+
+return OldDropdownMenu;
+
+});
diff --git a/addons/web/static/src/js/widgets/pie_chart.js b/addons/web/static/src/js/widgets/pie_chart.js
index 663e2b4cc98..30944d69584 100644
--- a/addons/web/static/src/js/widgets/pie_chart.js
+++ b/addons/web/static/src/js/widgets/pie_chart.js
@@ -34,14 +34,16 @@ var PieChart = Widget.extend({
});
this.subViewParams = {
- context: record.context,
- domain: domain,
- groupBy: [],
modelName: record.model,
withControlPanel: false,
isEmbedded: true,
mode: 'pie',
};
+ this.subViewParams.searchQuery = {
+ context: record.context,
+ domain: domain,
+ groupBy: [],
+ };
this.viewInfo = {
arch: arch,