summaryrefslogtreecommitdiff
path: root/addons
diff options
context:
space:
mode:
Diffstat (limited to 'addons')
-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
-rw-r--r--addons/web/static/src/scss/control_panel.scss4
-rw-r--r--addons/web/static/src/scss/dropdown_menu.scss4
-rw-r--r--addons/web/static/src/scss/graph_view.scss4
-rw-r--r--addons/web/static/src/scss/search_view.scss3
-rw-r--r--addons/web/static/src/scss/search_view_mobile.scss4
-rw-r--r--addons/web/static/src/scss/webclient_layout.scss24
-rw-r--r--addons/web/static/src/xml/base.xml238
-rw-r--r--addons/web/static/tests/chrome/action_manager_mobile_tests.js8
-rw-r--r--addons/web/static/tests/chrome/action_manager_tests.js107
-rw-r--r--addons/web/static/tests/chrome/search_tests.js172
-rw-r--r--addons/web/static/tests/core/registry_tests.js50
-rw-r--r--addons/web/static/tests/fields/basic_fields_mobile_tests.js2
-rw-r--r--addons/web/static/tests/fields/basic_fields_tests.js85
-rw-r--r--addons/web/static/tests/fields/relational_fields/field_many2many_tests.js85
-rw-r--r--addons/web/static/tests/fields/relational_fields/field_many2one_tests.js44
-rw-r--r--addons/web/static/tests/fields/relational_fields/field_one2many_tests.js175
-rw-r--r--addons/web/static/tests/fields/relational_fields_tests.js24
-rw-r--r--addons/web/static/tests/fields/special_fields.js2
-rw-r--r--addons/web/static/tests/helpers/test_utils.js4
-rw-r--r--addons/web/static/tests/helpers/test_utils_create.js147
-rw-r--r--addons/web/static/tests/tools/debug_manager_tests.js3
-rw-r--r--addons/web/static/tests/views/abstract_view_tests.js38
-rw-r--r--addons/web/static/tests/views/calendar_tests.js23
-rw-r--r--addons/web/static/tests/views/control_panel_tests.js334
-rw-r--r--addons/web/static/tests/views/form_tests.js295
-rw-r--r--addons/web/static/tests/views/graph_tests.js53
-rw-r--r--addons/web/static/tests/views/kanban_tests.js105
-rw-r--r--addons/web/static/tests/views/list_tests.js98
-rw-r--r--addons/web/static/tests/views/mobile_control_panel_tests.js (renamed from addons/web/static/tests/chrome/search_view_mobile_tests.js)0
-rw-r--r--addons/web/static/tests/views/pivot_tests.js48
-rw-r--r--addons/web/static/tests/views/search_filter_menu_tests.js167
-rw-r--r--addons/web/static/tests/views/search_filters_menu_tests.js93
-rw-r--r--addons/web/static/tests/views/search_groupby_menu_tests.js172
-rw-r--r--addons/web/static/tests/views/search_view_tests.js407
-rw-r--r--addons/web/static/tests/views/view_dialogs_tests.js31
-rw-r--r--addons/web/static/tests/widgets/dropdown_menu_tests.js172
-rw-r--r--addons/web/views/webclient_templates.xml42
-rw-r--r--addons/web_editor/static/tests/web_editor_tests.js4
107 files changed, 7152 insertions, 6255 deletions
diff --git a/addons/web/static/src/js/chrome/abstract_action.js b/addons/web/static/src/js/chrome/abstract_action.js
index c4755338bf0..226802ba76b 100644
--- a/addons/web/static/src/js/chrome/abstract_action.js
+++ b/addons/web/static/src/js/chrome/abstract_action.js
@@ -2,82 +2,126 @@ odoo.define('web.AbstractAction', function (require) {
"use strict";
/**
- * We define here the generic notion of Action (from the point of view of the
- * web client). In short, an action is a widget which controls the main part
- * of the screen (everything below the control panel).
- *
- * More precisely, the action manager is the component that coordinates a stack
- * of actions. Whenever the user navigates in the interface, switches views,
- * open different menus, the action manager creates/updates/destroys special
- * widgets which are actions. These actions need to answer to a standardised
- * API, which is the reason for this AbstractAction class.
- *
- * In practice, most actions are view controllers (coming from a
- * ir.action.act_window). However, some actions are 'client actions'. They
- * also need to be an AbstractAction for a better cooperation with the action
- * manager.
+ * We define here the AbstractAction widget, which implements the ActionMixin.
+ * All client actions must extend this widget.
*
* @module web.AbstractAction
*/
+var ActionMixin = require('web.ActionMixin');
+var ControlPanelView = require('web.ControlPanelView');
var Widget = require('web.Widget');
-var AbstractAction = Widget.extend({
+var AbstractAction = Widget.extend(ActionMixin, {
+ config: {
+ ControlPanelView: ControlPanelView,
+ },
/**
- * Called each time the action is attached into the DOM.
+ * If this flag is set to true, the client action will create a control
+ * panel whenever it is created.
+ *
+ * @type boolean
*/
- on_attach_callback: function () {},
+ hasControlPanel: false,
+
/**
- * Called each time the action is detached from the DOM.
+ * If true, this flag indicates that the client action should automatically
+ * fetch the <arch> of a search view (or control panel view). Note that
+ * to do that, it also needs a specific modelName.
+ *
+ * For example, the Discuss application adds the following line in its
+ * constructor:
+ *
+ * this.controlPanelParams.modelName = 'mail.message';
+ *
+ * @type boolean
*/
- on_detach_callback: function () {},
+ loadControlPanel: false,
+
/**
- * Called by the action manager when action is restored (typically, when the
- * user clicks on the action in the breadcrumb)
+ * A client action might want to use a search bar in its control panel, or
+ * it could choose not to use it.
+ *
+ * Note that it only makes sense if hasControlPanel is set to true.
*
- * @returns {Deferred|undefined}
+ * @type boolean
*/
- willRestore: function () {},
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
+ withSearchBar: false,
/**
- * In some situations, we need confirmation from the controller that the
- * current state can be destroyed without prejudice to the user. For
- * example, if the user has edited a form, maybe we should ask him if we
- * can discard all his changes when we switch to another action. In that
- * case, the action manager will call this method. If the returned
- * deferred is succesfully resolved, then we can destroy the current action,
- * otherwise, we need to stop.
+ * This parameter can be set to customize the available sub menus in the
+ * controlpanel (Filters/Group By/Favorites). This is basically a list of
+ * the sub menus that we want to use.
+ *
+ * Note that it only makes sense if hasControlPanel is set to true.
+ *
+ * For example, set ['filter', 'favorite'] to enable the Filters and
+ * Favorites menus.
*
- * @returns {Deferred} resolved if the action can be removed, rejected otherwise
+ * @type string[]
*/
- canBeRemoved: function () {
- return $.when();
- },
+ searchMenuTypes: [],
+
/**
- * This function is called when the current context (~state) of the action
- * should be known. For instance, if the action is a view controller,
- * this may be useful to reinstantiate the view in the same state.
+ * @override
+ *
+ * @param {Widget} parent
+ * @param {Object} action
+ * @param {Object} [options]
*/
- getContext: function () {
+ init: function (parent, action, options) {
+ this._super(parent);
+ this._title = action.display_name || action.name;
+ this.controlPanelParams = {
+ actionId: action.id,
+ context: action.context,
+ breadcrumbs: options && options.breadcrumbs || [],
+ title: this.getTitle(),
+ viewId: action.search_view_id && action.search_view_id[0],
+ withSearchBar: this.withSearchBar,
+ searchMenuTypes: this.searchMenuTypes,
+ };
},
/**
- * Gives the focus to the action
+ * The willStart method is actually quite complicated if the client action
+ * has a controlPanel, because it needs to prepare it.
+ *
+ * @override
*/
- giveFocus: function () {
+ willStart: function () {
+ var self = this;
+ if (this.hasControlPanel) {
+ var params = this.controlPanelParams;
+ var def;
+ if (this.loadControlPanel) {
+ def = this
+ .loadFieldView(params.modelName, params.context || {}, params.viewId, 'search')
+ .then(function (fieldsView) {
+ params.viewInfo = {
+ arch: fieldsView.arch,
+ fields: fieldsView.fields,
+ };
+ });
+ }
+ return $.when(def).then(function () {
+ var controlPanelView = new self.config.ControlPanelView(params);
+ return controlPanelView.getController(self).then(function (controlPanel) {
+ self._controlPanel = controlPanel;
+ return self._controlPanel.appendTo(document.createDocumentFragment());
+ });
+ });
+ }
+ return $.when();
},
/**
- * Renders the buttons to append, in most cases, to the control panel (in
- * the bottom left corner). When the action is rendered in a dialog, those
- * buttons might be moved to the dialog's footer.
- *
- * @param {jQuery Node} $node
+ * @override
*/
- renderButtons: function ($node) {
+ start: function () {
+ if (this._controlPanel) {
+ this._controlPanel.$el.prependTo(this.$el);
+ }
+ return this._super.apply(this, arguments);
},
});
diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js
index 80711fd24ed..ea8fdc24a18 100644
--- a/addons/web/static/src/js/chrome/abstract_web_client.js
+++ b/addons/web/static/src/js/chrome/abstract_web_client.js
@@ -60,10 +60,11 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi
},
load_filters: function (event) {
return data_manager
- .load_filters(event.data.dataset, event.data.action_id)
+ .load_filters(event.data)
.then(event.data.on_success);
},
create_filter: '_onCreateFilter',
+ delete_filter: '_onDeleteFilter',
push_state: '_onPushState',
show_effect: '_onShowEffect',
// session
@@ -218,7 +219,7 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi
this.action_manager = new ActionManager(this, session.user_context);
var fragment = document.createDocumentFragment();
return this.action_manager.appendTo(fragment).then(function () {
- dom.append(self.$('.o_main_content'), fragment, {
+ dom.append(self.$el, fragment, {
in_DOM: true,
callbacks: [{widget: self.action_manager}],
});
@@ -368,6 +369,18 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi
.then(e.data.on_success);
},
/**
+ * @private
+ * @param {OdooEvent} e
+ * @param {Object} e.data.filter the filter description
+ * @param {function} e.data.on_success called when the RPC succeeds with its
+ * returned value as argument
+ */
+ _onDeleteFilter: function (e) {
+ data_manager
+ .delete_filter(e.data.filterId)
+ .then(e.data.on_success);
+ },
+ /**
* Displays a warning in a dialog or with the notification service
*
* @private
diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js
index 18bbb6a3681..c798189c0a0 100644
--- a/addons/web/static/src/js/chrome/action_manager.js
+++ b/addons/web/static/src/js/chrome/action_manager.js
@@ -10,10 +10,8 @@ odoo.define('web.ActionManager', function (require) {
*/
var AbstractAction = require('web.AbstractAction');
-var Bus = require('web.Bus');
var concurrency = require('web.concurrency');
var Context = require('web.Context');
-var ControlPanel = require('web.ControlPanel');
var config = require('web.config');
var core = require('web.core');
var Dialog = require('web.Dialog');
@@ -24,7 +22,7 @@ var Widget = require('web.Widget');
var _t = core._t;
var ActionManager = Widget.extend({
- className: 'o_content',
+ className: 'o_action_manager',
custom_events: {
breadcrumb_clicked: '_onBreadcrumbClicked',
history_back: '_onHistoryBack',
@@ -64,17 +62,6 @@ var ActionManager = Widget.extend({
this.currentDialogController = null;
},
/**
- * @override
- */
- start: function () {
- // AAB: temporarily instantiate a unique main ControlPanel used by
- // controllers in the controllerStack
- this.controlPanel = new ControlPanel(this);
- var def = this.controlPanel.insertBefore(this.$el);
-
- return $.when(def, this._super.apply(this, arguments));
- },
- /**
* Called each time the action manager is attached into the DOM.
*/
on_attach_callback: function () {
@@ -266,14 +253,6 @@ var ActionManager = Widget.extend({
if (controller.scrollPosition) {
this.trigger_up('scrollTo', controller.scrollPosition);
}
-
- if (!controller.widget.need_control_panel) {
- this.controlPanel.do_hide();
- } else {
- this.controlPanel.update({
- breadcrumbs: this._getBreadcrumbs(),
- }, {clear: false});
- }
},
/**
* Closes the current dialog, if any. Because we listen to the 'closed'
@@ -330,19 +309,12 @@ var ActionManager = Widget.extend({
return this._executeActionInDialog(action, options);
}
+ var controller = self.controllers[action.controllerID];
return this.clearUncommittedChanges()
.then(function () {
- var controller = self.controllers[action.controllerID];
- var widget = controller.widget;
- // AAB: this will be moved to the Controller
- if (widget.need_control_panel) {
- // set the ControlPanel bus on the controller to allow it to
- // communicate its status
- widget.set_cp_bus(self.controlPanel.get_bus());
- }
return self.dp.add(self._startController(controller));
})
- .then(function (controller) {
+ .then(function () {
if (self.currentDialogController) {
self._closeDialog({ silent: true });
}
@@ -358,7 +330,7 @@ var ActionManager = Widget.extend({
}
// update the internal state and the DOM
- self._pushController(controller, options);
+ self._pushController(controller);
// toggle the fullscreen mode for actions in target='fullscreen'
self._toggleFullscreen();
@@ -387,12 +359,6 @@ var ActionManager = Widget.extend({
var self = this;
var controller = this.controllers[action.controllerID];
var widget = controller.widget;
- // AAB: this will be moved to the Controller
- if (widget.need_control_panel) {
- // set the ControlPanel bus on the controller to allow it to
- // communicate its status
- widget.set_cp_bus(new Bus());
- }
return this._startController(controller).then(function (controller) {
var prevDialogOnClose;
@@ -472,20 +438,18 @@ var ActionManager = Widget.extend({
}
var controllerID = _.uniqueId('controller_');
+
+ var index = this._getControllerStackIndex(options);
+ options.breadcrumbs = this._getBreadcrumbs(this.controllerStack.slice(0, index));
options.controllerID = controllerID;
+ var widget = new ClientAction(this, action, options);
var controller = {
actionID: action.jsID,
+ index: index,
jsID: controllerID,
- widget: new ClientAction(this, action, options),
+ title: widget.getTitle(),
+ widget: widget,
};
- // AAB: TODO: simplify this with AbstractAction (implement a getTitle
- // function that returns action.name by default, and that can be
- // overriden in client actions and view controllers)
- Object.defineProperty(controller, 'title', {
- get: function () {
- return controller.widget.get('title') || action.display_name || action.name;
- },
- });
this.controllers[controllerID] = controller;
action.controllerID = controllerID;
return this._executeAction(action, options).done(function () {
@@ -586,34 +550,52 @@ var ActionManager = Widget.extend({
return $.when();
},
/**
- * Returns a description of the current stack of controllers, used to render
- * the breadcrumbs. It is an array of Objects with keys 'title' (what to
- * display in the breadcrumbs) and 'controllerID' (the ID of the
- * corresponding controller, used to restore it when this part of the
+ * Returns a description of the controllers in the given controller stack.
+ * It is used to render the breadcrumbs. It is an array of Objects with keys
+ * 'title' (what to display in the breadcrumbs) and 'controllerID' (the ID
+ * of the corresponding controller, used to restore it when this part of the
* breadcrumbs is clicked).
- * Ignores the content of the stack of controllers if the action of the
- * last controller of the stack is flagged with 'no_breadcrumbs', indicating
- * that the breadcrumbs should not be displayed for that action.
*
* @private
+ * @param {string[]} controllerStack
* @returns {Object[]}
*/
- _getBreadcrumbs: function () {
+ _getBreadcrumbs: function (controllerStack) {
var self = this;
- var currentController = this.getCurrentController();
- var noBreadcrumbs = !currentController ||
- this.actions[currentController.actionID].context.no_breadcrumbs;
- if (noBreadcrumbs) {
- return [];
- }
- return _.map(this.controllerStack, function (controllerID) {
+ return _.map(controllerStack, function (controllerID) {
return {
- title: self.controllers[controllerID].title,
controllerID: controllerID,
+ title: self.controllers[controllerID].title,
};
});
},
/**
+ * Returns the index where a controller should be inserted in the controller
+ * stack according to the given options. By default, a controller is pushed
+ * on the top of the stack.
+ *
+ * @private
+ * @param {options} [options.clear_breadcrumbs=false] if true, insert at
+ * index 0 and remove all other controllers
+ * @param {options} [options.index=null] if given, that index is returned
+ * @param {options} [options.replace_last_action=false] if true, replace the
+ * last controller of the stack
+ * @returns {integer} index
+ */
+ _getControllerStackIndex: function (options) {
+ var index;
+ if ('index' in options) {
+ index = options.index;
+ } else if (options.clear_breadcrumbs) {
+ index = 0;
+ } else if (options.replace_last_action) {
+ index = this.controllerStack.length - 1;
+ } else {
+ index = this.controllerStack.length;
+ }
+ return index;
+ },
+ /**
* Returns an object containing information about the given controller, like
* its title, its action's id, the active_id and active_ids of the action...
*
@@ -625,7 +607,7 @@ var ActionManager = Widget.extend({
var controller = this.controllers[controllerID];
var action = this.actions[controller.actionID];
var state = {
- title: controller.title,
+ title: controller.widget.getTitle(),
};
if (action.id) {
state.action = action.id;
@@ -706,41 +688,28 @@ var ActionManager = Widget.extend({
* @param {Object} controller
* @param {string} controller.jsID
* @param {Widget} controller.widget
- * @param {Object} [options]
- * @param {Object} [options.clear_breadcrumbs=false] if true, destroys all
- * controllers from the controller stack before adding the given one
- * @param {Object} [options.replace_last_action=false] if true, replaces the
- * last controller of the controller stack by the given one
- * @param {integer} [options.index] if given, pushes the controller at that
- * position in the controller stack, and destroys the controllers with an
- * higher index
- */
- _pushController: function (controller, options) {
- options = options || {};
+ * @param {integer} controller.index the controller is pushed at that
+ * position in the controller stack and controllers with an higher index
+ * are destroyed
+ */
+ _pushController: function (controller) {
var self = this;
// detach the current controller
this._detachCurrentController();
- // empty the controller stack or replace the last controller as requested,
- // destroy the removed controllers and push the new controller to the stack
- var toDestroy;
- if (options.clear_breadcrumbs) {
- toDestroy = this.controllerStack;
- this.controllerStack = [];
- } else if (options.replace_last_action && this.controllerStack.length > 0) {
- toDestroy = [this.controllerStack.pop()];
- } else if (options.index !== undefined) {
- toDestroy = this.controllerStack.splice(options.index);
- // reject from the list of controllers to destroy the one that we are
- // currently pushing, or those linked to the same action as the one
- // linked to the controller that we are pushing
- toDestroy = _.reject(toDestroy, function (controllerID) {
- return controllerID === controller.jsID ||
- self.controllers[controllerID].actionID === controller.actionID;
- });
- }
+ // push the new controller to the stack at the given position, and
+ // destroy controllers with an higher index
+ var toDestroy = this.controllerStack.slice(controller.index);
+ // reject from the list of controllers to destroy the one that we are
+ // currently pushing, or those linked to the same action as the one
+ // linked to the controller that we are pushing
+ toDestroy = _.reject(toDestroy, function (controllerID) {
+ return controllerID === controller.jsID ||
+ self.controllers[controllerID].actionID === controller.actionID;
+ });
this._removeControllers(toDestroy);
+ this.controllerStack = this.controllerStack.slice(0, controller.index);
this.controllerStack.push(controller.jsID);
// append the new controller to the DOM
@@ -861,7 +830,7 @@ var ActionManager = Widget.extend({
return $.when(def).then(function () {
return $.when(controller.widget.do_show()).then(function () {
var index = _.indexOf(self.controllerStack, controllerID);
- self._pushController(controller, {index: index});
+ self._pushController(controller, index);
});
});
},
@@ -878,19 +847,7 @@ var ActionManager = Widget.extend({
* @returns {Deferred<Object>} resolved with the controller when it is ready
*/
_startController: function (controller) {
- var self = this;
var fragment = document.createDocumentFragment();
- // AAB: change this logic to stop using the properties mixin
- controller.widget.on("change:title", this, function () {
- if (self.getCurrentController() !== controller) {
- return;
- }
- var action = self.actions[controller.actionID];
- if (!action.flags || !action.flags.headless) {
- var breadcrumbs = self._getBreadcrumbs();
- self.controlPanel.update({breadcrumbs: breadcrumbs}, {clear: false});
- }
- });
return controller.widget.appendTo(fragment).then(function () {
return controller;
});
diff --git a/addons/web/static/src/js/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js
index e66ae801bc1..1a688ffa6b2 100644
--- a/addons/web/static/src/js/chrome/action_manager_act_window.js
+++ b/addons/web/static/src/js/chrome/action_manager_act_window.js
@@ -10,21 +10,12 @@ var ActionManager = require('web.ActionManager');
var config = require('web.config');
var Context = require('web.Context');
var core = require('web.core');
-var data = require('web.data'); // this will be removed at some point
var pyUtils = require('web.py_utils');
-var SearchView = require('web.SearchView');
var view_registry = require('web.view_registry');
-var _t = core._t;
-
ActionManager.include({
custom_events: _.extend({}, ActionManager.prototype.custom_events, {
- env_updated: '_onEnvUpdated',
execute_action: '_onExecuteAction',
- get_controller_context: '_onGetControllerContext',
- navigation_move: '_onNavigationMove',
- update_filters: '_onUpdateFilters',
- search: '_onSearch',
switch_view: '_onSwitchView',
}),
@@ -33,6 +24,26 @@ ActionManager.include({
//--------------------------------------------------------------------------
/**
+ * Override to handle the case of lazy-loaded controllers, which may be the
+ * last controller in the stack, but which should not be considered as
+ * current controller as they don't have an alive widget.
+ *
+ * Note: this function assumes that there can be at most one lazy loaded
+ * controller in the stack
+ *
+ * @override
+ */
+ getCurrentController: function () {
+ var currentController = this._super.apply(this, arguments);
+ var action = currentController && this.actions[currentController.actionID];
+ if (action && action.type === 'ir.actions.act_window' && !currentController.widget) {
+ var lastControllerID = this.controllerStack.pop();
+ currentController = this._super.apply(this, arguments);
+ this.controllerStack.push(lastControllerID);
+ }
+ return currentController;
+ },
+ /**
* Overrides to handle the case where an 'ir.actions.act_window' has to be
* loaded.
*
@@ -59,9 +70,9 @@ ActionManager.include({
currentAction.type === 'ir.actions.act_window') {
// the action to load is already the current one, so update it
this._closeDialog(true); // there may be a currently opened dialog, close it
- currentAction.env.currentId = state.id;
+ var viewOptions = {currentId: state.id};
var viewType = state.view_type || currentController.viewType;
- return this._switchController(currentAction, viewType);
+ return this._switchController(currentAction, viewType, viewOptions);
} else if (!core.action_registry.contains(state.action)) {
// the action to load isn't the current one, so execute it
var context = {};
@@ -114,59 +125,6 @@ ActionManager.include({
//--------------------------------------------------------------------------
/**
- * Instantiates a search view for a given action, and starts it so that it
- * is ready to be appended to the DOM.
- *
- * @private
- * @param {Object} action
- * @returns {Deferred} resolved with the search view when it is ready
- */
- _createSearchView: function (action) {
- // if requested, keep the searchview of the current action instead of
- // creating a new one
- if (action._keepSearchView) {
- var currentAction = this.getCurrentAction();
- if (currentAction) {
- action.searchView = currentAction.searchView;
- action.env = currentAction.env; // make those actions share the same env
- return $.when(currentAction.searchView);
- } else {
- // there is not searchview to keep, so reset the flag to false
- // to ensure that the one that will be created will be correctly
- // destroyed
- action._keepSearchView = false;
- }
- }
-
- // AAB: temporarily create a dataset, until the SearchView is refactored
- // and stops using it
- var dataset = new data.DataSetSearch(this, action.res_model, action.context, action.domain);
- if (action.res_id) {
- dataset.ids.push(action.res_id);
- dataset.index = 0;
- }
-
- // find 'search_default_*' keys in actions's context
- var searchDefaults = {};
- _.each(action.context, function (value, key) {
- var match = /^search_default_(.*)$/.exec(key);
- if (match) {
- searchDefaults[match[1]] = value;
- }
- });
- var searchView = new SearchView(this, dataset, action.searchFieldsView, {
- $buttons: $('<div>'),
- action: action,
- disable_custom_filters: action.flags.disableCustomFilters,
- search_defaults: searchDefaults,
- });
-
- return searchView.appendTo(document.createDocumentFragment()).then(function () {
- action.searchView = searchView;
- return searchView;
- });
- },
- /**
* Instantiates the controller for a given action and view type, and adds it
* to the list of controllers in the action.
*
@@ -181,6 +139,9 @@ ActionManager.include({
* @param {Object} [viewOptions] dict of options passed to the initialization
* of the controller's widget
* @param {Object} [options]
+ * @param {string} [options.controllerID=false] when the controller has
+ * previously been lazy-loaded, we want to keep its jsID when loading it
+ * @param {integer} [options.index=0] the controller's index in the stack
* @param {boolean} [options.lazy=false] set to true to differ the
* initialization of the controller's widget
* @returns {Deferred<Object>} resolved with the created controller
@@ -195,31 +156,36 @@ ActionManager.include({
return $.Deferred().reject();
}
- var controllerID = _.uniqueId('controller_');
+ options = options || {};
+ var index = options.index || 0;
+ var controllerID = options.controllerID || _.uniqueId('controller_');
var controller = {
actionID: action.jsID,
className: 'o_act_window', // used to remove the padding in dialogs
+ index: index,
jsID: controllerID,
viewType: viewType,
};
Object.defineProperty(controller, 'title', {
get: function () {
// handle the case where the widget is lazy loaded
- return controller.widget ? controller.widget.getTitle() : (action.display_name || action.name);
+ return controller.widget ?
+ controller.widget.getTitle() :
+ (action.display_name || action.name);
},
});
this.controllers[controllerID] = controller;
- if (!options || !options.lazy) {
+ if (!options.lazy) {
// build the view options from different sources
- viewOptions = _.extend({
+ var flags = action.flags || {};
+ viewOptions = _.extend({}, flags, flags[viewType], viewOptions, {
action: action,
- limit: action.limit,
- }, action.flags, action.flags[viewType], viewOptions, action.env);
- // pass the controllerID to the views as an hook for further
- // communication with trigger_up (e.g. for 'env_updated' event)
- viewOptions = _.extend(viewOptions, { controllerID: controllerID });
-
+ breadcrumbs: this._getBreadcrumbs(this.controllerStack.slice(0, index)),
+ // pass the controllerID to the views as an hook for further
+ // communication with trigger_up
+ controllerID: controllerID,
+ });
var view = new viewDescr.Widget(viewDescr.fieldsView, viewOptions);
var def = $.Deferred();
action.controllers[viewType] = def;
@@ -263,9 +229,6 @@ ActionManager.include({
// its reference is removed
controllerDef.reject();
});
- if (action.searchView && !action._keepSearchView) {
- action.searchView.destroy();
- }
},
/**
* Executes actions of type 'ir.actions.act_window'.
@@ -280,75 +243,64 @@ ActionManager.include({
*/
_executeWindowAction: function (action, options) {
var self = this;
-
- if (action.context.active_id || action.context.active_ids) {
- // we assume that when 'active_id' or 'active_ids' is used in the
- // context, we are in a 'related' action, so we disable the
- // searchview's default custom filters
- action.context.search_disable_custom_filters = true;
- }
- action.flags = this._generateActionFlags(action);
-
return this.dp.add(this._loadViews(action)).then(function (fieldsViews) {
var views = self._generateActionViews(action, fieldsViews);
action._views = action.views; // save the initial attribute
action.views = views;
- if (fieldsViews.search) {
- action.searchFieldsView = fieldsViews.search;
- }
- action.env = self._generateActionEnv(action, options);
+ action.controlPanelFieldsView = fieldsViews.search;
action.controllers = {};
- // select the first view to display, and optionally the main view
- // which will be lazyloaded
- var firstView = options.viewType && _.findWhere(views, {type: options.viewType});
- var mainView;
- if (firstView) {
- if (!firstView.multiRecord && views[0].multiRecord) {
- mainView = views[0];
+ // select the current view to display, and optionally the main view
+ // of the action which will be lazyloaded
+ var curView = options.viewType && _.findWhere(views, {type: options.viewType});
+ var lazyView;
+ if (curView) {
+ if (!curView.multiRecord && views[0].multiRecord) {
+ lazyView = views[0];
}
} else {
- firstView = views[0];
+ curView = views[0];
}
// use mobile-friendly view by default in mobile, if possible
if (config.device.isMobile) {
- if (!firstView.isMobileFriendly) {
- firstView = self._findMobileView(views, firstView.multiRecord) || firstView;
+ if (!curView.isMobileFriendly) {
+ curView = self._findMobileView(views, curView.multiRecord) || curView;
}
- if (mainView && !mainView.isMobileFriendly) {
- mainView = self._findMobileView(views, mainView.multiRecord) || mainView;
+ if (lazyView && !lazyView.isMobileFriendly) {
+ lazyView = self._findMobileView(views, lazyView.multiRecord) || lazyView;
}
}
- var def;
- if (action.flags.hasSearchView) {
- def = self._createSearchView(action).then(function (searchView) {
- // udpate domain, context and groupby in the env
- var searchData = searchView.build_search_data();
- _.extend(action.env, self._processSearchData(action, searchData));
- });
+ var lazyViewDef;
+ if (lazyView) {
+ // if the main view is lazy-loaded, its (lazy-loaded) controller is inserted
+ // into the controller stack (so that breadcrumbs can be correctly computed),
+ // so we force clear_breadcrumbs to false so that it won't be removed when the
+ // current controller will be inserted afterwards
+ options.clear_breadcrumbs = false;
+ // this controller being lazy-loaded, this call is actually sync
+ lazyViewDef = self._createViewController(action, lazyView.type, {}, {lazy: true})
+ .then(function (lazyLoadedController) {
+ self.controllerStack.push(lazyLoadedController.jsID);
+ });
}
- return $.when(def).then(function () {
- var defs = [];
- defs.push(self._createViewController(action, firstView.type));
- if (mainView) {
- defs.push(self._createViewController(action, mainView.type, {}, {lazy: true}));
- }
- return self.dp.add($.when.apply($, defs));
- }).then(function (controller, lazyLoadedController) {
- action.controllerID = controller.jsID;
- return self._executeAction(action, options).done(function () {
- if (lazyLoadedController) {
- // controller should be placed just before the current one
- var index = self.controllerStack.length - 1;
- self.controllerStack.splice(index, 0, lazyLoadedController.jsID);
- self.controlPanel.update({
- breadcrumbs: self._getBreadcrumbs(),
- }, {clear: false});
- }
- });
- }).fail(self._destroyWindowAction.bind(self, action));
+ return self.dp.add($.when(lazyViewDef))
+ .then(function () {
+ var viewOptions = {
+ controllerState: options.controllerState,
+ currentId: options.resID,
+ };
+ var curViewDef = self._createViewController(action, curView.type, viewOptions, {
+ index: self._getControllerStackIndex(options),
+ });
+ return self.dp.add(curViewDef);
+ })
+ .then(function (controller) {
+ action.controllerID = controller.jsID;
+ return self._executeAction(action, options);
+ })
+ .fail(self._destroyWindowAction.bind(self, action));
});
},
/**
@@ -368,49 +320,6 @@ ActionManager.include({
});
},
/**
- * Generates the initial environment of a given action, which is a dict
- * containing information like the model name, the domain, the context...
- * That information is shared between the controllers of the corresponding
- * action.
- *
- * @private
- * @param {Object} action
- * @param {Object} options
- * @param {integer} [options.resID]
- * @returns {Object}
- */
- _generateActionEnv: function (action, options) {
- var resID = options.resID || action.res_id;
- return {
- modelName: action.res_model,
- ids: resID ? [resID] : undefined,
- currentId: resID || undefined,
- domain: [],
- context: action.context || {},
- groupBy: [],
- };
- },
- /**
- * Generates the flags of a given action.
- *
- * @private
- * @param {Object} action
- * @returns {Object}
- */
- _generateActionFlags: function (action) {
- var popup = action.target === 'new';
- var inline = action.target === 'inline';
- var form = action.views[0][1] === 'form';
- return _.defaults({}, action.flags, {
- disableCustomFilters: action.context && action.context.search_disable_custom_filters,
- footerToButtons: popup,
- hasSearchView: !(popup && form) && !inline,
- hasSidebar: !popup && !inline,
- headless: (popup || inline) && form,
- mode: (popup || inline || action.target === 'fullscreen') && 'edit',
- });
- },
- /**
* Generate the description of the views of a given action. For each view,
* it generates a dict with information like the fieldsView, the view type,
* the Widget to use...
@@ -483,12 +392,14 @@ ActionManager.include({
* @returns {Deferred}
*/
_loadViews: function (action) {
+ var inDialog = action.target === 'new';
+ var inline = action.target === 'inline';
var options = {
action_id: action.id,
- toolbar: action.flags.hasSidebar,
+ toolbar: inDialog && inline,
};
var views = action.views.slice();
- if (action.flags.hasSearchView) {
+ if (!inline && !(inDialog && action.views[0][1] === 'form')) {
options.load_filters = true;
var searchviewID = action.search_view_id && action.search_view_id[0];
views.push([searchviewID || false, 'search']);
@@ -496,60 +407,6 @@ ActionManager.include({
return this.loadViews(action.res_model, action.context, views, options);
},
/**
- * Overrides to handle the 'keepSearchView' option. If set to true, the
- * search view of the current action will be re-used in the new action, i.e.
- * the environment (domain, context, groupby) will be shared between both
- * actions.
- *
- * @override
- */
- _preprocessAction: function (action, options) {
- this._super.apply(this, arguments);
- if (action.type === 'ir.actions.act_window' && options.keepSearchView) {
- action._keepSearchView = true;
- }
- },
- /**
- * Processes the search data sent by the search view.
- *
- * @private
- * @param {Object} action
- * @param {Object} searchData
- * @param {Object} [searchData.contexts=[]]
- * @param {Object} [searchData.domains=[]]
- * @param {Object} [searchData.groupbys=[]]
- * @returns {Object} an object with keys 'context', 'domain', 'groupBy'
- */
- _processSearchData: function (action, searchData) {
- var contexts = searchData.contexts;
- var domains = searchData.domains;
- var groupbys = searchData.groupbys;
- var action_context = action.context || {};
- var results = pyUtils.eval_domains_and_contexts({
- domains: [action.domain || []].concat(domains || []),
- contexts: [action_context].concat(contexts || []),
- group_by_seq: groupbys || [],
- eval_context: this.userContext,
- });
- var groupBy = results.group_by.length ?
- results.group_by :
- (action.context.group_by || []);
- groupBy = (typeof groupBy === 'string') ? [groupBy] : groupBy;
-
- if (results.error) {
- throw new Error(_.str.sprintf(_t("Failed to evaluate search criterions")+": \n%s",
- JSON.stringify(results.error)));
- }
-
- var context = _.omit(results.context, 'time_ranges');
-
- return {
- context: context,
- domain: results.domain,
- groupBy: groupBy,
- };
- },
- /**
* Overrides to handle the case of 'ir.actions.act_window' actions, i.e.
* destroys all controllers associated to the given action, and its search
* view.
@@ -598,8 +455,8 @@ ActionManager.include({
return this._super.apply(this, arguments);
},
/**
- * Handles the switch from a controller to another inside the same window
- * action.
+ * Handles the switch from a controller to another (either inside the same
+ * window action, or from a window action to another using the breadcrumbs).
*
* @private
* @param {Object} controller the controller to switch to
@@ -608,18 +465,55 @@ ActionManager.include({
*/
_switchController: function (action, viewType, viewOptions) {
var self = this;
+ var view = _.findWhere(action.views, {type: viewType});
+ if (!view) {
+ // can't switch to an unknown view
+ return $.Deferred().reject();
+ }
+ var currentController = this.getCurrentController();
+ var index;
+ if (currentController.actionID !== action.jsID) {
+ // the requested controller is from another action, so we went back
+ // to a previous action using the breadcrumbs
+ var controller = _.findWhere(this.controllers, {
+ actionID: action.jsID,
+ viewType: viewType,
+ });
+ index = _.indexOf(this.controllerStack, controller.jsID);
+ } else {
+ // the requested controller is from the same action as the current
+ // one, so we either
+ // 1) go one step back from a mono record view to a multi record
+ // one using the breadcrumbs
+ // 2) or we switched from a view to another using the view
+ // switcher
+ // 3) or we opened a record from a multi record view
+ if (view.multiRecord) {
+ // cases 1) and 2) (with multi record views): replace the first
+ // controller linked to the same action in the stack
+ index = _.findIndex(this.controllerStack, function (controllerID) {
+ return self.controllers[controllerID].actionID === action.jsID;
+ });
+ } else if (!_.findWhere(action.views, {type: currentController.viewType}).multiRecord) {
+ // case 2) (with mono record views): replace the last
+ // controller by the new one if they are from the same action
+ // and if they both are mono record
+ index = this.controllerStack.length - 1;
+ } else {
+ // case 3): insert the controller on the top of the controller
+ // stack
+ index = this.controllerStack.length;
+ }
+ }
- var newController = function () {
+ var newController = function (controllerID) {
+ var options = {
+ controllerID: controllerID,
+ index: index,
+ };
return self
- ._createViewController(action, viewType, viewOptions)
+ ._createViewController(action, viewType, viewOptions, options)
.then(function (controller) {
- // AAB: this will be moved to the Controller
- var widget = controller.widget;
- if (widget.need_control_panel) {
- // set the ControlPanel bus on the controller to allow it to
- // communicate its status
- widget.set_cp_bus(self.controlPanel.get_bus());
- }
return self._startController(controller);
});
};
@@ -637,15 +531,9 @@ ActionManager.include({
} else {
controllerDef = controllerDef.then(function (controller) {
if (!controller.widget) {
- // lazy loaded -> load it now
- return newController().done(function (newController) {
- // replace the old controller (without widget) by the new one
- var index = self.controllerStack.indexOf(controller.jsID);
- self.controllerStack[index] = newController.jsID;
- delete self.controllers[controller.jsID];
- });
+ // lazy loaded -> load it now (with same jsID)
+ return newController(controller.jsID);
} else {
- viewOptions = _.extend(viewOptions || {}, action.env);
return $.when(controller.widget.willRestore()).then(function () {
return controller.widget.reload(viewOptions).then(function () {
return controller;
@@ -656,22 +544,7 @@ ActionManager.include({
}
return this.dp.add(controllerDef).then(function (controller) {
- var view = _.findWhere(action.views, {type: viewType});
- var currentController = self.getCurrentController();
- var index;
- if (currentController.actionID !== action.jsID) {
- index = _.indexOf(self.controllerStack, controller.jsID);
- } else if (view.multiRecord) {
- // remove other controllers linked to the same action from the stack
- index = _.findIndex(self.controllerStack, function (controllerID) {
- return self.controllers[controllerID].actionID === action.jsID;
- });
- } else if (!_.findWhere(action.views, {type: currentController.viewType}).multiRecord) {
- // replace the last controller by the new one if they are from the
- // same action and if they both are mono record
- index = self.controllerStack.length - 1;
- }
- return self._pushController(controller, {index: index});
+ return self._pushController(controller);
});
},
@@ -680,18 +553,6 @@ ActionManager.include({
//--------------------------------------------------------------------------
/**
- * @private
- * @param {OdooEvent} ev
- * @param {string} ev.data.controllerID
- * @param {Object} ev.data.env
- */
- _onEnvUpdated: function (ev) {
- ev.stopPropagation();
- var controller = this.controllers[ev.data.controllerID];
- var action = this.actions[controller.actionID];
- _.extend(action.env, ev.data.env);
- },
- /**
* Handler for event 'execute_action', which is typically called when a
* button is clicked. The button may be of type 'object' (call a given
* method of a given model) or 'action' (execute a given action).
@@ -803,67 +664,6 @@ ActionManager.include({
});
},
/**
- * Handles a context request: provides to the caller the context of the
- * current controller.
- *
- * @private
- * @param {OdooEvent} ev
- * @param {function} ev.data.callback used to send the requested context
- */
- _onGetControllerContext: function (ev) {
- ev.stopPropagation();
- var currentController = this.getCurrentController();
- var context = currentController && currentController.widget.getContext();
- ev.data.callback(context || {});
- },
- /**
- * Called mainly from the control panel when the focus should be given to a
- * controller
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onNavigationMove : function (ev) {
- switch(ev.data.direction) {
- case 'down' :
- var currentController = this.getCurrentController().widget;
- currentController.giveFocus();
- ev.stopPropagation();
- break;
- }
- },
- /**
- * Handles a request to add/remove search view filters.
- *
- * @param {OdooEvent} ev
- * @param {string} ev.data.controllerID
- * @param {Array[Object]} [ev.data.newFilters]
- * @param {Array[Object]} [ev.data.filtersToRemove]
- * @param {function} ev.data.callback called with the added filters as arg
- */
- _onUpdateFilters: function (ev) {
- var controller = this.controllers[ev.data.controllerID];
- var action = this.actions[controller.actionID];
- var data = ev.data;
- var addedFilters = action.searchView.updateFilters(data.newFilters, data.filtersToRemove);
- data.callback(addedFilters);
- },
- /**
- * Called when there is a change in the search view, so the current action's
- * environment needs to be updated with the new domain, context and groupby.
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onSearch: function (ev) {
- ev.stopPropagation();
- // AAB: the id of the correct controller should be given in data
- var currentController = this.getCurrentController();
- var action = this.actions[currentController.actionID];
- _.extend(action.env, this._processSearchData(action, ev.data));
- currentController.widget.reload(_.extend({offset: 0}, action.env));
- },
- /**
* @private
* @param {OdooEvent} ev
* @param {string} ev.data.controllerID the id of the controller that
@@ -882,13 +682,11 @@ ActionManager.include({
// only switch to the requested view if the controller that
// triggered the request is the current controller
var action = this.actions[currentController.actionID];
- if ('res_id' in ev.data) {
- action.env.currentId = ev.data.res_id;
- }
- var options = {};
- if (viewType === 'form' && !action.env.currentId) {
- options.mode = 'edit';
- } else if (ev.data.mode) {
+ var options = {
+ controllerState: currentController.widget.exportState(),
+ currentId: ev.data.res_id,
+ };
+ if (ev.data.mode) {
options.mode = ev.data.mode;
}
this._switchController(action, viewType, options);
diff --git a/addons/web/static/src/js/chrome/action_mixin.js b/addons/web/static/src/js/chrome/action_mixin.js
new file mode 100644
index 00000000000..099244592ad
--- /dev/null
+++ b/addons/web/static/src/js/chrome/action_mixin.js
@@ -0,0 +1,182 @@
+odoo.define('web.ActionMixin', function (require) {
+"use strict";
+
+/**
+ * We define here the ActionMixin, the generic notion of action (from the point
+ * of view of the web client). In short, an action is a widget which controls
+ * the main part of the screen (everything below the navbar).
+ *
+ * More precisely, the action manager is the component that coordinates a stack
+ * of actions. Whenever the user navigates in the interface, switches views,
+ * open different menus, the action manager creates/updates/destroys special
+ * widgets which implements the ActionMixin. These actions need to answer to a
+ * standardised API, which is the reason for this mixin.
+ *
+ * In practice, most actions are view controllers (coming from an
+ * ir.action.act_window). However, some actions are 'client actions'. They
+ * also need to implement the ActionMixin for a better cooperation with the
+ * action manager.
+ *
+ * @module web.ActionMixin
+ */
+
+var core = require('web.core');
+
+var ActionMixin = {
+ template: 'Action',
+
+ /**
+ * The action mixin assumes that it is rendered with the 'Action' template.
+ * This template has a special zone ('.o_content') where the content should
+ * be added. Actions that want to automatically render a template there
+ * should define the contentTemplate key. In short, client actions should
+ * probably define a contentTemplate key, and not a template key.
+ */
+ contentTemplate: null,
+
+ /**
+ * If an action wants to use a control panel, it will be created and
+ * registered in this _controlPanel key (the widget). The way this control
+ * panel is created is up to the implementation (so, view controllers or
+ * client actions may have different needs).
+ *
+ * Note that most of the time, this key should be set by the framework, not
+ * by the code of the client action.
+ */
+ _controlPanel: null,
+
+ /**
+ * String containing the title of the client action (which may be needed to
+ * display in the breadcrumbs zone of the control panel).
+ *
+ * @see _setTitle
+ */
+ _title: '',
+
+ /**
+ * @override
+ */
+ renderElement: function () {
+ this._super.apply(this, arguments);
+ if (this.contentTemplate) {
+ var content = core.qweb.render(this.contentTemplate, {widget: this});
+ this.$('.o_content').append(content);
+ }
+ },
+ /**
+ * Called each time the action is attached into the DOM.
+ */
+ on_attach_callback: function () {},
+ /**
+ * Called each time the action is detached from the DOM.
+ */
+ on_detach_callback: function () {},
+ /**
+ * Called by the action manager when action is restored (typically, when the
+ * user clicks on the action in the breadcrumb)
+ *
+ * @returns {Deferred|undefined}
+ */
+ willRestore: function () {},
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * In some situations, we need confirmation from the controller that the
+ * current state can be destroyed without prejudice to the user. For
+ * example, if the user has edited a form, maybe we should ask him if we
+ * can discard all his changes when we switch to another action. In that
+ * case, the action manager will call this method. If the returned
+ * deferred is succesfully resolved, then we can destroy the current action,
+ * otherwise, we need to stop.
+ *
+ * @returns {Deferred} resolved if the action can be removed, rejected
+ * otherwise
+ */
+ canBeRemoved: function () {
+ return $.when();
+ },
+ /**
+ * This function is called when the current state of the action
+ * should be known. For instance, if the action is a view controller,
+ * this may be useful to reinstantiate a view in the same state.
+ * Typically the state can (and should) be encoded in a query object of
+ * the form
+ * {
+ * context: {...},
+ * groupBy: [...],
+ * domain = [...],
+ * orderedBy = [...],
+ * }
+ * where the context key can contain many information.
+ * This method is mainly called during the creation of a custom filter.
+ *
+ * @returns {Object}
+ */
+ getOwnedQueryParams: function () {
+ return {};
+ },
+ /**
+ * Returns a title that may be displayed in the breadcrumb area. For
+ * example, the name of the record (for a form view). This is actually
+ * important for the action manager: this is the way it is able to give
+ * the proper titles for other actions.
+ *
+ * @returns {string}
+ */
+ getTitle: function () {
+ return this._title;
+ },
+ /**
+ * Gives the focus to the action
+ */
+ giveFocus: function () {
+ },
+ /**
+ * Renders the buttons to append, in most cases, to the control panel (in
+ * the bottom left corner). When the action is rendered in a dialog, those
+ * buttons might be moved to the dialog's footer.
+ *
+ * @param {jQuery Node} $node
+ */
+ renderButtons: function ($node) {
+ },
+ /**
+ * This is the main method to customize the controlpanel content.
+ *
+ * @see updateContents method in ControlPanelRenderer for more info
+ *
+ * @param {Object} [status]
+ * @param {string} [status.title]
+ * @param {Object} [options]
+ * @param {boolean} [options.clear]
+ */
+ updateControlPanel: function (status, options) {
+ if (this._controlPanel) {
+ status = status || {};
+ status.title = status.title || this.getTitle();
+ this._controlPanel.updateContents(status, options || {});
+ }
+ },
+ // TODO: add hooks methods:
+ // - onRestoreHook (on_reverse_breadcrumbs)
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} title
+ */
+ _setTitle: function (title) {
+ this._title = title;
+ this.updateControlPanel({title: this._title}, {clear: false});
+ },
+};
+
+return ActionMixin;
+
+});
diff --git a/addons/web/static/src/js/chrome/control_panel.js b/addons/web/static/src/js/chrome/control_panel.js
deleted file mode 100644
index 5d0601820d0..00000000000
--- a/addons/web/static/src/js/chrome/control_panel.js
+++ /dev/null
@@ -1,286 +0,0 @@
-odoo.define('web.ControlPanelMixin', function (require) {
-"use strict";
-
-/**
- * Mixin allowing widgets to communicate with the ControlPanel. Widgets needing a
- * ControlPanel should use this mixin and call update_control_panel(cp_status) where
- * cp_status contains information for the ControlPanel to update itself.
- *
- * Note that the API is slightly awkward. Hopefully we will improve this when
- * we get the time to refactor the control panel.
- *
- * For example, here is what a typical client action would need to do to add
- * support for a control panel with some buttons::
- *
- * var ControlPanelMixin = require('web.ControlPanelMixin');
- *
- * var SomeClientAction = Widget.extend(ControlPanelMixin, {
- * ...
- * start: function () {
- * this._renderButtons();
- * this._updateControlPanel();
- * ...
- * },
- * do_show: function () {
- * ...
- * this._updateControlPanel();
- * },
- * _renderButtons: function () {
- * this.$buttons = $(QWeb.render('SomeTemplate.Buttons'));
- * this.$buttons.on('click', ...);
- * },
- * _updateControlPanel: function () {
- * this.update_control_panel({
- * cp_content: {
- * $buttons: this.$buttons,
- * },
- * });
- */
-var ControlPanelMixin = {
- need_control_panel: true,
- /**
- * @param {web.Bus} [cp_bus] Bus to communicate with the ControlPanel
- */
- set_cp_bus: function(cp_bus) {
- this.cp_bus = cp_bus;
- },
- /**
- * Triggers 'update' on the cp_bus to update the ControlPanel according to cp_status
- * @param {Object} [cp_status] see web.ControlPanel.update() for a description
- * @param {Object} [options] see web.ControlPanel.update() for a description
- */
- update_control_panel: function(cp_status, options) {
- if (this.cp_bus) {
- this.cp_bus.trigger("update", cp_status || {}, options || {});
- }
- },
-};
-
-return ControlPanelMixin;
-
-});
-
-odoo.define('web.ControlPanel', function (require) {
-"use strict";
-
-var Bus = require('web.Bus');
-var data = require('web.data');
-var Widget = require('web.Widget');
-
-var ControlPanel = Widget.extend({
- template: 'ControlPanel',
- /**
- * @param {String} [template] the QWeb template to render the ControlPanel.
- * By default, the template 'ControlPanel' will be used
- */
- init: function(parent, template) {
- this._super(parent);
- if (template) {
- this.template = template;
- }
-
- this.bus = new Bus(this);
- // the updateIndex is used to prevent concurrent updates of the control
- // panel depending on asynchronous code to be executed in the wrong order
- this.bus.updateIndex = 0;
- this.bus.on("update", this, this.update);
- },
- /**
- * Renders the control panel and creates a dictionnary of its exposed elements
- * @return {jQuery.Deferred}
- */
- start: function() {
- // Exposed jQuery nodesets
- this.nodes = {
- $buttons: this.$('.o_cp_buttons'),
- $pager: this.$('.o_cp_pager'),
- $searchview: this.$('.o_cp_searchview'),
- $searchview_buttons: this.$('.o_search_options'),
- $sidebar: this.$('.o_cp_sidebar'),
- $switch_buttons: this.$('.o_cp_switch_buttons'),
- };
-
- // Prevent the search dropdowns to close when clicking inside them
- this.$el.on('click.bs.dropdown', '.o_search_options .dropdown-menu', function (e) {
- e.stopPropagation();
- });
-
- // By default, hide the ControlPanel and remove its contents from the DOM
- this._toggle_visibility(false);
-
- return this._super();
- },
- /**
- * @return {Object} the Bus the ControlPanel is listening on
- */
- get_bus: function() {
- return this.bus;
- },
- /**
- * Updates the content and displays the ControlPanel
- * @param {Object} [status.active_view] the current active view
- * @param {Array} [status.breadcrumbs] the breadcrumbs to display (see _render_breadcrumbs() for
- * precise description)
- * @param {Object} [status.cp_content] dictionnary containing the new ControlPanel jQuery elements
- * @param {Boolean} [status.hidden] true if the ControlPanel should be hidden
- * @param {openerp.web.SearchView} [status.searchview] the searchview widget
- * @param {Boolean} [status.search_view_hidden] true if the searchview is hidden, false otherwise
- * @param {Boolean} [options.clear] set to true to clear from control panel
- * elements that are not in status.cp_content
- */
- update: function(status, options) {
- this.bus.updateIndex++;
-
- this._toggle_visibility(!status.hidden);
-
- // Don't update the ControlPanel in headless mode as the views have
- // inserted themselves the buttons where they want, so inserting them
- // again in the ControlPanel will remove them from where they should be
- if (!status.hidden) {
- options = _.defaults({}, options, {
- clear: true, // clear control panel by default
- });
- var new_cp_content = status.cp_content || {};
-
- // Render the breadcrumbs
- if (status.breadcrumbs) {
- this.$('.breadcrumb').html(this._render_breadcrumbs(status.breadcrumbs));
- }
-
- // Detach control_panel old content and attach new elements
- var toDetach = this.nodes;
- if (status.searchview && this.searchview === status.searchview) {
- // If the searchview is the same as before, don't detach it s.t.
- // we don't loose any floating content, nor the focus
- toDetach = _.omit(toDetach, '$searchview');
- new_cp_content = _.omit(new_cp_content, '$searchview');
- }
- if (options.clear) {
- this._detach_content(toDetach);
- // Show the searchview buttons area, which might have been hidden by
- // the searchview, as client actions may insert elements into it
- this.nodes.$searchview_buttons.show();
- } else {
- this._detach_content(_.pick(toDetach, _.keys(new_cp_content)));
- }
- this._attach_content(new_cp_content);
- if (options.clear || status.searchview) {
- this.searchview = status.searchview;
- }
-
- // Update the searchview and switch buttons
- if (status.searchview || options.clear) {
- this._update_search_view(status.searchview, status.search_view_hidden, status.groupable, status.enableTimeRangeMenu);
- }
- if (status.active_view_selector) {
- this._update_switch_buttons(status.active_view_selector);
- }
- }
- },
- /**
- * Private function that hides (or shows) the ControlPanel in headless (resp. non-headless) mode
- * Also detaches or attaches its contents to clean the DOM
- * @param {Boolean} [visible] true to show the control panel, false to hide it
- */
- _toggle_visibility: function(visible) {
- this.do_toggle(visible);
- if (!visible && !this.$content) {
- this.$content = this.$el.contents().detach();
- } else if (this.$content) {
- this.$content.appendTo(this.$el);
- this.$content = null;
- }
- },
- /**
- * Private function that detaches the content of the ControlPanel
- * @param {Object} [elements_to_detach] subset of this.nodes to detach
- */
- _detach_content: function(elements_to_detach) {
- _.each(elements_to_detach, function($nodeset) {
- $nodeset.contents().detach();
- });
- },
- /**
- * Private function that attaches content to the ControlPanel
- * @param {Object} [content] dictionnary of jQuery elements to attach, whose keys
- * are jQuery nodes identifiers in this.nodes
- */
- _attach_content: function(content) {
- var self = this;
- _.each(content, function($nodeset, $element) {
- if ($nodeset && self.nodes[$element]) {
- self.nodes[$element].append($nodeset);
- }
- });
- },
- /**
- * Private function that removes active class on all switch-buttons and adds
- * it to the one of the active view
- * @param {Object} [active_view_selector] the selector of the div to activate
- */
- _update_switch_buttons: function(active_view_selector) {
- _.each(this.nodes.$switch_buttons.find('button'), function(button) {
- $(button).removeClass('active');
- });
- this.$(active_view_selector).addClass('active');
- },
- /**
- * Private function that renders the breadcrumbs
- * @param {Array} [breadcrumbs] list of objects containing the following keys:
- * - action: the action to execute when clicking on this part of the breadcrumbs
- * - index: the index in the breadcrumbs (starting at 0)
- * - title: what to display in the breadcrumbs
- * @return {Array} list of breadcrumbs' li jQuery elements
- */
- _render_breadcrumbs: function (breadcrumbs) {
- var self = this;
- return breadcrumbs.map(function (bc, index) {
- return self._render_breadcrumbs_li(bc, index, breadcrumbs.length);
- });
- },
- /**
- * Private function that renders a breadcrumbs' li Jquery element
- */
- _render_breadcrumbs_li: function (bc, index, length) {
- var self = this;
- var is_last = (index === length-1);
- var li_content = bc.title && _.escape(bc.title.trim()) || data.noDisplayContent;
- var $bc = $('<li>', {class: 'breadcrumb-item'})
- .append(is_last ? li_content : $('<a>', {href: '#'}).html(li_content))
- .toggleClass('active', is_last);
- if (!is_last) {
- $bc.click(function (ev) {
- ev.preventDefault();
- self.trigger_up('breadcrumb_clicked', {controllerID: bc.controllerID});
- });
- }
- return $bc;
- },
- /**
- * Private function that updates the SearchView's visibility and extend the
- * breadcrumbs area if the SearchView is not visible
- *
- * @private
- * @param {SearchView} [searchview] the searchview Widget
- * @param {boolean} [isHidden] visibility of the searchview
- * @param {boolean} [groupable] visibility of the groupable menu (only
- * relevant if searchview is visible)
- */
- _update_search_view: function (searchview, isHidden, groupable, enableTimeRangeMenu) {
- if (searchview) {
- searchview.toggle_visibility(!isHidden);
- if (groupable !== undefined){
- searchview.groupby_menu.do_toggle(groupable);
- }
- if (enableTimeRangeMenu !== undefined){
- searchview.displayTimeRangeMenu(enableTimeRangeMenu);
- }
- }
- this.nodes.$searchview.toggle(!isHidden);
- this.$el.toggleClass('o_breadcrumb_full', !!isHidden);
- },
-});
-
-return ControlPanel;
-
-});
diff --git a/addons/web/static/src/js/core/registry.js b/addons/web/static/src/js/core/registry.js
index f925b0ea30a..c6d35229639 100644
--- a/addons/web/static/src/js/core/registry.js
+++ b/addons/web/static/src/js/core/registry.js
@@ -25,6 +25,8 @@ var Registry = Class.extend({
*/
init: function (mapping) {
this.map = Object.create(mapping || null);
+ this._scoreMapping = Object.create(null);
+ this._sortedKeys = null;
this.listeners = []; // listening callbacks on newly added items.
},
@@ -39,9 +41,12 @@ var Registry = Class.extend({
*
* @param {string} key
* @param {any} value
+ * @param {[number]} score if given, this value will be used to order keys
* @returns {Registry} can be used to chain add calls.
*/
- add: function (key, value) {
+ add: function (key, value, score) {
+ this._scoreMapping[key] = score === undefined ? key : score;
+ this._sortedKeys = null;
this.map[key] = value;
_.each(this.listeners, function (callback) {
callback(key, value);
@@ -49,7 +54,7 @@ var Registry = Class.extend({
return this;
},
/**
- * Check if the registry contains the value
+ * Check if the registry contains the key
*
* @param {string} key
* @returns {boolean}
@@ -58,18 +63,12 @@ var Registry = Class.extend({
return (key in this.map);
},
/**
- * Creates and returns a copy of the current mapping, with the provided
- * mapping argument added in (replacing existing keys if needed)
+ * Returns the content of the registry (an object mapping keys to values)
*
- * Parent and child remain linked, a new key in the parent (which is not
- * overwritten by the child) will appear in the child.
- *
- * @param {Object} [mapping={}] a mapping of keys to object-paths
+ * @returns {Object}
*/
- extend: function (mapping) {
- var child = new Registry(this.map);
- _.extend(child.map, mapping);
- return child;
+ entries: function () {
+ return Object.create(this.map);
},
/**
* Returns the value associated to the given key.
@@ -96,6 +95,23 @@ var Registry = Class.extend({
return null;
},
/**
+ * Return the list of keys in map object.
+ *
+ * The registry guarantees that the keys have a consistent order, defined by
+ * the 'score' value when the item has been added.
+ *
+ * @returns {string[]}
+ */
+ keys: function () {
+ var self = this;
+ if (!this._sortedKeys) {
+ this._sortedKeys = _.sortBy(Object.keys(this.map), function (key) {
+ return self._scoreMapping[key] || 0;
+ });
+ }
+ return this._sortedKeys;
+ },
+ /**
* Register a callback to execute when items are added to the registry.
*
* @param {function} callback function with parameters (key, value).
@@ -103,6 +119,17 @@ var Registry = Class.extend({
onAdd: function (callback) {
this.listeners.push(callback);
},
+ /**
+ * Return the list of values in map object
+ *
+ * @returns {string[]}
+ */
+ values: function () {
+ var self = this;
+ return this.keys().map(function (key) {
+ return self.map[key];
+ });
+ },
});
return Registry;
diff --git a/addons/web/static/src/js/core/service_mixins.js b/addons/web/static/src/js/core/service_mixins.js
index 9c127a1bc8c..e8088d45cb5 100644
--- a/addons/web/static/src/js/core/service_mixins.js
+++ b/addons/web/static/src/js/core/service_mixins.js
@@ -139,8 +139,8 @@ var ServicesMixin = {
promise.abort = abort.bind(def);
return promise;
},
- loadFieldView: function (dataset, view_id, view_type, options) {
- return this.loadViews(dataset.model, dataset.get_context().eval(), [[view_id, view_type]], options).then(function (result) {
+ loadFieldView: function (modelName, context, view_id, view_type, options) {
+ return this.loadViews(modelName, context, [[view_id, view_type]], options).then(function (result) {
return result[view_type];
});
},
@@ -155,11 +155,28 @@ var ServicesMixin = {
});
return def;
},
- loadFilters: function (dataset, action_id) {
+ loadFilters: function (modelName, actionId, context) {
var def = $.Deferred();
this.trigger_up('load_filters', {
- dataset: dataset,
- action_id: action_id,
+ modelName: modelName,
+ actionId: actionId,
+ context: context,
+ on_success: def.resolve.bind(def),
+ });
+ return def;
+ },
+ createFilter: function (filter) {
+ var def = $.Deferred();
+ this.trigger_up('create_filter', {
+ filter: filter,
+ on_success: def.resolve.bind(def),
+ });
+ return def;
+ },
+ deleteFilter: function (filterId) {
+ var def = $.Deferred();
+ this.trigger_up('delete_filter', {
+ filterId: filterId,
on_success: def.resolve.bind(def),
});
return def;
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index 60b0275c8c8..6d53d283f8a 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -16,7 +16,7 @@ odoo.define('web.relational_fields', function (require) {
var AbstractField = require('web.AbstractField');
var basicFields = require('web.basic_fields');
var concurrency = require('web.concurrency');
-var ControlPanel = require('web.ControlPanel');
+var ControlPanelView = require('web.ControlPanelView');
var dialogs = require('web.view_dialogs');
var core = require('web.core');
var data = require('web.data');
@@ -1030,7 +1030,14 @@ var FieldX2Many = AbstractField.extend({
}
var self = this;
var defs = [];
- this.control_panel = new ControlPanel(this, "X2ManyControlPanel");
+ var controlPanelView = new ControlPanelView({
+ template: 'X2ManyControlPanel',
+ withSearchBar: false,
+ });
+ var cpDef = controlPanelView.getController(this).then(function (controlPanel) {
+ self._controlPanel = controlPanel;
+ return self._controlPanel.prependTo(self.$el);
+ });
this.pager = new Pager(this, this.value.count, this.value.offset + 1, this.value.limit, {
single_page_hidden: true,
withAccessKey: false,
@@ -1053,9 +1060,9 @@ var FieldX2Many = AbstractField.extend({
});
this._renderButtons();
defs.push(this.pager.appendTo($('<div>'))); // start the pager
- defs.push(this.control_panel.prependTo(this.$el));
+ defs.push(cpDef);
return $.when.apply($, defs).then(function () {
- self.control_panel.update({
+ self._controlPanel.updateContents({
cp_content: {
$buttons: self.$buttons,
$pager: self.pager.$el,
diff --git a/addons/web/static/src/js/report/client_action.js b/addons/web/static/src/js/report/client_action.js
index 7041913c286..3a520e3d6b5 100644
--- a/addons/web/static/src/js/report/client_action.js
+++ b/addons/web/static/src/js/report/client_action.js
@@ -4,7 +4,6 @@ odoo.define('report.client_action', function (require) {
var AbstractAction = require('web.AbstractAction');
var config = require('web.config');
var core = require('web.core');
-var ControlPanelMixin = require('web.ControlPanelMixin');
var session = require('web.session');
var utils = require('report.utils');
@@ -17,9 +16,9 @@ var AUTHORIZED_MESSAGES = [
'report:do_action',
];
-var ReportAction = AbstractAction.extend(ControlPanelMixin, {
-
- template: 'report.client_action',
+var ReportAction = AbstractAction.extend({
+ hasControlPanel: true,
+ contentTemplate: 'report.client_action',
init: function (parent, action, options) {
this._super.apply(this, arguments);
@@ -27,7 +26,7 @@ var ReportAction = AbstractAction.extend(ControlPanelMixin, {
options = options || {};
this.action_manager = parent;
- this.title = options.display_name || options.name;
+ this._title = options.display_name || options.name;
this.edit_mode_available = false;
this.in_edit_mode = false;
@@ -42,7 +41,6 @@ var ReportAction = AbstractAction.extend(ControlPanelMixin, {
start: function () {
var self = this;
- this.set('title', this.title);
this.iframe = this.$('iframe')[0];
return $.when(this._super.apply(this, arguments), session.is_bound).then(function () {
var web_base_url = session['web.base.url'];
@@ -93,7 +91,7 @@ var ReportAction = AbstractAction.extend(ControlPanelMixin, {
},
_update_control_panel: function () {
- this.update_control_panel({
+ this.updateControlPanel({
cp_content: {
$buttons: this.$buttons,
},
diff --git a/addons/web/static/src/js/services/core.js b/addons/web/static/src/js/services/core.js
index 522d6b07302..3d6cfc4e684 100644
--- a/addons/web/static/src/js/services/core.js
+++ b/addons/web/static/src/js/services/core.js
@@ -39,8 +39,6 @@ return {
// registries
action_registry : new Registry(),
crash_registry: new Registry(),
- search_filters_registry: new Registry(),
- search_widgets_registry: new Registry(),
serviceRegistry: new Registry(),
/**
* @type {String}
diff --git a/addons/web/static/src/js/services/data_manager.js b/addons/web/static/src/js/services/data_manager.js
index bb7f25d206b..afedaa1148f 100644
--- a/addons/web/static/src/js/services/data_manager.js
+++ b/addons/web/static/src/js/services/data_manager.js
@@ -128,15 +128,20 @@ return core.Class.extend({
*
* @param {Object} [dataset] the dataset for which the filters are loaded
* @param {int} [action_id] the id of the action (optional)
+ * @param {Object} params
+ * @param {string} params.modelName
+ * @param {Object} params.context
+ * @param {integer} params.actionId
* @return {Deferred} resolved with the requested filters
*/
- load_filters: function (dataset, action_id) {
- var key = this._gen_key(dataset.model, action_id);
+ load_filters: function (params) {
+ var key = this._gen_key(params.modelName, params.actionId);
if (!this._cache.filters[key]) {
this._cache.filters[key] = rpc.query({
- args: [dataset.model, action_id],
+ args: [params.modelName, params.actionId],
kwargs: {
- context: dataset.get_context(),
+ context: params.context || {},
+ // get_context() de dataset
},
model: 'ir.filters',
method: 'get_filters',
@@ -158,26 +163,26 @@ return core.Class.extend({
model: 'ir.filters',
method: 'create_or_replace',
})
- .then(function (filter_id) {
+ .then(function (filterId) {
var key = [
filter.model_id,
filter.action_id || false,
].join(',');
self._invalidate(self._cache.filters, key);
- return filter_id;
+ return filterId;
});
},
/**
* Calls 'unlink' on 'ir_filters'.
*
- * @param {Object} [filter] the description of the filter to remove
+ * @param {integer} filterId, Id of the filter to remove
* @return {Deferred}
*/
- delete_filter: function (filter) {
+ delete_filter: function (filterId) {
var self = this;
return rpc.query({
- args: [filter.id],
+ args: [filterId],
model: 'ir.filters',
method: 'unlink',
})
diff --git a/addons/web/static/src/js/tools/debug_manager.js b/addons/web/static/src/js/tools/debug_manager.js
index 391d8613aac..a348f54acd9 100644
--- a/addons/web/static/src/js/tools/debug_manager.js
+++ b/addons/web/static/src/js/tools/debug_manager.js
@@ -345,8 +345,8 @@ DebugManager.include({
action: this._action,
can_edit: this._can_edit_views,
controller: this._controller,
+ controlPanelView: this._controller && this._controller._controlPanel,
manager: this,
- searchview: this._controller && this._controller.searchView,
view: this._controller && _.findWhere(this._action.views, {
type: this._controller.viewType,
}),
diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js
index d2d22a626d1..77584a272d6 100644
--- a/addons/web/static/src/js/views/abstract_controller.js
+++ b/addons/web/static/src/js/views/abstract_controller.js
@@ -12,18 +12,21 @@ odoo.define('web.AbstractController', function (require) {
* reading localstorage, ...) has to go through the controller.
*/
-var AbstractAction = require('web.AbstractAction');
+var ActionMixin = require('web.ActionMixin');
var ajax = require('web.ajax');
var concurrency = require('web.concurrency');
var config = require('web.config');
-var ControlPanelMixin = require('web.ControlPanelMixin');
var core = require('web.core');
+var mvc = require('web.mvc');
var QWeb = core.qweb;
-var AbstractController = AbstractAction.extend(ControlPanelMixin, {
+var AbstractController = mvc.Controller.extend(ActionMixin, {
custom_events: {
+ get_controller_query_params: '_onGetControllerQueryParams',
+ navigation_move: '_onNavigationMove',
open_record: '_onOpenRecord',
+ search: '_onSearch',
switch_view: '_onSwitchView',
},
events: {
@@ -31,50 +34,30 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
},
/**
- * @constructor
- * @param {Widget} parent
- * @param {AbstractModel} model
- * @param {AbstractRenderer} renderer
- * @param {object} params
+ * @override
* @param {string} params.modelName
* @param {string} [params.controllerID] an id to ease the communication
* with upstream components
+ * @param {ControlPanelController} [params.controlPanel]
* @param {any} [params.handle] a handle that will be given to the model (some id)
- * @param {any} params.initialState the initialState
* @param {boolean} params.isMultiRecord
* @param {Object[]} params.actionViews
* @param {string} params.viewType
- * @param {boolean} params.withControlPanel set to false to hide the
- * ControlPanel
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
- this.model = model;
- this.renderer = renderer;
+ this._controlPanel = params.controlPanel;
+ this._title = params.displayName;
this.modelName = params.modelName;
- this.handle = params.handle;
this.activeActions = params.activeActions;
this.controllerID = params.controllerID;
this.initialState = params.initialState;
this.bannerRoute = params.bannerRoute;
- // use a DropPrevious to correctly handle concurrent updates
- this.dp = new concurrency.DropPrevious();
- // those arguments are temporary, they won't be necessary as soon as the
- // ControlPanel will be handled by the View
- this.displayName = params.displayName;
this.isMultiRecord = params.isMultiRecord;
- this.searchable = params.searchable;
- this.searchView = params.searchView;
- this.searchViewHidden = params.searchViewHidden;
- this.groupable = params.groupable;
- this.enableTimeRangeMenu = params.enableTimeRangeMenu;
this.actionViews = params.actionViews;
this.viewType = params.viewType;
- this.withControlPanel = params.withControlPanel !== false;
- // override this.need_control_panel so that the ActionManager doesn't
- // update the control panel when it isn't visible (this is a temporary
- // hack that can be removed as soon as the CP'll be handled by the view)
- this.need_control_panel = this.withControlPanel;
+ // use a DropPrevious to correctly handle concurrent updates
+ this.dp = new concurrency.DropPrevious();
},
/**
* Simply renders and updates the url.
@@ -86,13 +69,13 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
this.$el.addClass('o_view_controller');
- // render the ControlPanel elements (buttons, pager, sidebar...)
- this.controlPanelElements = this._renderControlPanelElements();
+ return this._super.apply(this, arguments).then(function () {
+ if (self._controlPanel) {
+ // render the ControlPanel elements (buttons, pager, sidebar...)
+ self.controlPanelElements = self._renderControlPanelElements();
+ self._controlPanel.$el.prependTo(self.$el);
+ }
- return $.when(
- this._super.apply(this, arguments),
- this.renderer.appendTo(this.$el)
- ).then(function () {
return self._update(self.initialState);
});
},
@@ -106,14 +89,14 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
if (this.controlPanelElements && this.controlPanelElements.$switch_buttons) {
this.controlPanelElements.$switch_buttons.off();
}
- return this._super.apply(this, arguments);
+ this._super.apply(this, arguments);
},
/**
* Called each time the controller is attached into the DOM.
*/
on_attach_callback: function () {
- if (this.searchView) {
- this.searchView.on_attach_callback();
+ if (this._controlPanel) {
+ this._controlPanel.on_attach_callback();
}
this.renderer.on_attach_callback();
},
@@ -153,26 +136,18 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
return $.when();
},
/**
- * Returns any special keys that may be useful when reloading the view to
- * get the same effect. This is necessary for saving the current view in
- * the favorites. For example, a graph view might want to add a key to
- * save the current graph type.
+ * Export the state of the controller containing information that is shared
+ * between different controllers of a same action (like the current
+ * searchQuery of the controlPanel).
*
* @returns {Object}
*/
- getContext: function () {
- return {};
- },
- /**
- * Returns a title that may be displayed in the breadcrumb area. For
- * example, the name of the record.
- *
- * note: this will be moved to AbstractAction
- *
- * @returns {string}
- */
- getTitle: function () {
- return this.displayName;
+ exportState: function () {
+ var state = {};
+ if (this._controlPanel) {
+ state.cpState = this._controlPanel.exportState();
+ }
+ return state;
},
/**
* Gives the focus to the renderer
@@ -199,7 +174,16 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
* @returns {Deferred}
*/
reload: function (params) {
- return this.update(params || {});
+ params = params || {};
+ var def;
+ var controllerState = params.controllerState || {};
+ var cpState = controllerState.cpState;
+ if (this._controlPanel && cpState) {
+ def = this._controlPanel.importState(cpState).then(function (searchQuery) {
+ params = _.extend({}, params, searchQuery);
+ });
+ }
+ return $.when(def).then(this.update.bind(this, params, {}));
},
/**
* For views that require a pager, this method will be called to allow the
@@ -246,16 +230,16 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
// and this controller should no longer update the control panel.
// note that this won't be necessary as soon as each controller will have
// its own control panel
- var cpUpdateIndex = this.cp_bus && this.cp_bus.updateIndex;
+ var cpUpdateIndex = this._controlPanel && this._controlPanel.updateIndex;
return this.dp.add(def).then(function (handle) {
- if (self.cp_bus && cpUpdateIndex !== self.cp_bus.updateIndex) {
+ if (self._controlPanel && cpUpdateIndex !== self._controlPanel.updateIndex) {
return;
}
self.handle = handle || self.handle; // update handle if we reloaded
var state = self.model.get(self.handle);
var localState = self.renderer.getLocalState();
return self.dp.add(self.renderer.updateState(state, params)).then(function () {
- if (self.cp_bus && cpUpdateIndex !== self.cp_bus.updateIndex) {
+ if (self._controlPanel && cpUpdateIndex !== self._controlPanel.updateIndex) {
return;
}
self.renderer.setLocalState(localState);
@@ -327,7 +311,7 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
js.remove();
});
return $.when.apply($, defs).then(function () {
- $banner.prependTo(self.$el);
+ $banner.prependTo(self.$('> .o_content'));
self._$banner = $banner;
});
});
@@ -342,24 +326,20 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
* @returns {Object} an object containing the control panel jQuery elements
*/
_renderControlPanelElements: function () {
- var elements = {};
+ var elements = {
+ $buttons: $('<div>'),
+ $sidebar: $('<div>'),
+ $pager: $('<div>'),
+ };
- if (this.withControlPanel) {
- elements = {
- $buttons: $('<div>'),
- $sidebar: $('<div>'),
- $pager: $('<div>'),
- };
-
- this.renderButtons(elements.$buttons);
- this.renderSidebar(elements.$sidebar);
- this.renderPager(elements.$pager);
- // remove the unnecessary outer div
- elements = _.mapObject(elements, function($node) {
- return $node && $node.contents();
- });
- elements.$switch_buttons = this._renderSwitchButtons();
- }
+ this.renderButtons(elements.$buttons);
+ this.renderSidebar(elements.$sidebar);
+ this.renderPager(elements.$pager);
+ // remove the unnecessary outer div
+ elements = _.mapObject(elements, function($node) {
+ return $node && $node.contents();
+ });
+ elements.$switch_buttons = this._renderSwitchButtons();
return elements;
},
@@ -379,6 +359,7 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
var template = config.device.isMobile ? 'ControlPanel.SwitchButtons.Mobile' : 'ControlPanel.SwitchButtons';
var $switchButtons = $(QWeb.render(template, {
+ viewType: this.viewType,
views: views,
}));
// create bootstrap tooltips
@@ -392,8 +373,8 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
self.trigger_up('switch_view', {view_type: viewType});
}, 200, true));
+ // set active view's icon as view switcher button's icon in mobile
if (config.device.isMobile) {
- // set active view's icon as view switcher button's icon
var activeView = _.findWhere(views, {type: this.viewType});
$switchButtons.find('.o_switch_view_button_icon').addClass('fa fa-lg ' + activeView.icon);
}
@@ -401,6 +382,13 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
return $switchButtons;
},
/**
+ * @override
+ * @private
+ */
+ _startRenderer: function () {
+ return this.renderer.appendTo(this.$('.o_content'));
+ },
+ /**
* This method is called after each update or when the start method is
* completed.
*
@@ -413,23 +401,12 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
* @param {Object} state the state given by the model
* @returns {Deferred}
*/
- _update: function (state) {
+ _update: function () {
// AAB: update the control panel -> this will be moved elsewhere at some point
var cpContent = _.extend({}, this.controlPanelElements);
- if (this.searchView) {
- _.extend(cpContent, {
- $searchview: this.searchView.$el,
- $searchview_buttons: this.searchView.$buttons,
- });
- }
- this.update_control_panel({
- active_view_selector: '.o_cp_switch_' + this.viewType,
+ this.updateControlPanel({
cp_content: cpContent,
- hidden: !this.withControlPanel,
- searchview: this.searchView,
- search_view_hidden: !this.searchable || this.searchviewHidden,
- groupable: this.groupable,
- enableTimeRangeMenu: this.enableTimeRangeMenu,
+ title: this.getTitle(),
});
this._pushState();
@@ -479,6 +456,36 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
}
},
/**
+ * FIXME: this logic should be rethought
+ *
+ * Handles a context request: provides to the caller the state of the
+ * current controller.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {function} ev.data.callback used to send the requested state
+ */
+ _onGetControllerQueryParams: function (ev) {
+ ev.stopPropagation();
+ var state = this.getOwnedQueryParams();
+ ev.data.callback(state || {});
+ },
+ /**
+ * Called mainly from the control panel when the focus should be given to
+ * the controller
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNavigationMove : function (ev) {
+ switch(ev.data.direction) {
+ case 'down' :
+ ev.stopPropagation();
+ this.giveFocus();
+ break;
+ }
+ },
+ /**
* When an Odoo event arrives requesting a record to be opened, this method
* gets the res_id, and request a switch view in the appropriate mode
*
@@ -503,6 +510,20 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
});
},
/**
+ * Called when there is a change in the search view, so the current action's
+ * environment needs to be updated with the new domain, context and groupby.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {Array[]} ev.data.domain
+ * @param {Object} ev.data.context
+ * @param {string[]} ev.data.groupby
+ */
+ _onSearch: function (ev) {
+ ev.stopPropagation();
+ this.reload(_.extend({offset: 0}, ev.data));
+ },
+ /**
* Intercepts the 'switch_view' event to add the controllerID into the data,
* and lets the event bubble up.
*
diff --git a/addons/web/static/src/js/views/abstract_model.js b/addons/web/static/src/js/views/abstract_model.js
index 4ea6b217e65..da73ac664a3 100644
--- a/addons/web/static/src/js/views/abstract_model.js
+++ b/addons/web/static/src/js/views/abstract_model.js
@@ -14,52 +14,21 @@ odoo.define('web.AbstractModel', function (require) {
* in order to be able to notify its parent by bubbling events up.
*/
-var Class = require('web.Class');
var fieldUtils = require('web.field_utils');
-var mixins = require('web.mixins');
-var ServicesMixin = require('web.ServicesMixin');
-
-var AbstractModel = Class.extend(mixins.EventDispatcherMixin, ServicesMixin, {
- /**
- * @param {Widget} parent
- */
- init: function (parent) {
- mixins.EventDispatcherMixin.init.call(this);
- this.setParent(parent);
- },
+var mvc = require('web.mvc');
+var AbstractModel = mvc.Model.extend({
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
- * This method should return the complete state necessary for the renderer
- * to display the currently viewed data.
- *
- * @returns {*}
- */
- get: function () {
- },
- /**
- * The load method is called once in a model, when we load the data for the
- * first time. The method returns (a deferred that resolves to) some kind
- * of token/handle. The handle can then be used with the get method to
- * access a representation of the data.
- *
- * @param {Object} params
- * @param {string} params.modelName the name of the model
- * @returns {Deferred} The deferred resolves to some kind of handle
- */
- load: function (params) {
- return $.when();
- },
- /**
* When something changes, the data may need to be refetched. This is the
* job for this method: reloading (only if necessary) all the data and
* making sure that they are ready to be redisplayed.
*
* @param {Object} params
- * @returns {Deferred}
+ * @returns {Promise}
*/
reload: function (params) {
return $.when();
diff --git a/addons/web/static/src/js/views/abstract_renderer.js b/addons/web/static/src/js/views/abstract_renderer.js
index 059380f87bd..0f18263c9ee 100644
--- a/addons/web/static/src/js/views/abstract_renderer.js
+++ b/addons/web/static/src/js/views/abstract_renderer.js
@@ -7,22 +7,18 @@ odoo.define('web.AbstractRenderer', function (require) {
*
*/
-var Widget = require('web.Widget');
+var mvc = require('web.mvc');
/**
* @class AbstractRenderer
*/
-return Widget.extend({
+return mvc.Renderer.extend({
/**
- * @constructor
- * @param {Widget} parent
- * @param {any} state
- * @param {Object} params
+ * @override
* @param {string} [params.noContentHelp]
*/
init: function (parent, state, params) {
- this._super(parent);
- this.state = state;
+ this._super.apply(this, arguments);
this.arch = params.arch;
this.noContentHelp = params.noContentHelp;
},
diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js
index 024216b5619..75de2be9446 100644
--- a/addons/web/static/src/js/views/abstract_view.js
+++ b/addons/web/static/src/js/views/abstract_view.js
@@ -23,14 +23,16 @@ odoo.define('web.AbstractView', function (require) {
* in most case discarded.
*/
-var ajax = require('web.ajax');
-var Class = require('web.Class');
var AbstractModel = require('web.AbstractModel');
var AbstractRenderer = require('web.AbstractRenderer');
var AbstractController = require('web.AbstractController');
-var utils = require('web.utils');
+var ControlPanelView = require('web.ControlPanelView');
+var mvc = require('web.mvc');
+var viewUtils = require('web.viewUtils');
-var AbstractView = Class.extend({
+var Factory = mvc.Factory;
+
+var AbstractView = Factory.extend({
// name displayed in view switchers
display_name: '',
// indicates whether or not the view is mobile-friendly
@@ -40,22 +42,20 @@ var AbstractView = Class.extend({
// multi_record is used to distinguish views displaying a single record
// (e.g. FormView) from those that display several records (e.g. ListView)
multi_record: true,
- // determine if a search view should be displayed in the control panel and
- // allowed to interact with the view. Currently, the only not searchable
- // views are the form view and the diagram view.
- searchable: true,
// viewType is the type of the view, like 'form', 'kanban', 'list'...
viewType: undefined,
- // if searchable, this flag determines if the search view will display a
- // groupby menu or not. This is useful for the views which do not support
- // grouping data.
- groupable: true,
- enableTimeRangeMenu: false,
- config: {
+ // determines if a search bar is available
+ withSearchBar: true,
+ // determines the search menus available and their orders
+ searchMenuTypes: ['filter', 'groupBy', 'favorite'],
+ // determines if a control panel should be instantiated
+ withControlPanel: true,
+ // determines the MVC components to use
+ config: _.extend({}, Factory.prototype.config, {
Model: AbstractModel,
Renderer: AbstractRenderer,
Controller: AbstractController,
- },
+ }),
/**
* The constructor function is supposed to set 3 variables: rendererParams,
@@ -65,33 +65,32 @@ var AbstractView = Class.extend({
* @constructs AbstractView
*
* @param {Object} viewInfo
- * @param {Object} viewInfo.arch
+ * @param {Object|string} viewInfo.arch
* @param {Object} viewInfo.fields
* @param {Object} viewInfo.fieldsInfo
* @param {Object} params
- * @param {string} params.modelName The actual model name
- * @param {Object} params.context
- * @param {number} [params.count]
+ * @param {string} [params.modelName]
+ * @param {Object} [params.action={}]
+ * @param {Object} [params.context={}]
* @param {string} [params.controllerID]
- * @param {string[]} params.domain
- * @param {string[][]} params.timeRange
- * @param {string[][]} params.comparisonTimeRange
- * @param {string} params.timeRangeDescription
- * @param {string} params.comparisonTimeRangeDescription
- * @param {boolean} params.compare
- * @param {string[]} params.groupBy
+ * @param {number} [params.count]
* @param {number} [params.currentId]
- * @param {boolean} params.isEmbedded
+ * @param {string} [params.controllerState]
+ * @param {string} [params.displayName]
+ * @param {Array[]} [params.domain=[]]
* @param {number[]} [params.ids]
- * @param {boolean} [params.withControlPanel]
- * @param {boolean} [params.action.flags.headless]
- * @param {string} [params.action.display_name]
- * @param {string} [params.action.name]
- * @param {string} [params.action.help]
- * @param {string} [params.action.jsID]
- * @param {boolean} [params.action.views]
+ * @param {boolean} [params.isEmbedded=false]
+ * @param {Object} [params.searchQuery={}]
+ * @param {Object} [params.searchQuery.context={}]
+ * @param {Array[]} [params.searchQuery.domain=[]]
+ * @param {string[]} [params.searchQuery.groupBy=[]]
+ * @param {Object} [params.userContext={}]
+ * @param {boolean} [params.withControlPanel=true]
*/
init: function (viewInfo, params) {
+ var action = params.action || {};
+ params = _.defaults(params, this._extractParamsFromAction(action));
+
// in general, the fieldsView has to be processed by the View (e.g. the
// arch is a string that needs to be parsed) ; the only exception is for
// inline form views inside form views, as they are processed alongside
@@ -103,89 +102,51 @@ var AbstractView = Class.extend({
} else {
this.fieldsView = viewInfo;
}
- this.fields = this.fieldsView.viewFields;
this.arch = this.fieldsView.arch;
- // the boolean parameter 'isEmbedded' determines if the view should be considered
- // as a subview. For now this is only used by the graph controller that appends a
- // 'Group By' button beside the 'Measures' button when the graph view is embedded.
+ this.fields = this.fieldsView.viewFields;
+ this.userContext = params.userContext || {};
+ this.withControlPanel = params.withControlPanel;
+
+ // the boolean parameter 'isEmbedded' determines if the view should be
+ // considered as a subview. For now this is only used by the graph
+ // controller that appends a 'Group By' button beside the 'Measures'
+ // button when the graph view is embedded.
var isEmbedded = params.isEmbedded || false;
this.rendererParams = {
arch: this.arch,
isEmbedded: isEmbedded,
- noContentHelp: params.action && params.action.help,
+ noContentHelp: params.noContentHelp,
};
- var timeRangeMenuData = params.context.timeRangeMenuData;
- var timeRange = [];
- var comparisonTimeRange = [];
- var compare = false;
- var timeRangeDescription = "";
- var comparisonTimeRangeDescription = "";
- if (this.enableTimeRangeMenu && timeRangeMenuData) {
- timeRange = timeRangeMenuData.timeRange;
- comparisonTimeRange = timeRangeMenuData.comparisonTimeRange;
- compare = comparisonTimeRange.length > 0;
- timeRangeDescription = timeRangeMenuData.timeRangeDescription;
- comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription;
- this.rendererParams.timeRangeDescription = timeRangeDescription;
- this.rendererParams.comparisonTimeRangeDescription = comparisonTimeRangeDescription;
- }
-
this.controllerParams = {
- modelName: params.modelName,
+ actionViews: params.actionViews,
activeActions: {
edit: this.arch.attrs.edit ? JSON.parse(this.arch.attrs.edit) : true,
create: this.arch.attrs.create ? JSON.parse(this.arch.attrs.create) : true,
delete: this.arch.attrs.delete ? JSON.parse(this.arch.attrs.delete) : true,
duplicate: this.arch.attrs.duplicate ? JSON.parse(this.arch.attrs.duplicate) : true,
},
- groupable: this.groupable,
- enableTimeRangeMenu: this.enableTimeRangeMenu,
- isEmbedded: isEmbedded,
- controllerID: params.controllerID,
bannerRoute: this.arch.attrs.banner_route,
+ controllerID: params.controllerID,
+ displayName: params.displayName,
+ isEmbedded: isEmbedded,
+ isMultiRecord: this.multi_record,
+ modelName: params.modelName,
+ viewType: this.viewType,
};
- // AAB: these params won't be necessary as soon as the ControlPanel will
- // be instantiated by the View
- this.controllerParams.displayName = params.action && (params.action.display_name || params.action.name);
- this.controllerParams.isMultiRecord = this.multi_record;
- this.controllerParams.searchable = this.searchable;
- this.controllerParams.searchView = params.action && params.action.searchView;
- this.controllerParams.searchViewHidden = this.searchview_hidden; // AAB: use searchable instead where it is used?
- this.controllerParams.actionViews = params.action ? params.action.views : [];
- this.controllerParams.viewType = this.viewType;
- this.controllerParams.withControlPanel = true;
- if (params.action && params.action.flags) {
- this.controllerParams.withControlPanel = !params.action.flags.headless;
- } else if ('withControlPanel' in params) {
- this.controllerParams.withControlPanel = params.withControlPanel;
- }
-
- var groupBy = params.groupBy;
- if (typeof groupBy === 'string') {
- groupBy = [groupBy];
- }
+ var controllerState = params.controllerState || {};
+ var currentId = controllerState.currentId || params.currentId;
this.loadParams = {
context: params.context,
count: params.count || ((this.controllerParams.ids !== undefined) &&
this.controllerParams.ids.length) || 0,
domain: params.domain,
- timeRange: timeRange,
- timeRangeDescription: timeRangeDescription,
- comparisonTimeRange: comparisonTimeRange,
- comparisonTimeRangeDescription: comparisonTimeRangeDescription,
- compare: compare,
- groupedBy: groupBy,
modelName: params.modelName,
- res_id: params.currentId,
- res_ids: params.ids,
- orderedBy: params.context ? params.context.orderedBy : [],
+ res_id: currentId,
+ res_ids: controllerState.resIds || params.ids || (currentId ? [currentId] : undefined),
};
- if (params.modelName) {
- this.loadParams.modelName = params.modelName;
- }
// default_order is like:
// 'name,id desc'
// but we need it like:
@@ -197,8 +158,23 @@ var AbstractView = Class.extend({
return {name: order[0], asc: order[1] !== 'desc'};
});
}
+ if (params.searchQuery) {
+ this._updateMVCParams(params.searchQuery);
+ }
- this.userContext = params.userContext;
+ this.controlPanelParams = {
+ action: action,
+ activateDefaultFavorite: params.activateDefaultFavorite,
+ breadcrumbs: params.breadcrumbs,
+ context: this.loadParams.context,
+ domain: this.loadParams.domain,
+ modelName: params.modelName,
+ searchMenuTypes: params.searchMenuTypes,
+ state: controllerState.cpState,
+ viewInfo: params.controlPanelFieldsView,
+ withBreadcrumbs: params.withBreadcrumbs,
+ withSearchBar: params.withSearchBar,
+ };
},
//--------------------------------------------------------------------------
@@ -206,69 +182,48 @@ var AbstractView = Class.extend({
//--------------------------------------------------------------------------
/**
- * Main method of the view class. Create a controller, and make sure that
- * data and libraries are loaded.
- *
- * There is a unusual thing going in this method with parents: we create
- * renderer/model with parent as parent, then we have to reassign them at
- * the end to make sure that we have the proper relationships. This is
- * necessary to solve the problem that the controller need the model and the
- * renderer to be instantiated, but the model need a parent to be able to
- * load itself, and the renderer needs the data in its constructor.
- *
- * @param {Widget} parent The parent of the resulting Controller (most
- * likely an action manager)
- * @returns {Deferred} The deferred resolves to a controller
+ * @override
*/
getController: function (parent) {
var self = this;
- // check if a model already exists, as if not, one will be created and
- // we'll have to set the controller as its parent
- var alreadyHasModel = !!this.model;
- return $.when(this._loadData(parent), ajax.loadLibs(this)).then(function () {
- var state = self.model.get(arguments[0]);
- var renderer = self.getRenderer(parent, state);
- var Controller = self.Controller || self.config.Controller;
- var controllerParams = _.extend({
- initialState: state,
- }, self.controllerParams);
- var controller = new Controller(parent, self.model, renderer, controllerParams);
- renderer.setParent(controller);
-
- if (!alreadyHasModel) {
- // if we have a model, it already has a parent. Otherwise, we
- // set the controller, so the rpcs from the model actually work
- self.model.setParent(controller);
+ var def;
+ if (this.withControlPanel) {
+ def = this._createControlPanel(parent);
+ }
+ var _super = this._super.bind(this);
+ return $.when(def).then(function (controlPanel) {
+ if (controlPanel) {
+ var searchQuery = controlPanel.getSearchQuery();
+ self._updateMVCParams(searchQuery);
}
- return controller;
+ // get the parent of the model if it already exists, as _super will
+ // set the new controller as parent, which we don't want
+ var modelParent = self.model && self.model.getParent();
+ return _super(parent).done(function (controller) {
+ if (controlPanel) {
+ controlPanel.setParent(controller);
+ }
+ if (modelParent) {
+ // if we already add a model, restore its parent
+ self.model.setParent(modelParent);
+ }
+ });
+
});
},
/**
- * Returns the view model or create an instance of it if none
+ * Ensures that only one instance of AbstractModel is created
*
- * @param {Widget} parent the parent of the model, if it has to be created
- * @return {Object} instance of the view model
+ * @override
*/
- getModel: function (parent) {
+ getModel: function () {
if (!this.model) {
- var Model = this.config.Model;
- this.model = new Model(parent);
+ this.model = this._super.apply(this, arguments);
}
return this.model;
},
/**
- * Returns the a new view renderer instance
- *
- * @param {Widget} parent the parent of the model, if it has to be created
- * @param {Object} state the information related to the rendered view
- * @return {Object} instance of the view renderer
- */
- getRenderer: function (parent, state) {
- var Renderer = this.config.Renderer;
- return new Renderer(parent, state, this.rendererParams);
- },
- /**
- * this is useful to customize the actual class to use before calling
+ * This is useful to customize the actual class to use before calling
* createView.
*
* @param {Controller} Controller
@@ -282,16 +237,61 @@ var AbstractView = Class.extend({
//--------------------------------------------------------------------------
/**
- * Load initial data from the model
+ * Instantiates and starts a ControlPanelController.
*
* @private
- * @param {Widget} parent the parent of the model
- * @returns {Deferred<*>} a deferred that resolves to whatever the model
- * decide to return
+ * @param {Widget} parent
+ * @returns {ControlPanelController}
*/
- _loadData: function (parent) {
- var model = this.getModel(parent);
- return model.load(this.loadParams);
+ _createControlPanel: function (parent) {
+ var self = this;
+ var controlPanelView = new ControlPanelView(this.controlPanelParams);
+ return controlPanelView.getController(parent).then(function (controlPanel) {
+ self.controllerParams.controlPanel = controlPanel;
+ return controlPanel.appendTo(document.createDocumentFragment()).then(function () {
+ return controlPanel;
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {Object} [action]
+ * @param {Object} [action.context || {}]
+ * @param {boolean} [action.context.no_breadcrumbs=false]
+ * @param {integer} [action.context.active_id]
+ * @param {integer[]} [action.context.active_ids]
+ * @param {Object} [action.controlPanelFieldsView]
+ * @param {string} [action.display_name]
+ * @param {Array[]} [action.domain=[]]
+ * @param {string} [action.help]
+ * @param {integer} [action.id]
+ * @param {integer} [action.limit]
+ * @param {string} [action.name]
+ * @param {string} [action.res_model]
+ * @param {string} [action.target]
+ * @returns {Object}
+ */
+ _extractParamsFromAction: function (action) {
+ action = action || {};
+ var context = action.context || {};
+ var inline = action.target === 'inline';
+ return {
+ actionId: action.id || false,
+ actionViews: action.views || [],
+ activateDefaultFavorite: !context.active_id && !context.active_ids,
+ context: action.context || {},
+ controlPanelFieldsView: action.controlPanelFieldsView,
+ currentId: action.res_id ? action.res_id : undefined, // load returns 0
+ displayName: action.display_name || action.name,
+ domain: action.domain || [],
+ limit: action.limit,
+ modelName: action.res_model,
+ noContentHelp: action.help,
+ searchMenuTypes: inline ? [] : this.searchMenuTypes,
+ withBreadcrumbs: 'no_breadcrumbs' in context ? !context.no_breadcrumbs : true,
+ withControlPanel: this.withControlPanel,
+ withSearchBar: inline ? false : this.withSearchBar,
+ };
},
/**
* Processes a fieldsView. In particular, parses its arch.
@@ -303,12 +303,44 @@ var AbstractView = Class.extend({
*/
_processFieldsView: function (fieldsView) {
var fv = _.extend({}, fieldsView);
- var doc = $.parseXML(fv.arch).documentElement;
- var stripWhitespaces = doc.nodeName.toLowerCase() !== 'kanban';
- fv.arch = utils.xml_to_json(doc, stripWhitespaces);
+ fv.arch = viewUtils.parseArch(fv.arch);
fv.viewFields = _.defaults({}, fv.viewFields, fv.fields);
return fv;
},
+ /**
+ * Hook to update the renderer, controller and load params with the result
+ * of a search (i.e. a context, a domain and a groupBy).
+ *
+ * @private
+ * @param {Object} searchQuery
+ * @param {Object} searchQuery.context
+ * @param {Object} [searchQuery.context.timeRangeMenuData={}]
+ * @param {Array[]} [searchQuery.context.timeRangeMenuData.comparisonTimeRange=[]]
+ * @param {string} [searchQuery.context.timeRangeMenuData.comparisonTimeRangeDescription='']
+ * @param {string} [searchQuery.context.timeRangeMenuData.timeRangeDescription='']
+ * @param {Array[]} [searchQuery.context.timeRangeMenuData.timeRange=[]]
+ * @param {Array[]} searchQuery.domain
+ * @param {string[]} searchQuery.groupBy
+ */
+ _updateMVCParams: function (searchQuery) {
+ var timeRangeMenuData = searchQuery.context.timeRangeMenuData || {};
+ var comparisonTimeRange = timeRangeMenuData.comparisonTimeRange || [];
+ var comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription || '';
+ var timeRangeDescription = timeRangeMenuData.timeRangeDescription || '';
+ this.loadParams = _.extend(this.loadParams, {
+ compare: comparisonTimeRange.length > 0,
+ comparisonTimeRange: comparisonTimeRange,
+ comparisonTimeRangeDescription: comparisonTimeRangeDescription,
+ context: searchQuery.context,
+ domain: searchQuery.domain,
+ groupedBy: searchQuery.groupBy,
+ timeRange: timeRangeMenuData.timeRange || [],
+ timeRangeDescription: timeRangeMenuData.timeRangeDescription || '',
+ });
+ this.loadParams.orderedBy = searchQuery.orderedBy ? searchQuery.orderedBy : this.loadParams.orderedBy;
+ this.rendererParams.timeRangeDescription = timeRangeDescription;
+ this.rendererParams.comparisonTimeRangeDescription = comparisonTimeRangeDescription;
+ },
});
return AbstractView;
diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js
index 183885b9fb8..7f578da077d 100644
--- a/addons/web/static/src/js/views/basic/basic_controller.js
+++ b/addons/web/static/src/js/views/basic/basic_controller.js
@@ -36,8 +36,8 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
this.confirmOnDelete = params.confirmOnDelete;
this.hasButtons = params.hasButtons;
FieldManagerMixin.init.call(this, this.model);
- this.handle = params.initialState.id;
this.mode = params.mode || 'readonly';
+ this.handle = this.initialState.id;
},
/**
* @override
@@ -201,6 +201,18 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
self._updatePager();
});
},
+ /**
+ * @override
+ */
+ reload: function (params) {
+ if (params && params.controllerState) {
+ if (params.controllerState.currentId) {
+ params.currentId = params.controllerState.currentId;
+ }
+ params.ids = params.controllerState.resIds;
+ }
+ return this._super.apply(this, arguments);
+ },
//--------------------------------------------------------------------------
// Private
@@ -369,6 +381,20 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
}
},
/**
+ * Override to add the current record ID (currentId) and the list of ids
+ * (resIds) in the current dataPoint to the exported state.
+ *
+ * @override
+ */
+ exportState: function () {
+ var state = this._super.apply(this, arguments);
+ var env = this.model.get(this.handle, {env: true});
+ return _.extend(state, {
+ currentId: env.currentId,
+ resIds: env.ids,
+ });
+ },
+ /**
* Returns the new sidebar env
*
* @private
@@ -487,7 +513,6 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
var sidebarEnv = this._getSidebarEnv();
this.sidebar.updateEnv(sidebarEnv);
}
- this.trigger_up('env_updated', {controllerID: this.controllerID, env: env});
},
/**
* Helper method, to make sure the information displayed by the pager is up
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
index 598f88637d8..8266b1dee42 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -2053,14 +2053,12 @@ var BasicModel = AbstractModel.extend({
// We don't do it now because it's not an important case.
// However, we can for sure increment by 1 if we are on the last page.
-
var handleFieldValue = computedList.data[index].data[handleField];
if (position === 'top') {
handleFieldValue--;
} else if (list.count <= list.offset + list.limit - (list.tempLimitIncrement || 0)) {
handleFieldValue++;
}
-
return {
field: handleField,
value: handleFieldValue,
@@ -4383,7 +4381,9 @@ var BasicModel = AbstractModel.extend({
if (options.context !== undefined) {
element.context = options.context;
- element.orderedBy = options.context.orderedBy || element.orderedBy;
+ }
+ if (options.orderedBy !== undefined) {
+ element.orderedBy = options.orderedBy || element.orderedBy;
}
if (options.domain !== undefined) {
element.domain = options.domain;
diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js
index 8db200183ef..27821081f96 100644
--- a/addons/web/static/src/js/views/basic/basic_renderer.js
+++ b/addons/web/static/src/js/views/basic/basic_renderer.js
@@ -518,35 +518,6 @@ var BasicRenderer = AbstractRenderer.extend({
});
},
/**
- * Renders a button according to a given node element.
- *
- * @private
- * @param {Object} node
- * @param {Object} [options]
- * @param {string} [options.extraClass]
- * @param {boolean} [options.textAsTitle=false]
- * @returns {jQuery}
- */
- _renderButtonFromNode: function (node, options) {
- var btnOptions = {
- attrs: _.omit(node.attrs, 'icon', 'string', 'type', 'attrs', 'modifiers', 'options'),
- icon: node.attrs.icon,
- };
- if (options && options.extraClass) {
- var classes = btnOptions.attrs.class ? btnOptions.attrs.class.split(' ') : [];
- btnOptions.attrs.class = _.uniq(classes.concat(options.extraClass.split(' '))).join(' ');
- }
- var str = (node.attrs.string || '').replace(/_/g, '');
- if (str) {
- if (options && options.textAsTitle) {
- btnOptions.attrs.title = str;
- } else {
- btnOptions.text = str;
- }
- }
- return dom.renderButton(btnOptions);
- },
- /**
* Instantiates the appropriate AbstractField specialization for the given
* node and prepares its rendering and addition to the DOM. Indeed, the
* rendering of the widget will be started and the associated deferred will
diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js
index eb3efe7001d..d5d6cfa18d5 100644
--- a/addons/web/static/src/js/views/basic/basic_view.js
+++ b/addons/web/static/src/js/views/basic/basic_view.js
@@ -51,7 +51,6 @@ var BasicView = AbstractView.extend({
this.loadParams.fieldsInfo = this.fieldsInfo;
this.loadParams.fields = this.fields;
- this.loadParams.context = params.context || {};
this.loadParams.limit = parseInt(this.arch.attrs.limit, 10) || params.limit;
this.loadParams.viewType = this.viewType;
this.loadParams.parentID = params.parentID;
@@ -96,19 +95,17 @@ var BasicView = AbstractView.extend({
* @private
* @returns {Deferred}
*/
- _loadData: function () {
+ _loadData: function (model) {
if (this.recordID) {
- var self = this;
-
// Add the fieldsInfo of the current view to the given recordID,
// as it will be shared between two views, and it must be able to
// handle changes on fields that are only on this view.
- this.model.addFieldsInfo(this.recordID, {
+ model.addFieldsInfo(this.recordID, {
fields: this.fields,
fieldsInfo: this.fieldsInfo,
});
- var record = this.model.get(this.recordID);
+ var record = model.get(this.recordID);
var viewType = this.viewType;
var viewFields = Object.keys(record.fieldsInfo[viewType]);
var fieldNames = _.difference(viewFields, Object.keys(record.data));
@@ -168,14 +165,14 @@ var BasicView = AbstractView.extend({
// onchange RPCs), that we haven't been able to process earlier
// (because those fields were unknow at that time). So we ask
// the model to process them.
- def = this.model.applyRawChanges(record.id, viewType).then(function () {
- if (self.model.isNew(record.id)) {
- return self.model.applyDefaultValues(record.id, {}, {
+ def = model.applyRawChanges(record.id, viewType).then(function () {
+ if (model.isNew(record.id)) {
+ return model.applyDefaultValues(record.id, {}, {
fieldNames: fieldNames,
viewType: viewType,
});
} else {
- return self.model.reload(record.id, {
+ return model.reload(record.id, {
fieldNames: fieldNames,
keepChanges: true,
viewType: viewType,
@@ -184,7 +181,7 @@ var BasicView = AbstractView.extend({
});
}
return $.when(def).then(function () {
- return record.id;
+ return model.get(record.id);
});
}
return this._super.apply(this, arguments);
diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js
index b699879472a..0934ae462b3 100644
--- a/addons/web/static/src/js/views/calendar/calendar_controller.js
+++ b/addons/web/static/src/js/views/calendar/calendar_controller.js
@@ -75,7 +75,7 @@ var CalendarController = AbstractController.extend({
* @returns {string}
*/
getTitle: function () {
- return this.get('title');
+ return this._title;
},
/**
* Render the buttons according to the CalendarView.buttons template and
@@ -156,7 +156,7 @@ var CalendarController = AbstractController.extend({
* @returns {Deferred}
*/
_updateRecord: function (record) {
- return this.model.updateRecord(record).then(this.reload.bind(this));
+ return this.model.updateRecord(record).then(this.reload.bind(this, {}));
},
//--------------------------------------------------------------------------
@@ -430,7 +430,7 @@ var CalendarController = AbstractController.extend({
this.$buttons.find('.active').removeClass('active');
this.$buttons.find('.o_calendar_button_' + this.mode).addClass('active');
}
- this.set({title: this.displayName + ' (' + event.data.title + ')'});
+ this._setTitle(this.displayName + ' (' + event.data.title + ')');
},
});
diff --git a/addons/web/static/src/js/views/calendar/calendar_view.js b/addons/web/static/src/js/views/calendar/calendar_view.js
index 20d358c517c..90db648ffd3 100644
--- a/addons/web/static/src/js/views/calendar/calendar_view.js
+++ b/addons/web/static/src/js/views/calendar/calendar_view.js
@@ -29,7 +29,7 @@ var CalendarView = AbstractView.extend({
Renderer: CalendarRenderer,
},
viewType: 'calendar',
- groupable: false,
+ searchMenuTypes: ['filter', 'favorite'],
/**
* @override
diff --git a/addons/web/static/src/js/views/control_panel/control_panel_controller.js b/addons/web/static/src/js/views/control_panel/control_panel_controller.js
new file mode 100644
index 00000000000..991cee949e8
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/control_panel_controller.js
@@ -0,0 +1,237 @@
+odoo.define('web.ControlPanelController', function (require) {
+"use strict";
+
+var mvc = require('web.mvc');
+
+var ControlPanelController = mvc.Controller.extend({
+ className: 'o_cp_controller',
+ custom_events: {
+ facet_removed: '_onFacetRemoved',
+ get_search_query: '_onGetSearchQuery',
+ item_option_clicked: '_onItemOptionClicked',
+ item_trashed: '_onItemTrashed',
+ menu_item_clicked: '_onMenuItemClicked',
+ new_favorite: '_onNewFavorite',
+ new_filters: '_onNewFilters',
+ new_groupBy: '_onNewGroupBy',
+ activate_time_range: '_onActivateTimeRange',
+ autocompletion_filter: '_onAutoCompletionFilter',
+ },
+
+ /**
+ * @override
+ * @param {Object} params
+ * @param {string} params.modelName
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+
+ this.modelName = params.modelName;
+ },
+ /**
+ * Called when the control panel is inserted into the DOM.
+ */
+ on_attach_callback: function () {
+ this.renderer.on_attach_callback();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see ControlPanelModel (exportState)
+ * @returns {Object}
+ */
+ exportState: function () {
+ return this.model.exportState();
+ },
+ /**
+ * Compute the search related values that will be used to fetch data.
+ *
+ * @returns {Object} object with keys 'context', 'domain', 'groupBy'
+ */
+ getSearchQuery: function () {
+ return this.model.getQuery();
+ },
+ /**
+ * @param {Object} state a ControlPanelModel state
+ * @returns {Deferred<Object>} the result of `getSearchState`
+ */
+ importState: function (state) {
+ var defs = [];
+ this.model.importState(state);
+ defs.push(this.getSearchQuery());
+ defs.push(this.renderer.updateState(this.model.get()));
+ return $.when(defs).then(function (defsResults) {
+ return defsResults[0];
+ });
+ },
+ /**
+ * Called at each switch view. This is required until the control panel is
+ * shared between controllers of an action.
+ *
+ * @param {string} controllerID
+ */
+ setControllerID: function (controllerID) {
+ this.controllerID = controllerID;
+ },
+ /**
+ * Update the content and displays the ControlPanel.
+ *
+ * @see ControlPanelRenderer (updateContents)
+ * @param {Object} status
+ * @param {Object} [options]
+ */
+ updateContents: function (status, options) {
+ this.renderer.updateContents(status, options);
+ },
+ /**
+ * Update the domain of the search view by adding and/or removing filters.
+ *
+ * @todo: the way it is done could be improved, but the actual state of the
+ * searchview doesn't allow to do much better.
+
+ * @param {Object[]} newFilters list of filters to add, described by
+ * objects with keys domain (the domain as an Array), description (the text
+ * to display in the facet) and type with value 'filter'.
+ * @param {string[]} filtersToRemove list of filter ids to remove
+ * (previously added ones)
+ * @returns {string[]} list of added filters (to pass as filtersToRemove
+ * for a further call to this function)
+ */
+ updateFilters: function (newFilters, filtersToRemove) {
+ var newFilterIDS = this.model.createNewFilters(newFilters);
+ this.model.deactivateFilters(filtersToRemove);
+ this._reportNewQueryAndRender();
+ return newFilterIDS;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {jQuery}
+ */
+ _getSubMenus: function () {
+ return this.renderer.$subMenus;
+ },
+ /**
+ * @private
+ * @returns {Deferred}
+ */
+ _reportNewQueryAndRender: function () {
+ this.trigger_up('search', this.model.getQuery());
+ var state = this.model.get();
+ return this.renderer.updateState(state);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onActivateTimeRange: function (ev) {
+ ev.stopPropagation();
+ this.model.activateTimeRange(
+ ev.data.id,
+ ev.data.timeRangeId,
+ ev.data.comparisonTimeRangeId
+ );
+ this._reportNewQueryAndRender();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onAutoCompletionFilter: function (ev) {
+ ev.stopPropagation();
+ this.model.toggleAutoCompletionFilter(ev.data);
+ this._reportNewQueryAndRender();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onFacetRemoved: function (ev) {
+ ev.stopPropagation();
+ var group = ev.data.group;
+ if (!group) {
+ group = this.renderer.getLastFacet();
+ }
+ this.model.deactivateGroup(group.id);
+ this._reportNewQueryAndRender();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onGetSearchQuery: function (ev) {
+ ev.stopPropagation();
+ var query = this.getSearchQuery();
+ ev.data.callback(query);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onItemOptionClicked: function (ev) {
+ ev.stopPropagation();
+ this.model.toggleFilterWithOptions(ev.data.id, ev.data.optionId);
+ this._reportNewQueryAndRender();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onItemTrashed: function (ev) {
+ ev.stopPropagation();
+ var def = this.model.deleteFilterEverywhere(ev.data.id);
+ def.then(this._reportNewQueryAndRender.bind(this));
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onMenuItemClicked: function (ev) {
+ ev.stopPropagation();
+ this.model.toggleFilter(ev.data.id);
+ this._reportNewQueryAndRender();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNewFavorite: function (ev) {
+ ev.stopPropagation();
+ var def = this.model.createNewFavorite(ev.data);
+ def.then(this._reportNewQueryAndRender.bind(this));
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNewFilters: function (ev) {
+ ev.stopPropagation();
+ this.model.createNewFilters(ev.data.filters);
+ this._reportNewQueryAndRender();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNewGroupBy: function (ev) {
+ ev.stopPropagation();
+ this.model.createNewGroupBy(ev.data);
+ this._reportNewQueryAndRender();
+ },
+});
+
+return ControlPanelController;
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/control_panel_model.js b/addons/web/static/src/js/views/control_panel/control_panel_model.js
new file mode 100644
index 00000000000..9353022eacf
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/control_panel_model.js
@@ -0,0 +1,1093 @@
+odoo.define('web.ControlPanelModel', function (require) {
+"use strict";
+
+var controlPanelViewParameters = require('web.controlPanelViewParameters');
+var core = require('web.core');
+var Domain = require('web.Domain');
+var mvc = require('web.mvc');
+var pyUtils = require('web.py_utils');
+var searchBarAutocompleteRegistry = require('web.search_bar_autocomplete_sources_registry');
+var session = require('web.session');
+
+var _t = core._t;
+
+var DEFAULT_TIMERANGE = controlPanelViewParameters.DEFAULT_TIMERANGE;
+var TIME_RANGE_OPTIONS = controlPanelViewParameters.TIME_RANGE_OPTIONS;
+var COMPARISON_TIME_RANGE_OPTIONS = controlPanelViewParameters.COMPARISON_TIME_RANGE_OPTIONS;
+
+var ControlPanelModel = mvc.Model.extend({
+ /**
+ * @override
+ * @param {string} [params.actionId]
+ * @param {string} [params.context={}]
+ * @param {string} [params.domain=[]]
+ * @param {string} [params.field={}]
+ * @param {string} [params.modelName]
+ */
+ init: function (parent, params) {
+ this._super.apply(this, arguments);
+
+ // Tricks to avoid losing information on filter descriptions in control panel model configuration
+ TIME_RANGE_OPTIONS = TIME_RANGE_OPTIONS.map(function (option) {
+ return _.extend(option, {description: option.description.toString()});
+ });
+ COMPARISON_TIME_RANGE_OPTIONS = COMPARISON_TIME_RANGE_OPTIONS.map(function (option) {
+ return _.extend(option, {description: option.description.toString()});
+ });
+
+
+ this.modelName = params.modelName;
+ // info on fields of model this.modelName
+ this.fields = params.fields || {};
+
+ // info on current action
+ this.actionId = params.actionId;
+ this.actionContext = params.context || {};
+ this.actionDomain = params.domain || [];
+
+ // triple determining a control panel model configuration
+ this.filters = {};
+ this.groups = {};
+ this.query = [];
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Activate a given filter of type 'timeRange' with a timeRangeId
+ * and optionaly a comparsionTimeRangeId
+ *
+ * @param {string} filterId
+ * @param {string} timeRangeId
+ * @param {string} [comparisonTimeRangeId]
+ */
+ activateTimeRange: function (filterId, timeRangeId, comparisonTimeRangeId) {
+ var filter = this.filters[filterId];
+ filter.timeRangeId = timeRangeId || filter.defaultTimeRangeId;
+ filter.comparisonTimeRangeId = comparisonTimeRangeId;
+ var group = this.groups[filter.groupId];
+ var groupActive = group.activeFilterIds.length;
+ if (groupActive) {
+ group.activeFilterIds = [filterId];
+ } else {
+ this.toggleFilter(filterId);
+ }
+ },
+ /**
+ * Ccreate an ir_filter server side. If the operation is successful, a new
+ * filter of type 'favorite' is created and activated.
+ *
+ * @param {Object} newFavorite
+ */
+ createNewFavorite: function (newFavorite) {
+ return this._saveQuery(_.pick(
+ newFavorite,
+ ['description', 'isDefault', 'isShared', 'type']
+ )).then(function () {
+ newFavorite.on_success();
+ });
+ },
+ /**
+ * Create new filters of type 'filter' with same new groupId and groupNumber.
+ * They are activated.
+ *
+ * @param {Object[]} newFilters
+ * @returns {string[]} filterIds, ids of the newly created filters
+ */
+ createNewFilters: function (newFilters) {
+ var self = this;
+ var filterIds = [];
+ var groupNumber = this._generateNewGroupNumber();
+ this._createGroupOfFilters(newFilters);
+ newFilters.forEach(function (filter) {
+ filter.groupNumber = groupNumber;
+ self.toggleFilter(filter.id);
+ filterIds.push(filter.id);
+ });
+ return filterIds;
+ },
+ /**
+ * Create a new groupBy with the groupId shared by all filters of type 'groupBy'
+ * but a new groupNumber
+ * It is activated.
+ *
+ * @param {Object} newGroupBy
+ */
+ createNewGroupBy: function (newGroupBy) {
+ var id = _.uniqueId('__filter__');
+ newGroupBy.id = id;
+ newGroupBy.groupId = this._getGroupIdOfType('groupBy');
+ newGroupBy.groupNumber = this._generateNewGroupNumber();
+ this.filters[id] = newGroupBy;
+ if (_.contains(['date', 'datetime'], newGroupBy.fieldType)) {
+ this.toggleFilterWithOptions(newGroupBy.id);
+ } else {
+ this.toggleFilter(newGroupBy.id);
+ }
+ },
+ /**
+ * Ensure that the filters determined by the given filterIds are
+ * deactivated (if one or many of them are already deactivated, nothing bad happens)
+ *
+ * @param {string[]} filterIds
+ */
+ deactivateFilters: function (filterIds) {
+ var self = this;
+ filterIds.forEach(function (filterId) {
+ var filter = self.filters[filterId];
+ var group = self.groups[filter.groupId];
+ if (_.contains(group.activeFilterIds, filterId)) {
+ self.toggleFilter(filterId);
+ }
+ });
+ },
+ /**
+ * Deactivate all filters in a given group with given id.
+ *
+ * @param {string} groupId
+ */
+ deactivateGroup: function (groupId) {
+ var self = this;
+ var group = this.groups[groupId];
+ _.each(group.activeFilterIds, function (filterId) {
+ var filter = self.filters[filterId];
+ // TODO: put this logic in toggleFilter 'field' type
+ if (filter.autoCompleteValues) {
+ filter.autoCompleteValues = [];
+ }
+ });
+ // TODO: use toggleFilter here
+ group.activeFilterIds = [];
+ this.query.splice(this.query.indexOf(groupId), 1);
+ },
+ /**
+ * Delete a filter of type 'favorite' with given filterId server side and in control panel model.
+ * Of course this forces the filter to be removed from the search query.
+ *
+ * @param {string} filterId
+ */
+ deleteFilterEverywhere: function (filterId) {
+ var self = this;
+ var filter = this.filters[filterId];
+ var def = this.deleteFilter(filter.serverSideId).then(function () {
+ var activeFavoriteId = self.groups[filter.groupId].activeFilterIds[0];
+ var isActive = activeFavoriteId === filterId;
+ if (isActive) {
+ self.toggleFilter(filterId);
+ }
+ delete self.filters[filterId];
+ });
+ return def;
+ },
+ /**
+ * Return the state of the control panel (the filters, groups and the
+ * current query). This state can then be used in an other control panel
+ * model (with same key modelName) via the importState method.
+ *
+ * @returns {Object}
+ */
+ exportState: function () {
+ return {
+ filters: this.filters,
+ groups: this.groups,
+ query: this.query,
+ };
+ },
+ /**
+ * @override
+ *
+ * @returns {Object}
+ */
+ get: function () {
+ var self = this;
+ // we maintain a unique source activeFilterIds that contain information
+ // on active filters. But the renderer can have more information since
+ // it does not modifies filters activity.
+ // We thus give a different structure to renderer that may contain duplicated
+ // information.
+ // Note that filters are filters of filter type only, groupbys are groupbys,...!
+ var filterFields = [];
+ var filters = [];
+ var groupBys = [];
+ var timeRanges = [];
+ var favorites = [];
+ Object.keys(this.filters).forEach(function (filterId) {
+ var filter = _.extend({}, self.filters[filterId]);
+ var group = self.groups[filter.groupId];
+ filter.isActive = group.activeFilterIds.indexOf(filterId) !== -1;
+ if (filter.type === 'field') {
+ filterFields.push(filter);
+ }
+ if (filter.type === 'filter') {
+ filters.push(filter);
+ }
+ if (filter.type === 'groupBy') {
+ groupBys.push(filter);
+ }
+ if (filter.type === 'favorite') {
+ favorites.push(filter);
+ }
+ if (filter.type === 'timeRange') {
+ timeRanges.push(filter);
+ }
+ });
+ var facets = [];
+ // resolve active filters for facets
+ this.query.forEach(function (groupID) {
+ var group = self.groups[groupID];
+ var facet = _.extend({}, group);
+ facet.filters = facet.activeFilterIds.map(function (filterID) {
+ return self.filters[filterID];
+ });
+ facets.push(facet);
+ });
+ favorites = _.sortBy(favorites, 'groupNumber');
+ return {
+ facets: facets,
+ filterFields: filterFields,
+ filters: filters,
+ groupBys: groupBys,
+ timeRanges: timeRanges,
+ favorites: favorites,
+ groups: this.groups,
+ query: _.extend([], this.query),
+ fields: this.fields,
+ };
+ },
+ /**
+ * @returns {Object} An object called search query with keys domain, groupBy,
+ * context, orderedBy.
+ */
+ getQuery: function () {
+ var userContext = session.user_context;
+ var context = _.extend(
+ pyUtils.eval('contexts', this._getQueryContext(), userContext),
+ this._getTimeRangeMenuData(true)
+ );
+ var domain = Domain.prototype.stringToArray(this._getDomain(), userContext);
+ // this must be done because pyUtils.eval does not know that it needs to evaluate domains within contexts
+ if (context.timeRangeMenuData) {
+ if (typeof context.timeRangeMenuData.timeRange === 'string') {
+ context.timeRangeMenuData.timeRange = pyUtils.eval('domain', context.timeRangeMenuData.timeRange);
+ }
+ if (typeof context.timeRangeMenuData.comparisonTimeRange === 'string') {
+ context.timeRangeMenuData.comparisonTimeRange = pyUtils.eval('domain', context.timeRangeMenuData.comparisonTimeRange);
+ }
+ }
+ var action_context = this.actionContext;
+ var results = pyUtils.eval_domains_and_contexts({
+ domains: [this.actionDomain].concat([domain] || []),
+ contexts: [action_context].concat(context || []),
+ eval_context: session.user_context,
+ });
+ if (results.error) {
+ throw new Error(_.str.sprintf(_t("Failed to evaluate search criterions")+": \n%s",
+ JSON.stringify(results.error)));
+ }
+
+ var groupBys = this._getGroupBy();
+ var groupBy = groupBys.length ?
+ groupBys :
+ (this.actionContext.group_by || []);
+ groupBy = (typeof groupBy === 'string') ? [groupBy] : groupBy;
+
+ context = _.omit(results.context, 'time_ranges');
+
+ return {
+ context: context,
+ domain: results.domain,
+ groupBy: groupBy,
+ orderedBy: this._getOrderedBy(),
+ };
+ },
+ /**
+ * Set filters, groups, and query keys according to the given state.
+ *
+ * @param {Object} state
+ */
+ importState: function (state) {
+ this.filters = state.filters;
+ this.groups = state.groups;
+ this.query = state.query;
+ },
+ /**
+ * The load method is the place where the favorites are also loaded and
+ * the model state is first established.
+ * The computation of the active filters at initialization is accomplished
+ * here.
+ *
+ * @param {Object} params
+ * @param {boolean} [params.activateDefaultFavorite=false]
+ * @param {Array[]} [params.groups=[]]
+ * @param {Object} [params.initialState]
+ * @param {string[]} [params.searchMenuTypes=[]]
+ * @param {Object} [params.timeRanges]
+ * @param {boolean} [params.withSearchBar]
+ * @returns {Deferred}
+ */
+ load: function (params) {
+ var self = this;
+ this.searchMenuTypes = params.searchMenuTypes || [];
+ this.activateDefaultFavorite = params.activateDefaultFavorite;
+
+ if (!params.withSearchBar && params.searchMenuTypes.length === 0) {
+ // The model state will remain as set in init method
+ // and the info comming from arch put in params.groups (if any)
+ // will be lost. This is not a problem because the state
+ // won't be use elsewhere.
+ return $.when();
+ }
+ if (params.initialState) {
+ this.importState(params.initialState);
+ return $.when();
+ } else {
+ var groups = params.groups || [];
+ groups.forEach(function (group) {
+ self._createGroupOfFilters(group);
+ });
+ if (this._getGroupIdOfType('groupBy') === undefined) {
+ this._createEmptyGroup('groupBy');
+ }
+ this._createGroupOfTimeRanges();
+ return $.when.apply($, self._loadSearchDefaults()).then(function () {
+ return self._loadFavorites().then(function () {
+ if (self.query.length === 0) {
+ self._activateDefaultFilters();
+ self._activateDefaultTimeRanges(params.timeRanges);
+ }
+ });
+ });
+ }
+ },
+ /**
+ * Toggle a filter with given id in a way appropriate to its type.
+ *
+ * @param {Object} params
+ * @param {string} params.filterId
+ * @param {Object} params.autoCompleteValues
+ */
+ toggleAutoCompletionFilter: function (params) {
+ var filter = this.filters[params.filterId];
+ if (filter.type === 'field') {
+ filter.autoCompleteValues = params.autoCompleteValues;
+ // the autocompletion filter is dynamic
+ filter.domain = this._getAutoCompletionFilterDomain(filter);
+ // active the filter
+ var group = this.groups[filter.groupId];
+ if (!group.activeFilterIds.includes(filter.id)) {
+ group.activeFilterIds.push(filter.id);
+ this.query.push(group.id);
+ }
+ } else {
+ if (filter.hasOptions) {
+ this.toggleFilterWithOptions(filter.id);
+ } else {
+ this.toggleFilter(filter.id);
+ }
+ }
+ },
+ /**
+ * Toggle a filter throught the modification of this.groups and potentially
+ * of this.query and this.filters.
+ *
+ * @param {string} filterId
+ */
+ toggleFilter: function (filterId) {
+ var self = this;
+ var filter = this.filters[filterId];
+ var group = this.groups[filter.groupId];
+ var index = group.activeFilterIds.indexOf(filterId);
+ var initiaLength = group.activeFilterIds.length;
+ if (index === -1) {
+ // we need to deactivate all groups when activating a favorite
+ if (filter.type === 'favorite') {
+ this.query.forEach(function (groupId) {
+ self.groups[groupId].activeFilterIds = [];
+ });
+ this.query = [];
+ }
+ group.activeFilterIds.push(filterId);
+ // if initiaLength is 0, the group was not active.
+ if (filter.type === 'favorite' || initiaLength === 0) {
+ this.query.push(group.id);
+ }
+ } else {
+ if (filter.type === 'field' && filter.autoCompleteValues) {
+ filter.autoCompleteValues = [];
+ }
+ group.activeFilterIds.splice(index, 1);
+ // if initiaLength is 1, the group is now inactive.
+ if (initiaLength === 1) {
+ this.query.splice(this.query.indexOf(group.id), 1);
+ }
+ }
+ },
+ /**
+ * Used to toggle a given filter(Id) that has options with a given option(Id).
+ *
+ * @param {string} filterId
+ * @param {string} [optionId]
+ */
+ toggleFilterWithOptions: function (filterId, optionId) {
+ var filter = this.filters[filterId];
+ var group = this.groups[filter.groupId];
+ var alreadyActive = group.activeFilterIds.indexOf(filterId) !== -1;
+ if (alreadyActive) {
+ if (filter.currentOptionId === optionId) {
+ this.toggleFilter(filterId);
+ filter.currentOptionId = false;
+ } else {
+ filter.currentOptionId = optionId || filter.defaultOptionId;
+ }
+ } else {
+ this.toggleFilter(filterId);
+ filter.currentOptionId = optionId || filter.defaultOptionId;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Activate all filters (but favorites) with key isDefault=true
+ *
+ * @private
+ */
+ _activateDefaultFilters: function () {
+ var self = this;
+ Object.keys(this.filters).forEach(function (filterId) {
+ var filter = self.filters[filterId];
+ // if we are here, this means there is no favorite with isDefault set to true
+ if (filter.isDefault && filter.type !== 'favorite') {
+ if (filter.hasOptions) {
+ self.toggleFilterWithOptions(filter.id);
+ } else {
+ self.toggleFilter(filter.id);
+ }
+ }});
+ },
+ /**
+ * If defaultTimeRanges param is provided, activate the filter of type
+ * 'timeRange' it determines with the correct options.
+ *
+ * @private
+ * @param {Object} [defaultTimeRanges]
+ * @param {string} [defaultTimeRanges.field], field of type 'date' or 'datetime'
+ * @param {string} [defaultTimeRanges.range], to choose among the following:
+ * 'last_7_days', 'last_30_days', 'last_365_days', 'today', 'this_week',
+ * 'this_month', 'this_quarter', 'this_year', 'yesterday', 'last_week',
+ * 'last_month', 'last_quarter', 'last_year'
+ * @param {'previous_period'|'previous_year'} [defaultTimeRanges.comparisonRange]
+ */
+ _activateDefaultTimeRanges: function (defaultTimeRanges) {
+ var self = this;
+ if (defaultTimeRanges) {
+ var filterId = Object.keys(this.filters).find(function (filterId) {
+ var filter = self.filters[filterId];
+ return filter.type === 'timeRange' && filter.fieldName === defaultTimeRanges.field;
+ });
+ if (filterId) {
+ this.activateTimeRange(
+ filterId,
+ defaultTimeRanges.range,
+ defaultTimeRanges.comparisonRange
+ );
+ }
+ }
+ },
+ /**
+ * Create a new filter of type 'favorite' and toggle it.
+ * It belongs to the unique group of favorites.
+ *
+ * @private
+ * @param {Object} favorite
+ */
+ _addNewFavorite: function (favorite) {
+ var id = _.uniqueId('__filter__');
+ favorite.id = id;
+ favorite.groupId = this._getGroupIdOfType('favorite');
+ this.filters[id] = favorite;
+ this.toggleFilter(favorite.id);
+ },
+ /**
+ * Add a new empty group to this.groups of a specified type.
+ *
+ * @private
+ * @param {string} type
+ */
+ _createEmptyGroup: function (type) {
+ var id = _.uniqueId('__group__');
+ this.groups[id] = {
+ id: id,
+ type: type,
+ activeFilterIds: [],
+ };
+ },
+ /**
+ * Using a list of 'prefilters', create a new group in this.groups and a new
+ * filter for each prefilter. The new filters are put in the new group.
+ *
+ * @private
+ * @param {Object[]} group, list of 'prefilters'
+ */
+ _createGroupOfFilters: function (group) {
+ var self= this;
+ var type;
+ var groupId = _.uniqueId('__group__');
+ group.forEach(function (filter) {
+ var id = _.uniqueId('__filter__');
+ filter.id = id;
+ filter.groupId = groupId;
+ type = filter.type;
+ self.filters[id] = filter;
+ });
+ this.groups[groupId] = {
+ id: groupId,
+ type: type,
+ activeFilterIds: [],
+ };
+ },
+ /**
+ * Add a group of type 'timeRange' in this.groups and generate a filter
+ * of the same type for each suitable field in this.fields. The new filters
+ * are put in the new group.
+ *
+ * @private
+ */
+ _createGroupOfTimeRanges: function () {
+ var self = this;
+ var timeRanges = [];
+ Object.keys(this.fields).forEach(function (fieldName) {
+ var field = self.fields[fieldName];
+ var fieldType = field.type;
+ if (_.contains(['date', 'datetime'], fieldType) && field.sortable) {
+ timeRanges.push({
+ type: 'timeRange',
+ description: field.string,
+ fieldName : fieldName,
+ fieldType: fieldType,
+ timeRangeId: false,
+ comparisonTimeRangeId: false,
+ defaultTimeRangeId: DEFAULT_TIMERANGE,
+ timeRangeOptions: TIME_RANGE_OPTIONS,
+ comparisonTimeRangeOptions: COMPARISON_TIME_RANGE_OPTIONS
+ });
+ }
+ });
+ if (timeRanges.length) {
+ this._createGroupOfFilters(timeRanges);
+ } else {
+ // create empty timeRange group
+ this._createEmptyGroup('timeRange');
+ }
+ },
+ /**
+ * Create a new groupNumber not already used elsewhere.
+ * Group numbers are used to separate graphically groups of items in the
+ * search menus (filter menu, groupBy menu,...).
+ *
+ * @private
+ * @returns {number} groupNumber
+ */
+ _generateNewGroupNumber: function () {
+ var self = this;
+ var groupNumber = 1 + Object.keys(this.filters).reduce(
+ function (max, filterId) {
+ var filter = self.filters[filterId];
+ if (filter.groupNumber) {
+ max = Math.max(filter.groupNumber, max);
+ }
+ return max;
+ },
+ 1
+ );
+ return groupNumber;
+ },
+ /**
+ * @private
+ * @param {Object} filter
+ * @returns {string} domain
+ */
+ _getAutoCompletionFilterDomain: function (filter) {
+ var domain = "";
+ var field = this.fields[filter.attrs.name];
+ // TODO: should not do that, the domain logic should be put somewhere else
+ var Obj = searchBarAutocompleteRegistry.getAny([filter.attrs.widget, field.type]);
+ if (Obj) {
+ var obj = new (Obj) (this, filter, field, this.actionContext);
+ domain = obj.getDomain(filter.autoCompleteValues);
+ }
+ return domain;
+ },
+ /**
+ * Return the string representation of a domain created by combining
+ * appropriately (with an 'AND') the domains coming from the active groups.
+ *
+ * @private
+ * @returns {string} the string representation of a domain
+ */
+ _getDomain: function () {
+ var self = this;
+ var domains = this.query.map(function (groupId) {
+ var group = self.groups[groupId];
+ return self._getGroupDomain(group);
+ });
+ return pyUtils.assembleDomains(domains, 'AND');
+ },
+ /**
+ * Return the context of the provided filter.
+ *
+ * @private
+ * @param {Object} filter
+ * @returns {Object} context
+ */
+ _getFilterContext: function (filter) {
+ var context = {};
+ if (filter.type === 'favorite') {
+ _.extend(context, filter.context);
+ }
+ // the following code aims to restore this:
+ // https://github.com/odoo/odoo/blob/master/addons/web/static/src/js/views/search/search_inputs.js#L498
+ // this is required for the helpdesk tour to pass
+ // this seems weird to only do that for m2o fields, but a test fails if
+ // we do it for other fields (my guess being that the test should simply
+ // be adapted)
+ if (filter.type === 'field' && filter.isDefault) {
+ if (this.fields[filter.attrs.name].type === 'many2one') {
+ var value = filter.defaultValue;
+ // the following if required to make the main_flow_tour pass (see
+ // https://github.com/odoo/odoo/blob/master/addons/web/static/src/js/views/search/search_inputs.js#L461)
+ if (_.isArray(filter.defaultValue)) {
+ value = filter.defaultValue[0];
+ }
+ context['default_' + filter.attrs.name] = value;
+ }
+ }
+ return context;
+ },
+ /**
+ * Compute (if possible) the domain of the provided filter.
+ *
+ * @private
+ * @param {Object} filter
+ * @returns {string|undefined} domain, string representation of a domain
+ */
+ _getFilterDomain: function (filter) {
+ var domain;
+ if (filter.type === 'filter') {
+ domain = filter.domain;
+ if (filter.domain === undefined) {
+ domain = Domain.prototype.constructDomain(
+ filter.fieldName,
+ filter.currentOptionId,
+ filter.fieldType
+ );
+ }
+ }
+ if (filter.type === 'favorite') {
+ domain = filter.domain;
+ }
+ if (filter.type === 'field') {
+ domain = filter.domain;
+ }
+ return domain;
+ },
+ /**
+ * Compute the groupBys (if possible) of the provided filter.
+ *
+ * @private
+ * @param {Object} filter
+ * @returns {string[]|undefined} groupBys
+ */
+ _getFilterGroupBys: function (filter) {
+ var groupBys;
+ if (filter.type === 'groupBy') {
+ var groupBy = filter.fieldName;
+ if (filter.currentOptionId) {
+ groupBy = groupBy + ':' + filter.currentOptionId;
+ }
+ groupBys = [groupBy];
+ }
+ if (filter.type === 'favorite') {
+ groupBys = filter.groupBys;
+ }
+ return groupBys;
+ },
+ /**
+ * Return the concatenation of groupBys comming from the active filters.
+ * The array this.query encoding the order in which the groups have been
+ * activated, the results respect the appropriate logic: the groupBys
+ * coming from an active favorite (if any) come first, then come the
+ * groupBys comming from the active filters of type 'groupBy'.
+ *
+ * @private
+ * @returns {string[]} groupBys
+ */
+ _getGroupBy: function () {
+ var self = this;
+ var groupBys = this.query.reduce(
+ function (acc, groupId) {
+ var group = self.groups[groupId];
+ return acc.concat(self._getGroupGroupBys(group));
+ },
+ []
+ );
+ return groupBys;
+ },
+ /**
+ * Return the list of the contexts of the filters acitve in the given
+ * group.
+ *
+ * @private
+ * @param {Object} group
+ * @returns {Object[]}
+ */
+ _getGroupContexts: function (group) {
+ var self = this;
+ var contexts = group.activeFilterIds.map(function (filterId) {
+ var filter = self.filters[filterId];
+ return self._getFilterContext(filter);
+ });
+ return _.compact(contexts);
+ },
+ /**
+ * Return the string representation of a domain created by combining
+ * appropriately (with an 'OR') the domains coming from the filters
+ * active in the given group.
+ *
+ * @private
+ * @param {Object} group
+ * @returns {string} string representation of a domain
+ */
+ _getGroupDomain: function (group) {
+ var self = this;
+ var domains = group.activeFilterIds.map(function (filterId) {
+ var filter = self.filters[filterId];
+ return self._getFilterDomain(filter);
+ });
+ return pyUtils.assembleDomains(_.compact(domains), 'OR');
+ },
+ /**
+ * Return the groupBys coming form the filtes active in the given group.
+ *
+ * @private
+ * @param {Object} group
+ * @returns {string[]}
+ */
+ _getGroupGroupBys: function (group) {
+ var self = this;
+ var groupBys = group.activeFilterIds.reduce(
+ function (acc, filterId) {
+ var filter = self.filters[filterId];
+ acc = acc.concat(self._getFilterGroupBys(filter));
+ return acc;
+ },
+ []
+ );
+ return _.compact(groupBys);
+ },
+ /**
+ * Return the id of the group with the provided type.
+ *
+ * @private
+ * @param {'groupBy'|'favorite'|'timeRange'} type
+ * @returns {string}
+ */
+ _getGroupIdOfType: function (type) {
+ var self = this;
+ return Object.keys(this.groups).find(function (groupId) {
+ var group = self.groups[groupId];
+ return group.type === type;
+ });
+ },
+ /**
+ * Used to get the key orderedBy of a favorite.
+ *
+ * @private
+ * @returns {Object[]} orderedBy
+ */
+ _getOrderedBy: function () {
+ var orderedBy;
+ var id = this._getGroupIdOfType('favorite');
+ if (this.query.indexOf(id) !== -1) {
+ // if we are here, this means that the group of favorite is
+ // active and activeFilterIds is a list of length one.
+ var group = this.groups[id];
+ var activeFavoriteId = group.activeFilterIds[0];
+ var favorite = this.filters[activeFavoriteId];
+ orderedBy = favorite.orderedBy;
+ }
+ return orderedBy;
+ },
+ /**
+ * Return the list of the contexts of active filters.
+ *
+ * @private
+ * @returns {Object[]}
+ */
+ _getQueryContext: function () {
+ var self = this;
+ var contexts = this.query.reduce(
+ function (acc, groupId) {
+ var group = self.groups[groupId];
+ acc = acc.concat(self._getGroupContexts(group));
+ return acc;
+ },
+ []
+ );
+ return _.compact(contexts);
+ },
+ /**
+ * Return an empty object or an object with a key timeRangeMenuData
+ * containing info on time ranges and their descriptions if a filter of type
+ * 'timeRange' is activated (only one can be).
+ * The key timeRange and comparisonTimeRange will be string or array
+ * representation of domains according to the value of evaluation:
+ * array if evaluation is true, string if false.
+ *
+ * @private
+ * @param {boolean} [evaluation=false]
+ * @returns {Object}
+ */
+ _getTimeRangeMenuData: function (evaluation) {
+ var context = {};
+ // groupOfTimeRanges can be undefined in case with withSearchBar is false
+ var groupOfTimeRanges = this.groups[this._getGroupIdOfType('timeRange')];
+ if (groupOfTimeRanges && groupOfTimeRanges.activeFilterIds.length) {
+ var filter = this.filters[groupOfTimeRanges.activeFilterIds[0]];
+
+ var comparisonTimeRange = "[]";
+ var comparisonTimeRangeDescription;
+
+ var timeRange = Domain.prototype.constructDomain(
+ filter.fieldName,
+ filter.timeRangeId,
+ filter.fieldType
+ );
+ var timeRangeDescription = filter.timeRangeOptions.find(function (option) {
+ return option.optionId === filter.timeRangeId;
+ }).description.toString();
+ if (filter.comparisonTimeRangeId) {
+ comparisonTimeRange = Domain.prototype.constructDomain(
+ filter.fieldName,
+ filter.timeRangeId,
+ filter.fieldType,
+ null,
+ filter.comparisonTimeRangeId
+ );
+ comparisonTimeRangeDescription = filter.comparisonTimeRangeOptions.find(function (comparisonOption) {
+ return comparisonOption.optionId === filter.comparisonTimeRangeId;
+ }).description.toString();
+ }
+ if (evaluation) {
+ timeRange = Domain.prototype.stringToArray(timeRange);
+ comparisonTimeRange = Domain.prototype.stringToArray(comparisonTimeRange);
+ }
+ context = {
+ timeRangeMenuData: {
+ timeRange: timeRange,
+ timeRangeDescription: timeRangeDescription,
+ comparisonTimeRange: comparisonTimeRange,
+ comparisonTimeRangeDescription: comparisonTimeRangeDescription,
+ }
+ };
+ }
+ return context;
+ },
+ /**
+ * Load custom filters in db, then create a group of type 'favorite' and a
+ * filter of type 'favorite' for each loaded custom filters.
+ * The new filters are put in the new group.
+ * Finally, if there exists (a necessarily unique) default favorite, it is activated
+ * if this.activateDefaultFavorite is true.
+ *
+ * @private
+ * @returns {Deferred}
+ */
+ _loadFavorites: function () {
+ var self = this;
+ var def = this.loadFilters(this.modelName,this.actionId).then(function (favorites) {
+ if (favorites.length) {
+ favorites = favorites.map(function (favorite) {
+ var userId = favorite.user_id ? favorite.user_id[0] : false;
+ var groupNumber = userId ? 1 : 2;
+ var context = pyUtils.eval('context', favorite.context, session.user_context);
+ var groupBys = [];
+ if (context.group_by) {
+ groupBys = context.group_by;
+ delete context.group_by;
+ }
+ var sort = JSON.parse(favorite.sort);
+ var orderedBy = sort.map(function (order) {
+ var orderTerms = order.split(' ');
+ return {
+ name: orderTerms[0],
+ asc: orderTerms.length === 2 && orderTerms[1] === 'asc',
+ };
+ });
+ return {
+ type: 'favorite',
+ description: favorite.name,
+ isRemovable: true,
+ groupNumber: groupNumber,
+ isDefault: favorite.is_default,
+ domain: favorite.domain,
+ groupBys: groupBys,
+ // we want to keep strings as long as possible
+ context: favorite.context,
+ orderedBy: orderedBy,
+ userId: userId,
+ serverSideId: favorite.id,
+ };
+ });
+ self._createGroupOfFilters(favorites);
+ if (self.activateDefaultFavorite) {
+ var defaultFavoriteId = Object.keys(self.filters).find(function (filterId) {
+ var filter = self.filters[filterId];
+ return filter.type === 'favorite' && filter.isDefault;
+ });
+ if (defaultFavoriteId) {
+ self.toggleFilter(defaultFavoriteId);
+ }
+ }
+ } else {
+ self._createEmptyGroup('favorite');
+ }
+ });
+ return def;
+ },
+ /**
+ * Load search defaults and set the `domain` key on filter (of type `field`).
+ * Some search defaults need to fetch data (like m2o for example) so this
+ * is asynchronous.
+ *
+ * @private
+ * @returns {Deferred[]}
+ */
+ _loadSearchDefaults: function () {
+ var self = this;
+ var defs = [];
+ _.each(this.filters, function (filter) {
+ if (filter.type === 'field' && filter.isDefault) {
+ var def;
+ var field = self.fields[filter.attrs.name];
+ var value = filter.defaultValue;
+ if (field.type === 'many2one') {
+ if (value instanceof Array) {
+ // M2O search fields do not currently handle multiple default values
+ // there are many cases of {search_default_$m2ofield: [id]}, need
+ // to handle this as if it were a single value.
+ value = value[0];
+ }
+ def = self._rpc({
+ model: field.relation,
+ method: 'name_get',
+ args: [value],
+ context: self.actionContext,
+ }).then(function (result) {
+ var autocompleteValue = {
+ label: result[0][1],
+ value: value,
+ };
+ filter.autoCompleteValues.push(autocompleteValue);
+ filter.domain = self._getAutoCompletionFilterDomain(filter);
+ });
+ } else {
+ var autocompleteValue;
+ if (field.type === 'selection') {
+ var match = _.find(field.selection, function (sel) {
+ return sel[0] === value;
+ });
+ autocompleteValue = {
+ label: match[1],
+ value: match[0],
+ };
+ } else {
+ autocompleteValue = {
+ label: String(value),
+ value: value,
+ };
+ }
+ filter.autoCompleteValues.push(autocompleteValue);
+ filter.domain = self._getAutoCompletionFilterDomain(filter);
+ }
+ if (def) {
+ defs.push(def);
+ }
+ }
+ });
+ return defs;
+ },
+ /**
+ * Compute the search Query and save it as an ir.filter in db.
+ * No evaluation of domains is done in order to keep them dynamic.
+ * If the operatio is successful, a new filter of type 'favorite' is
+ * created and activated.
+ *
+ * @private
+ * @param {Object} favorite
+ * @returns {Deferred}
+ */
+ _saveQuery: function (favorite) {
+ var self = this;
+ var userContext = session.user_context;
+ var controllerQueryParams;
+ this.trigger_up('get_controller_query_params', {
+ callback: function (state) {
+ controllerQueryParams = state;
+ },
+ });
+ var queryContext = this._getQueryContext();
+ var timeRangeMenuInfo = this._getTimeRangeMenuData(false);
+ var context = pyUtils.eval(
+ 'contexts',
+ [userContext, controllerQueryParams.context, timeRangeMenuInfo].concat(queryContext)
+ );
+ context = _.omit(context, Object.keys(userContext));
+ var groupBys = this._getGroupBy();
+ if (groupBys.length) {
+ context.group_by = groupBys;
+ }
+ var domain = this._getDomain();
+ var userId = favorite.isShared ? false : session.uid;
+ var orderedBy = this._getOrderedBy() || [];
+ if (controllerQueryParams.orderedBy) {
+ orderedBy = controllerQueryParams.orderedBy;
+ }
+ var sort = orderedBy.map(function (order) {
+ return order.name + ((order.asc === false) ? " desc" : "");
+ });
+
+ var irFilter = {
+ name: favorite.description,
+ context: context,
+ domain: domain,
+ is_default: favorite.isDefault,
+ user_id: userId,
+ model_id: this.modelName,
+ action_id: this.actionId,
+ sort: JSON.stringify(sort),
+ };
+ return this.createFilter(irFilter).then(function (serverSideId) {
+ // we don't want the groupBys to be located in the context in control panel model
+ delete context.group_by;
+ favorite.isRemovable = true;
+ favorite.groupNumber = userId ? 1 : 2;
+ favorite.context = context;
+ favorite.groupBys = groupBys;
+ favorite.domain = domain;
+ favorite.orderedBy = orderedBy;
+ // not sure keys are usefull
+ favorite.userId = userId;
+ favorite.serverSideId = serverSideId;
+ self._addNewFavorite(favorite);
+ });
+ },
+});
+
+return ControlPanelModel;
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/control_panel_renderer.js b/addons/web/static/src/js/views/control_panel/control_panel_renderer.js
new file mode 100644
index 00000000000..eea7fdae430
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/control_panel_renderer.js
@@ -0,0 +1,387 @@
+odoo.define('web.ControlPanelRenderer', function (require) {
+"use strict";
+
+var config = require('web.config');
+var data = require('web.data');
+var FavoriteMenu = require('web.FavoriteMenu');
+var FilterMenu = require('web.FilterMenu');
+var GroupByMenu = require('web.GroupByMenu');
+var mvc = require('web.mvc');
+var SearchBar = require('web.SearchBar');
+var TimeRangeMenu = require('web.TimeRangeMenu');
+
+var Renderer = mvc.Renderer;
+
+var ControlPanelRenderer = Renderer.extend({
+ template: 'ControlPanel',
+ custom_events: {
+ get_action_info: '_onGetActionInfo',
+ },
+ events: _.extend({}, Renderer.prototype.events, {
+ 'click.bs.dropdown .o_search_options .dropdown-menu': '_onDropdownClicked',
+ 'click .o_searchview_more': '_onMore',
+ }),
+
+ /**
+ * @override
+ * @param {Object} [params.action] current action if any
+ * @param {Object} [params.context]
+ * @param {Object[]} [params.breadcrumbs=[]] list of breadcrumbs elements
+ * @param {boolean} [params.withBreadcrumbs=false] if false, breadcrumbs
+ * won't be rendered
+ * @param {boolean} [params.withSearchBar=false] if false, no search bar
+ * is rendered
+ * @param {string[]} [params.searchMenuTypes=[]] determines the search menus
+ * that are displayed.
+ * @param {String} [params.template] the QWeb template to render the
+ * ControlPanel. By default, the template 'ControlPanel' will be used.
+ * @param {string} [params.title=''] the title visible in control panel
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+ this._breadcrumbs = params.breadcrumbs || [];
+ this._title = params.title || '';
+ this.withBreadcrumbs = params.withBreadcrumbs;
+ this.withSearchBar = params.withSearchBar;
+ if (params.template) {
+ this.template = params.template;
+ }
+ this.context = params.context;
+
+ this.$subMenus = null;
+ this.action = params.action;
+ this.displaySearchMenu = true;
+ this.menusSetup = false;
+ this.searchMenuTypes = params.searchMenuTypes || [];
+ this.subMenus = {};
+ },
+ /**
+ * Render the control panel and create a dictionnary of its exposed elements.
+ *
+ * @override
+ */
+ start: function () {
+ var self = this;
+
+ // exposed jQuery nodesets
+ this.nodes = {
+ $buttons: this.$('.o_cp_buttons'),
+ $pager: this.$('.o_cp_pager'),
+ $sidebar: this.$('.o_cp_sidebar'),
+ $switch_buttons: this.$('.o_cp_switch_buttons'),
+ };
+
+ // if we don't use the default search bar and buttons, we expose the
+ // corresponding areas for custom content
+ if (!this.withSearchBar) {
+ this.nodes.$searchview = this.$('.o_cp_searchview');
+ }
+ if (this.searchMenuTypes.length === 0) {
+ this.nodes.$searchview_buttons = this.$('.o_search_options');
+ }
+
+ if (this.withBreadcrumbs) {
+ this._renderBreadcrumbs();
+ }
+
+ var superDef = this._super.apply(this, arguments);
+ var searchDef = this._renderSearch();
+ return $.when(superDef, searchDef).then(function () {
+ self._setSearchMenusVisibility();
+ });
+ },
+ /**
+ * @override
+ */
+ on_attach_callback: function () {
+ this._focusSearchInput();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {Object}
+ */
+ getLastFacet: function () {
+ return this.state.facets.slice(-1)[0];
+ },
+ /**
+ * This function is called when actions call 'updateControlPanel' with
+ * custom contents to insert in the exposed areas.
+ *
+ * @param {Object} status
+ * @param {Object} [status.cp_content] dictionnary containing the jQuery
+ * elements to insert in the exposed areas
+ * @param {string} [status.breadcrumbs] the breadcrumbs to display before
+ * the current controller
+ * @param {string} [status.title] the title of the current controller, to
+ * display at the end of the breadcrumbs
+ * @param {Object} [options]
+ * @param {Boolean} [options.clear=true] set to false to keep control panel
+ * elements that are not in status.cp_content (useful for partial updates)
+ */
+ updateContents: function (status, options) {
+ var new_cp_content = status.cp_content || {};
+ var clear = 'clear' in (options || {}) ? options.clear : true;
+
+ if (this.withBreadcrumbs) {
+ this._breadcrumbs = status.breadcrumbs || this._breadcrumbs;
+ this._title = status.title || this._title;
+ this._renderBreadcrumbs();
+ }
+
+ if (clear) {
+ this._detachContent(this.nodes);
+ } else {
+ this._detachContent(_.pick(this.nodes, _.keys(new_cp_content)));
+ }
+ this._attachContent(new_cp_content);
+ },
+ /**
+ * Update the state of the renderer state. It retriggers a full rerendering.
+ *
+ * @param {Object} state
+ * @returns {Deferred}
+ */
+ updateState: function (state) {
+ this.state = state;
+ return this._renderSearch();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} content dictionnary of jQuery elements to attach, whose
+ * keys are jQuery nodes identifiers in this.nodes
+ */
+ _attachContent: function (content) {
+ for (var $element in content) {
+ var $nodeset = content[$element];
+ if ($nodeset && this.nodes[$element]) {
+ this.nodes[$element].append($nodeset);
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {Object} content subset of this.nodes to detach
+ */
+ _detachContent: function (content) {
+ for (var $element in content) {
+ content[$element].contents().detach();
+ }
+ },
+ /**
+ * @private
+ */
+ _focusSearchInput: function () {
+ if (this.withSearchBar && !config.device.isMobile) {
+ // in mobile mode, we would rather not focus manually the
+ // input, because it opens up the integrated keyboard, which is
+ // not what you expect when you just selected a filter.
+ this.searchBar.focus();
+ }
+ },
+ /**
+ * @private
+ * @param {string} menuType
+ * @returns {Objects[]} menuItems
+ */
+ _getMenuItems: function (menuType) {
+ var menuItems;
+ if (menuType === 'filter') {
+ menuItems = this.state.filters;
+ }
+ if (menuType === 'groupBy') {
+ menuItems = this.state.groupBys;
+ }
+ if (menuType === 'timeRange') {
+ menuItems = this.state.timeRanges;
+ }
+ if (menuType === 'favorite') {
+ menuItems = this.state.favorites;
+ }
+ return menuItems;
+ },
+ /**
+ * @private
+ * @returns {jQueryElement}
+ */
+ _getSubMenusPlace: function () {
+ return $('<div>').appendTo(this.$('.o_search_options'));
+ },
+ /**
+ * @private
+ */
+ _renderBreadcrumbs: function () {
+ var self = this;
+ var breadcrumbsDescriptors = this._breadcrumbs.concat({title: this._title});
+ var breadcrumbs = breadcrumbsDescriptors.map(function (bc, index) {
+ return self._renderBreadcrumbsItem(bc, index, breadcrumbsDescriptors.length);
+ });
+ this.$('.breadcrumb').html(breadcrumbs);
+ },
+ /**
+ * Render a breadcrumbs' li jQuery element.
+ *
+ * @private
+ * @param {Object} bc
+ * @param {string} bc.title
+ * @param {string} bc.controllerID
+ * @param {integer} index
+ * @param {integer} length
+ * @returns {jQueryElement} $bc
+ */
+ _renderBreadcrumbsItem: function (bc, index, length) {
+ var self = this;
+ var is_last = (index === length-1);
+ var li_content = bc.title && _.escape(bc.title.trim()) || data.noDisplayContent;
+ var $bc = $('<li>', {class: 'breadcrumb-item'})
+ .append(is_last ? li_content : $('<a>', {href: '#'}).html(li_content))
+ .toggleClass('active', is_last);
+ if (!is_last) {
+ $bc.click(function (ev) {
+ ev.preventDefault();
+ self.trigger_up('breadcrumb_clicked', {controllerID: bc.controllerID});
+ });
+ }
+ return $bc;
+ },
+ /**
+ * Renderer the search bar and the search menus
+ *
+ * @private
+ * @returns {Deferred}
+ */
+ _renderSearch: function () {
+ var defs = [];
+ if (this.menusSetup) {
+ this._updateMenus();
+ } else {
+ this.menusSetup = true;
+ defs = defs.concat(this._setupMenus());
+ }
+ if (this.withSearchBar) {
+ defs.push(this._renderSearchBar());
+ }
+ return $.when(this, defs).then(this._focusSearchInput.bind(this));
+ },
+ /**
+ * @private
+ * @returns {Deferred}
+ */
+ _renderSearchBar: function () {
+ // TODO: might need a reload instead of a destroy/instantiate
+ var oldSearchBar = this.searchBar;
+ this.searchBar = new SearchBar(this, {
+ context: this.context,
+ facets: this.state.facets,
+ fields: this.state.fields,
+ filters: this.state.filters,
+ filterFields: this.state.filterFields,
+ groupBys: this.state.groupBys,
+ });
+ return this.searchBar.appendTo(this.$('.o_searchview')).then(function () {
+ if (oldSearchBar) {
+ oldSearchBar.destroy();
+ }
+ });
+ },
+ /**
+ * Hide or show the search menus according to this.displaySearchMenu.
+ *
+ * @private
+ */
+ _setSearchMenusVisibility: function () {
+ this.$('.o_searchview_more')
+ .toggleClass('fa-search-plus', !this.displaySearchMenu)
+ .toggleClass('fa-search-minus', this.displaySearchMenu);
+ this.$('.o_search_options')
+ .toggleClass('o_hidden', !this.displaySearchMenu);
+ },
+ /**
+ * Create a new menu of the given type and append it to this.$subMenus.
+ * This menu is also added to this.subMenus.
+ *
+ * @private
+ * @param {string} menuType
+ * @returns {Deferred}
+ */
+ _setupMenu: function (menuType) {
+ var Menu;
+ var menu;
+ if (menuType === 'filter') {
+ Menu = FilterMenu;
+ }
+ if (menuType === 'groupBy') {
+ Menu = GroupByMenu;
+ }
+ if (menuType === 'timeRange') {
+ Menu = TimeRangeMenu;
+ }
+ if (menuType === 'favorite') {
+ Menu = FavoriteMenu;
+ }
+ if (_.contains(['filter', 'groupBy', 'timeRange'], menuType)) {
+ menu = new Menu(this, this._getMenuItems(menuType), this.state.fields);
+ }
+ if (menuType === 'favorite') {
+ menu = new Menu(this, this._getMenuItems(menuType), this.action);
+ }
+ this.subMenus[menuType] = menu;
+ return menu.appendTo(this.$subMenus);
+ },
+ /**
+ * Instantiate the search menu determined by this.searchMenuTypes.
+ *
+ * @private
+ * @returns {Deferred[]}
+ */
+ _setupMenus: function () {
+ this.$subMenus = this._getSubMenusPlace();
+ return this.searchMenuTypes.map(this._setupMenu.bind(this));
+ },
+ /**
+ * Update the search menus.
+ *
+ * @private
+ */
+ _updateMenus: function () {
+ var self = this;
+ this.searchMenuTypes.forEach(function (menuType) {
+ self.subMenus[menuType].update(self._getMenuItems(menuType));
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Prevent the search dropdowns from closing when clicking inside them.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onDropdownClicked: function (ev) {
+ ev.stopPropagation();
+ },
+ /**
+ * Toggle the search menus visibility.
+ *
+ * @private
+ */
+ _onMore: function () {
+ this.displaySearchMenu = !this.displaySearchMenu;
+ this._setSearchMenusVisibility();
+ },
+});
+
+return ControlPanelRenderer;
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/control_panel_view.js b/addons/web/static/src/js/views/control_panel/control_panel_view.js
new file mode 100644
index 00000000000..3ba7afb2a60
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/control_panel_view.js
@@ -0,0 +1,238 @@
+odoo.define('web.ControlPanelView', function (require) {
+"use strict";
+
+var ControlPanelController = require('web.ControlPanelController');
+var ControlPanelModel = require('web.ControlPanelModel');
+var ControlPanelRenderer = require('web.ControlPanelRenderer');
+var controlPanelViewParameters = require('web.controlPanelViewParameters');
+var mvc = require('web.mvc');
+var pyUtils = require('web.py_utils');
+var viewUtils = require('web.viewUtils');
+
+var DEFAULT_INTERVAL = controlPanelViewParameters.DEFAULT_INTERVAL;
+var DEFAULT_PERIOD = controlPanelViewParameters.DEFAULT_PERIOD;
+var INTERVAL_OPTIONS = controlPanelViewParameters.INTERVAL_OPTIONS;
+var PERIOD_OPTIONS = controlPanelViewParameters.PERIOD_OPTIONS;
+
+var Factory = mvc.Factory;
+
+var ControlPanelView = Factory.extend({
+ config: _.extend({}, Factory.prototype.config, {
+ Controller: ControlPanelController,
+ Model: ControlPanelModel,
+ Renderer: ControlPanelRenderer,
+ }),
+
+ /**
+ * @override
+ * @param {Object} [params={}]
+ * @param {Object} [params.action={}]
+ * @param {Object} [params.context={}]
+ * @param {string} [params.domain=[]]
+ * @param {string} [params.modelName]
+ * @param {string[]} [params.searchMenuTypes=[]]
+ * determines search menus displayed.
+ * @param {Object} [params.state] used to determine the control panel model
+ * essential content at load. For instance, state can be the state of an
+ * other control panel model that we want to use.
+ * @param {string} [params.template] the QWeb template to render
+ * @param {Object} [params.viewInfo={arch: '<search/>', fields: {}}] a
+ * search fieldsview
+ * @param {string} [params.viewInfo.arch]
+ * @param {boolean} [params.withBreadcrumbs=true] if set to false,
+ * breadcrumbs won't be rendered
+ * @param {boolean} [params.withSearchBar=true] if set to false, no default
+ * search bar will be rendered
+ * @param {boolean} [activateDefaultFavorite=false] determine if the default
+ * custom filters can be activated
+ */
+ init: function (params) {
+ var self = this;
+ this._super();
+ params = params || {};
+ var viewInfo = params.viewInfo || {arch: '<search/>', fields: {}};
+ var context = _.extend({}, params.context);
+ var domain = params.domain || [];
+ var action = params.action || {};
+
+ this.searchDefaults = {};
+ Object.keys(context).forEach(function (key) {
+ var match = /^search_default_(.*)$/.exec(key);
+ if (match) {
+ self.searchDefaults[match[1]] = context[key];
+ delete context[key];
+ }
+ });
+
+ this.arch = viewUtils.parseArch(viewInfo.arch);
+ this.fields = viewInfo.fields;
+
+ this.controllerParams.modelName = params.modelName;
+
+ this.modelParams.context = context;
+ this.modelParams.domain = domain;
+ this.modelParams.modelName = params.modelName;
+ this.modelParams.actionId = action.id;
+ this.modelParams.fields = this.fields;
+
+ this.rendererParams.action = action;
+ this.rendererParams.breadcrumbs = params.breadcrumbs;
+ this.rendererParams.context = context;
+ this.rendererParams.searchMenuTypes = params.searchMenuTypes || [];
+ this.rendererParams.template = params.template;
+ this.rendererParams.withBreadcrumbs = params.withBreadcrumbs !== false;
+ this.rendererParams.withSearchBar = 'withSearchBar' in params ? params.withSearchBar : true;
+
+ this.loadParams.withSearchBar = 'withSearchBar' in params ? params.withSearchBar : true;
+ this.loadParams.searchMenuTypes = params.searchMenuTypes || [];
+ this.loadParams.activateDefaultFavorite = params.activateDefaultFavorite;
+ if (this.loadParams.withSearchBar) {
+ if (params.state) {
+ this.loadParams.initialState = params.state;
+ } else {
+ // groups are determined in _parseSearchArch
+ this.loadParams.groups = [];
+ this.loadParams.timeRanges = context.time_ranges;
+ this._parseSearchArch(this.arch);
+ }
+ }
+
+ PERIOD_OPTIONS = PERIOD_OPTIONS.map(function (option) {
+ return _.extend(option, {description: option.description.toString()});
+ });
+ INTERVAL_OPTIONS = INTERVAL_OPTIONS.map(function (option) {
+ return _.extend(option, {description: option.description.toString()});
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} child parsed arch node
+ * @returns {Object}
+ */
+ _evalArchChild: function (child) {
+ if (child.attrs.context) {
+ try {
+ var context = pyUtils.eval('context', child.attrs.context);
+ if (context.group_by) {
+ // let us extract basic data since we just evaluated context
+ // and use a correct tag!
+ child.tag = 'groupBy';
+ child.attrs.fieldName = context.group_by.split(':')[0];
+ child.attrs.defaultInterval = context.group_by.split(':')[1];
+ }
+ } catch (e) {}
+ }
+ return child;
+ },
+ /**
+ * @private
+ * @param {Object} filter
+ * @param {Object} attrs
+ */
+ _extractAttributes: function (filter, attrs) {
+ filter.isDefault = this.searchDefaults[attrs.name] ? true : false;
+ filter.description = attrs.string ||
+ attrs.help ||
+ attrs.name ||
+ attrs.domain ||
+ 'Ω';
+ if (filter.type === 'filter') {
+ filter.domain = attrs.domain;
+ if (attrs.date) {
+ filter.fieldName = attrs.date;
+ filter.fieldType = this.fields[attrs.date].type;
+ // we should be able to declare list of options per date filter
+ // (request of POs) (same remark for groupbys)
+ filter.hasOptions = true;
+ filter.options = PERIOD_OPTIONS;
+ filter.defaultOptionId = attrs.default_period ||
+ DEFAULT_PERIOD;
+ filter.currentOptionId = false;
+ }
+ } else if (filter.type === 'groupBy') {
+ filter.fieldName = attrs.fieldName;
+ filter.fieldType = this.fields[attrs.fieldName].type;
+ if (_.contains(['date', 'datetime'], filter.fieldType)) {
+ filter.hasOptions = true;
+ filter.options = INTERVAL_OPTIONS;
+ filter.defaultOptionId = attrs.defaultInterval ||
+ DEFAULT_INTERVAL;
+ filter.currentOptionId = false;
+ }
+ } else if (filter.type === 'field') {
+ var field = this.fields[attrs.name];
+ filter.attrs = attrs;
+ filter.autoCompleteValues = [];
+ if (filter.isDefault) {
+ // on field, default can be used with a value
+ filter.defaultValue = this.searchDefaults[filter.attrs.name];
+ }
+ if (!attrs.string) {
+ attrs.string = field.string;
+ }
+ }
+ },
+ /**
+ * Parse the arch of a 'search' view.
+ *
+ * @private
+ * @param {Object} arch arch with root node <search>
+ */
+ _parseSearchArch: function (arch) {
+ var self = this;
+ var preFilters = _.flatten(arch.children.map(function (child) {
+ return child.tag !== 'group' ?
+ self._evalArchChild(child) :
+ child.children.map(self._evalArchChild);
+ }));
+ preFilters.push({tag: 'separator'});
+
+ var filter;
+ var currentTag;
+ var currentGroup = [];
+ var groupOfGroupBys = [];
+ var groupNumber = 1;
+
+ _.each(preFilters, function (preFilter) {
+ if (preFilter.tag !== currentTag || _.contains(['separator', 'field'], preFilter.tag)) {
+ if (currentGroup.length) {
+ if (currentTag === 'groupBy') {
+ groupOfGroupBys = groupOfGroupBys.concat(currentGroup);
+ } else {
+ self.loadParams.groups.push(currentGroup);
+ }
+ }
+ currentTag = preFilter.tag;
+ currentGroup = [];
+ groupNumber++;
+ }
+ if (preFilter.tag !== 'separator') {
+ filter = {
+ type: preFilter.tag,
+ // we need to codify here what we want to keep from attrs
+ // and how, for now I put everything.
+ // In some sence, some filter are active (totally determined, given)
+ // and others are passive (require input(s) to become determined)
+ // What is the right place to process the attrs?
+ };
+ if (filter.type === 'filter' || filter.type === 'groupBy') {
+ filter.groupNumber = groupNumber;
+ }
+ self._extractAttributes(filter, preFilter.attrs);
+ currentGroup.push(filter);
+ }
+ });
+ if (groupOfGroupBys.length) {
+ this.loadParams.groups.push(groupOfGroupBys);
+ }
+ },
+});
+
+return ControlPanelView;
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/mobile_control_panel_controller.js b/addons/web/static/src/js/views/control_panel/mobile_control_panel_controller.js
new file mode 100644
index 00000000000..92537c4d8e0
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/mobile_control_panel_controller.js
@@ -0,0 +1,34 @@
+ odoo.define('web.MobileControlPanelController', function (require) {
+"use strict";
+
+var config = require('web.config');
+var ControlPanelController = require('web.ControlPanelController');
+
+if (!config.device.isMobile) {
+ return;
+}
+
+ControlPanelController.include({
+ custom_events:_.extend({}, ControlPanelController.prototype.custom_events, {
+ 'search_bar_cleared': '_onSearchBarCleared',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * There is a 'Clear' button in the mobile control panel view.
+ *
+ * @private
+ */
+ _onSearchBarCleared: function () {
+ var self = this;
+ this.model.get().query.forEach(function (groupId) {
+ self.model.deactivateGroup(groupId);
+ });
+ this._reportNewQueryAndRender();
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/views/search/search_view_mobile.js b/addons/web/static/src/js/views/control_panel/mobile_control_panel_renderer.js
index ea7fe33ad81..23852abcf53 100644
--- a/addons/web/static/src/js/views/search/search_view_mobile.js
+++ b/addons/web/static/src/js/views/control_panel/mobile_control_panel_renderer.js
@@ -1,16 +1,16 @@
- odoo.define('web.SearchViewMobile', function (require) {
+ odoo.define('web.MobileControlPanelRenderer', function (require) {
"use strict";
var config = require('web.config');
-var SearchView = require('web.SearchView');
+var ControlPanelRenderer = require('web.ControlPanelRenderer');
if (!config.device.isMobile) {
return;
}
-SearchView.include({
- template:'SearchViewMobile',
- events:_.extend({}, SearchView.prototype.events, {
+ControlPanelRenderer.include({
+ template:'MobileControlPanel',
+ events:_.extend({}, ControlPanelRenderer.prototype.events, {
'click .o_mobile_search_close, .o_mobile_search_show_result, .o_enable_searchview': '_toggleMobileSearchView',
'click': '_onOpenMobileSearchView',
'click .o_mobile_search_clear_facets': '_onEmptyAll',
@@ -19,42 +19,46 @@ SearchView.include({
}),
//--------------------------------------------------------------------------
- // Public
+ // Private
//--------------------------------------------------------------------------
/**
+ * Overridden to correctly place submenu inside the mobile section.
+ *
+ * @private
* @override
*/
- renderFacets: function () {
- this._super.apply(this, arguments);
- this.$('.o_mobile_search_clear_facets')
- .toggleClass('o_hidden', !this.query.length);
+ _getSubMenusPlace: function () {
+ return this.$('.o_mobile_search_filter');
},
/**
+ * @private
* @override
*/
- toggle_visibility: function (is_visible) {
- // Do not do anything, toggling visibility of searchview is handled
- // explicitly for mobile
+ _render: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self.$('.o_mobile_search_clear_facets')
+ .toggleClass('o_hidden', !self.state.query.length);
+ });
},
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
/**
+ * @private
* @override
*/
- _getButtonsElement: function () {
- return this.$('.o_mobile_search_filter');
+ _renderSearchBar: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self.searchBar.$el.detach().insertAfter(self.$('.o_mobile_search_header'));
+ });
},
/**
- * Toggle mobile search view screen
+ * Toggles mobile search view screen.
*
* @private
*/
_toggleMobileSearchView: function () {
- this.$('.o_enable_searchview').toggleClass('btn-secondary', !!this.query.length);
+ this.$('.o_enable_searchview').toggleClass('btn-secondary', !!this.state.query.length);
this.$('.o_mobile_search').toggleClass('o_hidden');
},
@@ -63,7 +67,7 @@ SearchView.include({
//--------------------------------------------------------------------------
/**
- * Toggle the little arrow in main buttons
+ * Toggles the little arrow in main buttons.
*
* @private
* @param {BootstrapEvent} ev
@@ -72,16 +76,15 @@ SearchView.include({
$(ev.currentTarget).find('.fa-chevron-right').toggleClass('fa-chevron-down');
},
/**
- * Clears all filters from the search view
+ * Clears all filters from the search view.
*
* @private
- * @param {MouseEvent} event
*/
_onEmptyAll: function () {
- this.query.reset();
+ this.trigger_up('search_bar_cleared');
},
/**
- * Open the mobile search view screen
+ * Opens the mobile search view screen.
*
* @private
* @param {MouseEvent} ev
@@ -93,4 +96,4 @@ SearchView.include({
},
});
-}); \ No newline at end of file
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js b/addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js
new file mode 100644
index 00000000000..0a2498c69df
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js
@@ -0,0 +1,142 @@
+odoo.define('web.AddNewFavoriteMenu', function (require) {
+"use strict";
+
+var core = require('web.core');
+var favoritesSubmenusRegistry = require('web.favorites_submenus_registry');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+
+var AddNewFavoriteMenu = Widget.extend({
+ template: 'AddNewFavoriteMenu',
+ events: _.extend({}, Widget.prototype.events, {
+ 'click .o_save_favorite': '_onSaveFavoriteClick',
+ 'click .o_add_favorite.o_menu_header': '_onMenuHeaderClick',
+ 'keyup .o_save_name input': '_onKeyUp',
+ }),
+ /**
+ * @override
+ * @param {Object} params
+ * @param {Object} params.action
+ * @param {Object} params.favorites
+ */
+ init: function (parent, params) {
+ this._super(parent);
+ this.favorites = params.favorites;
+ this.action = params.action;
+ this.isOpen = false;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this._render();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Close the menu and re-render the widget.
+ */
+ closeMenu: function () {
+ this.isOpen = false;
+ this._render();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _render: function () {
+ this.renderElement();
+ if (this.isOpen) {
+ var $input = this.$('.o_favorite_name input.o_input');
+ $input.val(this.action.name);
+ $input.focus();
+ }
+ },
+ /**
+ * @private
+ */
+ _saveFavorite: function () {
+ var self = this;
+ var $inputs = this.$('input');
+ var description = $inputs[0].value;
+ var isDefault = $inputs[1].checked;
+ var isShared = $inputs[2].checked;
+
+ if (!description.length){
+ this.do_warn(_t("Error"), _t("A name for your favorite is required."));
+ $inputs[0].focus();
+ return;
+ }
+ var descriptionAlreadyExists = !!this.favorites.find(function (favorite) {
+ return favorite.description === description;
+ });
+ if (descriptionAlreadyExists) {
+ this.do_warn(_t("Error"), _t("Filter with same name already exists."));
+ $inputs[0].focus();
+ return;
+ }
+ this.trigger_up('new_favorite', {
+ type: 'favorite',
+ description: description,
+ isDefault: isDefault,
+ isShared: isShared,
+ on_success: function () {self.generatorMenuIsOpen = false;},
+ });
+ this.closeMenu();
+ },
+ /**
+ * Hide and display the submenu which allows adding custom filters.
+ *
+ * @private
+ */
+ _toggleMenu: function () {
+ this.isOpen = !this.isOpen;
+ this._render();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onKeyUp: function (ev) {
+ if (ev.which === $.ui.keyCode.ENTER) {
+ this._saveFavorite();
+ }
+ },
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onMenuHeaderClick: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this._toggleMenu();
+ },
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onSaveFavoriteClick: function (ev) {
+ ev.stopPropagation();
+ this._saveFavorite();
+ },
+});
+
+favoritesSubmenusRegistry.add('add_new_favorite_menu', AddNewFavoriteMenu, 0);
+
+return AddNewFavoriteMenu;
+
+});
diff --git a/addons/web/static/src/js/views/search/time_range_menu_options.js b/addons/web/static/src/js/views/control_panel/search/control_panel_view_parameters.js
index 9c042935d0e..171737b66fb 100644
--- a/addons/web/static/src/js/views/search/time_range_menu_options.js
+++ b/addons/web/static/src/js/views/control_panel/search/control_panel_view_parameters.js
@@ -1,10 +1,13 @@
-odoo.define('web.TimeRangeMenuOptions', function (require) {
+odoo.define('web.controlPanelViewParameters', function (require) {
"use strict";
var core = require('web.core');
+
var _lt = core._lt;
-var PeriodOptions = [
+// for FilterMenu
+var DEFAULT_PERIOD = 'this_month';
+var PERIOD_OPTIONS = [
{description: _lt('Last 7 Days'), optionId: 'last_7_days', groupId: 1},
{description: _lt('Last 30 Days'), optionId: 'last_30_days', groupId: 1},
{description: _lt('Last 365 Days'), optionId: 'last_365_days', groupId: 1},
@@ -20,14 +23,34 @@ var PeriodOptions = [
{description: _lt('Last Year'), optionId: 'last_year', groupId: 3},
];
-var ComparisonOptions = [
+// for GroupBy menu
+var GROUPABLE_TYPES = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime'];
+var DEFAULT_INTERVAL = 'month';
+var INTERVAL_OPTIONS = [
+ {description: _lt('Day'), optionId: 'day', groupId: 1},
+ {description: _lt('Week'), optionId: 'week', groupId: 1},
+ {description: _lt('Month'), optionId: 'month', groupId: 1},
+ {description: _lt('Quarter'), optionId: 'quarter', groupId: 1},
+ {description: _lt('Year'), optionId: 'year', groupId: 1},
+ ];
+
+// for TimeRangeMenu
+var DEFAULT_TIMERANGE = DEFAULT_PERIOD;
+var TIME_RANGE_OPTIONS = PERIOD_OPTIONS;
+var COMPARISON_TIME_RANGE_OPTIONS = [
{description: _lt('Previous Period'), optionId: 'previous_period'},
{description: _lt('Previous Year'), optionId: 'previous_year'}
];
return {
- PeriodOptions: PeriodOptions,
- ComparisonOptions: ComparisonOptions,
+ COMPARISON_TIME_RANGE_OPTIONS: COMPARISON_TIME_RANGE_OPTIONS,
+ DEFAULT_INTERVAL: DEFAULT_INTERVAL,
+ DEFAULT_PERIOD: DEFAULT_PERIOD,
+ DEFAULT_TIMERANGE: DEFAULT_TIMERANGE,
+ GROUPABLE_TYPES: GROUPABLE_TYPES,
+ INTERVAL_OPTIONS: INTERVAL_OPTIONS,
+ PERIOD_OPTIONS: PERIOD_OPTIONS,
+ TIME_RANGE_OPTIONS: TIME_RANGE_OPTIONS,
};
-});
+}); \ No newline at end of file
diff --git a/addons/web/static/src/js/views/control_panel/search/favorite_menu.js b/addons/web/static/src/js/views/control_panel/search/favorite_menu.js
new file mode 100644
index 00000000000..8b9ae4d44d6
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/favorite_menu.js
@@ -0,0 +1,103 @@
+odoo.define('web.FavoriteMenu', function (require) {
+"use strict";
+
+var config = require('web.config');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var DropdownMenu = require('web.DropdownMenu');
+var favorites_submenus_registry = require('web.favorites_submenus_registry');
+
+var _t = core._t;
+
+var FavoriteMenu = DropdownMenu.extend({
+ /**
+ * @override
+ * @param {Object} action
+ */
+ init: function (parent, favorites, action) {
+ this._super(parent, favorites);
+ this.action = action;
+ this.isMobile = config.device.isMobile;
+ this.dropdownCategory = 'favorite';
+ this.dropdownTitle = _t('Favorites');
+ this.dropdownIcon = 'fa fa-star';
+ this.dropdownSymbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : false;
+ this.dropdownStyle.mainButton.class = 'o_favorites_menu_button ' +
+ this.dropdownStyle.mainButton.class;
+
+ },
+ /**
+ * Render the template used to register a new favorite and append it
+ * to the basic dropdown menu.
+ *
+ * @override
+ */
+ start: function () {
+ var self = this;
+ var params = {
+ favorites: this.items,
+ action: this.action,
+ };
+ this.$menu = this.$('.o_dropdown_menu');
+ this.$menu.addClass('o_favorites_menu');
+ this.subMenus = [];
+ favorites_submenus_registry.values().forEach(function (SubMenu) {
+ var subMenu = new SubMenu(self, params);
+ subMenu.appendTo(self.$menu);
+ self.subMenus.push(subMenu);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _closeSubMenus: function () {
+ _.invoke(this.subMenus, 'closeMenu');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _onBootstrapClose: function () {
+ this._super.apply(this, arguments);
+ this._closeSubMenus();
+ },
+ /**
+ * @override
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onTrashButtonClick: function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ var self = this;
+ var id = $(event.currentTarget).data('id');
+ var favorite = this.items.find(function (favorite) {
+ return favorite.id === id;
+ });
+ var globalWarning = _t("This filter is global and will be removed for everybody if you continue.");
+ var warning = _t("Are you sure that you want to remove this filter?");
+ var message = favorite.userId ? warning : globalWarning;
+
+ Dialog.confirm(self, message, {
+ title: _t("Warning"),
+ confirm_callback: function () {
+ self.trigger_up('item_trashed', {id: id});
+ },
+ });
+
+ },
+});
+
+return FavoriteMenu;
+
+}); \ No newline at end of file
diff --git a/addons/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js b/addons/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js
new file mode 100644
index 00000000000..b05f8dfe268
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js
@@ -0,0 +1,8 @@
+odoo.define('web.favorites_submenus_registry', function (require) {
+"use strict";
+
+var Registry = require('web.Registry');
+
+return new Registry();
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/filter_menu.js b/addons/web/static/src/js/views/control_panel/search/filter_menu.js
new file mode 100644
index 00000000000..70a242dd0f8
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/filter_menu.js
@@ -0,0 +1,219 @@
+odoo.define('web.FilterMenu', function (require) {
+"use strict";
+
+var config = require('web.config');
+var core = require('web.core');
+var Domain = require('web.Domain');
+var DropdownMenu = require('web.DropdownMenu');
+var search_filters = require('web.search_filters');
+var time = require('web.time');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+var FilterMenu = DropdownMenu.extend({
+ custom_events: {
+ confirm_proposition: '_onConfirmProposition',
+ remove_proposition: '_onRemoveProposition',
+ },
+ events: _.extend({}, DropdownMenu.prototype.events, {
+ 'click .o_add_custom_filter': '_onAddCustomFilterClick',
+ 'click .o_add_condition': '_onAddCondition',
+ 'click .o_apply_filter': '_onApplyClick',
+ }),
+ /**
+ * @override
+ * @param {Object} fields
+ */
+ init: function (parent, filters, fields) {
+ this._super(parent, filters);
+
+ // determines where the filter menu is displayed and its style
+ this.isMobile = config.device.isMobile;
+ // determines when the 'Add custom filter' submenu is open
+ this.generatorMenuIsOpen = false;
+ this.propositions = [];
+ this.fields = _.pick(fields, function (field, name) {
+ return field.selectable !== false && name !== 'id';
+ });
+ this.fields.id = {string: 'ID', type: 'id', searchable: true};
+ this.dropdownCategory = 'filter';
+ this.dropdownTitle = _t('Filters');
+ this.dropdownIcon = 'fa fa-filter';
+ this.dropdownSymbol = this.isMobile ?
+ 'fa fa-chevron-right float-right mt4' :
+ false;
+ this.dropdownStyle.mainButton.class = 'o_filters_menu_button ' +
+ this.dropdownStyle.mainButton.class;
+ },
+ /**
+ * Render the template used to add a new custom filter and append it
+ * to the basic dropdown menu.
+ *
+ * @override
+ */
+ start: function () {
+ this.$menu = this.$('.o_dropdown_menu');
+ this.$menu.addClass('o_filters_menu');
+ this._renderGeneratorMenu();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a proposition inside the custom filter edition menu.
+ *
+ * @private
+ * @returns {$.Deferred}
+ */
+ _appendProposition: function () {
+ // make modern sear_filters code!!! It works but...
+ var prop = new search_filters.ExtendedSearchProposition(this, this.fields);
+ this.propositions.push(prop);
+ this.$('.o_apply_filter').prop('disabled', false);
+ prop.insertBefore(this.$addFilterMenu);
+ },
+ /**
+ * Confirm a filter proposition, creates it and add it to the menu.
+ *
+ * @private
+ */
+ _commitSearch: function () {
+ var filters = _.invoke(this.propositions, 'get_filter').map(function (preFilter) {
+ return {
+ type: 'filter',
+ description: preFilter.attrs.string,
+ domain: Domain.prototype.arrayToString(preFilter.attrs.domain),
+ };
+ });
+ // TO DO intercepts 'new_filters' and decide what to do whith filters
+ // rewrite web.search_filters?
+ this.trigger_up('new_filters', {filters: filters});
+ _.invoke(this.propositions, 'destroy');
+ this.propositions = [];
+ this._toggleCustomFilterMenu();
+ },
+ /**
+ * @private
+ */
+ _renderGeneratorMenu: function () {
+ this.$el.find('.o_generator_menu').remove();
+ if (!this.generatorMenuIsOpen) {
+ _.invoke(this.propositions, 'destroy');
+ this.propositions = [];
+ }
+ var $generatorMenu = QWeb.render('FilterMenuGenerator', {widget: this});
+ this.$menu.append($generatorMenu);
+ this.$addFilterMenu = this.$menu.find('.o_add_filter_menu');
+ if (this.generatorMenuIsOpen && !this.propositions.length) {
+ this._appendProposition();
+ }
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderMenuItems: function () {
+ var self= this;
+ this._super.apply(this, arguments);
+ // the following code adds tooltip on date options in order
+ // to alert the user of the meaning of intervals
+ var $options = this.$('.o_item_option');
+ $options.each(function () {
+ var $option = $(this);
+ $option.tooltip({
+ delay: { show: 500, hide: 0 },
+ title: function () {
+ var itemId = $option.attr('data-item_id');
+ var optionId = $option.attr('data-option_id');
+ var fieldName = _.findWhere(self.items, {id: itemId}).fieldName;
+ var domain = Domain.prototype.constructDomain(fieldName, optionId, 'date', true);
+ var evaluatedDomain = Domain.prototype.stringToArray(domain);
+ var dateFormat = time.getLangDateFormat();
+ var dateStart = moment(evaluatedDomain[1][2], "YYYY-MM-DD", 'en').format(dateFormat);
+ var dateEnd = moment(evaluatedDomain[2][2], "YYYY-MM-DD", 'en').format(dateFormat);
+ if (optionId === 'today' || optionId === 'yesterday') {
+ return dateStart;
+ }
+ return _.str.sprintf(_t('From %s To %s'), dateStart, dateEnd);
+ }
+ });
+ });
+ },
+ /**
+ * Hide and display the submenu which allows adding custom filters.
+ *
+ * @private
+ */
+ _toggleCustomFilterMenu: function () {
+ this.generatorMenuIsOpen = !this.generatorMenuIsOpen;
+ this._renderGeneratorMenu();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAddCondition: function (ev) {
+ ev.stopPropagation();
+ this._appendProposition();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAddCustomFilterClick: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this._toggleCustomFilterMenu();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onApplyClick: function (ev) {
+ ev.stopPropagation();
+ this._commitSearch();
+ },
+ /**
+ * @override
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onBootstrapClose: function () {
+ this._super.apply(this, arguments);
+ this.generatorMenuIsOpen = false;
+ this._renderGeneratorMenu();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onConfirmProposition: function (ev) {
+ ev.stopPropagation();
+ this._commitSearch();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onRemoveProposition: function (ev) {
+ ev.stopPropagation();
+ this.propositions = _.without(this.propositions, ev.target);
+ if (!this.propositions.length) {
+ this.$('.o_apply_filter').prop('disabled', true);
+ }
+ ev.target.destroy();
+ },
+});
+
+return FilterMenu;
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/groupby_menu.js b/addons/web/static/src/js/views/control_panel/search/groupby_menu.js
new file mode 100644
index 00000000000..b67bf9ee75d
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/groupby_menu.js
@@ -0,0 +1,214 @@
+odoo.define('web.GroupByMenu', function (require) {
+"use strict";
+
+var config = require('web.config');
+var controlPanelViewParameters = require('web.controlPanelViewParameters');
+var core = require('web.core');
+var DropdownMenu = require('web.DropdownMenu');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+var DEFAULT_INTERVAL = controlPanelViewParameters.DEFAULT_INTERVAL;
+var GROUPABLE_TYPES = controlPanelViewParameters.GROUPABLE_TYPES;
+var INTERVAL_OPTIONS = controlPanelViewParameters.INTERVAL_OPTIONS;
+
+var GroupByMenu = DropdownMenu.extend({
+ events: _.extend({}, DropdownMenu.prototype.events, {
+ 'click .o_add_custom_group': '_onAddCustomGroupClick',
+ 'click button.o_apply_group': '_onButtonApplyClick',
+ 'click .o_group_selector': '_onGroupSelectorClick',
+ }),
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {Object[]} groupBys list of groupBys (type IGroupBy below)
+ * interface IGroupBy {
+ * itemId: string; unique id associated with the groupby
+ * fieldName: string; field name without interval!
+ * description: string; label printed on screen
+ * groupId: string;
+ * isActive: boolean; determines if the groupby is considered active
+ * hasOptions: boolean; true when the groupBy has fieldType 'date' or 'datetime' (optional)
+ * options: array of objects with 'optionId' and 'description' keys; (optional)
+ * defaultOptionId: string refers to an optionId that should be activated by default
+ * currentOptionId: string refers to an optionId that is activated if item is active (optional)
+ * }
+ * @param {Object} fields 'field_get' of a model: mapping from field name
+ * to an object with its properties
+ * @param {Object} options
+ * @param {string} options.headerStyle conditions the style of the main button
+ */
+ init: function (parent, groupBys, fields, options) {
+ var self = this;
+ this._super(parent, groupBys);
+ this.fields = fields;
+ // determines when the 'Add custom groupby' submenu is open
+ this.generatorMenuIsOpen = false;
+ // determines list of options used by groupBys of type 'date'
+ this.groupableFields = [];
+ _.each(fields, function (field, name) {
+ if (field.sortable && _.contains(GROUPABLE_TYPES, field.type)) {
+ self.groupableFields.push(_.extend({}, field, {
+ name: name,
+ }));
+ }
+ });
+ this.groupableFields = _.sortBy(this.groupableFields, 'string');
+ // determines the list of field names that can be added to the menu
+ // via the 'Add Custom Groupby' menu
+ this.presentedFields = this._setPresentedFields(groupBys);
+
+ // determines where the filter menu is displayed and partly its style
+ this.isMobile = config.device.isMobile;
+
+ this.dropdownCategory = 'groupby';
+ this.dropdownTitle = _t('Group By');
+ this.dropdownIcon = 'fa fa-bars';
+ this.dropdownSymbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : false;
+ // the default style of the groupby menu can be changed here using the options key 'headerStyle'
+ if (options && options.headerStyle === 'primary') {
+ this.dropdownStyle = {
+ el: {class: 'btn-group o_group_by_menu o_dropdown', attrs: {'role': 'group'}},
+ mainButton: {class: 'btn btn-primary dropdown-toggle'},
+ };
+ }
+ INTERVAL_OPTIONS = INTERVAL_OPTIONS.map(function (option) {
+ return _.extend(option, {description: option.description.toString()});
+ });
+ },
+
+ /**
+ * Render the template used to add a new custom groupby and append it
+ * to the basic dropdown menu.
+ *
+ * @override
+ */
+ start: function () {
+ this.$menu = this.$('.o_dropdown_menu');
+ this.$menu.addClass('o_group_by_menu');
+ this._renderGeneratorMenu();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @param {Object[]} groupBys
+ */
+ update: function (groupBys) {
+ this._super.apply(this, arguments);
+ this.presentedFields = this._setPresentedFields(groupBys);
+ this._renderGeneratorMenu();
+ },
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Method called via the 'Add Custom Groupby' menu to create a new groupby
+ * and add it (activated) to the menu. In case the field is of type date, it
+ * is activated using the option determined by the parameter
+ * 'DEFAULT_INTERVAL'.
+ *
+ * @private
+ * @param {string} fieldName
+ */
+ _addGroupby: function (fieldName) {
+ var field = this.presentedFields.find(function (field) {
+ return field.name === fieldName;
+ });
+ var groupBy = {
+ type: 'groupBy',
+ description: field.string,
+ fieldName: fieldName,
+ fieldType: field.type,
+ };
+ if (_.contains(['date', 'datetime'], field.type)) {
+ groupBy.hasOptions = true;
+ groupBy.options = INTERVAL_OPTIONS;
+ groupBy.defaultOptionId = DEFAULT_INTERVAL;
+ groupBy.currentOptionId = false;
+ }
+ this.trigger_up('new_groupBy', groupBy);
+ },
+ /**
+ * @private
+ */
+ _renderGeneratorMenu: function () {
+ this.$el.find('.o_generator_menu').remove();
+ var $generatorMenu = QWeb.render('GroupByMenuGenerator', {widget: this});
+ this.$menu.append($generatorMenu);
+ this.$addCustomGroup = this.$menu.find('.o_add_custom_group');
+ this.$groupSelector = this.$menu.find('.o_group_selector');
+ },
+ /**
+ * @private
+ */
+ _setPresentedFields: function (groupBys) {
+ return this.groupableFields.filter(function (field) {
+ var groupByFields = _.pluck(groupBys, 'fieldName');
+ return !_.contains(groupByFields, field.name);
+ });
+ },
+ /**
+ * @private
+ */
+ _toggleCustomGroupMenu: function () {
+ this.generatorMenuIsOpen = !this.generatorMenuIsOpen;
+ this._renderGeneratorMenu();
+ if (this.generatorMenuIsOpen) {
+ this.$groupSelector.focus();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Toggle the 'Add Custom Group'.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAddCustomGroupClick: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this._toggleCustomGroupMenu();
+ },
+ /*
+ * @override
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onBootstrapClose: function () {
+ this._super.apply(this, arguments);
+ this.generatorMenuIsOpen = false;
+ this._renderGeneratorMenu();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onButtonApplyClick: function (ev) {
+ ev.stopPropagation();
+ var fieldName = this.$groupSelector.val();
+ this.generatorMenuIsOpen = false;
+ this._addGroupby(fieldName);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onGroupSelectorClick: function (ev) {
+ ev.stopPropagation();
+ },
+});
+
+return GroupByMenu;
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js b/addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js
new file mode 100644
index 00000000000..56d43ee6708
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js
@@ -0,0 +1,160 @@
+odoo.define('web.GroupByMenuMixin', function (require) {
+"use strict";
+
+var GroupByMenu = require('web.GroupByMenu');
+var controlPanelViewParameters = require('web.controlPanelViewParameters');
+
+/**
+ * The aim of this mixin is to facilitate the interaction between
+ * a view controller and a dropdown menu with its control panel
+ * TO DO: the pivot subview has two types of groupbys so that it will not
+ * understand the current implementation of this mixin
+ *
+ * @mixin
+ * @name GroupByMenuMixin
+ */
+var GroupByMenuMixin = {
+ init: function () {
+ this.custom_events = _.extend({}, this.custom_events, {
+ menu_item_clicked: '_onMenuItemClicked',
+ item_option_clicked: '_onItemOptionClicked',
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Instantiate a widget GroupByMenu and incorporate it to the control panel
+ * of an embedded view. This is used for instance in the dashboard view in
+ * enterprise where there is no GroupBy menu in the search view because it
+ * would not make sense to have one at the global level.
+ * This function is called by renderButtons when the parameter
+ * 'this.isEmbedded' is set to true.
+ *
+ * private
+ * @param {jQuery} $node
+ * @param {Object} groupableFields
+ */
+ _addGroupByMenu: function ($node, groupableFields) {
+ this.sortedFieldNames = _.sortBy(Object.keys(groupableFields), function (fieldName) {
+ return groupableFields[fieldName].string;
+ });
+ this.groupableFields = groupableFields;
+ var groupBys = this._getGroupBys(this.model.get().groupedBy);
+ this.groupByMenu = new GroupByMenu(this, groupBys, this.groupableFields);
+ this.groupByMenu.insertAfter($node.find('div:first'));
+ },
+ /**
+ * This method puts the active groupBys in a convenient form.
+ *
+ * @private
+ * @param {string[]} activeGroupBys
+ * returns {string[]} groupBysNormalized
+ */
+ _normalizeActiveGroupBys: function (activeGroupBys) {
+ var groupBysNormalized = activeGroupBys.map(function (groupBy) {
+ return {fieldName: groupBy.split(':')[0], interval: groupBy.split(':')[1] || false};
+ });
+ return groupBysNormalized;
+ },
+ /**
+ * This method has to be implemented by the view controller that needs to
+ * interpret the click in an appropriate manner.
+ *
+ * @private
+ * @param {string[]} groupBys
+ */
+ _setGroupby: function (groupBys) {},
+ /**
+ * Return the list of groupBys in a form suitable for the groupByMenu. We do
+ * this each time because we want to be synchronized with the view model.
+ *
+ * @private
+ * @param {string[]} activeGroupBys
+ * returns {Object[]} groupBys
+ */
+ _getGroupBys: function (activeGroupBys) {
+ var self = this;
+ var groupBys = [];
+ var groupBysNormalized = this._normalizeActiveGroupBys(activeGroupBys);
+ this.sortedFieldNames.forEach(function (fieldName) {
+ var field = self.groupableFields[fieldName];
+ var groupByActivity = _.findWhere(groupBysNormalized, {fieldName: fieldName});
+ var groupBy = {
+ id: fieldName,
+ isActive: groupByActivity ? true : false,
+ description: field.string,
+ };
+ if (_.contains(['date', 'datetime'], field.type)) {
+ groupBy.hasOptions = true;
+ groupBy.options = controlPanelViewParameters.INTERVAL_OPTIONS;
+ groupBy.currentOptionId = groupByActivity && groupByActivity.interval ?
+ groupByActivity.interval :
+ false;
+ }
+ groupBys.push(groupBy);
+ });
+ return groupBys;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onItemOptionClicked: function (ev) {
+ var fieldName = ev.data.id;
+ var optionId = ev.data.optionId;
+ var activeGroupBys = this.model.get().groupedBy;
+ var currentOptionId = activeGroupBys.reduce(
+ function (optionId, groupby) {
+ if (groupby.split(':')[0] === fieldName){
+ optionId = groupby.split(':')[1] || controlPanelViewParameters.DEFAULT_INTERVAL;
+ }
+ return optionId;
+ },
+ false
+ );
+ var groupByFieldNames = _.map(activeGroupBys, function (groupby) {
+ return groupby.split(':')[0];
+ });
+ var indexOfGroupby = groupByFieldNames.indexOf(fieldName);
+ if (indexOfGroupby === -1) {
+ activeGroupBys.push(fieldName + ':' + optionId);
+ } else if (currentOptionId === optionId) {
+ activeGroupBys.splice(indexOfGroupby, 1);
+ } else {
+ activeGroupBys.splice(indexOfGroupby, 1, fieldName + ':' + optionId);
+ }
+ this._setGroupby(activeGroupBys);
+ this.groupByMenu.update(this._getGroupBys(activeGroupBys));
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onMenuItemClicked: function (ev) {
+ var fieldName = ev.data.id;
+ var activeGroupBys = this.model.get().groupedBy;
+ var groupByFieldNames = _.map(activeGroupBys, function (groupby) {
+ return groupby.split(':')[0];
+ });
+ var indexOfGroupby = groupByFieldNames.indexOf(fieldName);
+ if (indexOfGroupby === -1) {
+ activeGroupBys.push(fieldName);
+ } else {
+ activeGroupBys.splice(indexOfGroupby, 1);
+ }
+ this._setGroupby(activeGroupBys);
+ this.groupByMenu.update(this._getGroupBys(activeGroupBys));
+ },
+};
+
+return GroupByMenuMixin;
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/search_bar.js b/addons/web/static/src/js/views/control_panel/search/search_bar.js
new file mode 100644
index 00000000000..f9fa15fe67d
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/search_bar.js
@@ -0,0 +1,225 @@
+odoo.define('web.SearchBar', function (require) {
+"use strict";
+
+var AutoComplete = require('web.AutoComplete');
+var searchBarAutocompleteRegistry = require('web.search_bar_autocomplete_sources_registry');
+var SearchFacet = require('web.SearchFacet');
+var Widget = require('web.Widget');
+
+var SearchBar = Widget.extend({
+ template: 'SearchView.SearchBar',
+ events: _.extend({}, Widget.prototype.events, {
+ 'keydown': '_onKeydown',
+ }),
+ /**
+ * @override
+ * @param {Object} [params]
+ * @param {Object} [params.context]
+ * @param {Object[]} [params.facets]
+ * @param {Object} [params.fields]
+ * @param {Object[]} [params.filterFields]
+ * @param {Object[]} [params.filters]
+ * @param {Object[]} [params.groupBys]
+ */
+ init: function (parent, params) {
+ this._super.apply(this, arguments);
+
+ this.context = params.context;
+
+ this.facets = params.facets;
+ this.fields = params.fields;
+ this.filters = params.filters;
+ this.filterFields = params.filterFields;
+ this.groupBys = params.groupBys;
+
+ this.autoCompleteSources = [];
+ this.searchFacets = [];
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$input = this.$('input');
+ var self = this;
+ var defs = [this._super.apply(this, arguments)];
+ _.each(this.facets, function (facet) {
+ defs.push(self._renderFacet(facet));
+ });
+ defs.push(this._setupAutoCompletion());
+ return $.when.apply($, defs);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Focus the searchbar.
+ */
+ focus: function () {
+ this.$input.focus();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _focusFollowing: function () {
+ var focusedIndex = this._getFocusedFacetIndex();
+ var $toFocus;
+ if (focusedIndex === this.searchFacets.length - 1) {
+ $toFocus = this.$input;
+ } else {
+ $toFocus = this.searchFacets.length && this.searchFacets[focusedIndex + 1].$el;
+ }
+
+ if ($toFocus.length) {
+ $toFocus.focus();
+ }
+ },
+ /**
+ * @private
+ */
+ _focusPreceding: function () {
+ var focusedIndex = this._getFocusedFacetIndex();
+ var $toFocus;
+ if (focusedIndex === -1) {
+ $toFocus = this.searchFacets.length && _.last(this.searchFacets).$el;
+ } else if (focusedIndex === 0) {
+ $toFocus = this.$input;
+ } else {
+ $toFocus = this.searchFacets.length && this.searchFacets[focusedIndex - 1].$el;
+ }
+
+ if ($toFocus.length) {
+ $toFocus.focus();
+ }
+ },
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _getFocusedFacetIndex: function () {
+ return _.findIndex(this.searchFacets, function (searchFacet) {
+ return searchFacet.$el[0] === document.activeElement;
+ });
+ },
+ /**
+ * Provide auto-completion result for req.term.
+ *
+ * @private
+ * @param {Object} req request to complete
+ * @param {String} req.term searched term to complete
+ * @param {Function} callback
+ */
+ _getAutoCompleteSources: function (req, callback) {
+ var defs = _.invoke(this.autoCompleteSources, 'getAutocompletionValues', req.term);
+ $.when.apply($, defs).then(function () {
+ callback(_(arguments).chain()
+ .compact()
+ .flatten(true)
+ .value());
+ });
+ },
+ /**
+ * @private
+ * @param {Object} facet
+ * @returns {Deferred}
+ */
+ _renderFacet: function (facet) {
+ var searchFacet = new SearchFacet(this, facet);
+ this.searchFacets.push(searchFacet);
+ return searchFacet.insertBefore(this.$('input'));
+ },
+ /**
+ * @private
+ * @returns {Deferred}
+ */
+ _setupAutoCompletion: function () {
+ var self = this;
+ this._setupAutoCompletionWidgets();
+ this.autoComplete = new AutoComplete(this, {
+ source: this._getAutoCompleteSources.bind(this),
+ select: this._onAutoCompleteSelected.bind(this),
+ get_search_string: function () {
+ return self.$input.val().trim();
+ },
+ });
+ return this.autoComplete.appendTo(this.$el);
+ },
+ /**
+ * @private
+ */
+ _setupAutoCompletionWidgets: function () {
+ var self = this;
+ var registry = searchBarAutocompleteRegistry;
+ _.each(this.filterFields, function (filter) {
+ var field = self.fields[filter.attrs.name];
+ var Obj = registry.getAny([filter.attrs.widget, field.type]);
+ if (Obj) {
+ self.autoCompleteSources.push(new (Obj) (self, filter, field, self.context));
+ }
+ });
+ _.each(this.filters, function (filter) {
+ self.autoCompleteSources.push(new (registry.get('filter'))(self, filter));
+ });
+ _.each(this.groupBys, function (filter) {
+ self.autoCompleteSources.push(new (registry.get('groupby'))(self, filter));
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} e
+ * @param {Object} ui
+ * @param {Object} ui.item selected completion item
+ */
+ _onAutoCompleteSelected: function (e, ui) {
+ e.preventDefault();
+ var filter = ui.item.facet.filter;
+ if (filter.type === 'field') {
+ var values = filter.autoCompleteValues;
+ values.push(ui.item.facet.values[0]);
+ this.trigger_up('autocompletion_filter', {
+ filterId: filter.id,
+ autoCompleteValues: values,
+ });
+ } else {
+ this.trigger_up('autocompletion_filter', {
+ filterId: filter.id,
+ });
+ }
+ },
+ /**
+ * @private
+ * @param {KeyEvent} e
+ */
+ _onKeydown: function (e) {
+ switch(e.which) {
+ case $.ui.keyCode.LEFT:
+ this._focusPreceding();
+ e.preventDefault();
+ break;
+ case $.ui.keyCode.RIGHT:
+ this._focusFollowing();
+ e.preventDefault();
+ break;
+ case $.ui.keyCode.BACKSPACE:
+ if (this.$input.val() === '') {
+ this.trigger_up('facet_removed');
+ }
+ break;
+ }
+ },
+});
+
+return SearchBar;
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js b/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js
new file mode 100644
index 00000000000..f1ac0ee0f2e
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js
@@ -0,0 +1,485 @@
+odoo.define('web.SearchBarAutoCompleteSources', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+var core = require('web.core');
+var Domain = require('web.Domain');
+var field_utils = require('web.field_utils');
+var mixins = require('web.mixins');
+var pyUtils = require('web.py_utils');
+var ServicesMixin = require('web.ServicesMixin');
+var time = require('web.time');
+
+var _t = core._t;
+var _lt = core._lt;
+
+var FilterInterface = Class.extend(mixins.EventDispatcherMixin, {
+ completion_label: _lt("%s"),
+ /**
+ * @override
+ * @param {Object} filter
+ */
+ init: function (parent, filter) {
+ mixins.EventDispatcherMixin.init.call(this);
+ this.setParent(parent);
+
+ this.filter = filter;
+ },
+ /**
+ * Fetch auto-completion values for the widget.
+ *
+ * The completion values should be an array of objects with keys facet and
+ * label. They will be used by search_bar in @_onAutoCompleteSelected.
+ *
+ * @param {string} value value to getAutocompletionValues
+ * @returns {Deferred<null|Array>}
+ */
+ getAutocompletionValues: function (value) {
+ var result;
+ value = value.toLowerCase();
+ if (fuzzy.test(value, this.filter.description)) {
+ result = [{
+ label: _.str.sprintf(this.completion_label.toString(),
+ _.escape(this.filter.description)),
+ facet: {
+ filter: this.filter,
+ },
+ }];
+ }
+ return $.when(result);
+ },
+});
+
+var Filter = FilterInterface.extend({
+ completion_label: _lt("Filter on: %s"),
+});
+
+var GroupBy = FilterInterface.extend({
+ completion_label: _lt("Group by: %s"),
+});
+
+var Field = FilterInterface.extend(ServicesMixin, {
+ default_operator: '=',
+ /**
+ * @override
+ * @param {Object} field
+ * @param {Object} filter
+ * @param {Object} context needed for extra rpc (i.e. m2o)
+ */
+ init: function (parent, filter, field, context) {
+ this._super.apply(this, arguments);
+
+ this.field = field;
+ this.filter = filter;
+ this.attrs = _.extend({}, field, filter.attrs);
+ this.context = context;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getAutocompletionValues: function (value) {
+ return $.when([{
+ label: this._getAutocompletionLabel(value),
+ facet: this._getFacetValue(value),
+ }]);
+ },
+ /**
+ * TODO: the domain logic (getDomain, operator, etc.) should not be put here
+ * (this has nothing to do with the autocomplete sources).
+ *
+ * This method is used by the ControlPanelModel when setting the
+ * `filter_domain`.
+ *
+ * @param {Object[]} values
+ * @returns {string}
+ */
+ getDomain: function (values) {
+ if (!values.length) { return; }
+
+ var valueToDomain;
+ var self = this;
+ var domain = this.attrs.filter_domain;
+ if (domain) {
+ valueToDomain = function (facetValue) {
+ return Domain.prototype.stringToArray(
+ domain,
+ {
+ // these are the values that can be used in search view
+ // fields `filter_domain` attribute
+ self: self._valueFrom(facetValue),
+ raw_value: facetValue.value,
+ }
+ );
+ };
+ } else {
+ valueToDomain = function (facetValue) {
+ return self._makeDomain(
+ self.attrs.name,
+ self.attrs.operator || self.default_operator,
+ facetValue
+ );
+ };
+ }
+ var domains = values.map(valueToDomain);
+
+ domains = domains.map(Domain.prototype.arrayToString);
+ return pyUtils.assembleDomains(domains, 'OR');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {any} value
+ * @returns {string}
+ */
+ _getAutocompletionLabel: function (value) {
+ return _.str.sprintf(_.str.escapeHTML(
+ _t("Search %(field)s for: %(value)s")), {
+ field: '<em>' + _.escape(this.attrs.string) + '</em>',
+ value: '<strong>' + _.escape(value) + '</strong>'});
+ },
+ /**
+ * @private
+ * @param {any} value
+ * @returns {Object}
+ */
+ _getFacetValue: function (value) {
+ return {
+ filter: this.filter,
+ values: [{label: value, value: value}],
+ };
+ },
+ /**
+ * Function creating the returned domain for the field, override this
+ * methods in children if you only need to customize the field's domain
+ * without more complex alterations or tests (and without the need to
+ * change override the handling of filter_domain)
+ *
+ * @private
+ * @param {String} name the field's name
+ * @param {String} operator the field's operator (either attribute-specified or default operator for the field
+ * @param {Number|String} facet parsed value for the field
+ * @returns {Array<Array>} domain to include in the resulting search
+ */
+ _makeDomain: function (name, operator, facet) {
+ return [[name, operator, this._valueFrom(facet)]];
+ },
+ /**
+ * @private
+ * @param {Object} facetValue
+ * @param {any} facetValue.value
+ * @returns {any}
+ */
+ _valueFrom: function (facetValue) {
+ return facetValue.value;
+ },
+});
+
+var CharField = Field.extend({
+ default_operator: 'ilike',
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getAutocompletionValues: function (value) {
+ if (_.isEmpty(value)) { return $.when(null); }
+ return this._super.apply(this, arguments);
+ },
+});
+
+var NumberField = Field.extend({
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getAutocompletionValues: function (value) {
+ var val = this.parse(value);
+ if (isNaN(val)) { return $.when(); }
+ return this._super.apply(this, arguments);
+ },
+});
+
+var IntegerField = NumberField.extend({
+ error_message: _t("not a valid integer"),
+ parse: function (value) {
+ try {
+ return field_utils.parse.integer(value);
+ } catch (e) {
+ return NaN;
+ }
+ },
+});
+
+var FloatField = NumberField.extend({
+ error_message: _t("not a valid number"),
+ parse: function (value) {
+ try {
+ return field_utils.parse.float(value);
+ } catch (e) {
+ return NaN;
+ }
+ },
+});
+
+var SelectionField = Field.extend({
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getAutocompletionValues: function (value) {
+ var self = this;
+ var results = _(this.attrs.selection).chain()
+ .filter(function (sel) {
+ var selValue = sel[0], label = sel[1];
+ if (selValue === undefined || !label) { return false; }
+ return label.toLowerCase().indexOf(value.toLowerCase()) !== -1;
+ })
+ .map(function (sel) {
+ return {
+ label: _.escape(sel[1]),
+ indent: true,
+ facet: self._getFacetValue(sel)
+ };
+ }).value();
+ if (_.isEmpty(results)) { return $.when(null); }
+ return $.when.call(null, [{
+ label: _.escape(this.attrs.string)
+ }].concat(results));
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {any} value
+ * @returns {Object}
+ */
+ _getFacetValue: function (value) {
+ return {
+ filter: this.filter,
+ values: [{label: value[1], value: value[0]}],
+ };
+ },
+});
+
+var BooleanField = SelectionField.extend({
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ this.attrs.selection = [
+ [true, _t("Yes")],
+ [false, _t("No")]
+ ];
+ },
+});
+
+var DateField = Field.extend({
+ /**
+ * @override
+ */
+ getAutocompletionValues: function (value) {
+ // Make sure the value has a correct format before the creation of the moment object. See
+ // issue https://github.com/moment/moment/issues/1407
+ var t, v;
+ try {
+ t = (this.attrs && this.attrs.type === 'datetime') ? 'datetime' : 'date';
+ v = field_utils.parse[t](value, {type: t}, {timezone: true});
+ } catch (e) {
+ return $.when(null);
+ }
+
+ var m = moment(v, t === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD');
+ if (!m.isValid()) { return $.when(null); }
+ var dateString = field_utils.format[t](m, {type: t});
+ var label = this._getAutocompletionLabel(dateString);
+ var facet = this._getFacetValue(dateString, m.toDate());
+ return $.when([{
+ label: label,
+ facet: facet,
+ }]);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _getAutocompletionLabel: function (value) {
+ return _.str.sprintf(_.str.escapeHTML(
+ _t("Search %(field)s at: %(value)s")), {
+ field: '<em>' + _.escape(this.attrs.string) + '</em>',
+ value: '<strong>' + value + '</strong>'});
+ },
+ /**
+ * @override
+ * @param {any} rawValue
+ */
+ _getFacetValue: function (value, rawValue) {
+ var facet = this._super.apply(this, arguments);
+ facet.values[0].value = rawValue;
+ return facet;
+ },
+ /**
+ * @override
+ */
+ _valueFrom: function (facetValue) {
+ return time.date_to_str(facetValue.value);
+ },
+});
+
+var DateTimeField = DateField.extend({
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _valueFrom: function (facetValue) {
+ return time.datetime_to_str(facetValue.value);
+ },
+});
+
+var ManyToOneField = CharField.extend({
+ default_operator: {},
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getAutocompletionValues: function (value) {
+ if (_.isEmpty(value)) { return $.when(null); }
+ var label = this._getAutocompletionLabel(value);
+ return $.when([{
+ label: label,
+ facet: this._getFacetValue(value),
+ expand: this._expand.bind(this),
+ }]);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _getExpandedFacetValue: function (value) {
+ return {
+ filter: this.filter,
+ values: [{label: value[1], value: value[0]}],
+ };
+ },
+ /**
+ * @override
+ */
+ _getFacetValue: function (value) {
+ return {
+ filter: this.filter,
+ values: [{label: value, value: value, operator: 'ilike'}],
+ };
+ },
+ /**
+ * @override
+ */
+ _expand: function (value) {
+ var self = this;
+ var args = this.attrs.domain;
+ if (typeof args === 'string') {
+ try {
+ args = Domain.prototype.stringToArray(args);
+ } catch(e) {
+ args = [];
+ }
+ }
+ return this._rpc({
+ model: this.attrs.relation,
+ method: 'name_search',
+ kwargs: {
+ name: value,
+ args: args,
+ limit: 8,
+ context: this.context,
+ },
+ })
+ .then(function (results) {
+ if (_.isEmpty(results)) { return null; }
+ return _(results).map(function (result) {
+ return {
+ label: _.escape(result[1]),
+ facet: self._getExpandedFacetValue(result)
+ };
+ });
+ });
+ },
+ /**
+ * @override
+ */
+ _makeDomain: function (name, operator, facetValue) {
+ operator = facetValue.operator || operator;
+
+ switch(operator){
+ case this.default_operator:
+ return [[name, '=', facetValue.value]];
+ case 'ilike':
+ return [[name, 'ilike', facetValue.value]];
+ case 'child_of':
+ return [[name, 'child_of', facetValue.value]];
+ }
+ return this._super(name, operator, facetValue);
+ },
+ /**
+ * @override
+ */
+ _valueFrom: function (facetValue) {
+ return facetValue.label;
+ },
+});
+
+return {
+ BooleanField: BooleanField,
+ CharField: CharField,
+ DateField: DateField,
+ DateTimeField: DateTimeField,
+ Field: Field,
+ Filter: Filter,
+ FloatField: FloatField,
+ GroupBy: GroupBy,
+ IntegerField: IntegerField,
+ ManyToOneField: ManyToOneField,
+ NumberField: NumberField,
+ SelectionField: SelectionField,
+};
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js b/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js
new file mode 100644
index 00000000000..fd5d1052b0b
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js
@@ -0,0 +1,37 @@
+odoo.define('web.search_bar_autocomplete_sources_registry', function (require) {
+"use strict";
+
+var Registry = require('web.Registry');
+
+return new Registry();
+
+});
+
+odoo.define('web._search_bar_autocomplete_sources_registry', function(require) {
+"use strict";
+
+var registry = require('web.search_bar_autocomplete_sources_registry');
+var SearchBarAutoCompleteSources = require('web.SearchBarAutoCompleteSources');
+
+// fields
+registry
+ .add('char', SearchBarAutoCompleteSources.CharField)
+ .add('text', SearchBarAutoCompleteSources.CharField)
+ .add('html', SearchBarAutoCompleteSources.CharField)
+ .add('boolean', SearchBarAutoCompleteSources.BooleanField)
+ .add('integer', SearchBarAutoCompleteSources.IntegerField)
+ .add('id', SearchBarAutoCompleteSources.IntegerField)
+ .add('float', SearchBarAutoCompleteSources.FloatField)
+ .add('monetary', SearchBarAutoCompleteSources.FloatField)
+ .add('selection', SearchBarAutoCompleteSources.SelectionField)
+ .add('datetime', SearchBarAutoCompleteSources.DateTimeField)
+ .add('date', SearchBarAutoCompleteSources.DateField)
+ .add('many2one', SearchBarAutoCompleteSources.ManyToOneField)
+ .add('many2many', SearchBarAutoCompleteSources.CharField)
+ .add('one2many', SearchBarAutoCompleteSources.CharField);
+
+// others
+registry.add('filter', SearchBarAutoCompleteSources.Filter);
+registry.add('groupby', SearchBarAutoCompleteSources.GroupBy);
+
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/search_facet.js b/addons/web/static/src/js/views/control_panel/search/search_facet.js
new file mode 100644
index 00000000000..ca9a7ad6225
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/search_facet.js
@@ -0,0 +1,129 @@
+odoo.define('web.SearchFacet', function (require) {
+"use strict";
+
+var core = require('web.core');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+
+var SearchFacet = Widget.extend({
+ template: 'SearchView.SearchFacet',
+ events: _.extend({}, Widget.prototype.events, {
+ 'click .o_facet_remove': '_onFacetRemove',
+ 'keydown': '_onKeydown',
+ }),
+ /**
+ * @override
+ * @param {Object} facet
+ */
+ init: function (parent, facet) {
+ this._super.apply(this, arguments);
+
+ var self = this;
+ this.facet = facet;
+ this.facetValues = _.map(this.facet.filters, function (filter) {
+ return self._getFilterDescription(filter);
+ });
+ this.separator = this._getSeparator();
+ this.icon = this._getIcon();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the correct description according to filter.
+ *
+ * @private
+ * @returns {string}
+ */
+ _getFilterDescription: function (filter) {
+ if (filter.type === 'field') {
+ var values = _.pluck(filter.autoCompleteValues, 'label');
+ return values.join(_t(' or '));
+ }
+ var description = filter.description;
+ if (filter.hasOptions) {
+ var optionValue =_.findWhere(filter.options, {
+ optionId: filter.currentOptionId,
+ });
+ description += ': ' + optionValue.description;
+ }
+ if (filter.type === 'timeRange') {
+ var timeRangeValue =_.findWhere(filter.timeRangeOptions, {
+ optionId: filter.timeRangeId,
+ });
+ description += ': ' + timeRangeValue.description;
+ if (filter.comparisonTimeRangeId) {
+ var comparisonTimeRangeValue =_.findWhere(filter.comparisonTimeRangeOptions, {
+ optionId: filter.comparisonTimeRangeId,
+ });
+ description += ' / ' + comparisonTimeRangeValue.description;
+ }
+ }
+ return description;
+ },
+ /**
+ * Get the correct icon according to facet type.
+ *
+ * @private
+ * @returns {string}
+ */
+ _getIcon: function () {
+ var icon;
+ if (this.facet.type === 'filter') {
+ icon = 'fa-filter';
+ } else if (this.facet.type === 'groupBy') {
+ icon = 'fa-bars';
+ } else if (this.facet.type === 'favorite') {
+ icon = 'fa-star';
+ } else if (this.facet.type === 'timeRange') {
+ icon = 'fa-calendar';
+ }
+ return icon;
+ },
+ /**
+ * Get the correct separator according to facet type.
+ *
+ * @private
+ * @returns {string}
+ */
+ _getSeparator: function () {
+ var separator;
+ if (this.facet.type === 'filter') {
+ separator = _t('or');
+ } else if (this.facet.type === 'field') {
+ separator = _t('or');
+ } else if (this.facet.type === 'groupBy') {
+ separator = '>';
+ }
+ return separator;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onFacetRemove: function () {
+ this.trigger_up('facet_removed', {group: this.facet});
+ },
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown: function (ev) {
+ switch (ev.which) {
+ case $.ui.keyCode.BACKSPACE:
+ this.trigger_up('facet_removed', {group: this.facet});
+ break;
+ }
+ },
+});
+
+return SearchFacet;
+
+});
diff --git a/addons/web/static/src/js/views/search/search_filters.js b/addons/web/static/src/js/views/control_panel/search/search_filters.js
index 4455a7b9bdf..6ff84dce9c7 100644
--- a/addons/web/static/src/js/views/search/search_filters.js
+++ b/addons/web/static/src/js/views/control_panel/search/search_filters.js
@@ -4,12 +4,13 @@ odoo.define('web.search_filters', function (require) {
var core = require('web.core');
var datepicker = require('web.datepicker');
var field_utils = require('web.field_utils');
+var search_filters_registry = require('web.search_filters_registry');
var Widget = require('web.Widget');
var _t = core._t;
var _lt = core._lt;
-var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.ExtendedSearchProposition# */{
+var ExtendedSearchProposition = Widget.extend({
template: 'SearchView.extended_search.proposition',
events: {
'change .o_searchview_extended_prop_field': 'changed',
@@ -25,10 +26,7 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
},
},
/**
- * @constructs instance.web.search.ExtendedSearchProposition
- * @extends instance.web.Widget
- *
- * @param parent
+ * @override
* @param fields
*/
init: function (parent, fields) {
@@ -46,8 +44,8 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
},
changed: function () {
var nval = this.$(".o_searchview_extended_prop_field").val();
- if(this.attrs.selected === null || this.attrs.selected === undefined || nval != this.attrs.selected.name) {
- this.select_field(_.detect(this.fields, function (x) {return x.name == nval;}));
+ if(this.attrs.selected === null || this.attrs.selected === undefined || nval !== this.attrs.selected.name) {
+ this.select_field(_.detect(this.fields, function (x) {return x.name === nval;}));
}
},
operator_changed: function (e) {
@@ -71,7 +69,7 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
}
var type = field.type;
- var Field = core.search_filters_registry.getAny([type, "char"]);
+ var Field = search_filters_registry.getAny([type, "char"]);
this.value = new Field(this, field);
_.each(this.value.operators, function (operator) {
@@ -84,8 +82,9 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
},
get_filter: function () {
- if (this.attrs.selected === null || this.attrs.selected === undefined)
+ if (this.attrs.selected === null || this.attrs.selected === undefined) {
return null;
+ }
var field = this.attrs.selected,
op_select = this.$('.o_searchview_extended_prop_op')[0],
operator = op_select.options[op_select.selectedIndex];
@@ -172,6 +171,15 @@ var Char = Field.extend({
{value: "∃", text: _lt("is set")},
{value: "∄", text: _lt("is not set")}
],
+ get_domain: function (field, operator) {
+ switch (operator.value) {
+ case '∃': return [[field.name, '!=', false]];
+ case '∄': return [[field.name, '=', false]];
+ // does not work in all cases
+ // default: return "[('" + field.name + "', '" + operator.value + "', '" + this.get_value() + "')]";
+ default: return [[field.name, operator.value, this.get_value()]];
+ }
+ },
get_value: function () {
return this.$el.val();
}
@@ -358,23 +366,17 @@ var Boolean = Field.extend({
}
});
-core.search_filters_registry
- .add('char', Char)
- .add('text', Char)
- .add('one2many', Char)
- .add('many2one', Char)
- .add('many2many', Char)
- .add('datetime', DateTime)
- .add('date', Date)
- .add('integer', Integer)
- .add('float', Float)
- .add('monetary', Float)
- .add('boolean', Boolean)
- .add('selection', Selection)
- .add('id', Id);
-
return {
- ExtendedSearchProposition: ExtendedSearchProposition
+ Boolean: Boolean,
+ Char: Char,
+ Date: Date,
+ DateTime: DateTime,
+ ExtendedSearchProposition: ExtendedSearchProposition,
+ Field: Field,
+ Float: Float,
+ Id: Id,
+ Integer: Integer,
+ Selection: Selection,
};
});
diff --git a/addons/web/static/src/js/views/control_panel/search/search_filters_registry.js b/addons/web/static/src/js/views/control_panel/search/search_filters_registry.js
new file mode 100644
index 00000000000..d6cbb9be2d0
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/search_filters_registry.js
@@ -0,0 +1,30 @@
+odoo.define('web.search_filters_registry', function (require) {
+"use strict";
+
+var Registry = require('web.Registry');
+
+return new Registry();
+
+});
+
+odoo.define('web._search_filters_registry', function(require) {
+"use strict";
+
+var registry = require('web.search_filters_registry');
+var search_filters = require('web.search_filters');
+
+registry
+ .add('boolean', search_filters.Boolean)
+ .add('char', search_filters.Char)
+ .add('date', search_filters.Date)
+ .add('datetime', search_filters.DateTime)
+ .add('float', search_filters.Float)
+ .add('id', search_filters.Id)
+ .add('integer', search_filters.Integer)
+ .add('many2many', search_filters.Char)
+ .add('many2one', search_filters.Char)
+ .add('monetary', search_filters.Float)
+ .add('one2many', search_filters.Char)
+ .add('text', search_filters.Char)
+ .add('selection', search_filters.Selection);
+});
diff --git a/addons/web/static/src/js/views/control_panel/search/time_range_menu.js b/addons/web/static/src/js/views/control_panel/search/time_range_menu.js
new file mode 100644
index 00000000000..8b6c6552c47
--- /dev/null
+++ b/addons/web/static/src/js/views/control_panel/search/time_range_menu.js
@@ -0,0 +1,106 @@
+odoo.define('web.TimeRangeMenu', function (require) {
+"use strict";
+
+var config = require('web.config');
+var controlPanelViewParameters = require('web.controlPanelViewParameters');
+var Widget = require('web.Widget');
+
+var COMPARISON_TIME_RANGE_OPTIONS = controlPanelViewParameters.COMPARISON_TIME_RANGE_OPTIONS;
+var PERIOD_OPTIONS = controlPanelViewParameters.PERIOD_OPTIONS;
+
+var TimeRangeMenu = Widget.extend({
+ template: 'web.TimeRangeMenu',
+ events: {
+ 'click .o_apply_range': '_onApplyButtonClick',
+ 'click .o_comparison_checkbox': '_onCheckBoxClick',
+ },
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {Object[]} timeRanges
+ *
+ */
+ init: function (parent, timeRanges) {
+ this._super.apply(this, arguments);
+ // determine header style
+ this.isMobile = config.device.isMobile;
+ this.symbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : 'caret';
+ // fixed parameters
+ this.periodOptions = PERIOD_OPTIONS;
+ this.comparisonTimeRangeOptions = COMPARISON_TIME_RANGE_OPTIONS;
+ this.periodGroups = _.uniq(PERIOD_OPTIONS.map(function (option) {
+ return option.groupId;
+ }));
+ // variable parameters
+ this.timeRanges = timeRanges;
+ this.configuration = {
+ comparisonIsSelected: false,
+ comparisonTimeRangeId: false,
+ id: false,
+ timeRangeId: false,
+ };
+ this._configure();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {Object[]} timeRanges
+ */
+ update: function (timeRanges) {
+ this.timeRanges = timeRanges;
+ this._configure();
+ this.renderElement();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _configure: function () {
+ this.configuration = this.timeRanges.find(function (timeRange) {
+ return timeRange.isActive;
+ }) || this.configuration;
+ this.configuration.comparisonIsSelected = !!this.configuration.comparisonTimeRangeId;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onApplyButtonClick: function () {
+ var id = this.$('.o_date_field_selector').val();
+ var timeRangeId = this.$('.o_time_range_selector').val();
+ var comparisonTimeRangeId = false;
+ if (this.configuration.comparisonIsSelected) {
+ comparisonTimeRangeId = this.$('.o_comparison_time_range_selector').val();
+ }
+ this.trigger_up('activate_time_range', {
+ id: id,
+ timeRangeId: timeRangeId,
+ comparisonTimeRangeId: comparisonTimeRangeId
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onCheckBoxClick: function (ev) {
+ ev.stopPropagation();
+ this.configuration.comparisonIsSelected = this.$('.o_comparison_checkbox').prop('checked');
+ this.$('.o_comparison_time_range_selector').toggleClass('o_hidden');
+ this.$el.addClass('open');
+ }
+});
+
+return TimeRangeMenu;
+
+});
diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js
index 9c354be6765..5feefce716f 100644
--- a/addons/web/static/src/js/views/form/form_controller.js
+++ b/addons/web/static/src/js/views/form/form_controller.js
@@ -132,7 +132,7 @@ var FormController = BasicController.extend({
* @param {jQueryElement} $node
*/
renderButtons: function ($node) {
- var $footer = this.footerToButtons ? this.$('footer') : null;
+ var $footer = this.footerToButtons ? this.renderer.$('footer') : null;
var mustRenderFooterButtons = $footer && $footer.length;
if (!this.defaultButtons && !mustRenderFooterButtons) {
return;
@@ -219,7 +219,7 @@ var FormController = BasicController.extend({
var self = this;
return this._super.apply(this, arguments).then(function (changedFields) {
// the title could have been changed
- self.set('title', self.getTitle());
+ self._setTitle(self.getTitle());
self._updateEnv();
if (_t.database.multi_lang && changedFields.length) {
@@ -250,6 +250,9 @@ var FormController = BasicController.extend({
* @override
*/
update: function (params, options) {
+ if ('currentId' in params && !params.currentId) {
+ this.mode = 'edit'; // if there is no record, we are in 'edit' mode
+ }
params = _.extend({viewType: 'form', mode: this.mode}, params);
return this._super(params, options);
},
@@ -395,7 +398,7 @@ var FormController = BasicController.extend({
*/
_update: function () {
var title = this.getTitle();
- this.set('title', title);
+ this._setTitle(title);
this._updateButtons();
this._updateSidebar();
return this._super.apply(this, arguments).then(this.autofocus.bind(this));
@@ -406,7 +409,7 @@ var FormController = BasicController.extend({
_updateButtons: function () {
if (this.$buttons) {
if (this.footerToButtons) {
- var $footer = this.$('footer');
+ var $footer = this.renderer.$('footer');
if ($footer.length) {
this.$buttons.empty().append($footer);
}
diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js
index 3dd92818276..52bbc044d1f 100644
--- a/addons/web/static/src/js/views/form/form_renderer.js
+++ b/addons/web/static/src/js/views/form/form_renderer.js
@@ -5,6 +5,7 @@ var BasicRenderer = require('web.BasicRenderer');
var config = require('web.config');
var core = require('web.core');
var dom = require('web.dom');
+var viewUtils = require('web.viewUtils');
var _t = core._t;
var qweb = core.qweb;
@@ -439,7 +440,7 @@ var FormRenderer = BasicRenderer.extend({
* @returns {jQueryElement}
*/
_renderHeaderButton: function (node) {
- var $button = this._renderButtonFromNode(node);
+ var $button = viewUtils.renderButtonFromNode(node);
// Current API of odoo for rendering buttons is "if classes are given
// use those on top of the 'btn' and 'btn-{size}' classes, otherwise act
@@ -639,7 +640,7 @@ var FormRenderer = BasicRenderer.extend({
* @returns {jQueryElement}
*/
_renderStatButton: function (node) {
- var $button = this._renderButtonFromNode(node, {
+ var $button = viewUtils.renderButtonFromNode(node, {
extraClass: 'oe_stat_button',
});
$button.append(_.map(node.children, this._renderNode.bind(this)));
@@ -685,7 +686,7 @@ var FormRenderer = BasicRenderer.extend({
* @returns {jQueryElement}
*/
_renderTagButton: function (node) {
- var $button = this._renderButtonFromNode(node);
+ var $button = viewUtils.renderButtonFromNode(node);
$button.append(_.map(node.children, this._renderNode.bind(this)));
this._addOnClickAction($button, node);
this._handleAttributes($button, node);
diff --git a/addons/web/static/src/js/views/form/form_view.js b/addons/web/static/src/js/views/form/form_view.js
index 01474a01180..d49f2b005fa 100644
--- a/addons/web/static/src/js/views/form/form_view.js
+++ b/addons/web/static/src/js/views/form/form_view.js
@@ -18,7 +18,8 @@ var FormView = BasicView.extend({
display_name: _lt('Form'),
icon: 'fa-edit',
multi_record: false,
- searchable: false,
+ withSearchBar: false,
+ searchMenuTypes: [],
jsLibs: [],
viewType: 'form',
/**
@@ -27,17 +28,14 @@ var FormView = BasicView.extend({
init: function (viewInfo, params) {
this._super.apply(this, arguments);
- var mode = params.mode || params.context.form_view_initial_mode ||
- (params.currentId ? 'readonly' : 'edit');
+ var mode = params.mode || (params.currentId ? 'readonly' : 'edit');
this.loadParams.type = 'record';
this.controllerParams.disableAutofocus = params.disable_autofocus;
this.controllerParams.hasSidebar = params.hasSidebar;
this.controllerParams.toolbarActions = viewInfo.toolbar;
this.controllerParams.footerToButtons = params.footerToButtons;
- if ('action' in params && 'flags' in params.action) {
- this.controllerParams.footerToButtons = params.action.flags.footerToButtons;
- }
+
var defaultButtons = 'default_buttons' in params ? params.default_buttons : true;
this.controllerParams.defaultButtons = defaultButtons;
this.controllerParams.mode = mode;
@@ -64,6 +62,26 @@ var FormView = BasicView.extend({
//--------------------------------------------------------------------------
/**
+ * @override
+ * @param {string} [action.target]
+ */
+ _extractParamsFromAction: function (action) {
+ var params = this._super.apply(this, arguments);
+ var inDialog = action.target === 'new';
+ var inline = action.target === 'inline';
+ var fullscreen = action.target === 'fullscreen';
+ params.withControlPanel = !(inDialog || inline);
+ params.footerToButtons = inDialog;
+ params.hasSearchView = inDialog ? false : params.hasSearchView;
+ params.searchMenuTypes = inDialog ? [] : params.searchMenuTypes;
+ if (inDialog || inline || fullscreen) {
+ params.mode = 'edit';
+ } else if (action.context && action.context.form_view_initial_mode) {
+ params.mode = action.context.form_view_initial_mode;
+ }
+ return params;
+ },
+ /**
* Loads the subviews for x2many fields when they are not inline
*
* @private
diff --git a/addons/web/static/src/js/views/graph/graph_controller.js b/addons/web/static/src/js/views/graph/graph_controller.js
index 27bd59e476d..15f7fbeaa35 100644
--- a/addons/web/static/src/js/views/graph/graph_controller.js
+++ b/addons/web/static/src/js/views/graph/graph_controller.js
@@ -6,13 +6,11 @@ odoo.define('web.GraphController', function (require) {
var core = require('web.core');
var AbstractController = require('web.AbstractController');
-var GroupByMenuInterfaceMixin = require('web.GroupByMenuInterfaceMixin');
+var GroupByMenuMixin = require('web.GroupByMenuMixin');
var qweb = core.qweb;
-var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{
- className: 'o_graph',
-
+var GraphController = AbstractController.extend(GroupByMenuMixin,{
/**
* @override
* @param {Widget} parent
@@ -24,19 +22,24 @@ var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{
* @param {string[]} params.groupableFields,
*/
init: function (parent, model, renderer, params) {
- GroupByMenuInterfaceMixin.init.call(this);
+ GroupByMenuMixin.init.call(this);
this._super.apply(this, arguments);
this.measures = params.measures;
// this parameter condition the appearance of a 'Group By'
// button in the control panel owned by the graph view.
this.isEmbedded = params.isEmbedded;
+
// this parameter determines what is the list of fields
// that may be used within the groupby menu available when
// the view is embedded
this.groupableFields = params.groupableFields;
- // extends custom_events key. This allow the controller
- // to listen to information comming from the groupby menu.
- // this is used when the view is embedded.
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$el.addClass('o_graph_controller');
+ return this._super.apply(this, arguments);
},
/**
* @todo check if this can be removed (mostly duplicate with
@@ -62,18 +65,20 @@ var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{
* @override
* @returns {Object}
*/
- getContext: function () {
+ getOwnedQueryParams: function () {
var state = this.model.get();
return {
- graph_measure: state.measure,
- graph_mode: state.mode,
- graph_groupbys: state.groupedBy,
- // this parameter is not used anywher for now
- // the idea would be to seperate intervals from
- // fieldnames in groupbys. This could be done
- // in graph view only or everywhere but this is
- // a big refactoring.
- graph_intervalMapping: state.intervalMapping,
+ context: {
+ graph_measure: state.measure,
+ graph_mode: state.mode,
+ graph_groupbys: state.groupedBy,
+ // this parameter is not used anywher for now
+ // the idea would be to seperate intervals from
+ // fieldnames in groupbys. This could be done
+ // in graph view only or everywhere but this is
+ // a big refactoring.
+ graph_intervalMapping: state.intervalMapping,
+ }
};
},
/**
@@ -110,10 +115,10 @@ var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{
* override
*
* @private
- * @param {string[]} groupbys
+ * @param {string[]} groupBys
*/
- _setGroupby: function (groupbys) {
- this.update({groupBy: groupbys});
+ _setGroupby: function (groupBys) {
+ this.update({groupBy: groupBys});
},
/**
diff --git a/addons/web/static/src/js/views/graph/graph_renderer.js b/addons/web/static/src/js/views/graph/graph_renderer.js
index e68a4446ba5..fe4efab1b20 100644
--- a/addons/web/static/src/js/views/graph/graph_renderer.js
+++ b/addons/web/static/src/js/views/graph/graph_renderer.js
@@ -23,7 +23,7 @@ var CHART_TYPES = ['pie', 'bar', 'line'];
var MAX_LEGEND_LENGTH = 25 * (Math.max(1, config.device.size_class));
return AbstractRenderer.extend({
- className: "o_graph_container",
+ className: "o_graph_renderer",
/**
* @override
* @param {Widget} parent
diff --git a/addons/web/static/src/js/views/graph/graph_view.js b/addons/web/static/src/js/views/graph/graph_view.js
index a55da16c6da..4567daf0ead 100644
--- a/addons/web/static/src/js/views/graph/graph_view.js
+++ b/addons/web/static/src/js/views/graph/graph_view.js
@@ -36,7 +36,8 @@ var GraphView = AbstractView.extend({
Renderer: GraphRenderer,
},
viewType: 'graph',
- enableTimeRangeMenu: 'true',
+ searchMenuTypes: ['filter', 'groupBy', 'timeRange', 'favorite'],
+
/**
* @override
*/
diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js
index 25fb88c7bd4..2383a0ac730 100644
--- a/addons/web/static/src/js/views/kanban/kanban_controller.js
+++ b/addons/web/static/src/js/views/kanban/kanban_controller.js
@@ -238,7 +238,7 @@ var KanbanController = BasicController.extend({
self.renderer.updateColumn(db_id, data);
});
});
- }).fail(this.reload.bind(this));
+ }).fail(this.reload.bind(this, {}));
},
/**
* @private
@@ -279,7 +279,7 @@ var KanbanController = BasicController.extend({
} else if (this.on_create && this.on_create !== 'quick_create') {
// Execute the given action
this.do_action(this.on_create, {
- on_close: this.reload.bind(this),
+ on_close: this.reload.bind(this, {}),
additional_context: state.context,
});
} else {
diff --git a/addons/web/static/src/js/views/kanban/kanban_view.js b/addons/web/static/src/js/views/kanban/kanban_view.js
index 523f0ef1c57..9f589ffe2a7 100644
--- a/addons/web/static/src/js/views/kanban/kanban_view.js
+++ b/addons/web/static/src/js/views/kanban/kanban_view.js
@@ -30,13 +30,11 @@ var KanbanView = BasicView.extend({
*/
init: function (viewInfo, params) {
this._super.apply(this, arguments);
-
this.loadParams.limit = this.loadParams.limit || 40;
// in mobile, columns are lazy-loaded, so set 'openGroupByDefault' to
// false so that they will won't be loaded by the initial load
this.loadParams.openGroupByDefault = config.device.isMobile ? false : true;
this.loadParams.type = 'list';
- this.loadParams.groupBy = this.arch.attrs.default_group_by ? [this.arch.attrs.default_group_by] : (params.groupBy || []);
var progressBar;
utils.traverse(this.arch, function (n) {
var isProgressBar = (n.tag === 'progressbar');
@@ -106,6 +104,11 @@ var KanbanView = BasicView.extend({
}
return true;
},
+ _updateMVCParams: function () {
+ this._super.apply(this, arguments);
+ var defaultGroupBy = this.arch.attrs.default_group_by;
+ this.loadParams.groupBy = defaultGroupBy ? [defaultGroupBy] : (this.loadParams.groupedBy || []);
+ },
});
return KanbanView;
diff --git a/addons/web/static/src/js/views/kanban/quick_create_form_view.js b/addons/web/static/src/js/views/kanban/quick_create_form_view.js
index 71bd0073f84..af769d62095 100644
--- a/addons/web/static/src/js/views/kanban/quick_create_form_view.js
+++ b/addons/web/static/src/js/views/kanban/quick_create_form_view.js
@@ -56,10 +56,6 @@ var QuickCreateFormModel = BasicModel.extend({
});
var QuickCreateFormController = FormController.extend({
- custom_events: _.extend({}, FormController.prototype.custom_events, {
- env_updated: '_onEnvUpdated',
- }),
-
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
@@ -83,23 +79,10 @@ var QuickCreateFormController = FormController.extend({
getChanges: function () {
return this.model.getChanges(this.handle);
},
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Stops the propagation of the 'env_updated' event to prevent interferences
- * with the kanban controller.
- *
- * @private
- */
- _onEnvUpdated: function (ev) {
- ev.stopPropagation();
- },
});
var QuickCreateFormView = FormView.extend({
+ withControlPanel: false,
config: _.extend({}, FormView.prototype.config, {
Model: QuickCreateFormModel,
Renderer: QuickCreateFormRenderer,
diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js
index ab3adbf5922..fd5429d7c3e 100644
--- a/addons/web/static/src/js/views/list/list_controller.js
+++ b/addons/web/static/src/js/views/list/list_controller.js
@@ -87,10 +87,10 @@ var ListController = BasicController.extend({
/*
* @override
*/
- getContext: function () {
- var context = this._super.apply(this, arguments);
- context.orderedBy = this.model.get(this.handle, {raw: true}).orderedBy || [];
- return context;
+ getOwnedQueryParams: function () {
+ var state = this._super.apply(this, arguments);
+ var orderedBy = this.model.get(this.handle, {raw: true}).orderedBy || [];
+ return _.extend({}, state, {orderedBy: orderedBy});
},
/**
* Returns the list of currently selected res_ids (with the check boxes on
diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js
index 87542170dd4..ee081aa3c5b 100644
--- a/addons/web/static/src/js/views/list/list_renderer.js
+++ b/addons/web/static/src/js/views/list/list_renderer.js
@@ -8,6 +8,7 @@ var dom = require('web.dom');
var field_utils = require('web.field_utils');
var Pager = require('web.Pager');
var utils = require('web.utils');
+var viewUtils = require('web.viewUtils');
var _t = core._t;
@@ -329,7 +330,7 @@ var ListRenderer = BasicRenderer.extend({
*/
_renderButton: function (record, node) {
var self = this;
- var $button = this._renderButtonFromNode(node, {
+ var $button = viewUtils.renderButtonFromNode(node, {
extraClass: node.attrs.icon ? 'o_icon_button' : undefined,
textAsTitle: !!node.attrs.icon,
});
diff --git a/addons/web/static/src/js/views/list/list_view.js b/addons/web/static/src/js/views/list/list_view.js
index bc80017743b..291cb8de8a0 100644
--- a/addons/web/static/src/js/views/list/list_view.js
+++ b/addons/web/static/src/js/views/list/list_view.js
@@ -42,7 +42,6 @@ var ListView = BasicView.extend({
this.controllerParams.editable = this.arch.attrs.editable;
this.controllerParams.hasSidebar = params.hasSidebar;
this.controllerParams.toolbarActions = viewInfo.toolbar;
- this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf;
this.controllerParams.mode = mode;
this.controllerParams.selectedRecords = selectedRecords;
@@ -55,6 +54,15 @@ var ListView = BasicView.extend({
this.loadParams.limit = this.loadParams.limit || 80;
this.loadParams.type = 'list';
},
+
+ //--------------------------------------------------------------------------
+ // Private
+ //---
+
+ _updateMVCParams: function () {
+ this._super.apply(this, arguments);
+ this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf;
+ },
});
return ListView;
diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js
index 9dfe5dc11b5..a4833b40d81 100644
--- a/addons/web/static/src/js/views/pivot/pivot_controller.js
+++ b/addons/web/static/src/js/views/pivot/pivot_controller.js
@@ -22,7 +22,7 @@ var _t = core._t;
var QWeb = core.qweb;
var PivotController = AbstractController.extend({
- template: 'PivotView',
+ contentTemplate: 'PivotView',
events: {
'click .o_pivot_header_cell_opened': '_onOpenHeaderClick',
'click .o_pivot_header_cell_closed': '_onClosedHeaderClick',
@@ -83,12 +83,14 @@ var PivotController = AbstractController.extend({
* @override method from AbstractController
* @returns {Object}
*/
- getContext: function () {
+ getOwnedQueryParams: function () {
var state = this.model.get();
return {
- pivot_measures: state.measures,
- pivot_column_groupby: state.colGroupBys,
- pivot_row_groupby: state.rowGroupBys,
+ context: {
+ pivot_measures: state.measures,
+ pivot_column_groupby: state.colGroupBys,
+ pivot_row_groupby: state.rowGroupBys,
+ }
};
},
/**
@@ -172,6 +174,13 @@ var PivotController = AbstractController.extend({
.addClass('show');
},
/**
+ * @override
+ * @private
+ */
+ _startRenderer: function () {
+ return this.renderer.appendTo(this.$('.o_pivot'));
+ },
+ /**
* @private
*/
_update: function () {
diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js
index d85076f8271..38d8d80a9d7 100644
--- a/addons/web/static/src/js/views/pivot/pivot_view.js
+++ b/addons/web/static/src/js/views/pivot/pivot_view.js
@@ -28,7 +28,8 @@ var PivotView = AbstractView.extend({
Renderer: PivotRenderer,
},
viewType: 'pivot',
- enableTimeRangeMenu: 'true',
+ searchMenuTypes: ['filter', 'groupBy', 'timeRange', 'favorite'],
+
/**
* @override
* @param {Object} params
@@ -117,10 +118,9 @@ var PivotView = AbstractView.extend({
this.controllerParams.groupableFields = groupableFields;
// retrieve form and list view ids from the action to open those views
// when a data cell of the pivot view is clicked
-
this.controllerParams.views = [
- _findView(params.action && params.action.views, 'list'),
- _findView(params.action && params.action.views, 'form'),
+ _findView(params.actionViews, 'list'),
+ _findView(params.actionViews, 'form'),
];
function _findView(views, viewType) {
var view = _.find(views, function (view) {
diff --git a/addons/web/static/src/js/views/search/favorites_menu.js b/addons/web/static/src/js/views/search/favorites_menu.js
deleted file mode 100644
index 46949e901a3..00000000000
--- a/addons/web/static/src/js/views/search/favorites_menu.js
+++ /dev/null
@@ -1,335 +0,0 @@
-odoo.define('web.FavoriteMenu', function (require) {
-"use strict";
-
-var config = require('web.config');
-var core = require('web.core');
-var data_manager = require('web.data_manager');
-var Domain = require('web.Domain');
-var pyUtils = require('web.py_utils');
-var session = require('web.session');
-var Widget = require('web.Widget');
-
-var _t = core._t;
-
-return Widget.extend({
- template: 'SearchView.FavoriteMenu',
- events: {
- 'click .dropdown-item': function (ev) {
- ev.preventDefault();
- },
- 'click .o_save_search': function (ev) {
- ev.preventDefault();
- ev.stopPropagation();
- this.toggle_save_menu();
- },
- 'click .o_save_name button': 'save_favorite',
- 'hidden.bs.dropdown': '_closeMenus',
- 'keyup .o_save_name input': function (ev) {
- if (ev.which === $.ui.keyCode.ENTER) {
- this.save_favorite();
- }
- },
- },
- init: function (parent, query, target_model, action, filters) {
- this._super.apply(this,arguments);
- this.searchview = parent;
- this.query = query;
- this.target_model = target_model;
- this.action = action;
- this.action_id = action.id;
- this.filters = {};
- this.isMobile = config.device.isMobile;
- _.each(filters, this.add_filter.bind(this));
- },
- start: function () {
- var self = this;
- this.$filters = {};
- this.$save_search = this.$('.o_save_search');
- this.$save_name = this.$('.o_save_name');
- this.$inputs = this.$save_name.find('input');
- this.$user_divider = this.$('.dropdown-divider.user_filter');
- this.$shared_divider = this.$('.dropdown-divider.shared_filter');
- this.$inputs.eq(0).val(this.searchview.get_title());
- var $shared_filter = this.$inputs.eq(1),
- $default_filter = this.$inputs.eq(2);
- $shared_filter.click(function () {$default_filter.prop('checked', false);});
- $default_filter.click(function () {$shared_filter.prop('checked', false);});
-
- this.query
- .on('remove', function (facet) {
- if (facet.get('is_custom_filter')) {
- self.clear_selection();
- }
- })
- .on('reset', this.proxy('clear_selection'));
-
- _.each(this.filters, this.append_filter.bind(this));
-
- return this._super();
- },
- toggle_save_menu: function (is_open) {
- this.$save_search
- .toggleClass('o_closed_menu', !(_.isUndefined(is_open)) ? !is_open : undefined)
- .toggleClass('o_open_menu', is_open);
- this.$save_name.toggle(is_open);
- if (this.$save_search.hasClass('o_open_menu')) {
- this.$save_name.find('input').first().focus();
- }
- },
- _closeMenus: function () {
- this.toggle_save_menu(false);
- },
- save_favorite: function () {
- var self = this,
- filter_name = this.$inputs[0].value,
- default_filter = this.$inputs[1].checked,
- shared_filter = this.$inputs[2].checked;
- if (!filter_name.length){
- this.do_warn(_t("Error"), _t("Filter name is required."));
- this.$inputs.first().focus();
- return;
- }
- if (_.chain(this.filters)
- .pluck('name')
- .contains(filter_name).value()) {
- this.do_warn(_t("Error"), _t("Filter with same name already exists."));
- this.$inputs.first().focus();
- return;
- }
- var user_context = this.getSession().user_context;
- // get search params with domains not evaluated
- var search = this.searchview.build_search_data(true);
- var domain = pyUtils.assembleDomains(search.domains, 'AND');
- var controllerContext;
- this.trigger_up('get_controller_context', {
- callback: function (ctx) {
- if (ctx && ctx.orderedBy) {
- var sort = _.map(ctx.orderedBy, function (obj) {
- var order = obj.asc ? 'ASC' : 'DESC';
- return obj.name + ' ' + order;
- });
- self.searchview.dataset.set_sort(sort);
- }
- controllerContext = _.omit(ctx, ['orderedBy']);
- },
- });
-
- var results = pyUtils.eval_domains_and_contexts({
- domains: [],
- contexts: [user_context].concat(search.contexts.concat(controllerContext || [])),
- group_by_seq: search.groupbys || [],
- });
- if (!_.isEmpty(results.group_by)) {
- results.context.group_by = results.group_by;
- }
- // Don't save user_context keys in the custom filter, otherwise end
- // up with e.g. wrong uid or lang stored *and used in subsequent
- // reqs*
- var ctx = results.context;
- _(_.keys(session.user_context)).each(function (key) {
- delete ctx[key];
- });
- var filter = {
- name: filter_name,
- user_id: shared_filter ? false : session.uid,
- model_id: this.target_model,
- context: results.context,
- domain: domain,
- sort: JSON.stringify(this.searchview.dataset._sort),
- is_default: default_filter,
- action_id: this.action_id,
- };
- return this._createFilter(filter).then(function (id) {
- filter.id = id;
- self.toggle_save_menu(false);
- self.$save_name.find('input').val('').prop('checked', false);
- self.add_filter(filter);
- self.append_filter(filter);
- self.toggle_filter(filter, true);
- });
- },
- get_default_filter: function () {
- var personal_filter = _.find(this.filters, function (filter) {
- return filter.user_id && filter.is_default;
- });
- if (personal_filter) {
- return personal_filter;
- }
- return _.find(this.filters, function (filter) {
- return !filter.user_id && filter.is_default;
- });
- },
- /**
- * Generates a mapping key (in the filters and $filter mappings) for the
- * filter descriptor object provided (as returned by ``get_filters``).
- *
- * The mapping key is guaranteed to be unique for a given (user_id, name)
- * pair.
- *
- * @param {Object} filter
- * @param {String} filter.name
- * @param {Number|Pair<Number, String>} [filter.user_id]
- * @return {String} mapping key corresponding to the filter
- */
- key_for: function (filter) {
- var user_id = filter.user_id,
- action_id = filter.action_id,
- uid = (user_id instanceof Array) ? user_id[0] : user_id,
- act_id = (action_id instanceof Array) ? action_id[0] : action_id;
- return _.str.sprintf('(%s)(%s)%s', uid, act_id, filter.name);
- },
- /**
- * Generates a :js:class:`~instance.web.search.Facet` descriptor from a
- * filter descriptor
- *
- * @param {Object} filter
- * @param {String} filter.name
- * @param {Object} [filter.context]
- * @param {Array} [filter.domain]
- * @return {Object}
- */
- facet_for: function (filter) {
- var self = this;
- return {
- category: _t("Custom Filter"),
- icon: 'fa-star',
- field: {
- get_context: function () {
- var sortParsed = JSON.parse(filter.sort || "[]");
- var orderedBy = [];
- _.each(sortParsed, function (sort) {
- orderedBy.push({
- name: sort[0] === '-' ? sort.slice(1) : sort,
- asc: sort[0] === '-' ? false : true,
- });
- });
-
- return _.defaults({}, filter.context, {orderedBy : orderedBy});
- },
- get_groupby: function () { return [filter.context]; },
- // facet is not used
- get_domain: function (facet, noEvaluation) {
- noEvaluation = noEvaluation || false;
- var userContext = self.getSession().user_context;
- var domain = pyUtils.assembleDomains([filter.domain]);
- if (!noEvaluation) {
- return Domain.prototype.stringToArray(domain, userContext);
- }
- return domain;
- }
- },
- _id: filter.id,
- is_custom_filter: true,
- values: [{label: filter.name, value: null}]
- };
- },
- clear_selection: function () {
- this.$('.selected').removeClass('selected');
- },
- /**
- * Adds a filter description to the filters dict
- * @param {Object} [filter] the filter description
- */
- add_filter: function (filter) {
- this.filters[this.key_for(filter)] = filter;
- },
- /**
- * Creates a $filter JQuery node, adds it to the $filters dict and appends it to the filter menu
- * @param {Object} [filter] the filter description
- */
- append_filter: function (filter) {
- var self = this;
- var key = this.key_for(filter);
-
- if (filter.user_id) {
- this.$user_divider.show();
- } else {
- this.$shared_divider.show();
- }
- if (!(key in this.$filters)) {
- var $filter = $('<div>', {class: 'position-relative'})
- .addClass(filter.user_id ? 'o-searchview-custom-private'
- : 'o-searchview-custom-public')
- .append($('<a>', {href: '#', class: 'dropdown-item'}).text(filter.name))
- .append($('<span>', {
- class: 'fa fa-trash-o o-remove-filter',
- on: {
- click: function (event) {
- event.stopImmediatePropagation();
- self.remove_filter(filter, $filter, key);
- },
- },
- }))
- .insertBefore(filter.user_id ? this.$user_divider : this.$shared_divider);
- this.$filters[key] = $filter;
- }
- this.$filters[key].unbind('click').click(function () {
- self.toggle_filter(filter);
- });
- },
- toggle_filter: function (filter, preventSearch) {
- var current = this.query.find(function (facet) {
- return facet.get('_id') === filter.id;
- });
- if (current) {
- this.query.remove(current);
- this.$filters[this.key_for(filter)].find('.dropdown-item').removeClass('selected');
- return;
- }
- this.query.reset([this.facet_for(filter)], {
- preventSearch: preventSearch || false});
-
- // Load sort settings on view
- if (!_.isUndefined(filter.sort)){
- var sort_items = JSON.parse(filter.sort);
- this.searchview.dataset.set_sort(sort_items);
- }
-
- this.$filters[this.key_for(filter)].find('.dropdown-item').addClass('selected');
- },
- remove_filter: function (filter, $filter, key) {
- var self = this;
- var global_warning = _t("This filter is global and will be removed for everybody if you continue."),
- warning = _t("Are you sure that you want to remove this filter?");
- if (!confirm(filter.user_id ? warning : global_warning)) {
- return;
- }
- return data_manager
- .delete_filter(filter)
- .done(function () {
- $filter.remove();
- delete self.$filters[key];
- delete self.filters[key];
- var has_user_filter = _.find(self.filters, function(filter) { return filter.user_id; });
- var has_shared_filer = _.find(self.filters, function(filter) { return !filter.user_id; });
- if (!has_user_filter) {
- self.$user_divider.hide();
- }
- if (!has_shared_filer) {
- self.$shared_divider.hide();
- }
- });
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Creates a new search view filter.
- *
- * @private
- * @param {Object} filter the filter description
- * @returns {$.Deferred} resolved with the RPC's result when it succeeds
- */
- _createFilter: function (filter) {
- var def = $.Deferred();
- this.trigger_up('create_filter', {
- filter: filter,
- on_success: def.resolve.bind(def),
- });
- return def;
- },
-});
-
-});
diff --git a/addons/web/static/src/js/views/search/filters_menu.js b/addons/web/static/src/js/views/search/filters_menu.js
deleted file mode 100644
index f61bbff8045..00000000000
--- a/addons/web/static/src/js/views/search/filters_menu.js
+++ /dev/null
@@ -1,253 +0,0 @@
-odoo.define('web.FiltersMenu', function (require) {
-"use strict";
-
-var config = require('web.config');
-var core = require('web.core');
-var Domain = require('web.Domain');
-var DropdownMenu = require('web.DropdownMenu');
-var search_filters = require('web.search_filters');
-var time = require('web.time');
-var TimeRangeMenuOptions = require('web.TimeRangeMenuOptions');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-var PERIOD_OPTIONS = TimeRangeMenuOptions.PeriodOptions;
-
-var DEFAULT_PERIOD = 'this_month';
-
-var FiltersMenu = DropdownMenu.extend({
- custom_events: {
- remove_proposition: '_onRemoveProposition',
- confirm_proposition: '_onConfirmProposition',
- },
- events: _.extend({}, DropdownMenu.prototype.events, {
- 'click .o_add_custom_filter': '_onAddCustomFilterClick',
- 'click .o_add_condition': '_appendProposition',
- 'click .o_apply_filter': '_onApplyClick',
- ':hover .o_item_option': '_onOptionHover',
- }),
-
- /**
- * @override
- * @param {Widget} parent
- * @param {Object[]} filters list of filters (type IFilter below)
- * interface IFilter {
- * itemId: string; unique id associated with the filter
- * domain: string;
- * isPeriod: boolean
- * fieldName: string; fieldName used to generate a domain in case isDate is true (and domain empty)
- * description: string; label printed on screen
- * groupId: string;
- * isActive: boolean; (optional) determines if the filter is considered active
- * isOpen: boolean; (optional) in case there are options the submenu presenting the options
- * is opened or closed according to isOpen
- * isRemovable: boolean; (optional) can be removed from menu
- * options: array of objects with 'optionId' and 'description' keys; (optional)
- * defaultOptionId: string refers to an optionId (optional)
- * currentOptionId: string refers to an optionId that is activated if item is active (optional)
- * }
- * @param {Object} fields
- */
- init: function (parent, filters, fields) {
- // determines where the filter menu is displayed and its style
- this.isMobile = config.device.isMobile;
- // determines list of options used by filter of type 'date'
- this.periodOptions = PERIOD_OPTIONS;
- this.defaultOptionId = DEFAULT_PERIOD;
- // determines when the 'Add custom filter' submenu is open
- this.generatorMenuIsOpen = false;
- this.propositions = [];
- this.fields = _.pick(fields, function (field, name) {
- return field.selectable !== false && name !== 'id';
- });
- this.fields.id = {string: 'ID', type: 'id', searchable: true};
- var dropdownHeader = {
- category: 'filterCategory',
- title: _t('Filters'),
- icon: 'fa fa-filter',
- symbol: this.isMobile ? 'fa fa-chevron-right float-right mt4' : false,
- };
- this._super(parent, dropdownHeader, filters, this.fields);
- },
-
- /**
- * render the template used to add a new custom filter and append it
- * to the basic dropdown menu
- *
- * @private
- */
- start: function () {
- this.$menu = this.$('.o_dropdown_menu');
- this.$menu.addClass('o_filters_menu');
- var generatorMenu = QWeb.render('FiltersMenuGenerator', {widget: this});
- this.$menu.append(generatorMenu);
- this.$addCustomFilter = this.$menu.find('.o_add_custom_filter');
- this.$addFilterMenu = this.$menu.find('.o_add_filter_menu');
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Add a proposition inside the custom filter edition menu
- *
- * @private
- * @returns {$.Deferred}
- */
- _appendProposition: function () {
- var prop = new search_filters.ExtendedSearchProposition(this, this.fields);
- this.propositions.push(prop);
- this.$('.o_apply_filter').prop('disabled', false);
- return prop.insertBefore(this.$addFilterMenu);
- },
- /**
- * Confirm a filter selection, creates it and add it to the menu
- *
- * @private
- */
- _commitSearch: function () {
- var self = this;
- var filters = _.invoke(this.propositions, 'get_filter');
- _.each(filters, function (filter) {
- filter.attrs.domain = Domain.prototype.arrayToString(filter.attrs.domain);
- });
- var groupId = _.uniqueId('__group__');
- var data = [];
- filters.forEach(function (filter) {
- var filterName = _.uniqueId('__filter__');
- var filterItem = {
- itemId: filterName,
- description: filter.attrs.string,
- groupId: groupId,
- isActive: true,
- };
- self._prepareItem(filterItem);
- data.push({
- itemId: filterName,
- groupId: groupId,
- filter: filter,
- });
- self.items.push(filterItem);
- });
- this._renderMenuItems();
- this.trigger_up('new_filters', data);
- _.invoke(this.propositions, 'destroy');
- this.propositions = [];
- this._toggleCustomFilterMenu();
- },
- /**
- * override
- *
- * @private
- * @param {Object} item
- */
- _prepareItem: function (item) {
- if (item.isPeriod) {
- item.options = this.periodOptions;
- }
- this._super.apply(this, arguments);
- },
- /**
- * override
- *
- * @private
- */
- _renderMenuItems: function () {
- var self= this;
- this._super.apply(this, arguments);
- // the following code adds tooltip on date options in order
- // to alert the user of the meaning of intervals
- var $options = this.$('.o_filters_menu .o_item_option');
- $options.each(function () {
- var $option = $(this);
- $option.tooltip({
- delay: { show: 500, hide: 0 },
- title: function () {
- var itemId = $option.attr('data-item_id');
- var optionId = $option.attr('data-option_id');
- var fieldName = _.findWhere(self.items, {itemId: itemId}).fieldName;
- var domain = Domain.prototype.constructDomain(fieldName, optionId, 'date', true);
- var evaluatedDomain = Domain.prototype.stringToArray(domain);
- var dateFormat = time.getLangDateFormat();
- var dateStart = moment(evaluatedDomain[1][2], "YYYY-MM-DD", 'en').format(dateFormat);
- var dateEnd = moment(evaluatedDomain[2][2], "YYYY-MM-DD", 'en').format(dateFormat);
- if (optionId === 'today' || optionId === 'yesterday') {
- return dateStart;
- }
- return _.str.sprintf(_t('From %s To %s'), dateStart, dateEnd);
- }
- });
- });
- },
- /**
- * Hide and display the sub menu which allows adding custom filters
- *
- * @private
- */
- _toggleCustomFilterMenu: function () {
- var self = this;
- this.generatorMenuIsOpen = !this.generatorMenuIsOpen;
- var def;
- if (this.generatorMenuIsOpen && !this.propositions.length) {
- def = this._appendProposition();
- }
- if (!this.generatorMenuIsOpen) {
- _.invoke(this.propositions, 'destroy');
- this.propositions = [];
- }
- $.when(def).then(function () {
- self.$addCustomFilter
- .toggleClass('o_closed_menu', !self.generatorMenuIsOpen)
- .toggleClass('o_open_menu', self.generatorMenuIsOpen);
- self.$('.o_add_filter_menu').toggle();
- });
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onAddCustomFilterClick: function (event) {
- event.preventDefault();
- event.stopPropagation();
- this._toggleCustomFilterMenu();
- },
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onApplyClick: function (event) {
- event.stopPropagation();
- this._commitSearch();
- },
- /**
- * @private
- * @param {OdooEvent} event
- */
- _onConfirmProposition: function (event) {
- event.stopPropagation();
- this._commitSearch();
- },
- /**
- * @private
- * @param {OdooEvent} event
- */
- _onRemoveProposition: function (event) {
- event.stopPropagation();
- this.propositions = _.without(this.propositions, event.target);
- if (!this.propositions.length) {
- this.$('.o_apply_filter').prop('disabled', true);
- }
- event.target.destroy();
- },
-});
-
-return FiltersMenu;
-
-});
diff --git a/addons/web/static/src/js/views/search/groupby_menu.js b/addons/web/static/src/js/views/search/groupby_menu.js
deleted file mode 100644
index 207455c1a47..00000000000
--- a/addons/web/static/src/js/views/search/groupby_menu.js
+++ /dev/null
@@ -1,232 +0,0 @@
-odoo.define('web.GroupByMenu', function (require) {
-"use strict";
-
-var config = require('web.config');
-var core = require('web.core');
-var DropdownMenu = require('web.DropdownMenu');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-var GROUPABLE_TYPES = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime'];
-
-var DEFAULT_INTERVAL = 'month';
-
-var GroupByMenu = DropdownMenu.extend({
- events: _.extend({}, DropdownMenu.prototype.events,
- {
- 'click .o_add_custom_group': '_onAddCustomGroupClick',
- 'click button.o_apply_group': '_onButtonApplyClick',
- 'click .o_group_selector': '_onGroupSelectorClick',
- }),
-
- /**
- * @override
- * @param {Widget} parent
- * @param {Object[]} groupbys list of groupbys (type IGroupBy below)
- * interface IGroupBy {
- * itemId: string; unique id associated with the groupby
- * fieldName: string; field name without interval!
- * description: string; label printed on screen
- * groupId: string;
- * isDate: boolean;
- * isActive: boolean; (optional) determines if the groupby is considered active
- * isOpen: boolean; (optional) in case there are options the submenu presenting the options
- * is opened or closed according to isOpen
- * isRemovable: boolean; (optional) can be removed from menu
- * options: array of objects with 'optionId' and 'description' keys; (optional)
- * currentOptionId: string refers to an optionId that is activated if item is active (optional)
- * }
- * @param {Object} fields 'field_get' of a model: mapping from field name
- * to an object with its properties
- * @param {Object} options
- * @param {string} options.headerStyle conditions the style of the main button
- */
- init: function (parent, groupbys, fields, options) {
- var self = this;
- this.fields = fields;
- // determines when the 'Add custom groupby' submenu is open
- this.generatorMenuIsOpen = false;
- // determines list of options used by groupbys of type 'date'
- this.intervalOptions = [
- {description: 'Day', optionId: 'day', groupId: 1},
- {description: 'Week', optionId: 'week', groupId: 1},
- {description: 'Month', optionId: 'month', groupId: 1},
- {description: 'Quarter', optionId: 'quarter', groupId: 1},
- {description: 'Year', optionId: 'year', groupId: 1},
- ];
- this.defaultOptionId = DEFAULT_INTERVAL;
- this.groupableFields = [];
- _.each(fields, function (field, name) {
- if (field.sortable && _.contains(GROUPABLE_TYPES, field.type)) {
- self.groupableFields.push(_.extend({}, field, {
- name: name,
- isDate: _.contains(['date', 'datetime'], field.type),
- }));
- }
- });
- this.groupableFields = _.sortBy(this.groupableFields, 'string');
- _.each(groupbys, this._prepareItem.bind(this));
- // determines the list of field names that can be added to the menu
- // via the 'Add Custom Groupby' menu
- this.presentedFields = _.filter(this.groupableFields, function (field) {
- var groupByFields = _.pluck(groupbys, 'fieldName');
- return !_.contains(groupByFields, field.name);
- });
- // determines where the filter menu is displayed and partly its style
- this.isMobile = config.device.isMobile;
- // the default style of the groupby menu can be changed here using the options key 'headerStyle'
- var style;
- if (options && options.headerStyle === 'primary') {
- style = {
- el: {class: 'btn-group o_graph_groupbys_menu o_dropdown', attrs: {'role': 'group'}},
- mainButton: {class: 'btn btn-primary dropdown-toggle'},
- };
- }
- var dropdownHeader = {
- category: 'groupByCategory',
- title: _t('Group By'),
- icon: 'fa fa-bars',
- symbol: this.isMobile ? 'fa fa-chevron-right float-right mt4' : false,
- style: style,
- };
- this._super(parent, dropdownHeader, groupbys, options);
- },
-
- /**
- * render the template used to add a new custom groupby and append it
- * to the basic dropdown menu
- *
- * @private
- */
- start: function () {
- this.$menu = this.$('.o_dropdown_menu');
- this.$menu.addClass('o_group_by_menu');
- var $generatorMenu = QWeb.render('GroupbyMenuGenerator', {widget: this});
- this.$menu.append($generatorMenu);
- this.$addCustomGroup = this.$menu.find('.o_add_custom_group');
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * method called via the 'Add Custom Groupby' menu to create
- * a new groupby and add it (activated) to the menu
- * In case the field is of type date, it is activated using
- * the option determined by the parameter 'DEFAULT_INTERVAL'
- *
- * @private
- * @param {string} fieldName
- */
- _addGroupby: function (fieldName) {
- var field = _.findWhere(this.groupableFields, {name: fieldName});
- var groupbyName = _.uniqueId('__groupby__');
- var groupby = {
- itemId: groupbyName,
- description: field.string,
- fieldName: fieldName,
- groupId: _.uniqueId('__group__'),
- isActive: true,
- };
- var eventData = _.clone(groupby);
- this._prepareItem(groupby);
- if (groupby.hasOptions) {
- groupby.defaultOptionId = DEFAULT_INTERVAL;
- groupby.currentOptionId = DEFAULT_INTERVAL;
- groupby.isDate = true;
- eventData.optionId = groupby.currentOptionId;
- eventData.isDate = true;
- }
- this.items.push(groupby);
- var fieldIndex = this.presentedFields.indexOf(field);
- this.presentedFields.splice(fieldIndex, 1);
- this._renderGeneratorMenu();
- this._renderMenuItems();
- this.trigger_up('new_groupby', eventData);
- },
- /**
- * override
- *
- * @private
- * @param {Object} item
- */
- _prepareItem: function (item) {
- if (_.contains(['date', 'datetime'], this.fields[item.fieldName].type)) {
- item.options = this.intervalOptions;
- }
- // super has to be called here because we need to add options to groupby
- // before to call it since some keys in a groupby are initialized using
- // the keys 'options' and 'defaultOptionId'
- this._super.apply(this, arguments);
- },
- /**
- * @private
- */
- _renderGeneratorMenu: function () {
- this.$el.find('.o_generator_menu').remove();
- var $generatorMenu = QWeb.render('GroupbyMenuGenerator', {widget: this});
- this.$menu.append($generatorMenu);
- this.$addCustomGroup = this.$menu.find('.o_add_custom_group');
- this.$groupSelector = this.$menu.find('.o_group_selector');
- },
- /**
- * @private
- */
- _toggleCustomGroupMenu: function () {
- this.generatorMenuIsOpen = !this.generatorMenuIsOpen;
- this._renderGeneratorMenu();
- if (this.generatorMenuIsOpen) {
- this.$groupSelector.focus();
- }
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * toggle the 'Add Custom Group'
- *
- * @private
- * @param {MouseEvent} event
- */
- _onAddCustomGroupClick: function (event) {
- event.preventDefault();
- event.stopPropagation();
- this._toggleCustomGroupMenu();
- },
- /*
- * override
- *
- * @private
- * @param {jQueryEvent} event
- */
- _onBootstrapClose: function () {
- this._super.apply(this, arguments);
- this.generatorMenuIsOpen = false;
- this._renderGeneratorMenu();
- },
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onButtonApplyClick: function (event) {
- event.stopPropagation();
- var fieldName = this.$groupSelector.val();
- this._addGroupby(fieldName);
- this._toggleCustomGroupMenu();
- },
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onGroupSelectorClick: function (event) {
- event.stopPropagation();
- },
-});
-
-return GroupByMenu;
-
-});
diff --git a/addons/web/static/src/js/views/search/groupby_menu_interface_mixin.js b/addons/web/static/src/js/views/search/groupby_menu_interface_mixin.js
deleted file mode 100644
index d782eb99015..00000000000
--- a/addons/web/static/src/js/views/search/groupby_menu_interface_mixin.js
+++ /dev/null
@@ -1,117 +0,0 @@
-odoo.define('web.GroupByMenuInterfaceMixin', function (require) {
-"use strict";
-
-var GroupByMenu = require('web.GroupByMenu');
-
-/**
- * The aim of this mixin is to facilitate the interaction between
- * a view controller and a dropdown menu with its control panel
- * TO DO: the pivot subview has two types of groupbys so that it will not
- * understand the current implementation of this mixin
- *
- * @mixin
- * @name GroupByMenuInterfaceMixin
- */
-var GroupByMenuInterfaceMixin = {
-
- init: function () {
- this.custom_events = _.extend({}, this.custom_events, {
- menu_item_toggled: '_onMenuItemToggled',
- item_option_changed : '_onItemOptionChanged',});
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * this function instantiate a widget GroupByMenu and
- * incorporate it to the control panel of the graph view.
- * This is used for instance in the dashboard view in enterprise
- * where there is no Group by menu in the search view because it
- * would not make sense to have one at the global level.
- * this function is called by renderButtons when the parameter
- * 'this.isEmbedded' is set to true.
- *
- * private
- * @param {jQuery} $node
- */
- _addGroupByMenu: function ($node, groupableFields) {
- var groupbys = [];
- var activeGroupbys = [];
- _.each(this.model.get().groupedBy, function (groupby) {
- activeGroupbys.push({fieldName: groupby.split(':')[0], interval: groupby.split(':')[1] || false});
- });
- var groupId = '__group__1';
- _.each(groupableFields, function (field, fieldName) {
- var groupby = _.findWhere(activeGroupbys, {fieldName: fieldName});
- groupbys.push({
- isActive: groupby ? true : false,
- description: field.string,
- itemId: fieldName,
- fieldName: fieldName,
- groupId: groupId,
- defaultOptionId: groupby ? groupby.interval : false,
- });
- });
- groupbys = _.sortBy(groupbys, 'description');
- var groupByMenu = new GroupByMenu(this, groupbys, groupableFields, {headerStyle: 'primary'});
- groupByMenu.insertAfter($node.find('div:first'));
- },
-
- /**
- * This method has to be implemented by the view controller
- * that needs to interpret the click in an appropriate
- * manner
- *
- * @private
- * @param {string[]} groupbys
- */
- _setGroupby: function (groupbys) {
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onItemOptionChanged: function (ev) {
- var fieldName = ev.data.itemId;
- var interval = ev.data.optionId;
- var groupbys = this.model.get().groupedBy;
- var groupbysNormalized = _.map(groupbys, function (groupby) {
- return groupby.split(':')[0];
- });
- var indexOfGroupby = groupbysNormalized.indexOf(fieldName);
- groupbys.splice(indexOfGroupby, 1, fieldName + ':' + interval);
- this._setGroupby(groupbys);
- },
- /**
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onMenuItemToggled: function (ev) {
- var fieldName = ev.data.itemId;
- var interval = ev.data.optionId;
- var groupbys = this.model.get().groupedBy;
- var groupbysNormalized = _.map(groupbys, function (groupby) {
- return groupby.split(':')[0];
- });
- var indexOfGroupby = groupbysNormalized.indexOf(fieldName);
- if (indexOfGroupby === -1) {
- groupbys.push(fieldName + (interval ? (':' + interval) : ''));
- } else {
- groupbys.splice(indexOfGroupby, 1);
- }
- this._setGroupby(groupbys);
- },
-};
-
-return GroupByMenuInterfaceMixin;
-
-}); \ No newline at end of file
diff --git a/addons/web/static/src/js/views/search/search_inputs.js b/addons/web/static/src/js/views/search/search_inputs.js
deleted file mode 100644
index 43fbea08f9c..00000000000
--- a/addons/web/static/src/js/views/search/search_inputs.js
+++ /dev/null
@@ -1,840 +0,0 @@
-odoo.define('web.search_inputs', function (require) {
-"use strict";
-
-var Context = require('web.Context');
-var core = require('web.core');
-var Domain = require('web.Domain');
-var field_utils = require('web.field_utils');
-var pyUtils = require('web.py_utils');
-var time = require('web.time');
-var utils = require('web.utils');
-var Widget = require('web.Widget');
-
-var _t = core._t;
-var _lt = core._lt;
-
-var Input = Widget.extend( /** @lends instance.web.search.Input# */{
- /**
- * @constructs instance.web.search.Input
- * @extends instance.web.Widget
- *
- * @param parent
- */
- init: function (parent) {
- this._super(parent);
- this.searchview = parent;
- this.load_attrs({});
- },
- /**
- * Fetch auto-completion values for the widget.
- *
- * The completion values should be an array of objects with keys category,
- * label, value prefixed with an object with keys type=section and label
- *
- * @param {String} value value to complete
- * @returns {jQuery.Deferred<null|Array>}
- */
- complete: function (value) {
- return $.when(null);
- },
- /**
- * Returns a Facet instance for the provided defaults if they apply to
- * this widget, or null if they don't.
- *
- * This default implementation will try calling
- * :js:func:`instance.web.search.Input#facet_for` if the widget's name
- * matches the input key
- *
- * @param {Object} defaults
- * @returns {jQuery.Deferred<null|Object>}
- */
- facet_for_defaults: function (defaults) {
- if (!this.attrs ||
- !(this.attrs.name in defaults && defaults[this.attrs.name])) {
- return $.when(null);
- }
- return this.facet_for(defaults[this.attrs.name]);
- },
- get_context: function () {
- throw new Error(
- "get_context not implemented for widget " + this.attrs.type);
- },
- get_groupby: function () {
- throw new Error(
- "get_groupby not implemented for widget " + this.attrs.type);
- },
- get_domain: function () {
- throw new Error(
- "get_domain not implemented for widget " + this.attrs.type);
- },
- load_attrs: function (attrs) {
- if (!_.isObject(attrs.modifiers)) {
- attrs.modifiers = attrs.modifiers ? JSON.parse(attrs.modifiers) : {};
- }
- this.attrs = attrs;
- },
- /**
- * Returns whether the input is "visible". The default behavior is to
- * query the ``modifiers.invisible`` flag on the input's description or
- * view node.
- *
- * @returns {Boolean}
- */
- visible: function () {
- return !this.attrs.modifiers.invisible;
- },
-});
-
-var Field = Input.extend( /** @lends instance.web.search.Field# */ {
- template: 'SearchView.field',
- default_operator: '=',
- /**
- * @constructs instance.web.search.Field
- * @extends instance.web.search.Input
- *
- * @param view_section
- * @param field
- * @param parent
- */
- init: function (view_section, field, parent) {
- this._super(parent);
- this.load_attrs(_.extend({}, field, view_section.attrs));
- },
- facet_for: function (value) {
- return $.when({
- field: this,
- category: this.attrs.string || this.attrs.name,
- values: [{label: String(value), value: value}]
- });
- },
- value_from: function (facetValue) {
- return facetValue.get('value');
- },
- get_context: function (facet) {
- var self = this;
- // A field needs a context to send when active
- var context = this.attrs.context;
- if (_.isEmpty(context) || !facet.values.length) {
- return;
- }
- var contexts = facet.values.map(function (facetValue) {
- return new Context(context)
- .set_eval_context({self: self.value_from(facetValue)});
- });
-
- if (contexts.length === 1) { return contexts[0]; }
-
- return _.extend(new Context(), {
- __contexts: contexts
- });
- },
- get_groupby: function () {},
- /**
- * Function creating the returned domain for the field, override this
- * methods in children if you only need to customize the field's domain
- * without more complex alterations or tests (and without the need to
- * change override the handling of filter_domain)
- *
- * @param {String} name the field's name
- * @param {String} operator the field's operator (either attribute-specified or default operator for the field
- * @param {Number|String} facet parsed value for the field
- * @returns {Array<Array>} domain to include in the resulting search
- */
- make_domain: function (name, operator, facet) {
- return [[name, operator, this.value_from(facet)]];
- },
- get_domain: function (facet, noDomainEvaluation) {
- if (!facet.values.length) { return; }
-
- var value_to_domain;
- var self = this;
- var domain = this.attrs.filter_domain;
- if (domain) {
- value_to_domain = function (facetValue) {
- return Domain.prototype.stringToArray(
- domain,
- {self: self.value_from(facetValue), raw_value: facetValue.attributes.value}
- );
- };
- } else {
- value_to_domain = function (facetValue) {
- return self.make_domain(
- self.attrs.name,
- self.attrs.operator || self.default_operator,
- facetValue
- );
- };
- }
- var domains = facet.values.map(value_to_domain);
-
- // here 'noDomainEvaluation' means 'string representation'
- if (noDomainEvaluation) {
- domains = domains.map(Domain.prototype.arrayToString);
- return pyUtils.assembleDomains(domains, 'OR');
- }
- if (domains.length === 1) {
- return domains[0];
- }
- _.each(domains, Domain.prototype.normalizeArray);
- var ors = _.times(domains.length - 1, _.constant("|"));
- return ors.concat.apply(ors, domains);
- }
-});
-
-/**
- * Implementation of the ``char`` OpenERP field type:
- *
- * * Default operator is ``ilike`` rather than ``=``
- *
- * * The Javascript and the HTML values are identical (strings)
- *
- * @class
- * @extends instance.web.search.Field
- */
-var CharField = Field.extend( /** @lends instance.web.search.CharField# */ {
- default_operator: 'ilike',
- complete: function (value) {
- if (_.isEmpty(value)) { return $.when(null); }
- var label = _.str.sprintf(_.str.escapeHTML(
- _t("Search %(field)s for: %(value)s")), {
- field: '<em>' + _.escape(this.attrs.string) + '</em>',
- value: '<strong>' + _.escape(value) + '</strong>'});
- return $.when([{
- label: label,
- facet: {
- category: this.attrs.string,
- field: this,
- values: [{label: value, value: value}]
- }
- }]);
- }
-});
-
-var NumberField = Field.extend(/** @lends instance.web.search.NumberField# */{
- complete: function (value) {
- var val = this.parse(value);
- if (isNaN(val)) { return $.when(); }
- var label = _.str.sprintf(
- _t("Search %(field)s for: %(value)s"), {
- field: '<em>' + _.escape(this.attrs.string) + '</em>',
- value: '<strong>' + _.escape(value) + '</strong>'});
- return $.when([{
- label: label,
- facet: {
- category: this.attrs.string,
- field: this,
- values: [{label: value, value: val}]
- }
- }]);
- },
-});
-
-/**
- * @class
- * @extends instance.web.search.NumberField
- */
-var IntegerField = NumberField.extend(/** @lends instance.web.search.IntegerField# */{
- error_message: _t("not a valid integer"),
- parse: function (value) {
- try {
- return field_utils.parse.integer(value);
- } catch (e) {
- return NaN;
- }
- }
-});
-
-/**
- * @class
- * @extends instance.web.search.NumberField
- */
-var FloatField = NumberField.extend(/** @lends instance.web.search.FloatField# */{
- error_message: _t("not a valid number"),
- parse: function (value) {
- try {
- return field_utils.parse.float(value);
- } catch (e) {
- return NaN;
- }
- }
-});
-
-/**
- * Utility function for m2o & selection fields taking a selection/name_get pair
- * (value, name) and converting it to a Facet descriptor
- *
- * @param {instance.web.search.Field} field holder field
- * @param {Array} pair pair value to convert
- */
-function facet_from(field, pair) {
- return {
- field: field,
- category: field.attrs.string,
- values: [{label: pair[1], value: pair[0]}]
- };
-}
-
-/**
- * @class
- * @extends instance.web.search.Field
- */
-var SelectionField = Field.extend(/** @lends instance.web.search.SelectionField# */{
- // This implementation is a basic <select> field, but it may have to be
- // altered to be more in line with the GTK client, which uses a combo box
- // (~ jquery.autocomplete):
- // * If an option was selected in the list, behave as currently
- // * If something which is not in the list was entered (via the text input),
- // the default domain should become (`ilike` string_value) but **any
- // ``context`` or ``filter_domain`` becomes falsy, idem if ``@operator``
- // is specified. So at least get_domain needs to be quite a bit
- // overridden (if there's no @value and there is no filter_domain and
- // there is no @operator, return [[name, 'ilike', str_val]]
- template: 'SearchView.field.selection',
- init: function () {
- this._super.apply(this, arguments);
- // prepend empty option if there is no empty option in the selection list
- this.prepend_empty = !_(this.attrs.selection).detect(function (item) {
- return !item[1];
- });
- },
- complete: function (needle) {
- var self = this;
- var results = _(this.attrs.selection).chain()
- .filter(function (sel) {
- var value = sel[0], label = sel[1];
- if (value === undefined || !label) { return false; }
- return label.toLowerCase().indexOf(needle.toLowerCase()) !== -1;
- })
- .map(function (sel) {
- return {
- label: _.escape(sel[1]),
- indent: true,
- facet: facet_from(self, sel)
- };
- }).value();
- if (_.isEmpty(results)) { return $.when(null); }
- return $.when.call(null, [{
- label: _.escape(this.attrs.string)
- }].concat(results));
- },
- facet_for: function (value) {
- var match = _(this.attrs.selection).detect(function (sel) {
- return sel[0] === value;
- });
- if (!match) { return $.when(null); }
- return $.when(facet_from(this, match));
- }
-});
-
-var BooleanField = SelectionField.extend(/** @lends instance.web.search.BooleanField# */{
- /**
- * @constructs instance.web.search.BooleanField
- * @extends instance.web.search.BooleanField
- */
- init: function () {
- this._super.apply(this, arguments);
- this.attrs.selection = [
- [true, _t("Yes")],
- [false, _t("No")]
- ];
- }
-});
-
-/**
- * @class
- * @extends instance.web.search.DateField
- */
-var DateField = Field.extend(/** @lends instance.web.search.DateField# */{
- value_from: function (facetValue) {
- var serverFormat = (this.attrs && this.attrs.type === 'datetime') ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD';
- return facetValue.get('valueAsMoment').clone().format(serverFormat);
- },
- complete: function (needle) {
- // Make sure the needle has a correct format before the creation of the moment object. See
- // issue https://github.com/moment/moment/issues/1407
- var t, v;
- try {
- t = (this.attrs && this.attrs.type === 'datetime') ? 'datetime' : 'date';
- v = field_utils.parse[t](needle, {type: t}, {timezone: true});
- } catch (e) {
- return $.when(null);
- }
-
- var m = moment(v, t === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD');
- if (!m.isValid()) { return $.when(null); }
- var date_string = field_utils.format[t](m, {type: t});
- var label = _.str.sprintf(_.str.escapeHTML(
- _t("Search %(field)s at: %(value)s")), {
- field: '<em>' + _.escape(this.attrs.string) + '</em>',
- value: '<strong>' + date_string + '</strong>'});
- return $.when([{
- label: label,
- facet: {
- category: this.attrs.string,
- field: this,
- values: [{label: date_string, value: m.toDate(), valueAsMoment: m.clone()}]
- }
- }]);
- }
-});
-
-/**
- * Implementation of the ``datetime`` openerp field type:
- *
- * * Uses the same widget as the ``date`` field type (a simple date)
- *
- * * Builds a slighly more complex, it's a datetime range (includes time)
- * spanning the whole day selected by the date widget
- *
- * @class
- * @extends instance.web.DateField
- */
-var DateTimeField = DateField;
-
-var ManyToOneField = CharField.extend({
- default_operator: {},
- init: function (view_section, field, parent) {
- this._super(view_section, field, parent);
- this.searchview = parent;
- },
-
- complete: function (value) {
- if (_.isEmpty(value)) { return $.when(null); }
- var label = _.str.sprintf(_.str.escapeHTML(
- _t("Search %(field)s for: %(value)s")), {
- field: '<em>' + _.escape(this.attrs.string) + '</em>',
- value: '<strong>' + _.escape(value) + '</strong>'});
- return $.when([{
- label: label,
- facet: {
- category: this.attrs.string,
- field: this,
- values: [{label: value, value: value, operator: 'ilike'}]
- },
- expand: this.expand.bind(this),
- }]);
- },
-
- expand: function (needle) {
- var self = this;
- // FIXME: "concurrent" searches (multiple requests, mis-ordered responses)
- var context = pyUtils.eval(
- 'contexts', [this.searchview.dataset.get_context()]);
- var args = this.attrs.domain;
- if (typeof args === 'string') {
- try {
- args = Domain.prototype.stringToArray(args);
- } catch(e) {
- args = [];
- }
- }
- return this._rpc({
- model: this.attrs.relation,
- method: 'name_search',
- kwargs: {
- name: needle,
- args: args,
- limit: 8,
- context: context
- },
- })
- .then(function (results) {
- if (_.isEmpty(results)) { return null; }
- return _(results).map(function (result) {
- return {
- label: _.escape(result[1]),
- facet: facet_from(self, result)
- };
- });
- });
- },
- facet_for: function (value) {
- var self = this;
- if (value instanceof Array) {
- if (value.length === 2 && _.isString(value[1])) {
- return $.when(facet_from(this, value));
- }
- utils.assert(value.length <= 1,
- _t("M2O search fields do not currently handle multiple default values"));
- // there are many cases of {search_default_$m2ofield: [id]}, need
- // to handle this as if it were a single value.
- value = value[0];
- }
- var context = pyUtils.eval('contexts', [this.searchview.dataset.get_context()]);
-
- return this._rpc({
- model: this.attrs.relation,
- method: 'name_get',
- args: [value],
- context: context,
- })
- .then(function (names) {
- if (_(names).isEmpty()) { return null; }
- return facet_from(self, names[0]);
- });
- },
- value_from: function (facetValue) {
- return facetValue.get('label');
- },
- make_domain: function (name, operator, facetValue) {
- operator = facetValue.get('operator') || operator;
-
- switch(operator){
- case this.default_operator:
- return [[name, '=', facetValue.get('value')]];
- case 'ilike':
- return [[name, 'ilike', facetValue.get('value')]];
- case 'child_of':
- return [[name, 'child_of', facetValue.get('value')]];
- }
- return this._super(name, operator, facetValue);
- },
- get_context: function (facet) {
- var values = facet.values;
- if (_.isEmpty(this.attrs.context) && values.length === 1) {
- var c = {};
- var v = values.at(0);
- if (v.get('operator') !== 'ilike') {
- c['default_' + this.attrs.name] = v.get('value');
- }
- return c;
- }
- return this._super(facet);
- }
-});
-
-var FilterGroup = Input.extend(/** @lends instance.web.search.FilterGroup# */{
- template: 'SearchView.filters',
- events: {
- click: 'toggle_filter',
- },
-
- icon: "fa-filter",
- completion_label: _lt("Filter on: %s"),
-
- /**
- * Inclusive group of filters, creates a continuous "button" with clickable
- * sections (the normal display for filters is to be a self-contained button)
- *
- * @constructs instance.web.search.FilterGroup
- * @extends instance.web.search.Input
- *
- * @param {Array<instance.web.search.Filter>} filters elements of the group
- * @param {instance.web.SearchView} parent parent in which the filters are contained
- * @param {Object} intervalMapping, a key is a field name and the corresponding value
- * is the current interval used
- * (necessarily the field is of type 'date' or 'datetime')
- */
- init: function (filters, parent, intervalMapping, periodMapping) {
- // If all filters are group_by and we're not initializing a GroupbyGroup,
- // create a GroupbyGroup instead of the current FilterGroup
- if (!(this instanceof GroupbyGroup) &&
- _(filters).all(function (f) {
- if (!f.attrs.context) { return false; }
- var c = pyUtils.eval('context', f.attrs.context);
- return !_.isEmpty(c.group_by);})) {
- return new GroupbyGroup(filters, parent, intervalMapping);
- }
- this._super(parent);
- this.filters = filters;
- this.searchview = parent;
- this.intervalMapping = intervalMapping;
- this.periodMapping = periodMapping || [];
- this.searchview.query.on('add remove change reset', this.proxy('search_change'));
- },
- start: function () {
- return $.when(null);
- },
- /**
- * Handles change of the search query: any of the group's filter which is
- * in the search query should be visually checked in the drawer
- */
- search_change: function () {
- var self = this;
- var $filters = this.$el ? this.$el.removeClass('selected') : $();
- var facet = this.searchview.query.find(_.bind(this.match_facet, this));
- if (!facet) { return; }
- facet.values.each(function (v) {
- var i = _(self.filters).indexOf(v.get('value'));
- if (i === -1) { return; }
- $filters.filter(function () {
- return Number($(this).data('index')) === i;
- }).addClass('selected');
- });
- },
- /**
- * Matches the group to a facet, in order to find if the group is
- * represented in the current search query
- */
- match_facet: function (facet) {
- return facet.get('field') === this;
- },
- make_facet: function (values) {
- return {
- cat: 'filterCategory',
- category: _t("Filter"),
- icon: this.icon,
- separator: _t(" or "),
- values: values,
- field: this
- };
- },
- make_value: function (filter) {
- return {
- label: filter.attrs.string || filter.attrs.help || filter.attrs.name,
- value: filter
- };
- },
- facet_for_defaults: function (defaults) {
- var self = this;
- var fs = _(this.filters).chain()
- .filter(function (f) {
- return f.attrs && f.attrs.name && !!defaults[f.attrs.name];
- }).map(function (f) {
- return self.make_value(f);
- }).value();
- if (_.isEmpty(fs)) { return $.when(null); }
- return $.when(this.make_facet(fs));
- },
- /**
- * Fetches contexts for all enabled filters in the group
- *
- * @param {openerp.web.search.Facet} facet
- * @return {*} combined contexts of the enabled filters in this group
- */
- get_context: function (facet) {
- var contexts = facet.values.chain()
- .map(function (f) { return f.get('value').attrs.context; })
- .without('{}')
- .reject(_.isEmpty)
- .value();
-
- if (!contexts.length) { return; }
- if (contexts.length === 1) { return contexts[0]; }
- return _.extend(new Context(), {
- __contexts: contexts
- });
- },
- /**
- * Fetches group_by sequence for all enabled filters in the group
- *
- * @param {VS.model.SearchFacet} facet
- * @return {Array} enabled filters in this group
- */
- get_groupby: function (facet) {
- var self = this;
- return facet.values.chain()
- .map(function (f) {
- var fieldName = f.get('value').attrs.fieldName;
- var defaultInterval = f.get('value').attrs.defaultInterval;
- var couple = _.findWhere(self.intervalMapping, {groupby: f.get('value')});
- var interval = couple ? couple.interval: undefined;
- var context = _.clone(f.get('value').attrs.context);
- if (context && interval) {
- if (defaultInterval) {
- context = context.replace(defaultInterval, interval);
- } else if (fieldName){
- context = context.replace(fieldName, fieldName + ':' + interval);
- }
- }
- return context;
- })
- .without('{}')
- .reject(_.isEmpty)
- .value();
- },
- /**
- * Handles domains-fetching for all the filters within it: groups them.
- *
- * @param {VS.model.SearchFacet} facet
- * @param {boolean} noEvaluation determines if the domains are evaluated or not.
- By default, domains are evaluated.
- * @return {*} combined domains of the enabled filters in this group
- */
- get_domain: function (facet, noEvaluation) {
- noEvaluation = noEvaluation || false;
- var self = this;
- var userContext = this.getSession().user_context;
- var domains = facet.values.chain()
- .map(function (f) {
- var filter = f.get('value');
- var attributes = filter.attrs;
- var domain = attributes.domain;
- var fieldName = attributes.date;
- if (!domain && fieldName) {
- var couple = _.findWhere(self.periodMapping, {filter: filter});
- var period = couple ? couple.period: 'this_month';
- var type = couple ? couple.type: 'date';
- domain = Domain.prototype.constructDomain(fieldName, period, type);
- }
- return domain;
- })
- .without('[]')
- .reject(_.isEmpty)
- .map(function (domain) {
- domain = domain.replace(/%%/g, '%');
- return domain;
- })
- .value();
-
- if (!domains.length) {
- return;
- }
- // domains belong the same groups. We need to make a union.
- var domain = pyUtils.assembleDomains(domains, 'OR');
- if (!noEvaluation) {
- return Domain.prototype.stringToArray(domain, userContext);
- }
- return domain;
- },
- toggle_filter: function (e) {
- e.preventDefault();
- e.stopPropagation();
- this.toggle(this.filters[Number($(e.currentTarget).data('index'))]);
- },
- toggle: function (filter, options) {
- this.searchview.query.toggle(this.make_facet([this.make_value(filter)]), options);
- },
- is_visible: function () {
- return _.some(this.filters, function (filter) {
- return !filter.attrs.invisible;
- });
- },
- complete: function (item) {
- var self = this;
- item = item.toLowerCase();
- var facet_values = _(this.filters).chain()
- .filter(function (filter) { return filter.visible(); })
- .filter(function (filter) {
- var at = {
- string: filter.attrs.string || '',
- help: filter.attrs.help || '',
- name: filter.attrs.name || ''
- };
- var include = _.str.include;
- return include(at.string.toLowerCase(), item)
- || include(at.help.toLowerCase(), item)
- || include(at.name.toLowerCase(), item);
- })
- .map(this.make_value)
- .value();
- if (_(facet_values).isEmpty()) { return $.when(null); }
- return $.when(_.map(facet_values, function (facet_value) {
- return {
- label: _.str.sprintf(self.completion_label.toString(),
- _.escape(facet_value.label)),
- facet: self.make_facet([facet_value])
- };
- }));
- },
- /*
- * @param {Object} intervalMapping
- */
- updateIntervalMapping: function (intervalMapping) {
- this.intervalMapping = intervalMapping;
- },
- /*
- * @param {Object} periodMapping
- */
- updatePeriodMapping: function (periodMapping) {
- this.intervalMapping = periodMapping;
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
-});
-
-var GroupbyGroup = FilterGroup.extend({
- icon: 'fa-bars',
- completion_label: _lt("Group by: %s"),
- init: function (filters, parent) {
- this._super.apply(this, arguments);
- this.searchview = parent;
- // Not flanders: facet unicity is handled through the
- // (category, field) pair of facet attributes. This is all well and
- // good for regular filter groups where a group matches a facet, but for
- // groupby we want a single facet. So cheat: add an attribute on the
- // view which proxies to the first GroupbyGroup, so it can be used
- // for every GroupbyGroup and still provides the various methods needed
- // by the search view. Use weirdo name to avoid risks of conflicts
- if (!this.searchview._s_groupby) {
- this.searchview._s_groupby = {
- help: "See GroupbyGroup#init",
- get_context: this.proxy('get_context'),
- get_domain: this.proxy('get_domain'),
- get_groupby: this.proxy('get_groupby')
- };
- }
- },
- match_facet: function (facet) {
- return facet.get('field') === this.searchview._s_groupby;
- },
- make_facet: function (values) {
- return {
- cat: 'groupByCategory',
- category: _t("Group By"),
- icon: this.icon,
- separator: " > ",
- values: values,
- field: this.searchview._s_groupby
- };
- }
-});
-
-var Filter = Input.extend(/** @lends instance.web.search.Filter# */{
- template: 'SearchView.filter',
- /**
- * Implementation of the OpenERP filters (button with a context and/or
- * a domain sent as-is to the search view)
- *
- * Filters are only attributes holder, the actual work (compositing
- * domains and contexts, converting between facets and filters) is
- * performed by the filter group.
- *
- * @constructs instance.web.search.Filter
- * @extends instance.web.search.Input
- *
- * @param node
- * @param parent
- */
- init: function (node, parent) {
- this._super(parent);
- this.load_attrs(node.attrs);
- },
- facet_for: function () { return $.when(null); },
- get_context: function () { },
- get_domain: function () { },
-});
-
-/**
- * Registry of search fields, called by :js:class:`instance.web.SearchView` to
- * find and instantiate its field widgets.
- */
-
-core.search_widgets_registry
- .add('char', CharField)
- .add('text', CharField)
- .add('html', CharField)
- .add('boolean', BooleanField)
- .add('integer', IntegerField)
- .add('id', IntegerField)
- .add('float', FloatField)
- .add('monetary', FloatField)
- .add('selection', SelectionField)
- .add('datetime', DateTimeField)
- .add('date', DateField)
- .add('many2one', ManyToOneField)
- .add('many2many', CharField)
- .add('one2many', CharField);
-
-
-return {
- FilterGroup: FilterGroup,
- Filter: Filter,
- Field: Field,
- GroupbyGroup: GroupbyGroup,
-};
-
-});
diff --git a/addons/web/static/src/js/views/search/search_view.js b/addons/web/static/src/js/views/search/search_view.js
deleted file mode 100644
index 69a45649793..00000000000
--- a/addons/web/static/src/js/views/search/search_view.js
+++ /dev/null
@@ -1,1244 +0,0 @@
-odoo.define('web.SearchView', function (require) {
-"use strict";
-
-var AutoComplete = require('web.AutoComplete');
-var config = require('web.config');
-var core = require('web.core');
-var Domain = require('web.Domain');
-var FavoriteMenu = require('web.FavoriteMenu');
-var FiltersMenu = require('web.FiltersMenu');
-var GroupByMenu = require('web.GroupByMenu');
-var pyUtils = require('web.py_utils');
-var search_inputs = require('web.search_inputs');
-var TimeRangeMenu = require('web.TimeRangeMenu');
-var TimeRangeMenuOptions = require('web.TimeRangeMenuOptions');
-var utils = require('web.utils');
-var Widget = require('web.Widget');
-
-var _t = core._t;
-var ComparisonOptions = TimeRangeMenuOptions.ComparisonOptions;
-var PeriodOptions = TimeRangeMenuOptions.PeriodOptions;
-
-var Backbone = window.Backbone;
-
-var FacetValue = Backbone.Model.extend({});
-
-var FacetValues = Backbone.Collection.extend({
- model: FacetValue
-});
-
-var DEFAULT_INTERVAL = 'month';
-var DEFAULT_PERIOD = 'this_month';
-
-var Facet = Backbone.Model.extend({
- initialize: function (attrs) {
- var values = attrs.values;
- delete attrs.values;
-
- Backbone.Model.prototype.initialize.apply(this, arguments);
-
- this.values = new FacetValues(values || []);
- this.values.on('add remove change reset', function (_, options) {
- this.trigger('change', this, options);
- }, this);
- },
- get: function (key) {
- if (key !== 'values') {
- return Backbone.Model.prototype.get.call(this, key);
- }
- return this.values.toJSON();
- },
- set: function (key, value) {
- if (key !== 'values') {
- return Backbone.Model.prototype.set.call(this, key, value);
- }
- this.values.reset(value);
- },
- toJSON: function () {
- var out = {};
- var attrs = this.attributes;
- for(var att in attrs) {
- if (!attrs.hasOwnProperty(att) || att === 'field') {
- continue;
- }
- out[att] = attrs[att];
- }
- out.values = this.values.toJSON();
- return out;
- }
-});
-
-var SearchQuery = Backbone.Collection.extend({
- model: Facet,
- initialize: function () {
- Backbone.Collection.prototype.initialize.apply(
- this, arguments);
- this.on('change', function (facet) {
- if(!facet.values.isEmpty()) { return; }
-
- this.remove(facet, {silent: true});
- }, this);
- },
- add: function (values, options) {
- options = options || {};
-
- if (!values) {
- values = [];
- } else if (!(values instanceof Array)) {
- values = [values];
- }
-
- _(values).each(function (value) {
- var model = this._prepareModel(value, options);
- var previous = this.detect(function (facet) {
- return facet.get('category') === model.get('category') &&
- facet.get('field') === model.get('field');
- });
- if (previous) {
- previous.values.add(model.get('values'), _.omit(options, 'at', 'merge'));
- return;
- }
- Backbone.Collection.prototype.add.call(this, model, options);
- }, this);
- // warning: in backbone 1.0+ add is supposed to return the added models,
- // but here toggle may delegate to add and return its value directly.
- // return value of neither seems actually used but should be tested
- // before change, probably
- return this;
- },
- toggle: function (value, options) {
- options = options || {};
-
- var facet = this.detect(function (facet) {
- return facet.get('category') === value.category
- && facet.get('field') === value.field;
- });
- if (!facet) {
- return this.add(value, options);
- }
-
- var changed = false;
- _(value.values).each(function (val) {
- var already_value = facet.values.detect(function (v) {
- return v.get('value') === val.value
- && v.get('label') === val.label;
- });
- // toggle value
- if (already_value) {
- facet.values.remove(already_value, {silent: true});
- } else {
- facet.values.add(val, {silent: true});
- }
- changed = true;
- });
- // "Commit" changes to values array as a single call, so observers of
- // change event don't get misled by intermediate incomplete toggling
- // states
- facet.trigger('change', facet);
- return this;
- }
-});
-
-var InputView = Widget.extend({
- template: 'SearchView.InputView',
- events: {
- focus: function () { this.trigger('focused', this); },
- blur: function () { this.$el.val(''); this.trigger('blurred', this); },
- keydown: 'onKeydown',
- },
- onKeydown: function (e) {
- switch (e.which) {
- case $.ui.keyCode.BACKSPACE:
- if(this.$el.val() === '') {
- var preceding = this.getParent().siblingSubview(this, -1);
- if (preceding && (preceding instanceof FacetView)) {
- preceding.model.destroy();
- }
- }
- break;
-
- case $.ui.keyCode.LEFT: // Stop propagation to parent if not at beginning of input value
- if(this.el.selectionStart > 0) {
- e.stopPropagation();
- }
- break;
-
- case $.ui.keyCode.RIGHT: // Stop propagation to parent if not at end of input value
- if(this.el.selectionStart < this.$el.val().length) {
- e.stopPropagation();
- }
- break;
- }
- }
-});
-
-var FacetView = Widget.extend({
- template: 'SearchView.FacetView',
- events: {
- 'focus': function () { this.trigger('focused', this); },
- 'blur': function () {
- this.trigger('blurred', this); },
- 'click': function (e) {
- if ($(e.target).hasClass('o_facet_remove')) {
- this.model.destroy();
- return false;
- }
- this.$el.focus();
- e.stopPropagation();
- },
- 'keydown': function (e) {
- var keys = $.ui.keyCode;
- switch (e.which) {
- case keys.BACKSPACE:
- case keys.DELETE:
- this.model.destroy();
- return false;
- }
- }
- },
- /*
- * @param {Widget} parent
- * @param {Object} model
- * @param {Object} intervalMapping, a key is a field name and the corresponding value
- * is the current interval used
- * (necessarily the field is of type 'date' or 'datetime')
- * @param {Object} periodMapping, a key is a field name and the corresponding value
- * is the current period used
- * (necessarily the field is of type 'date' or 'datetime')
- */
- init: function (parent, model, intervalMapping, periodMapping) {
- this._super(parent);
- this.model = model;
- this.intervalMapping = intervalMapping;
- this.periodMapping = periodMapping;
- this.model.on('change', this.model_changed, this);
- },
- destroy: function () {
- this.model.off('change', this.model_changed, this);
- this._super();
- },
- start: function () {
- var self = this;
- var $e = this.$('.o_facet_values').last();
- return $.when(this._super()).then(function () {
- return $.when.apply(null, self.model.values.map(function (value, index) {
- if (index > 0) {
- $('<span/>', {html: self.model.get('separator') || _t(" or ")}).addClass('o_facet_values_sep').appendTo($e);
- }
- var couple;
- var option;
- if (value.attributes.value && value.attributes.value.attrs) {
- if (value.attributes.value.attrs.isPeriod) {
- couple = _.findWhere(self.periodMapping, {filter: value.attributes.value});
- option = couple ? couple.period : value.attributes.value.attrs.default_period;
- }
- if (value.attributes.value.attrs.isDate) {
- couple = _.findWhere(self.intervalMapping, {groupby: value.attributes.value});
- option = couple ?
- couple.interval :
- (value.attributes.value.attrs.defaultInterval || DEFAULT_INTERVAL);
- }
- }
- return new FacetValueView(self, value, option).appendTo($e);
- }));
- });
- },
- model_changed: function () {
- this.$el.text(this.$el.text() + '*');
- }
-});
-
-var FacetValueView = Widget.extend({
- template: 'SearchView.FacetView.Value',
- /*
- * @param {Widget} parent
- * @param {Object} model
- * @param {Object} option (optional) is used in case the facet value
- * corresponds to a groupby with an associated 'date'
- * 'datetime' field.
- * @param {Object} comparison (optional) is used in case the facet value
- * comes from the time range menu and comparison is active
- */
- init: function (parent, model, option) {
- this._super(parent);
- this.model = model;
-
- var optionDescription = _.extend({}, {
- day: 'Day',
- week: 'Week',
- month: 'Month',
- quarter: 'Quarter',
- year: 'Year',
- }, {
- today: 'Today',
- this_week: 'This Week',
- this_month: 'This Month',
- this_quarter: 'This Quarter',
- this_year: 'This Year',
- yesterday: 'Yesterday',
- last_week: 'Last Week',
- last_month: 'Last Month',
- last_quarter: 'Last Quarter',
- last_year: 'Last Year',
- last_7_days: 'Last 7 Days',
- last_30_days: 'Last 30 Days',
- last_365_days: 'Last 365 Days',
- });
- if (option) {
- var optionLabel = optionDescription[option];
- this.optionLabel = _t(optionLabel);
- }
- this.model.on('change', this.model_changed, this);
- },
- destroy: function () {
- this.model.off('change', this.model_changed, this);
- this._super();
- },
- model_changed: function () {
- this.$el.text(this.$el.text() + '*');
- }
-});
-
-var SearchView = Widget.extend({
- events: {
- 'click .o_searchview_more': function (e) {
- $(e.target).toggleClass('fa-search-plus fa-search-minus');
- var visibleSearchMenu = this.call('local_storage', 'getItem', 'visible_search_menu');
- this.call('local_storage', 'setItem', 'visible_search_menu', visibleSearchMenu !== 'true');
- this.toggle_buttons();
- },
- 'keydown .o_searchview_input, .o_searchview_facet': function (e) {
- switch(e.which) {
- case $.ui.keyCode.LEFT:
- this.focusPreceding(e.target);
- e.preventDefault();
- break;
- case $.ui.keyCode.RIGHT:
- if(!this.autocomplete.is_expandable()) {
- this.focusFollowing(e.target);
- }
- e.preventDefault();
- break;
- case $.ui.keyCode.DOWN:
- if (!this.autocomplete.is_expanded()) {
- e.preventDefault();
- this.trigger_up('navigation_move', {direction: 'down'});
- break;
- }
- }
- },
- },
- custom_events: {
- menu_item_toggled: '_onItemToggled',
- item_option_changed: '_onItemOptionChanged',
- new_groupby: '_onNewGroupby',
- new_filters: '_onNewFilters',
- time_range_modified: '_onTimeRangeModified',
- time_range_removed: '_onTimeRangeRemoved',
- },
- defaults: _.extend({}, Widget.prototype.defaults, {
- hidden: false,
- disable_custom_filters: false,
- disable_groupby: false,
- disable_favorites: false,
- disable_filters: false,
- disableTimeRangeMenu: true,
- }),
- template: "SearchView",
-
- /**
- * @constructs SearchView
- * @extends View
- *
- * @param parent
- * @param dataset
- * @param fvg
- * @param {Object} [options]
- * @param {Boolean} [options.hidden=false] hide the search view
- * @param {Boolean} [options.disable_custom_filters=false] do not load custom filters from ir.filters
- */
- init: function (parent, dataset, fvg, options) {
- this._super.apply(this, arguments);
- this.options = options;
- this.dataset = dataset;
- this.fields_view = this._processFieldsView(_.clone(fvg));
-
- this.fields = this.fields_view.fields;
- this.query = undefined;
- this.title = this.options.action && this.options.action.name;
- this.action = this.options.action || {};
- this.search_fields = [];
-
- this.hasFavorites = false;
-
- this.noDateFields = true;
- var field;
- for (var key in this.fields) {
- field = this.fields[key];
- if (_.contains(['date', 'datetime'], field.type) && field.sortable) {
- this.noDateFields = false;
- break;
- }
- }
- this.activeItemIds = {
- groupByCategory: [],
- filterCategory: []
- };
- this.groupsMapping = [];
- this.groupbysMapping = [];
- this.filtersMapping = [];
- this.intervalMapping = [];
- this.periodMapping = [];
-
- this.filters = [];
- this.groupbys = [];
- this.timeRanges = options.action && options.action.context ?
- options.action.context.time_ranges : undefined;
- var visibleSearchMenu = this.call('local_storage', 'getItem', 'visible_search_menu');
- this.visible_filters = (visibleSearchMenu !== 'false');
- this.input_subviews = []; // for user input in searchbar
- this.search_defaults = this.options.search_defaults || {};
- this.headless = this.options.hidden && _.isEmpty(this.search_defaults);
- this.$buttons = this.options.$buttons;
-
- this.filters_menu = undefined;
- this.groupby_menu = undefined;
- this.favorite_menu = undefined;
- },
- willStart: function () {
- var self = this;
- var def;
- if (!this.options.disable_favorites) {
- def = this.loadFilters(this.dataset, this.action.id).then(function (filters) {
- self.favorite_filters = filters;
- });
- }
- return $.when(this._super(), def);
- },
- start: function () {
- var self= this;
- if (this.headless) {
- this.do_hide();
- }
- this.toggle_visibility(false);
- this.setup_global_completion();
- this.query = new SearchQuery()
- .on('add change reset remove', this.proxy('do_search'))
- .on('change', this.proxy('renderChangedFacets'))
- .on('add reset remove', this.proxy('renderFacets'));
- this.$('.o_searchview_more')
- .toggleClass('fa-search-minus', this.visible_filters)
- .toggleClass('fa-search-plus', !this.visible_filters);
- var def;
- this.prepare_search_inputs();
- var $buttons = this._getButtonsElement();
- if ($buttons) {
- if (!this.options.disable_favorites) {
- this.favorite_menu = new FavoriteMenu(this, this.query, this.dataset.model, this.action, this.favorite_filters);
- def = this.favorite_menu.appendTo($buttons);
- }
- }
- return $.when(def)
- .then(this.set_default_filters.bind(this))
- .then(function () {
- var menu_defs = [];
- self.timeRangeMenu = self._createTimeRangeMenu();
- menu_defs.push(self.timeRangeMenu.prependTo($buttons));
- self.timeRangeMenu.do_hide();
- self.displayedTimeRangeMenu = self.options.disableTimeRangeMenu !== undefined &&
- !self.options.disableTimeRangeMenu;
- self.displayTimeRangeMenu(self.displayedTimeRangeMenu);
- if (!self.options.disable_groupby) {
- self.groupby_menu = self._createGroupByMenu();
- menu_defs.push(self.groupby_menu.prependTo($buttons));
- }
- if (!self.options.disable_filters) {
- self.filters_menu = self._createFiltersMenu();
- menu_defs.push(self.filters_menu.prependTo($buttons));
- }
- return $.when.apply($, menu_defs);
- });
- },
- /*
- *
- * @param {boolean}
- */
- displayTimeRangeMenu: function (b) {
- if (!b || this.noDateFields) {
- this.timeRangeMenu.do_hide();
- } else {
- this.timeRangeMenu.do_show();
- }
- },
- on_attach_callback: function () {
- this._focusInput();
- },
- get_title: function () {
- return this.title;
- },
- set_default_filters: function () {
- var self = this,
- default_custom_filter = this.$buttons && this.favorite_menu && this.favorite_menu.get_default_filter();
- if (!self.options.disable_custom_filters && default_custom_filter) {
- this.hasFavorites = true;
- return this.favorite_menu.toggle_filter(default_custom_filter, true);
- }
- if (!_.isEmpty(this.search_defaults) || this.timeRanges) {
- var inputs = this.search_fields.concat(this.filters, this.groupbys);
- var search_defaults = _.invoke(inputs, 'facet_for_defaults', this.search_defaults);
- var defaultTimeRange = this._searchDefaultTimeRange();
- search_defaults.push(defaultTimeRange);
- return $.when.apply(null, search_defaults).then(function () {
- var facets = _.compact(arguments);
- self.query.reset(facets, {preventSearch: true});
- });
- }
- this.query.reset([], {preventSearch: true});
- return $.when();
- },
- /**
- * Performs the search view collection of widget data.
- *
- * If the collection went well (all fields are valid), then triggers
- * :js:func:`instance.web.SearchView.on_search`.
- *
- * If at least one field failed its validation, triggers
- * :js:func:`instance.web.SearchView.on_invalid` instead.
- *
- * @param [_query]
- * @param {Object} [options]
- */
- do_search: function (_query, options) {
- if (options && options.preventSearch) {
- return;
- }
- var search = this.build_search_data();
- this.trigger_up('search', search);
- },
- /**
- * @param {boolean} noDomainEvaluation determines if domain are evaluated or not.
- * By default, domains are evaluated.
- *
- * Extract search data from the view's facets.
- *
- * Result is an object with 3 (own) properties:
- *
- * domains
- * Array of domains
- * contexts
- * Array of contexts
- * groupbys
- * Array of domains, in groupby order rather than view order
- *
- * @return {Object}
- */
- build_search_data: function (noDomainEvaluation) {
- var domains = [], contexts = [], groupbys = [];
- noDomainEvaluation = noDomainEvaluation || false;
- this.query.each(function (facet) {
- // field is actually a FilterGroup!
- var field = facet.get('field');
- var domain = field.get_domain(facet, noDomainEvaluation);
- if (domain) {
- domains.push(domain);
- }
- var context = field.get_context(facet, noDomainEvaluation);
- if (context) {
- contexts.push(context);
- }
- var group_by = field.get_groupby(facet);
- if (group_by) {
- groupbys.push.apply(groupbys, group_by);
- }
- });
- var intervalMappingNormalized = {};
- _.each(this.intervalMapping, function (couple) {
- var fieldName = couple.groupby.fieldName;
- var interval = couple.interval;
- intervalMappingNormalized[fieldName] = interval;
- });
- return {
- domains: domains,
- contexts: contexts,
- groupbys: groupbys,
- intervalMapping: intervalMappingNormalized,
- };
- },
- toggle_visibility: function (is_visible) {
- this.do_toggle(!this.headless && is_visible);
- if (this.$buttons) {
- this.$buttons.toggle(!this.headless && is_visible && this.visible_filters);
- }
- this._focusInput();
- },
- /**
- * puts the focus on the search input
- */
- _focusInput: function () {
- if (!config.device.touch && config.device.size_class >= config.device.SIZES.MD) {
- this.$('input').focus();
- }
- },
- toggle_buttons: function (is_visible) {
- this.visible_filters = is_visible || !this.visible_filters;
- if (this.$buttons) {
- this.$buttons.toggle(this.visible_filters);
- }
- },
- /**
- * Sets up search view's view-wide auto-completion widget
- */
- setup_global_completion: function () {
- var self = this;
- this.autocomplete = new AutoComplete(this, {
- source: this.proxy('complete_global_search'),
- select: this.proxy('select_completion'),
- get_search_string: function () {
- return self.$('.o_searchview_input').val().trim();
- },
- });
- this.autocomplete.appendTo(this.$('.o_searchview_input_container'));
- },
- /**
- * Provide auto-completion result for req.term (an array to `resp`)
- *
- * @param {Object} req request to complete
- * @param {String} req.term searched term to complete
- * @param {Function} resp response callback
- */
- complete_global_search: function (req, resp) {
- var inputs = this.search_fields.concat(this.filters, this.groupbys);
- $.when.apply(null, _(inputs).chain()
- .filter(function (input) { return input.visible(); })
- .invoke('complete', req.term)
- .value()).then(function () {
- resp(_(arguments).chain()
- .compact()
- .flatten(true)
- .value());
- });
- },
- /**
- * Action to perform in case of selection: create a facet (model)
- * and add it to the search collection
- *
- * @param {Object} e selection event, preventDefault to avoid setting value on object
- * @param {Object} ui selection information
- * @param {Object} ui.item selected completion item
- */
- select_completion: function (e, ui) {
- var facet = ui.item.facet;
- e.preventDefault();
- if(facet && facet.values && facet.values.length && String(facet.values[0].value).trim() !== "") {
- this.query.add(facet);
- } else {
- this.query.trigger('add');
- }
- },
- subviewForRoot: function (subview_root) {
- return _(this.input_subviews).detect(function (subview) {
- return subview.$el[0] === subview_root;
- });
- },
- siblingSubview: function (subview, direction, wrap_around) {
- var index = _(this.input_subviews).indexOf(subview) + direction;
- if (wrap_around && index < 0) {
- index = this.input_subviews.length - 1;
- } else if (wrap_around && index >= this.input_subviews.length) {
- index = 0;
- }
- return this.input_subviews[index];
- },
- focusPreceding: function (subview_root) {
- return this.siblingSubview(
- this.subviewForRoot(subview_root), -1, true)
- .$el.focus();
- },
- focusFollowing: function (subview_root) {
- return this.siblingSubview(
- this.subviewForRoot(subview_root), +1, true)
- .$el.focus();
- },
- /**
- */
- renderFacets: function () {
- var self = this;
- var started = [];
- _.invoke(this.input_subviews, 'destroy');
- this.input_subviews = [];
-
- var activeItemIds = {
- groupByCategory: [],
- filterCategory: [],
- };
- var timeRangeMenuIsActive;
- this.query.each(function (facet) {
- var values = facet.get('values');
- if (facet.attributes.cat === "groupByCategory") {
- activeItemIds.groupByCategory = activeItemIds.groupByCategory.concat(
- _.uniq(
- values.reduce(
- function (acc, value) {
- var groupby = value.value;
- var description = _.findWhere(self.groupbysMapping, {groupby: groupby});
- if (description) {
- acc.push(description.groupbyId);
- }
- return acc;
- },
- []
- )
- )
- );
- }
- if (facet.attributes.cat === "filterCategory") {
- activeItemIds.filterCategory = activeItemIds.filterCategory.concat(
- _.uniq(
- values.reduce(
- function (acc, value) {
- var filter = value.value;
- var description = _.findWhere(self.filtersMapping, {filter: filter});
- if (description) {
- acc.push(description.filterId);
- }
- return acc;
- },
- []
- )
- )
- );
- }
- if (facet.attributes.cat === "timeRangeCategory") {
- timeRangeMenuIsActive = true;
- }
- var f = new FacetView(this, facet, this.intervalMapping, this.periodMapping);
- started.push(f.appendTo(self.$('.o_searchview_input_container')));
- self.input_subviews.push(f);
- }, this);
-
- var i = new InputView(this);
- started.push(i.appendTo(self.$('.o_searchview_input_container')));
- self.input_subviews.push(i);
- _.each(this.input_subviews, function (childView) {
- childView.on('focused', self, self.proxy('childFocused'));
- childView.on('blurred', self, self.proxy('childBlurred'));
- });
-
- $.when.apply(null, started).then(function () {
- if (!config.device.isMobile) {
- // in mobile mode, we would rathor not focusing manually the
- // input, because it opens up the integrated keyboard, which is
- // not what you expect when you just selected a filter.
- _.last(self.input_subviews).$el.focus();
- }
- if (self.groupby_menu) {
- self.groupby_menu.updateItemsStatus(activeItemIds.groupByCategory);
- }
- if (self.filters_menu) {
- self.filters_menu.updateItemsStatus(activeItemIds.filterCategory); }
- if (self.displayedTimeRangeMenu && !timeRangeMenuIsActive) {
- self.timeRangeMenu.deactivate();
- }
- });
- },
- childFocused: function () {
- this.$el.addClass('active');
- },
- childBlurred: function () {
- this.$el.val('').removeClass('active').trigger('blur');
- this.autocomplete.close();
- },
- /**
- * Call the renderFacets method with the correct arguments.
- * This is due to the fact that change events are called with two arguments
- * (model, options) while add, reset and remove events are called with
- * (collection, model, options) as arguments
- */
- renderChangedFacets: function (model, options) {
- this.renderFacets(undefined, model, options);
- },
- // it should parse the arch field of the view, instantiate the corresponding
- // filters/fields, and put them in the correct variables:
- // * this.search_fields is a list of all the fields,
- // * this.filters: groups of filters
- // * this.group_by: group_bys
- prepare_search_inputs: function () {
- var self = this,
- arch = this.fields_view.arch;
-
- var filters = [].concat.apply([], _.map(arch.children, function (item) {
- return item.tag !== 'group' ? eval_item(item) : item.children.map(eval_item);
- }));
- function eval_item (item) {
- var category = 'filters';
- if (item.attrs.context) {
- try {
- var context = pyUtils.eval('context', item.attrs.context);
- if (context.group_by) {
- category = 'group_by';
- item.attrs.fieldName = context.group_by.split(':')[0];
- item.attrs.isDate = _.contains(['date', 'datetime'], self.fields[item.attrs.fieldName].type);
- item.attrs.defaultInterval = context.group_by.split(':')[1];
- }
- } catch (e) {}
- }
- if (item.attrs.date) {
- item.attrs.default_period = item.attrs.default_period || DEFAULT_PERIOD;
- item.attrs.type = self.fields[item.attrs.date].type;
- }
- item.attrs.isPeriod = !!item.attrs.date;
- return {
- item: item,
- category: category,
- };
- }
- var current_group = [],
- current_category = 'filters',
- categories = {filters: this.filters, group_by: this.groupbys, timeRanges: this.timeRanges};
-
- _.each(filters.concat({category:'filters', item: 'separator'}), function (filter) {
- if (filter.item.tag === 'filter' && filter.category === current_category) {
- return current_group.push(new search_inputs.Filter(filter.item, self));
- }
- if (current_group.length) {
- var group = new search_inputs.FilterGroup(current_group, self, self.intervalMapping, self.periodMapping);
- categories[current_category].push(group);
- current_group = [];
- }
- if (filter.item.tag === 'field') {
- var attrs = filter.item.attrs;
- var field = self.fields_view.fields[attrs.name];
-
- // M2O combined with selection widget is pointless and broken in search views,
- // but has been used in the past for unsupported hacks -> ignore it
- if (field.type === "many2one" && attrs.widget === "selection") {
- attrs.widget = undefined;
- }
- var Obj = core.search_widgets_registry.getAny([attrs.widget, field.type]);
- if (Obj) {
- self.search_fields.push(new (Obj) (filter.item, field, self));
- }
- }
- if (filter.item.tag === 'filter') {
- current_group.push(new search_inputs.Filter(filter.item, self));
- }
- current_category = filter.category;
- });
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Updates the domain of the search view by adding and/or removing filters.
- *
- * @todo: the way it is done could be improved, but the actual state of the
- * searchview doesn't allow to do much better.
-
- * @param {Array<Object>} newFilters list of filters to add, described by
- * objects with keys domain (the domain as an Array), and help (the text
- * to display in the facet)
- * @param {Array<Object>} filtersToRemove list of filters to remove
- * (previously added ones)
- * @returns {Array<Object>} list of added filters (to pass as filtersToRemove
- * for a further call to this function)
- */
- updateFilters: function (newFilters, filtersToRemove) {
- var self = this;
- var addedFilters = _.map(newFilters, function (filter) {
- var domain = filter.domain;
- if (domain instanceof Array) {
- domain = Domain.prototype.arrayToString(domain);
- }
- filter = {
- attrs: {domain: domain, help: filter.help},
- };
- var filterWidget = new search_inputs.Filter(filter);
- var filterGroup = new search_inputs.FilterGroup([filterWidget], self, self.intervalMapping, self.periodMapping);
- var facet = filterGroup.make_facet([filterGroup.make_value(filter)]);
- self.query.add([facet], {silent: true});
-
- return _.last(self.query.models);
- });
-
- _.each(filtersToRemove, function (filter) {
- self.query.remove(filter, {silent: true});
- });
-
- this.query.trigger('reset');
-
- return addedFilters;
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
-
- /**
- * Will return $element where Filters, Group By and Favorite buttons are
- * going to be pushed. This method is overriden by the mobile search view
- * to add these buttons somewhere else in the dom.
- *
- * @private
- * @returns {jQueryElement}
- */
- _getButtonsElement: function () {
- return this.$buttons;
- },
- /**
- * Create a groupby menu. Note that this method has a side effect: it
- * builds a mapping from a filter name to a 'search filter'.
- *
- * @private
- * @returns {Widget} the processed fieldsView
- */
- _createFiltersMenu: function () {
- var self = this;
- var filters = [];
-
- this.filters.forEach(function (group) {
- var groupId = _.uniqueId('__group__');
- group.filters.forEach(function (filter) {
- if (!filter.attrs.invisible) {
- var filterId = _.uniqueId('__filter__');
- var isPeriod = filter.attrs.isPeriod;
- var defaultPeriod = filter.attrs.default_period;
- var isActive = !self.hasFavorites && !!self.search_defaults[filter.attrs.name];
- filters.push({
- itemId: filterId,
- description: filter.attrs.string || filter.attrs.help ||
- filter.attrs.name || filter.attrs.domain || 'Ω',
- domain: filter.attrs.domain,
- fieldName: filter.attrs.date,
- isPeriod: isPeriod,
- defaultOptionId: defaultPeriod,
- isActive: isActive,
- groupId: groupId,
- });
- self.filtersMapping.push({filterId: filterId, filter: filter, groupId: groupId});
- if (isPeriod) {
- self.periodMapping.push({filter: filter, period: defaultPeriod, type: filter.attrs.type});
- }
- }
- });
- self.groupsMapping.push({groupId: groupId, group: group, category: 'Filters'});
- });
-
- return new FiltersMenu(self, filters, self.fields);
- },
-
- /**
- * Create a groupby menu. Note that this method has a side effect: it
- * builds a mapping from a filter name to a 'search filter'.
- *
- * @private
- * @returns {Widget} the processed fieldsView
- */
- _createGroupByMenu: function () {
- var self = this;
- var groupbys = [];
-
- this.groupbys.forEach(function (group) {
- var groupId = _.uniqueId('__group__');
- group.filters.forEach(function (groupby) {
- if (!groupby.attrs.invisible) {
- var groupbyId = _.uniqueId('__groupby__');
- var fieldName = groupby.attrs.fieldName;
- var isDate = groupby.attrs.isDate;
- var defaultInterval = groupby.attrs.defaultInterval || DEFAULT_INTERVAL;
- var isActive = !self.hasFavorites && !!self.search_defaults[groupby.attrs.name];
- groupbys.push({
- itemId: groupbyId,
- description: groupby.attrs.string || groupby.attrs.help || groupby.attrs.name
- || groupby.attrs.fieldName || 'Ω',
- isDate: isDate,
- fieldName: fieldName,
- defaultOptionId: defaultInterval,
- isActive: isActive,
- groupId: groupId,
- });
- if (isDate) {
- self.intervalMapping.push({groupby: groupby, interval: defaultInterval});
- }
- self.groupbysMapping.push({groupbyId: groupbyId, groupby: groupby, groupId: groupId});
- }
- });
- self.groupsMapping.push({groupId: groupId, group: group, category: 'Group By'});
- group.updateIntervalMapping(self.intervalMapping);
- });
- return new GroupByMenu(this, groupbys, this.fields);
- },
- /**
- * Create a time range menu.
- *
- * @private
- * @returns {Widget} the range menu
- */
- _createTimeRangeMenu: function () {
- return new TimeRangeMenu(this, this.fields, this.timeRanges);
- },
- /**
- * Processes a fieldsView in place. In particular, parses its arch.
- *
- * @todo: this function is also defined in AbstractView ; this code
- * duplication could be removed once the SearchView will be rewritten.
- * @private
- * @param {Object} fv
- * @param {string} fv.arch
- * @returns {Object} the processed fieldsView
- */
- _processFieldsView: function (fv) {
- var doc = $.parseXML(fv.arch).documentElement;
- fv.arch = utils.xml_to_json(doc, true);
- return fv;
- },
- /**
- * @returns {Deferred}
- */
- _searchDefaultTimeRange: function () {
- if (this.timeRanges) {
- var timeRange = "[]";
- var timeRangeDescription;
- var comparisonTimeRange = "[]";
- var comparisonTimeRangeDescription;
-
- var dateField = {
- name: this.timeRanges.field,
- type: this.fields[this.timeRanges.field].type,
- description: this.fields[this.timeRanges.field].string,
- };
-
- timeRange = Domain.prototype.constructDomain(
- dateField.name,
- this.timeRanges.range,
- dateField.type
- );
- timeRangeDescription = _.findWhere(
- PeriodOptions,
- {optionId: this.timeRanges.range}
- ).description;
-
- if (this.timeRanges.comparison_range) {
- comparisonTimeRange = Domain.prototype.constructDomain(
- dateField.name,
- this.timeRanges.range,
- dateField.type,
- null,
- this.timeRanges.comparison_range
- );
- comparisonTimeRangeDescription = _.findWhere(
- ComparisonOptions,
- {optionId: this.timeRanges.comparison_range}
- ).description;
- }
-
- return $.when({
- cat: 'timeRangeCategory',
- category: _t("Time Range"),
- icon: 'fa fa-calendar',
- field: {
- get_context: function (facet, noDomainEvaluation) {
- if (!noDomainEvaluation) {
- timeRange = Domain.prototype.stringToArray(timeRange);
- comparisonTimeRange = Domain.prototype.stringToArray(comparisonTimeRange);
- }
- return {
- timeRangeMenuData: {
- timeRange: timeRange,
- timeRangeDescription: timeRangeDescription,
- comparisonTimeRange: comparisonTimeRange,
- comparisonTimeRangeDescription: comparisonTimeRangeDescription,
- }
- };
- },
- get_groupby: function () {},
- get_domain: function () {}
- },
- isRange: true,
- values: [{
- label: dateField.description + ': ' + timeRangeDescription +
- (
- comparisonTimeRangeDescription ?
- (' / ' + comparisonTimeRangeDescription) :
- ''
- ),
- value: null,
- }],
- });
- } else {
- return $.when();
- }
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- *
- * this function is called in response to an event 'on_item_toggled'.
- * this kind of event is triggered by the filters menu or the groupby menu
- * when a user has clicked on a item (a filter or a groupby).
- * The query is modified accordingly to the new state (active or not) of that item
- *
- * @private
- * @param {OdooEvent} event
- */
- _onItemToggled: function (event) {
- var group;
- if (event.data.category === 'groupByCategory') {
- var groupby = _.findWhere(this.groupbysMapping, {groupbyId: event.data.itemId}).groupby;
- group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group;
- if (event.data.optionId) {
- var interval = event.data.optionId;
- _.findWhere(this.intervalMapping, {groupby: groupby}).interval = interval;
- group.updateIntervalMapping(this.intervalMapping);
- }
- group.toggle(groupby);
- }
- if (event.data.category === 'filterCategory') {
- var filter = _.findWhere(this.filtersMapping, {filterId: event.data.itemId}).filter;
- group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group;
- if (event.data.optionId) {
- var period = event.data.optionId;
- _.findWhere(this.periodMapping, {filter: filter}).period = period;
- group.updatePeriodMapping(this.periodMapping);
- }
- group.toggle(filter);
- }
- },
- /**
- * this function is called when a new groupby has been added to the groupby menu
- * via the 'Add Custom Groupby' submenu. The query is modified with the new groupby
- * added to it as active. The communication betwenn the groupby menu and the search view
- * is maintained by properly updating the mappings.
- * @private
- * @param {OdooEvent} event
- */
- _onNewGroupby: function (event) {
- var isDate = event.data.isDate;
- var attrs = {
- context:"{'group_by':'" + event.data.fieldName + "''}",
- name: event.data.description,
- fieldName: event.data.fieldName,
- isDate: isDate,
- };
- var groupby = new search_inputs.Filter({attrs: attrs}, this);
- if (event.data.optionId) {
- var interval = event.data.optionId;
- this.intervalMapping.push({groupby: groupby, interval: interval});
- }
- var group = new search_inputs.FilterGroup([groupby], this, this.intervalMapping, this.periodMapping);
- this.groupbysMapping.push({
- groupbyId: event.data.itemId,
- groupby: groupby,
- groupId: event.data.groupId,
- });
- this.groupsMapping.push({
- groupId: event.data.groupId,
- group: group,
- category: 'Group By',
- });
- group.toggle(groupby);
- },
- /**
- * this function is called when a new filter has been added to the filters menu
- * via the 'Add Custom Filter' submenu. The query is modified with the new filter
- * added to it as active. The communication betwenn the filters menu and the search view
- * is maintained by properly updating the mappings.
- * @private
- * @param {OdooEvent} event
- */
- _onNewFilters: function (event) {
- var self= this;
- var filter;
- var filters = [];
- var groupId;
-
- _.each(event.data, function (filterDescription) {
- filter = new search_inputs.Filter(filterDescription.filter, this);
- filters.push(filter);
- self.filtersMapping.push({
- filterId: filterDescription.itemId,
- filter: filter,
- groupId: filterDescription.groupId,
- });
- // filters belong to the same group
- if (!groupId) {
- groupId = filterDescription.groupId;
- }
- });
- var group = new search_inputs.FilterGroup(filters, this, this.intervalMapping, this.periodMapping);
- filters.forEach(function (filter) {
- group.toggle(filter, {silent: true});
- });
- this.groupsMapping.push({
- groupId: groupId,
- group: group,
- category: 'Filters',
- });
- this.query.trigger('reset');
- },
- /**
- * this function is called when the option related to an item (filter or groupby) has been
- * changed by the user. The query is modified appropriately.
- *
- * @private
- * @param {OdooEvent} event
- */
- _onItemOptionChanged: function (event) {
- var group;
- if (event.data.category === 'groupByCategory') {
- var groupby = _.findWhere(this.groupbysMapping, {groupbyId: event.data.itemId}).groupby;
- var interval = event.data.optionId;
- _.findWhere(this.intervalMapping, {groupby: groupby}).interval = interval;
- group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group;
- group.updateIntervalMapping(this.intervalMapping);
- this.query.trigger('reset');
- }
- if (event.data.category === 'filterCategory') {
- var filter = _.findWhere(this.filtersMapping, {filterId: event.data.itemId}).filter;
- var period = event.data.optionId;
- _.findWhere(this.periodMapping, {filter: filter}).period = period;
- group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group;
- group.updatePeriodMapping(this.periodMapping);
- this.query.trigger('reset');
- }
- },
- /*
- * @private
- * @param {JQueryEvent} event
- */
- _onTimeRangeModified: function () {
- var facet = this.timeRangeMenu.facetFor();
- var current = this.query.find(function (facet) {
- return facet.get('cat') === 'timeRangeCategory';
- });
- if (current) {
- this.query.remove(current, {silent: true});
- }
- this.query.add(facet);
- },
- /*
- * @private
- */
- _onTimeRangeRemoved: function () {
- var current = this.query.find(function (facet) {
- return facet.get('cat') === 'timeRangeCategory';
- });
- if (current) {
- this.query.remove(current);
- }
- },
-});
-
-_.extend(SearchView, {
- SearchQuery: SearchQuery,
- Facet: Facet,
-});
-
-return SearchView;
-
-});
diff --git a/addons/web/static/src/js/views/search/time_range_menu.js b/addons/web/static/src/js/views/search/time_range_menu.js
deleted file mode 100644
index 0378c132aab..00000000000
--- a/addons/web/static/src/js/views/search/time_range_menu.js
+++ /dev/null
@@ -1,198 +0,0 @@
-odoo.define('web.TimeRangeMenu', function (require) {
-"use strict";
-
-var config = require('web.config');
-var core = require('web.core');
-var Domain = require('web.Domain');
-var TimeRangeMenuOptions = require('web.TimeRangeMenuOptions');
-var Widget = require('web.Widget');
-
-var _t = core._t;
-var ComparisonOptions = TimeRangeMenuOptions.ComparisonOptions;
-var PeriodOptions = TimeRangeMenuOptions.PeriodOptions;
-
-var TimeRangeMenu = Widget.extend({
- template: 'web.TimeRangeMenu',
- events: {
- 'click .o_apply_range': '_onApplyButtonClick',
- 'click .o_comparison_checkbox': '_onCheckBoxClick',
- },
-
- /**
- * override
- * @param {Widget} parent
- * @param {Object} fields
- * @param {Object} configuration
- *
- */
- init: function(parent, fields, configuration) {
- var self = this;
- this.isMobile = config.device.isMobile;
- this.symbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : 'caret';
- this._super(parent);
- this.dateFields = [];
- _.each(fields, function (field, name) {
- if (field.sortable && _.contains(['date', 'datetime'], field.type)) {
- self.dateFields.push(_.extend({}, field, {
- name: name,
- }));
- }
- });
- this.periodOptions = PeriodOptions;
- this.periodGroups = PeriodOptions.reduce(
- function (acc, option) {
- if (!_.contains(acc, option.groupId)) {
- acc.push(option.groupId);
- }
- return acc;
- },
- []
- );
-
- this.comparisonOptions = ComparisonOptions;
-
- // Following steps determine initial configuration
- this.isActive = false;
- this.timeRangeId = undefined;
- this.comparisonIsSelected = false;
- this.comparisonTimeRangeId = undefined;
- this.dateField = {};
- if (configuration && configuration.field && configuration.range) {
- this.isActive = true;
- var dateField = _.findWhere(this.dateFields, {name: configuration.field});
- this.dateField = {
- name: dateField.name,
- description: dateField.string,
- type: dateField.type,
- };
- this.timeRangeId = configuration.range;
- if (configuration.comparison_range) {
- this.comparisonIsSelected = true;
- this.comparisonTimeRangeId = configuration.comparison_range;
- }
- }
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- deactivate: function () {
- this.isActive = false;
- this.comparisonIsSelected = false;
- this.renderElement();
- },
- /**
- * Generates a :js:class:`~instance.web.search.Facet` descriptor from a
- * filter descriptor
- *
- * @returns {Object}
- */
- facetFor: function () {
- var fieldDescription;
- var timeRange = "[]";
- var timeRangeDescription;
- var comparisonTimeRange = "[]";
- var comparisonTimeRangeDescription;
-
- if (this.isActive) {
- fieldDescription = this.dateField.description;
- if (this.timeRangeId !== 'custom') {
- timeRange = Domain.prototype.constructDomain(
- this.dateField.name,
- this.timeRangeId,
- this.dateField.type
- );
- timeRangeDescription = _.findWhere(
- this.periodOptions,
- {optionId: this.timeRangeId}
- ).description;
- }
- if (this.comparisonIsSelected) {
- comparisonTimeRange = Domain.prototype.constructDomain(
- this.dateField.name,
- this.timeRangeId,
- this.dateField.type,
- null,
- this.comparisonTimeRangeId
- );
- comparisonTimeRangeDescription = _.findWhere(
- this.comparisonOptions,
- {optionId: this.comparisonTimeRangeId}
- ).description;
- }
- }
-
- return {
- cat: 'timeRangeCategory',
- category: _t("Time Range"),
- icon: 'fa fa-calendar',
- field: {
- get_context: function (facet, noDomainEvaluation) {
- if (!noDomainEvaluation) {
- timeRange = Domain.prototype.stringToArray(timeRange);
- comparisonTimeRange = Domain.prototype.stringToArray(comparisonTimeRange);
- }
- return {
- timeRangeMenuData: {
- timeRange: timeRange,
- timeRangeDescription: timeRangeDescription,
- comparisonTimeRange: comparisonTimeRange,
- comparisonTimeRangeDescription: comparisonTimeRangeDescription,
- }
- };
- },
- get_groupby: function () {},
- get_domain: function () {}
- },
- isRange: true,
- values: [{
- label: fieldDescription + ': ' + timeRangeDescription +
- (
- comparisonTimeRangeDescription ?
- (' / ' + comparisonTimeRangeDescription) :
- ''
- ),
- value: null,
- }],
- };
- },
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _onApplyButtonClick: function () {
- this.isActive = true;
- var dateFieldName = this.$('.o_date_field_selector').val();
- this.timeRangeId = this.$('.o_time_range_selector').val();
- if (this.comparisonIsSelected) {
- this.comparisonTimeRangeId = this.$('.o_comparison_time_range_selector').val();
- }
- this.dateField = {
- name: dateFieldName,
- type: _.findWhere(this.dateFields, {name: dateFieldName}).type,
- description: _.findWhere(this.dateFields, {name: dateFieldName}).string,
- };
-
- this.renderElement();
- this.trigger_up('time_range_modified');
- },
- /**
- * @private
- *
- * @param {JQueryEvent} ev
- */
- _onCheckBoxClick: function (ev) {
- ev.stopPropagation();
- this.comparisonIsSelected = this.$('.o_comparison_checkbox').prop('checked');
- this.$('.o_comparison_time_range_selector').toggleClass('o_hidden');
- this.$el.addClass('open');
- }
-});
-
-return TimeRangeMenu;
-
-});
diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js
index ee11afbc3bd..fb9cd5afa0c 100644
--- a/addons/web/static/src/js/views/view_dialogs.js
+++ b/addons/web/static/src/js/views/view_dialogs.js
@@ -3,13 +3,10 @@ odoo.define('web.view_dialogs', function (require) {
var config = require('web.config');
var core = require('web.core');
-var data = require('web.data');
var Dialog = require('web.Dialog');
var dom = require('web.dom');
var ListController = require('web.ListController');
var ListView = require('web.ListView');
-var pyUtils = require('web.py_utils');
-var SearchView = require('web.SearchView');
var view_registry = require('web.view_registry');
var _t = core._t;
@@ -21,9 +18,6 @@ var _t = core._t;
var ViewDialog = Dialog.extend({
custom_events: _.extend({}, Dialog.prototype.custom_events, {
push_state: '_onPushState',
- env_updated: function (event) {
- event.stopPropagation();
- },
}),
/**
* @constructor
@@ -45,10 +39,6 @@ var ViewDialog = Dialog.extend({
this.domain = options.domain || [];
this.context = options.context || {};
this.options = _.extend(this.options || {}, options || {});
-
- // FIXME: remove this once a dataset won't be necessary anymore to interact
- // with data_manager and instantiate views
- this.dataset = new data.DataSet(this, this.res_model, this.context);
},
//--------------------------------------------------------------------------
@@ -182,7 +172,7 @@ var FormViewDialog = ViewDialog.extend({
if (this.options.fields_view) {
fields_view_def = $.when(this.options.fields_view);
} else {
- fields_view_def = this.loadFieldView(this.dataset, this.options.view_id, 'form');
+ fields_view_def = this.loadFieldView(this.res_model, this.context, this.options.view_id, 'form');
}
fields_view_def.then(function (viewInfo) {
@@ -271,17 +261,23 @@ var FormViewDialog = ViewDialog.extend({
});
var SelectCreateListController = ListController.extend({
- // Override the ListView to handle the custom events 'open_record' (triggered when clicking on a
- // row of the list) such that it triggers up 'select_record' with its res_id.
- custom_events: _.extend({}, ListController.prototype.custom_events, {
- open_record: function (event) {
- var selectedRecord = this.model.get(event.data.id);
- this.trigger_up('select_record', {
- id: selectedRecord.res_id,
- display_name: selectedRecord.data.display_name,
- });
- },
- }),
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override to select the clicked record instead of opening it
+ *
+ * @override
+ * @private
+ */
+ _onOpenRecord: function (ev) {
+ var selectedRecord = this.model.get(ev.data.id);
+ this.trigger_up('select_record', {
+ id: selectedRecord.res_id,
+ display_name: selectedRecord.data.display_name,
+ });
+ },
});
/**
@@ -299,13 +295,6 @@ var SelectCreateDialog = ViewDialog.extend({
event.stopPropagation();
this.$footer.find(".o_select_button").prop('disabled', !event.data.selection.length);
},
- search: function (event) {
- event.stopPropagation(); // prevent this event from bubbling up to the action manager
- var d = event.data;
- var searchData = this._process_search_data(d.domains, d.contexts, d.groupbys);
- this.list_controller.reload(searchData);
- },
- get_controller_context: '_onGetControllerContext',
}),
/**
@@ -320,7 +309,7 @@ var SelectCreateDialog = ViewDialog.extend({
this._super.apply(this, arguments);
_.defaults(this.options, { initial_view: 'search' });
this.on_selected = this.options.on_selected || (function () {});
- this.initial_ids = this.options.initial_ids;
+ this.initialIDs = this.options.initial_ids;
},
open: function () {
@@ -328,26 +317,13 @@ var SelectCreateDialog = ViewDialog.extend({
return this.create_edit_record();
}
var self = this;
- var user_context = this.getSession().user_context;
-
var _super = this._super.bind(this);
- var context = pyUtils.eval_domains_and_contexts({
- domains: [],
- contexts: [user_context, this.context]
- }).context;
- var search_defaults = {};
- _.each(context, function (value_, key) {
- var match = /^search_default_(.*)$/.exec(key);
- if (match) {
- search_defaults[match[1]] = value_;
- }
- });
- this.loadViews(this.dataset.model, this.dataset.get_context().eval(), [[false, 'list'], [false, 'search']], {})
- .then(this.setup.bind(this, search_defaults))
+ this.loadViews(this.res_model, this.context, [[false, 'list'], [false, 'search']], {})
+ .then(this.setup.bind(this))
.then(function (fragment) {
self.opened().then(function () {
dom.append(self.$el, fragment, {
- callbacks: [{widget: self.list_controller}],
+ callbacks: [{widget: self.listController}],
in_DOM: true,
});
self.set_buttons(self.__buttons);
@@ -357,65 +333,50 @@ var SelectCreateDialog = ViewDialog.extend({
return this;
},
- setup: function (search_defaults, fields_views) {
+ setup: function (fieldsViews) {
var self = this;
var fragment = document.createDocumentFragment();
- var searchDef = $.Deferred();
-
- // Set the dialog's header and its search view
- var $header = $('<div/>').addClass('o_modal_header').appendTo(fragment);
- var $pager = $('<div/>').addClass('o_pager').appendTo($header);
- var options = {
- $buttons: $('<div/>').addClass('o_search_options').appendTo($header),
- search_defaults: search_defaults,
- };
- var searchview = new SearchView(this, this.dataset, fields_views.search, options);
- searchview.prependTo($header).done(function () {
- var d = searchview.build_search_data();
- if (self.initial_ids) {
- d.domains.push([["id", "in", self.initial_ids]]);
- self.initial_ids = undefined;
- }
- var searchData = self._process_search_data(d.domains, d.contexts, d.groupbys);
- searchDef.resolve(searchData);
- });
-
- return $.when(searchDef).then(function (searchResult) {
- // Set the list view
- var listView = new ListView(fields_views.list, _.extend({
- context: searchResult.context,
- domain: searchResult.domain,
- groupBy: searchResult.groupBy,
- modelName: self.dataset.model,
- hasSelectors: !self.options.disable_multiple_selection,
- readonly: true,
- }, self.options.list_view_options));
- listView.setController(SelectCreateListController);
- return listView.getController(self);
- }).then(function (controller) {
- self.list_controller = controller;
- // Set the dialog's buttons
+ var domain = this.domain;
+ if (this.initialIDs) {
+ domain = domain.concat([['id', 'in', this.initialIDs]]);
+ }
+ var listView = new ListView(fieldsViews.list, _.extend({
+ action: {
+ controlPanelFieldsView: fieldsViews.search,
+ },
+ action_buttons: false,
+ context: this.context,
+ domain: domain,
+ hasSelectors: !this.options.disable_multiple_selection,
+ modelName: this.res_model,
+ readonly: true,
+ withBreadcrumbs: false,
+ }, this.options.list_view_options));
+ listView.setController(SelectCreateListController);
+ return listView.getController(this).then(function (controller) {
+ self.listController = controller;
+ // render the footer buttons
self.__buttons = [{
text: _t("Cancel"),
- classes: "btn-secondary o_form_button_cancel",
+ classes: 'btn-secondary o_form_button_cancel',
close: true,
}];
if (!self.options.no_create) {
self.__buttons.unshift({
text: _t("Create"),
- classes: "btn-primary",
+ classes: 'btn-primary',
click: self.create_edit_record.bind(self)
});
}
if (!self.options.disable_multiple_selection) {
self.__buttons.unshift({
text: _t("Select"),
- classes: "btn-primary o_select_button",
+ classes: 'btn-primary o_select_button',
disabled: true,
close: true,
click: function () {
- var records = self.list_controller.getSelectedRecords();
+ var records = self.listController.getSelectedRecords();
var values = _.map(records, function (record) {
return {
id: record.res_id,
@@ -426,28 +387,11 @@ var SelectCreateDialog = ViewDialog.extend({
},
});
}
- return self.list_controller.appendTo(fragment);
+ return self.listController.appendTo(fragment);
}).then(function () {
- searchview.toggle_visibility(true);
- self.list_controller.do_show();
- self.list_controller.renderPager($pager);
return fragment;
});
},
- _process_search_data: function (domains, contexts, groupbys) {
- var results = pyUtils.eval_domains_and_contexts({
- domains: [this.domain].concat(domains),
- contexts: [this.context].concat(contexts),
- group_by_seq: groupbys || [],
- eval_context: this.getSession().user_context,
- });
- var context = _.omit(results.context, function (value, key) { return key.indexOf('search_default_') === 0; });
- return {
- context: context,
- domain: results.domain,
- groupBy: results.group_by,
- };
- },
create_edit_record: function () {
var self = this;
var dialog = new FormViewDialog(this, _.extend({}, this.options, {
@@ -469,23 +413,6 @@ var SelectCreateDialog = ViewDialog.extend({
this.trigger_up('form_dialog_discarded');
return true;
},
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Handles a context request: provides to the caller the context of the
- * list controller.
- *
- * @private
- * @param {OdooEvent} ev
- * @param {function} ev.data.callback used to send the requested context
- */
- _onGetControllerContext: function (ev) {
- ev.stopPropagation();
- var context = this.list_controller && this.list_controller.getContext();
- ev.data.callback(context || {});
- }
});
return {
diff --git a/addons/web/static/src/js/views/view_utils.js b/addons/web/static/src/js/views/view_utils.js
index 57ae9547d2f..94485c1401a 100644
--- a/addons/web/static/src/js/views/view_utils.js
+++ b/addons/web/static/src/js/views/view_utils.js
@@ -1,7 +1,10 @@
-odoo.define('web.viewUtils', function () {
+odoo.define('web.viewUtils', function (require) {
"use strict";
-var utils = {
+var dom = require('web.dom');
+var utils = require('web.utils');
+
+var viewUtils = {
/**
* Returns the value of a group dataPoint, i.e. the value of the groupBy
* field for the records in that group.
@@ -40,8 +43,45 @@ var utils = {
}
return true;
},
+ /**
+ * @param {string} arch view arch
+ * @returns {Object} parsed arch
+ */
+ parseArch: function (arch) {
+ var doc = $.parseXML(arch).documentElement;
+ var stripWhitespaces = doc.nodeName.toLowerCase() !== 'kanban';
+ return utils.xml_to_json(doc, stripWhitespaces);
+ },
+ /**
+ * Renders a button according to a given arch node element.
+ *
+ * @param {Object} node
+ * @param {Object} [options]
+ * @param {string} [options.extraClass]
+ * @param {boolean} [options.textAsTitle=false]
+ * @returns {jQuery}
+ */
+ renderButtonFromNode: function (node, options) {
+ var btnOptions = {
+ attrs: _.omit(node.attrs, 'icon', 'string', 'type', 'attrs', 'modifiers', 'options'),
+ icon: node.attrs.icon,
+ };
+ if (options && options.extraClass) {
+ var classes = btnOptions.attrs.class ? btnOptions.attrs.class.split(' ') : [];
+ btnOptions.attrs.class = _.uniq(classes.concat(options.extraClass.split(' '))).join(' ');
+ }
+ var str = (node.attrs.string || '').replace(/_/g, '');
+ if (str) {
+ if (options && options.textAsTitle) {
+ btnOptions.attrs.title = str;
+ } else {
+ btnOptions.text = str;
+ }
+ }
+ return dom.renderButton(btnOptions);
+ },
};
-return utils;
+return viewUtils;
});
diff --git a/addons/web/static/src/js/widgets/dropdown_menu.js b/addons/web/static/src/js/widgets/dropdown_menu.js
index e92c714d838..11873718576 100644
--- a/addons/web/static/src/js/widgets/dropdown_menu.js
+++ b/addons/web/static/src/js/widgets/dropdown_menu.js
@@ -6,81 +6,48 @@ var Widget = require('web.Widget');
var QWeb = core.qweb;
-
-
var DropdownMenu = Widget.extend({
template: 'web.DropdownMenu',
+
events: {
'click .o_menu_item': '_onItemClick',
'click .o_item_option': '_onOptionClick',
- 'click span.o_trash_button': '_onTrashClick',
+ 'click span.o_trash_button': '_onTrashButtonClick',
'hidden.bs.dropdown': '_onBootstrapClose',
'click .dropdown-item-text': '_onDropDownItemTextClick',
},
- /**
- * override
- *
- * @param {Widget} parent
- * @param {Object} dropdownHeader object used to customize the dropdown menu.
- * @param {String} dropdownHeader.title
- * @param {String} dropdownHeader.icon
- * @param {String} dropdownHeader.symbol
- * @param {String} dropdownHeader.category descripbes the type of items
- * @param {String} dropdownHeader.style the button style
- * @param {Object[]} items list of menu items
- *
- * Menu items:
- *
- * * itemId: string; (optional) unique id associated with the item
- * * description: string; label printed on screen
- * * groupId: string;
- * * isActive: boolean; (optional) determines if the item is considered active
- * * isOpen: boolean; (optional) in case there are options the submenu presenting the options is opened or closed according to isOpen
- * * isRemovable: boolean; (optional) can be removed from menu options: array of objects with 'optionId' and 'description' keys; (optional)
- * * currentOptionId: string refers to an optionId that is activated if item is active (optional)
- */
- init: function (parent, dropdownHeader, items) {
+ init: function (parent, items) {
this._super(parent);
- this.dropdownCategory = dropdownHeader.category;
- this.dropdownTitle = dropdownHeader.title;
- this.dropdownIcon = dropdownHeader.icon;
- this.dropdownSymbol = dropdownHeader.symbol || false;
+ // should be specified
+ this.dropdownCategory = null;
+ this.dropdownTitle = null;
+ this.dropdownIcon = null;
+ this.dropdownSymbol = false;
// this parameter fixes the menu style. By default,
// the style used is the one used in the search view
- this.dropdownStyle = dropdownHeader.style || {
+ this.dropdownStyle = {
el: {class: 'btn-group o_dropdown', attrs: {}},
- mainButton: {class: 'o_dropdown_toggler_btn btn btn-secondary dropdown-toggle' + (this.dropdownSymbol ? ' o-no-caret' : '')},
- };
+ mainButton: {
+ class: 'o_dropdown_toggler_btn btn btn-secondary ' +
+ 'dropdown-toggle ' +
+ (this.dropdownSymbol ? 'o-no-caret' : '')
+ },
+ };
this.items = items;
- _.each(this.items, this._prepareItem.bind(this));
- },
- /**
- * override
- */
- start: function () {
- this.$menu = this.$('.o_dropdown_menu');
+ this.openItems = {};
},
+
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
- * @param {string[]} activeItemIds
+ * @param {Object} items
*/
- updateItemsStatus: function (activeItemIds) {
- _.each(this.items, function (item) {
- if (!_.contains(activeItemIds, item.itemId)) {
- item.isActive = false;
- item.currentOptionId = false;
- } else {
- item.isActive = true;
- item.currentOptionId = item.hasOptions ?
- (item.currentOptionId || item.defaultOptionId) :
- false;
- }
- });
+ update: function (items) {
+ this.items = items;
this._renderMenuItems();
},
@@ -90,97 +57,11 @@ var DropdownMenu = Widget.extend({
/**
* @private
- * @param {string} itemId
- */
- _removeItem: function (itemId) {
- var item, itemIndex;
- item = _.findWhere(this.items, {itemId: itemId});
- itemIndex = this.items.indexOf(item);
- this.items.splice(itemIndex, 1);
- this._renderMenuItems();
- var eventData = {
- category: this.dropdownCategory,
- itemId: itemId,
- groupId: item.groupId,
- };
- this.trigger_up('menu_item_deleted', eventData);
- },
- /**
- * @private
*/
_renderMenuItems: function () {
var newMenuItems = QWeb.render('DropdownMenu.MenuItems', {widget: this});
this.$el.find('.o_menu_item, .dropdown-divider[data-removable="1"]').remove();
- this.$('.o_dropdown_menu').prepend($(newMenuItems));
- },
- /**
- * this function computes some default values for some of the item keys
- * according to values passed at initialization
- *
- * @private
- * @param {Object} item
- */
- _prepareItem: function (item) {
- item.itemId = item.itemId || _.uniqueId('__item__');
- item.isOpen = item.isOpen || false;
- item.isRemovable = item.isRemovable || false;
- if (item.options && item.options.length !== 0) {
- item.options = _.sortBy(item.options, 'groupId');
- item.hasOptions = true;
- item.defaultOptionId = item.defaultOptionId || this.defaultOptionId;
- item.currentOptionId = item.isActive && item.defaultOptionId ? item.defaultOptionId : false;
- }
- },
- /**
- * @private
- * @param {string} itemId
- */
- _toggleMenuItem: function (itemId) {
- var item = _.findWhere(this.items, {itemId: itemId});
- if (!item.hasOptions) {
- item.isActive = !item.isActive;
- var eventData = {
- category: this.dropdownCategory,
- itemId: itemId,
- isActive: item.isActive,
- groupId: item.groupId,
- };
- this._renderMenuItems();
- this.trigger_up('menu_item_toggled', eventData);
- }
- if (item.hasOptions) {
- item.isOpen = !item.isOpen;
- this._renderMenuItems();
- }
- },
- /**
- * @private
- * @param {string} itemId
- * @param {string} optionId
- */
- _toggleItemOption: function (itemId, optionId) {
- var item = _.findWhere(this.items, {itemId: itemId});
- var initialState = item.isActive;
- if (item.currentOptionId === optionId) {
- item.isActive = false;
- item.currentOptionId = false;
- } else {
- item.isActive = true;
- item.currentOptionId = optionId;
- }
- var eventData = {
- category: this.dropdownCategory,
- itemId: itemId,
- groupId: item.groupId,
- isActive: item.isActive,
- optionId: item.currentOptionId,
- };
- this._renderMenuItems();
- if (item.isActive !== initialState) {
- this.trigger_up('menu_item_toggled', eventData);
- } else {
- this.trigger_up('item_option_changed', eventData);
- }
+ this.$('.o_dropdown_menu').prepend(newMenuItems);
},
//--------------------------------------------------------------------------
@@ -195,9 +76,7 @@ var DropdownMenu = Widget.extend({
* @param {jQueryEvent} event
*/
_onBootstrapClose: function () {
- _.each(this.items, function (item) {
- item.isOpen = false;
- });
+ this.openItems = {};
this._renderMenuItems();
},
/**
@@ -216,8 +95,16 @@ var DropdownMenu = Widget.extend({
_onItemClick: function (event) {
event.preventDefault();
event.stopPropagation();
- var itemId = $(event.currentTarget).data('id');
- this._toggleMenuItem(itemId);
+ var id = $(event.currentTarget).data('id');
+ var item = this.items.find(function (item) {
+ return item.id === id;
+ });
+ if (item.hasOptions) {
+ this.openItems[id] = !this.openItems[id];
+ this._renderMenuItems();
+ } else {
+ this.trigger_up('menu_item_clicked', {id: id});
+ }
},
/**
* @private
@@ -227,20 +114,19 @@ var DropdownMenu = Widget.extend({
event.preventDefault();
event.stopPropagation();
var optionId = $(event.currentTarget).data('option_id');
- var itemId = $(event.currentTarget).data('item_id');
- this._toggleItemOption(itemId, optionId);
+ var id = $(event.currentTarget).data('item_id');
+ this.trigger_up('item_option_clicked', {id: id, optionId: optionId});
},
/**
* @private
* @param {MouseEvent} event
+ *
+ * To implement in child
*/
- _onTrashClick: function (event) {
- event.stopPropagation();
- var itemId = $(event.currentTarget).data('id');
- this._removeItem(itemId);
- },
+ _onTrashButtonClick: function (event) {
+ },
});
return DropdownMenu;
-});
+}); \ No newline at end of file
diff --git a/addons/web/static/src/js/widgets/old/dropdown_menu.js b/addons/web/static/src/js/widgets/old/dropdown_menu.js
new file mode 100644
index 00000000000..429fdd03067
--- /dev/null
+++ b/addons/web/static/src/js/widgets/old/dropdown_menu.js
@@ -0,0 +1,236 @@
+odoo.define('web.OldDropdownMenu', function (require) {
+"use strict";
+
+var core = require('web.core');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+
+
+
+var OldDropdownMenu = Widget.extend({
+ template: 'web.OldDropdownMenu',
+ events: {
+ 'click .o_menu_item': '_onItemClick',
+ 'click .o_item_option': '_onOptionClick',
+ 'click span.o_trash_button': '_onTrashClick',
+ 'hidden.bs.dropdown': '_onBootstrapClose',
+ },
+
+ /**
+ * override
+ *
+ * @param {Widget} parent
+ * @param {Object} dropdownHeader object used to customize the dropdown menu.
+ * @param {String} dropdownHeader.title
+ * @param {String} dropdownHeader.icon
+ * @param {String} dropdownHeader.symbol
+ * @param {String} dropdownHeader.category descripbes the type of items
+ * @param {String} dropdownHeader.style the button style
+ * @param {Object[]} items list of menu items
+ *
+ * Menu items:
+ *
+ * * itemId: string; (optional) unique id associated with the item
+ * * description: string; label printed on screen
+ * * groupId: string;
+ * * isActive: boolean; (optional) determines if the item is considered active
+ * * isOpen: boolean; (optional) in case there are options the submenu presenting the options is opened or closed according to isOpen
+ * * isRemovable: boolean; (optional) can be removed from menu options: array of objects with 'optionId' and 'description' keys; (optional)
+ * * currentOptionId: string refers to an optionId that is activated if item is active (optional)
+ */
+ init: function (parent, dropdownHeader, items) {
+ this._super(parent);
+ this.dropdownCategory = dropdownHeader.category;
+ this.dropdownTitle = dropdownHeader.title;
+ this.dropdownIcon = dropdownHeader.icon;
+ this.dropdownSymbol = dropdownHeader.symbol || false;
+ // this parameter fixes the menu style. By default,
+ // the style used is the one used in the search view
+ this.dropdownStyle = dropdownHeader.style || {
+ el: {class: 'btn-group o_dropdown', attrs: {}},
+ mainButton: {class: 'o_dropdown_toggler_btn btn btn-secondary dropdown-toggle' + (this.dropdownSymbol ? ' o-no-caret' : '')},
+ };
+ this.items = items;
+ _.each(this.items, this._prepareItem.bind(this));
+ },
+ /**
+ * override
+ */
+ start: function () {
+ this.$menu = this.$('.o_dropdown_menu');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {string[]} activeItemIds
+ */
+ updateItemsStatus: function (activeItemIds) {
+ _.each(this.items, function (item) {
+ if (!_.contains(activeItemIds, item.itemId)) {
+ item.isActive = false;
+ item.currentOptionId = false;
+ } else {
+ item.isActive = true;
+ item.currentOptionId = item.hasOptions ?
+ (item.currentOptionId || item.defaultOptionId) :
+ false;
+ }
+ });
+ this._renderMenuItems();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} itemId
+ */
+ _removeItem: function (itemId) {
+ var item, itemIndex;
+ item = _.findWhere(this.items, {itemId: itemId});
+ itemIndex = this.items.indexOf(item);
+ this.items.splice(itemIndex, 1);
+ this._renderMenuItems();
+ var eventData = {
+ category: this.dropdownCategory,
+ itemId: itemId,
+ groupId: item.groupId,
+ };
+ this.trigger_up('menu_item_deleted', eventData);
+ },
+ /**
+ * @private
+ */
+ _renderMenuItems: function () {
+ var newMenuItems = QWeb.render('OldDropdownMenu.MenuItems', {widget: this});
+ this.$el.find('.o_menu_item, .dropdown-divider[data-removable="1"]').remove();
+ this.$('.o_dropdown_menu').prepend($(newMenuItems));
+ },
+ /**
+ * this function computes some default values for some of the item keys
+ * according to values passed at initialization
+ *
+ * @private
+ * @param {Object} item
+ */
+ _prepareItem: function (item) {
+ item.itemId = item.itemId || _.uniqueId('__item__');
+ item.isOpen = item.isOpen || false;
+ item.isRemovable = item.isRemovable || false;
+ if (item.options && item.options.length !== 0) {
+ item.options = _.sortBy(item.options, 'groupId');
+ item.hasOptions = true;
+ item.defaultOptionId = item.defaultOptionId || this.defaultOptionId;
+ item.currentOptionId = item.isActive && item.defaultOptionId ? item.defaultOptionId : false;
+ }
+ },
+ /**
+ * @private
+ * @param {string} itemId
+ */
+ _toggleMenuItem: function (itemId) {
+ var item = _.findWhere(this.items, {itemId: itemId});
+ if (!item.hasOptions) {
+ item.isActive = !item.isActive;
+ var eventData = {
+ category: this.dropdownCategory,
+ itemId: itemId,
+ isActive: item.isActive,
+ groupId: item.groupId,
+ };
+ this._renderMenuItems();
+ this.trigger_up('menu_item_toggled', eventData);
+ }
+ if (item.hasOptions) {
+ item.isOpen = !item.isOpen;
+ this._renderMenuItems();
+ }
+ },
+ /**
+ * @private
+ * @param {string} itemId
+ * @param {string} optionId
+ */
+ _toggleItemOption: function (itemId, optionId) {
+ var item = _.findWhere(this.items, {itemId: itemId});
+ var initialState = item.isActive;
+ if (item.currentOptionId === optionId) {
+ item.isActive = false;
+ item.currentOptionId = false;
+ } else {
+ item.isActive = true;
+ item.currentOptionId = optionId;
+ }
+ var eventData = {
+ category: this.dropdownCategory,
+ itemId: itemId,
+ groupId: item.groupId,
+ isActive: item.isActive,
+ optionId: item.currentOptionId,
+ };
+ this._renderMenuItems();
+ if (item.isActive !== initialState) {
+ this.trigger_up('menu_item_toggled', eventData);
+ } else {
+ this.trigger_up('item_option_changed', eventData);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method is called when Bootstrap trigger a 'close' event (for example
+ * when the user clicks outside the dropdown menu).
+ *
+ * @private
+ * @param {jQueryEvent} event
+ */
+ _onBootstrapClose: function () {
+ _.each(this.items, function (item) {
+ item.isOpen = false;
+ });
+ this._renderMenuItems();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onItemClick: function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ var itemId = $(event.currentTarget).data('id');
+ this._toggleMenuItem(itemId);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onOptionClick: function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ var optionId = $(event.currentTarget).data('option_id');
+ var itemId = $(event.currentTarget).data('item_id');
+ this._toggleItemOption(itemId, optionId);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onTrashClick: function (event) {
+ event.stopPropagation();
+ var itemId = $(event.currentTarget).data('id');
+ this._removeItem(itemId);
+ },
+});
+
+return OldDropdownMenu;
+
+});
diff --git a/addons/web/static/src/js/widgets/pie_chart.js b/addons/web/static/src/js/widgets/pie_chart.js
index 663e2b4cc98..30944d69584 100644
--- a/addons/web/static/src/js/widgets/pie_chart.js
+++ b/addons/web/static/src/js/widgets/pie_chart.js
@@ -34,14 +34,16 @@ var PieChart = Widget.extend({
});
this.subViewParams = {
- context: record.context,
- domain: domain,
- groupBy: [],
modelName: record.model,
withControlPanel: false,
isEmbedded: true,
mode: 'pie',
};
+ this.subViewParams.searchQuery = {
+ context: record.context,
+ domain: domain,
+ groupBy: [],
+ };
this.viewInfo = {
arch: arch,
diff --git a/addons/web/static/src/scss/control_panel.scss b/addons/web/static/src/scss/control_panel.scss
index b4f7d189f9e..dd74c8ac010 100644
--- a/addons/web/static/src/scss/control_panel.scss
+++ b/addons/web/static/src/scss/control_panel.scss
@@ -19,10 +19,6 @@
}
}
- &.o_breadcrumb_full > .breadcrumb {
- width: 100%;
- }
-
> .o_cp_searchview {
width: 50%;
min-height: $o-cp-breadcrumb-height;
diff --git a/addons/web/static/src/scss/dropdown_menu.scss b/addons/web/static/src/scss/dropdown_menu.scss
index 0ff7fd7f561..6e6c3a6b640 100644
--- a/addons/web/static/src/scss/dropdown_menu.scss
+++ b/addons/web/static/src/scss/dropdown_menu.scss
@@ -42,4 +42,8 @@
content: "\f00c";
}
}
+ .o_trash_button {
+ padding-left: 8px;
+ @include o-position-absolute($right: $dropdown-item-padding-x * 0.5);
+ }
} \ No newline at end of file
diff --git a/addons/web/static/src/scss/graph_view.scss b/addons/web/static/src/scss/graph_view.scss
index 547e72ffab1..71ec5fbca9c 100644
--- a/addons/web/static/src/scss/graph_view.scss
+++ b/addons/web/static/src/scss/graph_view.scss
@@ -1,5 +1,5 @@
-.o_graph {
- .o_graph_container {
+.o_graph_controller {
+ .o_graph_renderer {
height: 100%;
overflow: auto;
.o_graph_svg_container {
diff --git a/addons/web/static/src/scss/search_view.scss b/addons/web/static/src/scss/search_view.scss
index 4079d082d17..22baf6d0a0e 100644
--- a/addons/web/static/src/scss/search_view.scss
+++ b/addons/web/static/src/scss/search_view.scss
@@ -14,6 +14,7 @@
}
.o_searchview_input_container {
+ position: relative;
display: flex;
flex-flow: row wrap;
@@ -182,7 +183,6 @@
cursor: pointer;
}
.o_add_filter_menu { // apply and add buttons
- display: none; // Needed by the webclient
@include o-search-options-dropdown-custom-item;
}
}
@@ -195,7 +195,6 @@
max-height: calc(100vh - 125px);
overflow: auto;
- .dropdown-divider {display: none;} // hide the divider by default (no favorite)
.o-searchview-custom-private, .o-searchview-custom-public {
.o-remove-filter {
@include o-position-absolute(50%, 12px);
diff --git a/addons/web/static/src/scss/search_view_mobile.scss b/addons/web/static/src/scss/search_view_mobile.scss
index 4b44fa1d85c..c3c264649ea 100644
--- a/addons/web/static/src/scss/search_view_mobile.scss
+++ b/addons/web/static/src/scss/search_view_mobile.scss
@@ -54,6 +54,10 @@
.o_dropdown_toggler_btn {
width: 100%;
text-align: left;
+
+ &:after {
+ display: none;
+ }
}
// We disable the backdrop in this case because it prevents any
diff --git a/addons/web/static/src/scss/webclient_layout.scss b/addons/web/static/src/scss/webclient_layout.scss
index c03e519021b..a030ac88535 100644
--- a/addons/web/static/src/scss/webclient_layout.scss
+++ b/addons/web/static/src/scss/webclient_layout.scss
@@ -9,30 +9,24 @@ html {
display: flex;
flex-flow: column nowrap;
- > .o_main {
+ > .o_action_manager {
+ direction: ltr; //Define direction attribute here so when rtlcss preprocessor run, it converts it to rtl
flex: 1 1 auto;
- display: flex;
- min-height: 0; // Allow flex to shrink this element as much as it wants
height: 100%;
+ overflow: hidden;
- .o_main_content {
- flex: 1 1 auto;
+ > .o_action {
+ height: 100%;
display: flex;
flex-flow: column nowrap;
-
- .o_control_panel {
+ > .o_cp_controller {
flex: 0 0 auto;
}
- .o_content {
+ > .o_content {
flex: 1 1 auto;
- height: 100%;
+ position: relative; // Allow to redistribute the 100% height to its child
overflow: auto;
- position: relative;
-
- > .o_view_controller {
- @include o-position-absolute(0, 0, 0, 0); // Get the 100% height of its flex parent
- height: 100%;
- }
+ height: 100%;
}
}
}
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index 661aab2d9b6..102e70efa1e 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -180,10 +180,17 @@
</div>
</form>
+<div t-name="Action" class="o_action">
+ <!-- CP -->
+ <div class="o_content"/>
+</div>
+
<t t-name="ControlPanel">
<div class="o_control_panel">
<ol class="breadcrumb" role="navigation"/>
- <div class="o_cp_searchview" role="search"/>
+ <div class="o_cp_searchview" role="search">
+ <t t-call="SearchView"/>
+ </div>
<div class="o_cp_left">
<div class="o_cp_buttons" role="toolbar" aria-label="Control panel toolbar"/>
<aside class="o_cp_sidebar"/>
@@ -204,7 +211,7 @@
<t t-name="ControlPanel.SwitchButton">
<button type="button" t-att-accesskey="view.accessKey"
- t-attf-class="btn btn-secondary fa fa-lg #{view.icon} o_cp_switch_#{view.type}"
+ t-attf-class="btn btn-secondary fa fa-lg #{view.icon} o_cp_switch_#{view.type} #{viewType === view.type ? ' active' : ''}"
t-attf-aria-label="View #{view.type}"
t-att-data-view-type="view.type" t-attf-title="View #{view.type}" tabindex="-1"/>
</t>
@@ -276,7 +283,7 @@
<a role="menuitem" href="#" data-action="edit" data-model="ir.ui.view" t-att-data-id="view.fieldsView.view_id" class="dropdown-item">
Edit View: <t t-esc="view.displayName"/>
</a>
- <a t-if="searchview and searchview.$el.is(':visible')" role="menuitem" href="#" data-action="edit" data-model="ir.ui.view" t-att-data-id="action.searchFieldsView.view_id" class="dropdown-item">Edit SearchView</a>
+ <a t-if="controlPanelView and controlPanelView.$el.is(':visible')" role="menuitem" href="#" data-action="edit" data-model="ir.ui.view" t-att-data-id="action.controlPanelFieldsView.view_id" class="dropdown-item">Edit ControlPanelView</a>
</t>
</t>
</t>
@@ -1073,29 +1080,29 @@
<t t-if="widget.items.length !== 0">
<t t-set="group" t-value="0"/>
<t t-foreach="widget.items" t-as="item">
- <div t-if="group !== item.groupId" class="dropdown-divider" data-removable="1"/>
+ <div t-if="group !== item.groupNumber" class="dropdown-divider" data-removable="1"/>
<div class="o_menu_item"
- t-att-data-id="item.itemId">
+ t-att-data-id="item.id">
<a href="#" t-attf-class="dropdown-item#{item.isActive ? ' selected ' : ''}">
<t t-esc="item.description"/>
- <span t-if="item.isRemovable" t-att-data-id="item.itemId" class="o_trash_button" > <span class="fa fa-trash-o"></span> </span>
- <span t-if="item.hasOptions" t-att-data-id="item.itemId" class="o_submenu_switcher">
- <span t-att-class="item.isOpen ? 'fa fa-caret-down' : 'fa fa-caret-right'"></span>
+ <span t-if="item.isRemovable" t-att-data-id="item.id" class="o_trash_button" > <span class="fa fa-trash-o"></span> </span>
+ <span t-if="item.hasOptions" t-att-data-id="item.id" class="o_submenu_switcher">
+ <span t-att-class="widget.openItems[item.id] ? 'fa fa-caret-down' : 'fa fa-caret-right'"></span>
</span>
</a>
- <ul t-if="item.hasOptions &amp;&amp; item.isOpen" class="list-unstyled">
+ <ul t-if="item.hasOptions &amp;&amp; widget.openItems[item.id]" class="list-unstyled">
<t t-set="subgroup" t-value="0"/>
- <t t-foreach="_.values(item.options)" t-as="option">
- <li t-if="subgroup !== option.groupId" class="dropdown-divider" data-removable="1"/>
+ <t t-foreach="item.options" t-as="option">
+ <li t-if="subgroup !== option.groupNumber" class="dropdown-divider" data-removable="1"/>
<li class="o_item_option"
- t-att-data-item_id="item.itemId" t-att-data-option_id="option.optionId">
+ t-att-data-item_id="item.id" t-att-data-option_id="option.optionId">
<a href="#" t-attf-class="dropdown-item#{item.currentOptionId === option.optionId ? ' selected': ''} pl-5"><t t-esc="option.description"/></a>
</li>
- <t t-set="subgroup" t-value="option.groupId"/>
+ <t t-set="subgroup" t-value="option.groupNumber"/>
</t>
</ul>
</div>
- <t t-set="group" t-value="item.groupId"/>
+ <t t-set="group" t-value="item.groupNumber"/>
</t>
</t>
</t>
@@ -1109,9 +1116,9 @@
</div>
</div>
-<t t-name="GroupbyMenuGenerator">
- <div t-if="Object.keys(widget.presentedFields).length !== 0 &amp;&amp; widget.items.length !== 0" class="dropdown-divider o_generator_menu"/>
- <button t-if="Object.keys(widget.presentedFields).length !== 0" type="button" class="dropdown-item o_generator_menu o_add_custom_group">Add Custom Group</button>
+<t t-name="GroupByMenuGenerator">
+ <div t-if="widget.presentedFields.length !== 0 &amp;&amp; widget.items.length !== 0" class="dropdown-divider o_generator_menu"/>
+ <button t-if="widget.presentedFields.length !== 0" type="button" class="dropdown-item o_generator_menu o_add_custom_group">Add Custom Group</button>
<div t-if="widget.generatorMenuIsOpen" class="dropdown-item-text o_generator_menu">
<select class="w-auto o_input o_group_selector o_add_group">
<t t-foreach="widget.presentedFields" t-as="field">
@@ -1124,10 +1131,10 @@
</div>
</t>
-<t t-name="FiltersMenuGenerator">
- <div class="dropdown-divider"/>
- <button t-if="Object.keys(widget.fields).length !== 0" type="button" class="dropdown-item o_add_custom_filter o_closed_menu">Add Custom Filter</button>
- <div class="dropdown-item-text o_add_filter_menu">
+<t t-name="FilterMenuGenerator">
+ <div t-if="Object.keys(widget.fields).length !== 0 &amp;&amp; widget.items.length !== 0" class="dropdown-divider o_generator_menu"/>
+ <button t-if="Object.keys(widget.fields).length !== 0" type="button" class="dropdown-item o_generator_menu o_add_custom_filter">Add Custom Filter</button>
+ <div t-if="widget.generatorMenuIsOpen" class="dropdown-item-text o_generator_menu o_add_filter_menu">
<button class="btn btn-primary o_apply_filter" type="button">Apply</button>
<button class="btn btn-secondary o_add_condition" type="button">
<span class="fa fa-plus-circle"/>
@@ -1145,15 +1152,15 @@
<div class="dropdown-item-text">
<label for="date_field_selector">Based On</label>
<select id="date_field_selector" class="o_input o_date_field_selector">
- <t t-foreach="widget.dateFields" t-as="field">
- <t t-if="widget.dateField.name === field.name">
- <option t-att-value="field.name" selected="selected">
- <t t-esc="field.string"/>
+ <t t-foreach="widget.timeRanges" t-as="timeRange">
+ <t t-if="widget.configuration.id === timeRange.id">
+ <option t-att-value="timeRange.id" selected="selected">
+ <t t-esc="timeRange.description"/>
</option>
</t>
<t t-else="">
- <option t-att-value="field.name">
- <t t-esc="field.string"/>
+ <option t-att-value="timeRange.id">
+ <t t-esc="timeRange.description"/>
</option>
</t>
</t>
@@ -1166,7 +1173,7 @@
<optgroup t-if="group !== 1"/>
<t t-foreach="widget.periodOptions" t-as="option">
<t t-if="option.groupId === group">
- <t t-if="widget.timeRangeId === option.optionId">
+ <t t-if="widget.configuration.timeRangeId === option.optionId">
<option t-att-value="option.optionId" selected="selected">
<t t-esc="option.description"/>
</option>
@@ -1182,14 +1189,14 @@
</select>
</div>
<div class="dropdown-item-text">
- <t t-set="isSelected" t-value="widget.comparisonIsSelected"/>
+ <t t-set="isSelected" t-value="widget.configuration.comparisonIsSelected"/>
<div class="custom-control custom-checkbox">
<input type="checkbox" id="comparison_checkbox" class="custom-control-input o_comparison_checkbox" t-att-checked="isSelected ? 'checked' : undefined"/>
<label for="comparison_checkbox" class="custom-control-label">Compare To</label>
</div>
- <select t-att-class="'o_input o_comparison_time_range_selector' + (widget.comparisonIsSelected ? '' : ' o_hidden')">
- <t t-foreach="widget.comparisonOptions" t-as="option">
- <t t-if="widget.comparisonTimeRangeId === option.optionId">
+ <select t-att-class="'o_input o_comparison_time_range_selector' + (widget.configuration.comparisonIsSelected ? '' : ' o_hidden')">
+ <t t-foreach="widget.comparisonTimeRangeOptions" t-as="option">
+ <t t-if="widget.configuration.comparisonTimeRangeId === option.optionId">
<option t-att-value="option.optionId" selected="selected">
<t t-esc="option.description"/>
</option>
@@ -1208,41 +1215,27 @@
</div>
</div>
-
<div t-name="SearchView" class="o_searchview" role="search" aria-autocomplete="list">
<span class="o_searchview_more fa" title="Advanced Search..." role="img" aria-label="Advanced Search..."/>
- <div class="o_searchview_input_container"/>
</div>
-<t t-name="SearchViewMobile">
- <div class="o_searchview" role="search" aria-autocomplete="list">
- <button class="o_enable_searchview btn fa fa-search"/>
- <div class="o_mobile_search o_hidden">
- <div class="o_mobile_search_header">
- <span class="o_mobile_search_close float-left mt16 mb16 mr8 ml16">
- <i class="fa fa-arrow-left"/>
- <strong class="float-right ml8">FILTER</strong>
- </span>
- <span class="float-right o_mobile_search_clear_facets mt16 mr16">CLEAR</span>
- </div>
- <div class="o_searchview_input_container"/>
- <div class="o_mobile_search_filter o_search_options mb8 mt8 ml16 mr16"/>
- <div class="btn btn-primary o_mobile_search_show_result">SEE RESULT</div>
- </div>
- </div>
-</t>
-<input t-name="SearchView.InputView" type="text" class="o_searchview_input" accesskey="Q" placeholder="Search..." role="searchbox" aria-haspopup="true"/>
-<div t-name="SearchView.FacetView" tabindex="0" class="o_searchview_facet" role="img" aria-label="search">
- <span t-if="widget.model.has('icon')" t-att-class="'fa ' + widget.model.get('icon') + ' o_searchview_facet_label'"/>
- <span t-if="!widget.model.has('icon')" class="o_searchview_facet_label">
- <t t-esc="widget.model.get('category')"/>
+<div t-name="SearchView.SearchBar" class="o_searchview_input_container">
+ <input type="text" class="o_searchview_input" accesskey="Q" placeholder="Search..." role="searchbox" aria-haspopup="true"/>
+</div>
+
+<div t-name="SearchView.SearchFacet" tabindex="0" class="o_searchview_facet" role="img" aria-label="search">
+ <span t-if="widget.facet.type === 'field'" class="o_searchview_facet_label">
+ <t t-esc="widget.facet.filters[0].attrs.string"/>
</span>
- <div class="o_facet_values"/>
+ <span t-else="1" t-att-class="'fa ' + widget.icon + ' o_searchview_facet_label'"/>
+ <div class="o_facet_values">
+ <t t-foreach="widget.facetValues" t-as="facetValue">
+ <span t-if="facetValue_index > 0" class="o_facet_values_sep" t-esc="widget.separator"/> <span t-esc="facetValue"/>
+ </t>
+ </div>
<div class="fa fa-sm fa-remove o_facet_remove" role="img" aria-label="Remove" title="Remove"/>
</div>
-<span t-name="SearchView.FacetView.Value">
- <t t-esc="widget.model.get('label') + (widget.optionLabel ? (': ' + widget.optionLabel) : '')"/>
-</span>
+
<t t-name="SearchView.autocomplete">
<div class="dropdown-menu o_searchview_autocomplete" role="menu"/>
</t>
@@ -1259,65 +1252,6 @@
</a>
</t>
</t>
-<t t-name="SearchView.field">
- <label t-att-class="'oe_label' + (attrs.help ? '_help' : '')"
- t-att-title="attrs.help"
- t-att-for="element_id"
- t-att-style="style">
- <t t-esc="attrs.string || attrs.name"/>
- <span t-if="attrs.help">?</span>
- </label>
- <div t-att-style="style">
- <input type="text" size="15" class="o_input"
- t-att-name="attrs.name"
- t-att-autofocus="attrs.default_focus === '1' ? 'autofocus' : undefined"
- t-att-id="element_id"
- t-att-value="defaults[attrs.name] || ''"/>
- <t t-if="filters.length" t-raw="filters.render(defaults)"/>
- </div>
-</t>
-<t t-name="SearchView.date">
- <label t-att-class="'oe_label' + (attrs.help ? '_help' : '')"
- t-att-title="attrs.help"
- t-att-for="element_id"
- t-att-style="style">
- <t t-esc="attrs.string || attrs.name"/>
- <span t-if="attrs.help">?</span>
- </label>
- <div t-att-style="style">
- <span t-att-id="element_id"/>
- <t t-if="filters.length" t-raw="filters.render(defaults)"/>
- </div>
-</t>
-<t t-name="SearchView.field.selection">
- <label t-att-title="attrs.help"
- t-att-class="'oe_label' + (attrs.help ? '_help' : '')"
- t-att-for="element_id"
- t-att-style="style">
- <t t-esc="attrs.string || attrs.name"/>
- <span t-if="attrs.help">?</span>
- </label>
- <div t-att-style="style">
- <select class="o_input"
- t-att-name="attrs.name"
- t-att-id="element_id"
- t-att-autofocus="attrs.default_focus === '1' || undefined">
- <option t-if="prepend_empty"/>
- <t t-foreach="attrs.selection" t-as="option">
- <t t-set="selected" t-value="defaults[attrs.name] === option[0]"/>
- <option t-if="selected"
- t-attf-selected="selected"
- t-att-value="option_index">
- <t t-esc="option[1]"/>
- </option>
- <option t-if="!selected" t-att-value="option_index">
- <t t-esc="option[1]"/>
- </option>
- </t>
- </select>
- <t t-if="filters.length" t-raw="filters.render(defaults)"/>
- </div>
-</t>
<t t-name="SearchView.extended_search.proposition">
<div role="menuitem" class="dropdown-item-text o_filter_condition">
<span class="o_or_filter">or</span>
@@ -1351,30 +1285,56 @@
</select>
</t>
-<div t-name="SearchView.FavoriteMenu" class="btn-group o_dropdown">
- <button t-attf-class="o_dropdown_toggler_btn btn btn-secondary dropdown-toggle#{widget.isMobile ? ' o-no-caret' : ''}" data-toggle="dropdown" aria-expanded="false" tabindex="-1" aria-label="Favorites" title="Favorites">
- <span class="fa fa-star"/> Favorites <span t-if="widget.isMobile" class="fa fa-chevron-right float-right mt4"/>
- </button>
- <div class="dropdown-menu o_favorites_menu" role="menu">
- <div class="dropdown-divider user_filter"/>
- <div class="dropdown-divider shared_filter"/>
- <a role="menuitem" href="#" class="dropdown-item o_save_search o_closed_menu">Save current search</a>
- <div role="menuitem" class="dropdown-item-text o_save_name">
- <input type="text" class="o_input"></input>
+<t t-name="MobileControlPanel">
+ <div class="o_control_panel">
+ <ol class="breadcrumb" role="navigation"/>
+ <div class="o_cp_searchview" role="search">
+ <t t-call="MobileSearchView"/>
</div>
- <div role="menuitem" class="dropdown-item-text o_save_name">
- <div class="custom-control custom-checkbox">
- <input type="checkbox" id="o_fav_menu_use_by_default" class="custom-control-input"/>
- <label for="o_fav_menu_use_by_default" class="custom-control-label">Use by default</label>
- </div>
+ <div class="o_cp_left">
+ <div class="o_cp_buttons" role="toolbar" aria-label="Control panel toolbar"/>
+ <aside class="o_cp_sidebar"/>
</div>
- <div role="menuitem" class="dropdown-item-text o_save_name">
- <div class="custom-control custom-checkbox">
- <input type="checkbox" id="o_fav_menu_share_all_users" class="custom-control-input"/>
- <label for="o_fav_menu_share_all_users" class="custom-control-label">Share with all users <span class="fa fa-users" role="img" aria-label="Users" title="Users"/></label>
+ <div class="o_cp_right">
+ <div class="btn-group o_search_options" role="search"/>
+ <nav class="o_cp_pager" role="search" aria-label="Pager"/>
+ <nav class="btn-group o_cp_switch_buttons" role="toolbar" aria-label="View switcher"/>
+ </div>
+ </div>
+</t>
+
+<t t-name="MobileSearchView">
+ <div class="o_searchview" role="search" aria-autocomplete="list">
+ <button class="o_enable_searchview btn fa fa-search"/>
+ <div class="o_mobile_search o_hidden">
+ <div class="o_mobile_search_header">
+ <span class="o_mobile_search_close float-left mt16 mb16 mr8 ml16">
+ <i class="fa fa-arrow-left"/>
+ <strong class="float-right ml8">FILTER</strong>
+ </span>
+ <span class="float-right o_mobile_search_clear_facets mt16 mr16">CLEAR</span>
</div>
+ <div class="o_mobile_search_filter o_search_options mb8 mt8 ml16 mr16"/>
+ <div class="btn btn-primary o_mobile_search_show_result">SEE RESULT</div>
+ </div>
+ </div>
+</t>
+
+<div t-name="AddNewFavoriteMenu">
+ <button type="button" class="dropdown-item o_generator_menu o_add_favorite o_menu_header">Save Current Search</button>
+ <div t-if="widget.isOpen" class="dropdown-item-text o_generator_menu">
+ <div role="menuitem" class="dropdown-item-text o_favorite_name">
+ <input type="text" class="o_input"></input>
+ </div>
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox" id="o_favorite_use_by_default" class="custom-control-input"/>
+ <label for="o_favorite_use_by_default" class="custom-control-label">Use by default</label>
+ </div>
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox" id="o_favorite_share_all_users" class="custom-control-input"/>
+ <label for="o_favorite_share_all_users" class="custom-control-label">Share with all users <span class="fa fa-users" role="img" aria-label="Users" title="Users"/></label>
</div>
- <div class="dropdown-item-text o_save_name">
+ <div class="dropdown-item-text o_save_favorite">
<button role="menuitem" type="button" class="btn btn-primary">Save</button>
</div>
</div>
diff --git a/addons/web/static/tests/chrome/action_manager_mobile_tests.js b/addons/web/static/tests/chrome/action_manager_mobile_tests.js
index b105c861c26..d1bc7a5e489 100644
--- a/addons/web/static/tests/chrome/action_manager_mobile_tests.js
+++ b/addons/web/static/tests/chrome/action_manager_mobile_tests.js
@@ -99,7 +99,7 @@ QUnit.module('ActionManager', {
assert.containsOnce(actionManager, '.o_form_view');
// go back to lazy loaded view
- testUtils.dom.click($('.o_control_panel .breadcrumb .breadcrumb-item:first'));
+ testUtils.dom.click(actionManager.$('.o_control_panel .breadcrumb .breadcrumb-item:first'));
assert.containsNone(actionManager, '.o_form_view');
assert.containsNone(actionManager, '.o_list_view');
assert.containsOnce(actionManager, '.o_kanban_view');
@@ -125,9 +125,9 @@ QUnit.module('ActionManager', {
actionManager.doAction(1);
- assert.containsOnce($('.o_control_panel'), '.o_cp_switch_buttons button[data-toggle="dropdown"]');
- assert.hasClass($('.o_cp_switch_buttons .o_cp_switch_kanban'), 'active');
- assert.hasClass($('.o_cp_switch_buttons .o_switch_view_button_icon'), 'fa-th-large');
+ assert.containsOnce(actionManager.$('.o_control_panel'), '.o_cp_switch_buttons button[data-toggle="dropdown"]');
+ assert.hasClass(actionManager.$('.o_cp_switch_buttons .o_cp_switch_kanban'), 'active');
+ assert.hasClass(actionManager.$('.o_cp_switch_buttons .o_switch_view_button_icon'), 'fa-th-large');
actionManager.destroy();
});
diff --git a/addons/web/static/tests/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js
index 3eea9bda3a0..e1b14b4d363 100644
--- a/addons/web/static/tests/chrome/action_manager_tests.js
+++ b/addons/web/static/tests/chrome/action_manager_tests.js
@@ -6,7 +6,6 @@ var NotificationService = require('web.NotificationService');
var AbstractAction = require('web.AbstractAction');
var AbstractStorageService = require('web.AbstractStorageService');
var BasicFields = require('web.basic_fields');
-var ControlPanelMixin = require('web.ControlPanelMixin');
var core = require('web.core');
var ListController = require('web.ListController');
var StandaloneFieldManagerMixin = require('web.StandaloneFieldManagerMixin');
@@ -647,9 +646,9 @@ QUnit.module('ActionManager', {
assert.expect(2);
var ClientAction = AbstractAction.extend({
- className: 'o_client_action_test',
start: function () {
this.$el.text('Hello World');
+ this.$el.addClass('o_client_action_test');
},
});
core.action_registry.add('HelloWorldTest', ClientAction);
@@ -983,7 +982,7 @@ QUnit.module('ActionManager', {
QUnit.test('should not push a loaded state of a client action', function (assert) {
assert.expect(4);
- var ClientAction = Widget.extend({
+ var ClientAction = AbstractAction.extend({
init: function (parent, action, options) {
this._super.apply(this, arguments);
this.controllerID = options.controllerID;
@@ -1033,8 +1032,8 @@ QUnit.module('ActionManager', {
QUnit.test('change a param of an ir.actions.client in the url', function (assert) {
assert.expect(7);
- var ClientAction = AbstractAction.extend(ControlPanelMixin, {
- className: 'o_client_action',
+ var ClientAction = AbstractAction.extend({
+ hasControlPanel: true,
init: function (parent, action) {
this._super.apply(this, arguments);
var context = action.context;
@@ -1042,11 +1041,13 @@ QUnit.module('ActionManager', {
},
start: function () {
assert.step('start');
- this.$el.text(this.a);
+ this.$('.o_content').text(this.a);
+ this.$el.addClass('o_client_action');
this.trigger_up('push_state', {
controllerID: this.controllerID,
state: {a: this.a},
});
+ return this._super.apply(this, arguments);
},
});
core.action_registry.add('ClientAction', ClientAction);
@@ -1060,7 +1061,7 @@ QUnit.module('ActionManager', {
// execute the client action
actionManager.doAction(9);
- assert.strictEqual(actionManager.$('.o_client_action').text(), 'default value',
+ assert.strictEqual(actionManager.$('.o_client_action .o_content').text(), 'default value',
"should have rendered the client action");
assert.strictEqual($('.o_control_panel .breadcrumb-item').length, 1,
"there should be one controller in the breadcrumbs");
@@ -1071,7 +1072,7 @@ QUnit.module('ActionManager', {
a: 'new value',
});
- assert.strictEqual(actionManager.$('.o_client_action').text(), 'new value',
+ assert.strictEqual(actionManager.$('.o_client_action .o_content').text(), 'new value',
"should have rerendered the client action with the correct param");
assert.strictEqual($('.o_control_panel .breadcrumb-item').length, 1,
"there should still be one controller in the breadcrumbs");
@@ -1672,9 +1673,9 @@ QUnit.module('ActionManager', {
assert.expect(3);
var ClientAction = AbstractAction.extend({
- className: 'o_client_action_test',
start: function () {
this.$el.text('Hello World');
+ this.$el.addClass('o_client_action_test');
},
});
core.action_registry.add('HelloWorldTest', ClientAction);
@@ -1700,11 +1701,13 @@ QUnit.module('ActionManager', {
QUnit.test('client action with control panel', function (assert) {
assert.expect(4);
- var ClientAction = AbstractAction.extend(ControlPanelMixin, {
- className: 'o_client_action_test',
+ var ClientAction = AbstractAction.extend({
+ hasControlPanel: true,
start: function () {
- this.$el.text('Hello World');
- this.set('title', 'Hello'); // AAB: drop this and replace by getTitle()
+ this.$('.o_content').text('Hello World');
+ this.$el.addClass('o_client_action_test');
+ this._setTitle('Hello');
+ return this._super.apply(this, arguments);
},
});
core.action_registry.add('HelloWorldTest', ClientAction);
@@ -1718,7 +1721,7 @@ QUnit.module('ActionManager', {
"there should be one controller in the breadcrumbs");
assert.strictEqual($('.o_control_panel .breadcrumb-item').text(), 'Hello',
"breadcrumbs should still display the title of the controller");
- assert.strictEqual(actionManager.$('.o_client_action_test').text(),
+ assert.strictEqual(actionManager.$('.o_client_action_test .o_content').text(),
'Hello World', "should have correctly rendered the client action");
actionManager.destroy();
@@ -1728,12 +1731,7 @@ QUnit.module('ActionManager', {
QUnit.test('state is pushed for client actions', function (assert) {
assert.expect(2);
- var ClientAction = AbstractAction.extend(ControlPanelMixin, {
- className: 'o_client_action_test',
- start: function () {
- this.$el.text('Hello World');
- },
- });
+ var ClientAction = AbstractAction.extend({});
var actionManager = createActionManager({
intercepts: {
push_state: function () {
@@ -1754,16 +1752,18 @@ QUnit.module('ActionManager', {
QUnit.test('breadcrumb is updated on title change', function (assert) {
assert.expect(2);
- var ClientAction = Widget.extend(ControlPanelMixin, {
- className: 'o_client_action_test',
+ var ClientAction = AbstractAction.extend({
+ hasControlPanel: true,
events: {
click: function () {
- this.set("title", 'new title');
+ this._setTitle('new title');
},
},
start: function () {
- this.set("title", 'initial title');
- this.$el.text('Hello World');
+ this._setTitle('initial title');
+ this.$('.o_content').text('Hello World');
+ this.$el.addClass('o_client_action_test');
+ return this._super.apply(this, arguments);
},
});
var actionManager = createActionManager();
@@ -2985,9 +2985,11 @@ QUnit.module('ActionManager', {
assert.expect(4);
testUtils.mock.patch(ListController, {
- getContext: function () {
+ getOwnedQueryParams: function () {
return {
- shouldBeInFilterContext: true,
+ context: {
+ shouldBeInFilterContext: true,
+ }
};
},
});
@@ -3031,9 +3033,9 @@ QUnit.module('ActionManager', {
// save filter
testUtils.dom.click($('.o_control_panel .o_search_options .o_dropdown_toggler_btn:contains(Favorites)'));
- testUtils.dom.click($('.o_control_panel .o_save_search'));
- $('.o_control_panel .o_save_name input[type=text]').val('some name'); // name the filter
- testUtils.dom.click($('.o_control_panel .o_save_name button'));
+ testUtils.dom.click($('.o_control_panel .o_add_favorite'));
+ $('.o_control_panel .o_favorite_name input[type=text]').val('some name'); // name the filter
+ testUtils.dom.click($('.o_control_panel .o_save_favorite button'));
testUtils.mock.unpatch(ListController);
actionManager.destroy();
@@ -3064,47 +3066,6 @@ QUnit.module('ActionManager', {
actionManager.destroy();
});
- QUnit.test("doAction with option 'keepSearchView'", function (assert) {
- assert.expect(4);
-
- this.actions.push({
- id: 33,
- name: 'Partners',
- res_model: 'partner',
- search_view_id: [1, 'a specific search view'],
- type: 'ir.actions.act_window',
- views: [[false, 'list']],
- });
-
- var checkRPC = false;
- var actionManager = createActionManager({
- actions: this.actions,
- archs: this.archs,
- data: this.data,
- mockRPC: function (route, args) {
- if (checkRPC && route === '/web/dataset/search_read') {
- assert.deepEqual(args.domain, [['bar', '=', 1]],
- "should search with the correct domain");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- actionManager.doAction(33);
-
- checkRPC = true;
- testUtils.dom.click($('.o_control_panel .o_search_options .o_dropdown_toggler_btn:contains(Filters)'));
- testUtils.dom.click($('.o_control_panel .o_filters_menu a:contains(Bar)'));
- assert.strictEqual($('.o_control_panel .o_facet_values').text().trim(), 'Bar',
- "the filter on Bar should appear in the search view");
-
- actionManager.doAction(3, {keepSearchView: true});
- assert.strictEqual($('.o_control_panel .o_facet_values').text().trim(), 'Bar',
- "the filter on Bar should still be in the search view");
-
- actionManager.destroy();
- });
-
QUnit.test("current act_window action is stored in session_storage", function (assert) {
assert.expect(1);
@@ -3259,12 +3220,14 @@ QUnit.module('ActionManager', {
assert.expect(4);
var ClientAction = AbstractAction.extend({
- className: 'o_test',
on_attach_callback: function () {
assert.step('on_attach_callback');
assert.ok(actionManager.currentDialogController,
"the currentDialogController should have been set already");
},
+ start: function () {
+ this.$el.addClass('o_test');
+ },
});
core.action_registry.add('test', ClientAction);
diff --git a/addons/web/static/tests/chrome/search_tests.js b/addons/web/static/tests/chrome/search_tests.js
deleted file mode 100644
index c2d290134ed..00000000000
--- a/addons/web/static/tests/chrome/search_tests.js
+++ /dev/null
@@ -1,172 +0,0 @@
-odoo.define('web.search_tests', function (require) {
-"use strict";
-
-var SearchView = require('web.SearchView');
-
-QUnit.module('chrome', {}, function () {
-
- QUnit.module('Search View');
-
- QUnit.test('Adding a facet to the query creates a facet and a value', function (assert) {
- assert.expect(3);
-
- var query = new SearchView.SearchQuery();
- var field = {};
- query.add({
- category: 'Foo',
- field: field,
- values: [{label: 'Value', value: 3}]
- });
-
- var facet = query.at(0);
- assert.strictEqual(facet.get('category'), 'Foo');
- assert.strictEqual(facet.get('field'), field);
- assert.deepEqual(facet.get('values'), [{label: 'Value', value: 3}]);
- });
-
- QUnit.test('Adding two facets', function (assert) {
- assert.expect(3);
-
- var query = new SearchView.SearchQuery();
- query.add([
- { category: 'Foo', field: {}, values: [{label: 'Value', value: 3}] },
- { category: 'Bar', field: {}, values: [{label: 'Value 2', value: 4}] }
- ]);
-
- assert.strictEqual(query.length, 2);
- assert.strictEqual(query.at(0).values.length, 1);
- assert.strictEqual(query.at(1).values.length, 1);
- });
-
- QUnit.test('If a facet already exists, add values to it', function (assert) {
- assert.expect(2);
-
- var query = new SearchView.SearchQuery();
- var field = {};
- query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]});
- query.add({category: 'A', field: field, values: [{label: 'V2', value: 1}]});
-
- assert.strictEqual(query.length, 1, "adding an existing facet should merge new values into old facet");
- var facet = query.at(0);
- assert.deepEqual(facet.get('values'), [
- {label: 'V1', value: 0},
- {label: 'V2', value: 1}
- ]);
- });
-
- QUnit.test('Facet being implicitly changed should trigger change, not add', function (assert) {
- assert.expect(2);
-
- var query = new SearchView.SearchQuery();
- var field = {}, added = false, changed = false;
- query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]});
- query.on('add', function () { added = true; })
- .on('change', function () { changed = true; });
- query.add({category: 'A', field: field, values: [{label: 'V2', value: 1}]});
-
- assert.ok(!added, "query.add adding values to a facet should not trigger an add");
- assert.ok(changed, "query.add adding values to a facet should not trigger a change");
- });
-
- QUnit.test('Toggling a facet, value which does not exist should add it', function (assert) {
- assert.expect(3);
-
- var query = new SearchView.SearchQuery();
- var field = {};
- query.toggle({category: 'A', field: field, values: [{label: 'V1', value: 0}]});
-
- assert.strictEqual(query.length, 1, "Should have created a single facet");
- var facet = query.at(0);
- assert.strictEqual(facet.values.length, 1, "Facet should have a single value");
- assert.deepEqual(facet.get('values'), [{label: 'V1', value: 0}],
- "Facet's value should match input");
- });
-
- QUnit.test('Toggling a facet which exists with a value which does not should add the value to the facet', function (assert) {
- assert.expect(3);
-
- var field = {};
- var query = new SearchView.SearchQuery();
- query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]});
- query.toggle({category: 'A', field: field, values: [{label: 'V2', value: 1}]});
-
- assert.strictEqual(query.length, 1, "Should have edited the existing facet");
- var facet = query.at(0);
- assert.strictEqual(facet.values.length, 2, "Should have added the value to the existing facet");
- assert.deepEqual(facet.get('values'), [
- {label: 'V1', value: 0},
- {label: 'V2', value: 1}
- ]);
- });
-
- QUnit.test('Toggling a facet which exists with a value which does as well should remove the value from the facet', function (assert) {
- assert.expect(3);
-
- var field = {};
- var query = new SearchView.SearchQuery();
- query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]});
- query.add({category: 'A', field: field, values: [{label: 'V2', value: 1}]});
-
- query.toggle({category: 'A', field: field, values: [{label: 'V2', value: 1}]});
-
- assert.strictEqual(query.length, 1, 'Should have the same single facet');
- var facet = query.at(0);
- assert.strictEqual(facet.values.length, 1, "Should only have one value left in the facet");
- assert.deepEqual(facet.get('values'), [
- {label: 'V1', value: 0}
- ]);
- });
-
- QUnit.test('Toggling off the last value of a facet should remove the facet', function (assert) {
- assert.expect(1);
-
- var field = {};
- var query = new SearchView.SearchQuery();
- query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]});
-
- query.toggle({category: 'A', field: field, values: [{label: 'V1', value: 0}]});
-
- assert.strictEqual(query.length, 0, 'Should have removed the facet');
- });
-
- QUnit.test('Intermediate emptiness should not remove the facet', function (assert) {
- assert.expect(3);
-
- var field = {};
- var query = new SearchView.SearchQuery();
- query.add({category: 'A', field: field, values: [{label: 'V1', value: 0}]});
-
- query.toggle({category: 'A', field: field, values: [
- {label: 'V1', value: 0},
- {label: 'V2', value: 1}
- ]});
-
- assert.strictEqual(query.length, 1, 'Should not have removed the facet');
- var facet = query.at(0);
- assert.strictEqual(facet.values.length, 1, "Should have one value");
- assert.deepEqual(facet.get('values'), [
- {label: 'V2', value: 1}
- ]);
- });
-
- QUnit.test('Reseting with multiple facets should still work to load defaults', function (assert) {
- assert.expect(3);
-
- var query = new SearchView.SearchQuery();
- var field = {};
- query.reset([
- {category: 'A', field: field, values: [{label: 'V1', value: 0}]},
- {category: 'A', field: field, values: [{label: 'V2', value: 1}]}]);
-
- assert.strictEqual(query.length, 1, 'Should have created a single facet');
- assert.strictEqual(query.at(0).values.length, 2, 'the facet should have merged two values');
- assert.deepEqual(query.at(0).get('values'), [
- {label: 'V1', value: 0},
- {label: 'V2', value: 1}
- ]);
- });
-
- // TODO add a test to check that combining two filters with a or normalizes the two filters before
- // Note: maybe domain.js will be improved to make this easier
-});
-});
diff --git a/addons/web/static/tests/core/registry_tests.js b/addons/web/static/tests/core/registry_tests.js
index d28e429da24..f87238fafb8 100644
--- a/addons/web/static/tests/core/registry_tests.js
+++ b/addons/web/static/tests/core/registry_tests.js
@@ -19,35 +19,6 @@ QUnit.module('core', {}, function () {
assert.strictEqual(registry.get('foo'), foo);
});
- QUnit.test('extension', function (assert) {
- assert.expect(2);
-
- var foo = {};
- var foo2 = {};
- var registry = new Registry({
- foo: foo,
- });
- var registry2 = registry.extend({foo: foo2});
- assert.strictEqual(registry.get('foo'), foo);
- assert.strictEqual(registry2.get('foo'), foo2);
- });
-
- QUnit.test('remain-linked', function (assert) {
- assert.expect(2);
-
- var foo = {};
- var foo2 = {};
- var registry = new Registry({
- foo: foo,
- });
-
- var registry2 = registry.extend();
-
- registry.add('foo2', foo2);
-
- assert.strictEqual(registry.get('foo2'), foo2);
- assert.strictEqual(registry2.get('foo2'), foo2);
- });
QUnit.test('multiget', function (assert) {
assert.expect(1);
@@ -64,17 +35,18 @@ QUnit.module('core', {}, function () {
"Registry getAny should find first defined key");
});
- QUnit.test('extended-multiget', function (assert) {
- assert.expect(1);
+ QUnit.test('keys and values are properly ordered', function (assert) {
+ assert.expect(2);
- var foo = {};
- var bar = {};
- var registry = new Registry({
- foo: foo,
- bar: bar,
- });
- var registry2 = registry.extend();
- assert.strictEqual(registry2.getAny(['qux', 'grault', 'bar', 'foo']), bar);
+ var registry = new Registry();
+
+ registry
+ .add('fred', 'foo', 3)
+ .add('george', 'bar', 2)
+ .add('ronald', 'qux', 4);
+
+ assert.deepEqual(registry.keys(), ['george', 'fred', 'ronald']);
+ assert.deepEqual(registry.values(), ['bar', 'foo', 'qux']);
});
});
diff --git a/addons/web/static/tests/fields/basic_fields_mobile_tests.js b/addons/web/static/tests/fields/basic_fields_mobile_tests.js
index 05f1b93226e..fd302ebeb36 100644
--- a/addons/web/static/tests/fields/basic_fields_mobile_tests.js
+++ b/addons/web/static/tests/fields/basic_fields_mobile_tests.js
@@ -164,7 +164,7 @@ QUnit.module('basic_fields', {
});
var val = '<script>throw Error();</script><script>throw Error();</script>';
- testUtils.fields.editInput(form.$('input'), val);
+ testUtils.fields.editInput(form.$('input.o_field_widget[name="foo"]'), val);
// save
testUtils.form.clickSave(form);
diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js
index 32450f3d56b..f5568abe28e 100644
--- a/addons/web/static/tests/fields/basic_fields_tests.js
+++ b/addons/web/static/tests/fields/basic_fields_tests.js
@@ -18,9 +18,6 @@ var DebouncedField = basicFields.DebouncedField;
var JournalDashboardGraph = basicFields.JournalDashboardGraph;
var _t = core._t;
-var DOM = testUtils.DOM;
-var mock = testUtils.mock;
-
QUnit.module('fields', {}, function () {
QUnit.module('basic_fields', {
@@ -166,7 +163,7 @@ QUnit.module('basic_fields', {
});
// change the value
- testUtils.fields.editInput(form.$('input'), 'new value');
+ testUtils.fields.editInput(form.$('input[name=foo]'), 'new value');
assert.verifySteps([], "_setValue shouldn't have been called yet");
// save
@@ -236,12 +233,12 @@ QUnit.module('basic_fields', {
"checkbox should now be unchecked");
// check the checkbox by clicking on label
- testUtils.dom.click(form.$('label:first'));
+ testUtils.dom.click(form.$('.o_form_view label:first'));
assert.containsOnce(form, '.o_field_boolean input:checked',
"checkbox should now be checked");
// uncheck it back
- testUtils.dom.click(form.$('label:first'));
+ testUtils.dom.click(form.$('.o_form_view label:first'));
assert.containsNone(form, '.o_field_boolean input:checked',
"checkbox should now be unchecked");
@@ -394,17 +391,17 @@ QUnit.module('basic_fields', {
assert.containsOnce(list, 'button i.fa.fa-circle.text-muted',
"should have 1 muted button");
- assert.hasAttrValue(list.$('button').first(), 'title', "Reported in last payslips",
- "active buttons should have proper tooltip");
- assert.hasAttrValue(list.$('button').last(), 'title', "To Report in Payslip",
- "inactive buttons should have proper tooltip");
+ assert.hasAttrValue(list.$('.o_list_view button').first(), 'title',
+ "Reported in last payslips", "active buttons should have proper tooltip");
+ assert.hasAttrValue(list.$('.o_list_view button').last(), 'title',
+ "To Report in Payslip", "inactive buttons should have proper tooltip");
// clicking on first button to check the state is properly changed
- testUtils.dom.click(list.$('button').first());
+ testUtils.dom.click(list.$('.o_list_view button').first());
assert.containsN(list, 'button i.fa.fa-circle.o_toggle_button_success', 3,
"should have 3 green buttons");
- testUtils.dom.click(list.$('button').first());
+ testUtils.dom.click(list.$('.o_list_view button').first());
assert.containsN(list, 'button i.fa.fa-circle.o_toggle_button_success', 4,
"should have 4 green buttons");
list.destroy();
@@ -553,14 +550,14 @@ QUnit.module('basic_fields', {
'The value should be displayed properly.');
testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('input').val(), '0.000',
+ assert.strictEqual(form.$('input[name=qux]').val(), '0.000',
'The value should be rendered with correct precision.');
- testUtils.fields.editInput(form.$('input'), '108.2458938598598');
- assert.strictEqual(form.$('input').val(), '108.2458938598598',
+ testUtils.fields.editInput(form.$('input[name=qux]'), '108.2458938598598');
+ assert.strictEqual(form.$('input[name=qux]').val(), '108.2458938598598',
'The value should not be formated yet.');
- testUtils.fields.editInput(form.$('input'), '18.8958938598598');
+ testUtils.fields.editInput(form.$('input[name=qux]'), '18.8958938598598');
testUtils.form.clickSave(form);
assert.strictEqual(form.$('.o_field_widget').first().text(), '18.896',
'The new value should be rounded properly.');
@@ -691,13 +688,13 @@ QUnit.module('basic_fields', {
'The value should be displayed properly.');
testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('input').val(), '-8.9',
+ assert.strictEqual(form.$('.o_field_widget[name=qux] input').val(), '-8.9',
'The input should be rendered without the currency symbol.');
- assert.strictEqual(form.$('input').parent().children().first().text(), '$',
+ assert.strictEqual(form.$('.o_field_widget[name=qux] input').parent().children().first().text(), '$',
'The input should be preceded by a span containing the currency symbol.');
testUtils.fields.editInput(form.$('.o_field_monetary input'), '109.2458938598598');
- assert.strictEqual(form.$('input').val(), '109.2458938598598',
+ assert.strictEqual(form.$('.o_field_widget[name=qux] input').val(), '109.2458938598598',
'The value should not be formated yet.');
testUtils.form.clickSave(form);
@@ -730,7 +727,7 @@ QUnit.module('basic_fields', {
'Float field with option type must have a type attribute.');
assert.hasAttrValue(form.$('.o_field_widget'), 'type', 'number',
'Float field with option type must have a type attribute equals to "number".');
- testUtils.fields.editInput(form.$('input'), '123456.7890');
+ testUtils.fields.editInput(form.$('input[name=qux]'), '123456.7890');
testUtils.form.clickSave(form);
testUtils.form.clickEdit(form);
assert.strictEqual(form.$('.o_field_widget').val(), '123456.789',
@@ -763,7 +760,7 @@ QUnit.module('basic_fields', {
assert.hasAttrValue(form.$('.o_field_widget'), 'type', 'text',
'Float field with option type must have a text type (default type).');
- testUtils.fields.editInput(form.$('input'), '123456.7890');
+ testUtils.fields.editInput(form.$('input[name=qux]'), '123456.7890');
testUtils.form.clickSave(form);
testUtils.form.clickEdit(form);
assert.strictEqual(form.$('.o_field_widget').val(), '123,456.8',
@@ -1086,7 +1083,7 @@ QUnit.module('basic_fields', {
},
});
- testUtils.fields.editInput(form.$('input'), '<script>throw Error();</script>');
+ testUtils.fields.editInput(form.$('input[name=foo]'), '<script>throw Error();</script>');
testUtils.form.clickSave(form);
assert.strictEqual(form.$('.o_field_widget').text(), '<script>throw Error();</script>',
'the value should have been properly escaped');
@@ -3329,13 +3326,13 @@ QUnit.module('basic_fields', {
'The value should be displayed properly.');
testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('input').val(), '9.10',
+ assert.strictEqual(form.$('.o_field_widget[name=qux] input').val(), '9.10',
'The input should be rendered without the currency symbol.');
- assert.strictEqual(form.$('input').parent().children().first().text(), '$',
+ assert.strictEqual(form.$('.o_field_widget[name=qux] input').parent().children().first().text(), '$',
'The input should be preceded by a span containing the currency symbol.');
testUtils.fields.editInput(form.$('.o_field_monetary input'), '108.2458938598598');
- assert.strictEqual(form.$('input').val(), '108.2458938598598',
+ assert.strictEqual(form.$('.o_field_widget[name=qux] input').val(), '108.2458938598598',
'The value should not be formated yet.');
testUtils.form.clickSave(form);
@@ -3370,13 +3367,13 @@ QUnit.module('basic_fields', {
'The value should be displayed properly.');
testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('input').first().val(), '0.00',
+ assert.strictEqual(form.$('.o_field_widget[name=qux] input').val(), '0.00',
'The input should be rendered without the currency symbol.');
- assert.strictEqual(form.$('input').parent().children().eq(1).text(), '€',
+ assert.strictEqual(form.$('.o_field_widget[name=qux] input').parent().children().eq(1).text(), '€',
'The input should be followed by a span containing the currency symbol.');
- testUtils.fields.editInput(form.$('input').first(), '108.2458938598598');
- assert.strictEqual(form.$('input').first().val(), '108.2458938598598',
+ testUtils.fields.editInput(form.$('.o_field_widget[name=qux] input'), '108.2458938598598');
+ assert.strictEqual(form.$('.o_field_widget[name=qux] input').val(), '108.2458938598598',
'The value should not be formated yet.');
testUtils.form.clickSave(form);
@@ -3427,13 +3424,13 @@ QUnit.module('basic_fields', {
'The value should be displayed properly.');
testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('input').first().val(), '99.1234',
+ assert.strictEqual(form.$('.o_field_widget[name=qux] input').val(), '99.1234',
'The input should be rendered without the currency symbol.');
- assert.strictEqual(form.$('input').parent().children().eq(1).text(), 'Bs.F',
+ assert.strictEqual(form.$('.o_field_widget[name=qux] input').parent().children().eq(1).text(), 'Bs.F',
'The input should be followed by a span containing the currency symbol.');
- testUtils.fields.editInput(form.$('input').first(), '99.111111111');
- assert.strictEqual(form.$('input').first().val(), '99.111111111',
+ testUtils.fields.editInput(form.$('.o_field_widget[name=qux] input'), '99.111111111');
+ assert.strictEqual(form.$('.o_field_widget[name=qux] input').val(), '99.111111111',
'The value should not be formated yet.');
testUtils.form.clickSave(form);
@@ -3743,11 +3740,11 @@ QUnit.module('basic_fields', {
'Integer field should be considered set for value 0.');
testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('input').val(), '0',
+ assert.strictEqual(form.$('input[name=int_field]').val(), '0',
'The value should be rendered correctly in edit mode.');
- testUtils.fields.editInput(form.$('input'), '-18');
- assert.strictEqual(form.$('input').val(), '-18',
+ testUtils.fields.editInput(form.$('input[name=int_field]'), '-18');
+ assert.strictEqual(form.$('input[name=int_field]').val(), '-18',
'The value should be correctly displayed in the input.');
testUtils.form.clickSave(form);
@@ -3831,7 +3828,7 @@ QUnit.module('basic_fields', {
assert.hasAttrValue(form.$('.o_field_widget'), 'type', 'number',
'Integer field with option type must have a type attribute equals to "number".');
- testUtils.fields.editInput(form.$('input'), '1234567890');
+ testUtils.fields.editInput(form.$('input[name=int_field]'), '1234567890');
testUtils.form.clickSave(form);
testUtils.form.clickEdit(form);
assert.strictEqual(form.$('.o_field_widget').val(), '1234567890',
@@ -3864,7 +3861,7 @@ QUnit.module('basic_fields', {
assert.hasAttrValue(form.$('.o_field_widget'), 'type', 'text',
'Integer field without option type must have a text type (default type).');
- testUtils.fields.editInput(form.$('input'), '1234567890');
+ testUtils.fields.editInput(form.$('input[name=int_field]'), '1234567890');
testUtils.form.clickSave(form);
testUtils.form.clickEdit(form);
assert.strictEqual(form.$('.o_field_widget').val(), '1,234,567,890',
@@ -3903,11 +3900,11 @@ QUnit.module('basic_fields', {
'The formatted time value should be displayed properly.');
testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('input').val(), '09:06',
+ assert.strictEqual(form.$('input[name=qux]').val(), '09:06',
'The value should be rendered correctly in the input.');
- testUtils.fields.editInput(form.$('input'), '-11:48');
- assert.strictEqual(form.$('input').val(), '-11:48',
+ testUtils.fields.editInput(form.$('input[name=qux]'), '-11:48');
+ assert.strictEqual(form.$('input[name=qux]').val(), '-11:48',
'The new value should be displayed properly in the input.');
testUtils.form.clickSave(form);
@@ -3945,10 +3942,10 @@ QUnit.module('basic_fields', {
'The formatted value should be displayed properly.');
testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('input').val(), '4.55',
+ assert.strictEqual(form.$('input[name=qux]').val(), '4.55',
'The value should be rendered correctly in the input.');
- testUtils.fields.editInput(form.$('input'), '2.3');
+ testUtils.fields.editInput(form.$('input[name=qux]'), '2.3');
testUtils.form.clickSave(form);
assert.strictEqual(form.$('.o_field_widget').first().text(), '2.30',
@@ -4057,7 +4054,7 @@ QUnit.module('basic_fields', {
View: ListView,
model: 'partner',
data: this.data,
- arch: '<tree editable="bottom"><field name="foo" widget="phone"/></tree>',
+ arch: '<tree editable="bottom"><field name="foo" widget="phone"/></tree>',
config: {
device: {
size_class: config.device.SIZES.LG,
diff --git a/addons/web/static/tests/fields/relational_fields/field_many2many_tests.js b/addons/web/static/tests/fields/relational_fields/field_many2many_tests.js
index e6f2c06173f..949af90fafe 100644
--- a/addons/web/static/tests/fields/relational_fields/field_many2many_tests.js
+++ b/addons/web/static/tests/fields/relational_fields/field_many2many_tests.js
@@ -262,7 +262,7 @@ QUnit.module('fields', {}, function () {
'the removed record should not be in kanban anymore');
// save the record
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -431,7 +431,7 @@ QUnit.module('fields', {}, function () {
'the updated row still has the correct values');
// save
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.containsN(form, '.o_list_view td.o_list_number', 2,
'should contain 2 subrecords');
assert.strictEqual(form.$('.o_list_view .o_data_row td:first').text(),
@@ -545,7 +545,7 @@ QUnit.module('fields', {}, function () {
'new name', 'the updated row still has the correct values');
// save
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.containsN(form, '.o_list_view td.o_list_number', 2,
'should contain 2 subrecords');
assert.strictEqual(form.$('.o_list_view .o_data_row td:first').text(),
@@ -687,73 +687,26 @@ QUnit.module('fields', {}, function () {
}
});
- assert.notOk(form.$('.custom-checkbox input').eq(0).prop('checked'),
+ assert.notOk(form.$('.o_form_view .custom-checkbox input').eq(0).prop('checked'),
"first checkbox should not be checked");
- assert.notOk(form.$('.custom-checkbox input').eq(1).prop('checked'),
+ assert.notOk(form.$('.o_form_view .custom-checkbox input').eq(1).prop('checked'),
"second checkbox should not be checked");
- assert.ok(form.$('.custom-checkbox input').eq(2).prop('checked'),
+ assert.ok(form.$('.o_form_view .custom-checkbox input').eq(2).prop('checked'),
"third checkbox should be checked");
- testUtils.dom.click(form.$('.custom-checkbox input:checked'));
- testUtils.dom.click(form.$('.custom-checkbox input').first());
- testUtils.dom.click(form.$('.custom-checkbox input').first());
- testUtils.dom.click(form.$('.custom-checkbox input').first());
+ testUtils.dom.click(form.$('.o_form_view .custom-checkbox input:checked'));
+ testUtils.dom.click(form.$('.o_form_view .custom-checkbox input').first());
+ testUtils.dom.click(form.$('.o_form_view .custom-checkbox input').first());
+ testUtils.dom.click(form.$('.o_form_view .custom-checkbox input').first());
- assert.ok(form.$('.custom-checkbox input').eq(0).prop('checked'),
+ assert.ok(form.$('.o_form_view .custom-checkbox input').eq(0).prop('checked'),
"first checkbox should be checked");
- assert.notOk(form.$('.custom-checkbox input').eq(1).prop('checked'),
+ assert.notOk(form.$('.o_form_view .custom-checkbox input').eq(1).prop('checked'),
"second checkbox should not be checked");
- assert.notOk(form.$('.custom-checkbox input').eq(2).prop('checked'),
+ assert.notOk(form.$('.o_form_view .custom-checkbox input').eq(2).prop('checked'),
"third checkbox should not be checked");
- testUtils.form.clickSave(form);;
-
- form.destroy();
- });
-
- QUnit.test('many2many checkboxes with default values', function (assert) {
- assert.expect(7);
-
- this.data.partner.fields.turtles.default = [3];
- this.data.partner.fields.turtles.type = "many2many";
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles" widget="many2many_checkboxes">' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'create') {
- assert.deepEqual(args.args[0].turtles, [[6, false, [1]]],
- "correct values should have been sent to create");
- }
- return this._super.apply(this, arguments);
- }
- });
-
- assert.notOk(form.$('.custom-checkbox input').eq(0).prop('checked'),
- "first checkbox should not be checked");
- assert.notOk(form.$('.custom-checkbox input').eq(1).prop('checked'),
- "second checkbox should not be checked");
- assert.ok(form.$('.custom-checkbox input').eq(2).prop('checked'),
- "third checkbox should be checked");
-
- testUtils.dom.click(form.$('.custom-checkbox input:checked'));
- testUtils.dom.click(form.$('.custom-checkbox input').first());
- testUtils.dom.click(form.$('.custom-checkbox input').first());
- testUtils.dom.click(form.$('.custom-checkbox input').first());
-
- assert.ok(form.$('.custom-checkbox input').eq(0).prop('checked'),
- "first checkbox should be checked");
- assert.notOk(form.$('.custom-checkbox input').eq(1).prop('checked'),
- "second checkbox should not be checked");
- assert.notOk(form.$('.custom-checkbox input').eq(2).prop('checked'),
- "third checkbox should not be checked");
-
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -904,7 +857,7 @@ QUnit.module('fields', {}, function () {
testUtils.dom.click($('.modal .modal-footer .btn-primary').first());
// there is nothing left to save -> should not do a 'write' RPC
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.verifySteps([
'read', // read initial record (on partner)
@@ -980,27 +933,27 @@ QUnit.module('fields', {}, function () {
assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
'there should be 40 records displayed on page 1');
- testUtils.dom.click(form.$('.o_pager_next'));
+ testUtils.dom.click(form.$('.o_field_widget[name=timmy] .o_pager_next'));
assert.verifySteps(['read', 'onchange', 'read', 'read']);
assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
'41-45 / 45', "pager should be correct");
assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 5,
'there should be 5 records displayed on page 2');
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
'1-40 / 45', "pager should be correct");
assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
'there should be 40 records displayed on page 1');
- testUtils.dom.click(form.$('.o_pager_next'));
+ testUtils.dom.click(form.$('.o_field_widget[name=timmy] .o_pager_next'));
assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
'41-45 / 45', "pager should be correct");
assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 5,
'there should be 5 records displayed on page 2');
- testUtils.dom.click(form.$('.o_pager_next'));
+ testUtils.dom.click(form.$('.o_field_widget[name=timmy] .o_pager_next'));
assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
'1-40 / 45', "pager should be correct");
assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
diff --git a/addons/web/static/tests/fields/relational_fields/field_many2one_tests.js b/addons/web/static/tests/fields/relational_fields/field_many2one_tests.js
index 855b7695415..84fac135ad2 100644
--- a/addons/web/static/tests/fields/relational_fields/field_many2one_tests.js
+++ b/addons/web/static/tests/fields/relational_fields/field_many2one_tests.js
@@ -244,7 +244,7 @@ QUnit.module('fields', {}, function () {
// save and close modal
testUtils.dom.click($('.modal .modal-footer .btn-primary:first'));
// save form
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
// click next on pager
testUtils.dom.click(form.pager.$('.o_pager_next'));
@@ -316,7 +316,7 @@ QUnit.module('fields', {}, function () {
// save and close modal
testUtils.dom.click($('.modal .modal-footer .btn-primary:first'));
// save form
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
// click next on pager
testUtils.dom.click(form.pager.$('.o_pager_next'));
@@ -562,7 +562,7 @@ QUnit.module('fields', {}, function () {
// save the main record, and check that no extra rpcs are done (record
// is not dirty, only a related record was modified)
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.verifySteps(['read', 'get_formview_id', 'load_views', 'read', 'write', 'onchange', 'read']);
form.destroy();
});
@@ -681,7 +681,7 @@ QUnit.module('fields', {}, function () {
'value of the m2o should have been correctly updated');
// save
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.strictEqual(form.$('a.o_form_uri').text(), 'Partner 20',
"should display correct value after save");
@@ -709,7 +709,7 @@ QUnit.module('fields', {}, function () {
// Remove value from many2one and then save, there should not have href with id and model on m2o anchor
testUtils.form.clickEdit(form);
form.$('.o_field_many2one input').val('').trigger('keyup').trigger('focusout');
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.hasAttrValue(form.$('a.o_form_uri'), 'href', "#",
"href should have #");
@@ -795,10 +795,10 @@ QUnit.module('fields', {}, function () {
testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('input').val(), "first record",
+ assert.strictEqual(form.$('.o_field_widget[name=trululu] input').val(), "first record",
"actual field value should be displayed to be edited");
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.ok(form.$('a:contains(and some address)').length,
"should still display additional result");
@@ -959,7 +959,7 @@ QUnit.module('fields', {}, function () {
},
});
await testUtils.fields.many2one.searchAndClickItem('trululu', 'b');
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.verifySteps(['name_create'],
"should wait for the name_create before creating the record");
@@ -1059,7 +1059,7 @@ QUnit.module('fields', {}, function () {
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
await testUtils.fields.many2one.searchAndClickItem('trululu', {search: 'b'});
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.verifySteps(['name_create'],
"should wait for the name_create before creating the record");
@@ -1138,7 +1138,7 @@ QUnit.module('fields', {}, function () {
assert.hasClass(form.$('.o_data_row:nth(1)'),'o_selected_row',
"the second row should be in edition");
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.containsOnce(form, '.o_data_row',
"there should be 1 row saved (the second one was empty and invalid)");
@@ -1674,7 +1674,7 @@ QUnit.module('fields', {}, function () {
displayName = 'new value';
testUtils.dom.click(form.$('.o_data_cell'));
testUtils.fields.editInput(form.$('.o_data_cell input'), displayName);
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -1727,7 +1727,7 @@ QUnit.module('fields', {}, function () {
},
});
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -1866,7 +1866,7 @@ QUnit.module('fields', {}, function () {
// Saving the modal and then the original model
testUtils.dom.click($modal.find('.modal-footer .btn-primary'));
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.verifySteps(['onchange sequence', 'partner_type write']);
@@ -1897,7 +1897,7 @@ QUnit.module('fields', {}, function () {
});
testUtils.form.clickEdit(form);
- testUtils.dom.click(form.$('input'));
+ testUtils.dom.click(form.$('.o_field_widget[name=product_id] input'));
form.destroy();
});
@@ -1923,7 +1923,7 @@ QUnit.module('fields', {}, function () {
});
testUtils.form.clickEdit(form);
- testUtils.dom.click(form.$('input:eq(2)'));
+ testUtils.dom.click(form.$('.o_field_widget[name=trululu] input'));
form.destroy();
});
@@ -1956,7 +1956,7 @@ QUnit.module('fields', {}, function () {
});
assert.strictEqual(count, 3, "should have done 3 rpcs (default_get, onchange, name_get)");
- assert.strictEqual(form.$('input').val(), 'hello world',
+ assert.strictEqual(form.$('.o_field_widget[name=trululu] input').val(), 'hello world',
"should have taken the correct display name");
form.destroy();
});
@@ -1973,15 +1973,15 @@ QUnit.module('fields', {}, function () {
'</form>',
res_id: 1,
});
- assert.strictEqual(form.$('a').text(), '', 'the tag a should be empty');
+ assert.strictEqual(form.$('a[name=product_id]').text(), '', 'the tag a should be empty');
testUtils.form.clickEdit(form);
testUtils.fields.many2one.clickOpenDropdown('product_id');
testUtils.fields.many2one.clickItem('product_id','xphone');
- assert.strictEqual(form.$('input').val(), "xphone", "should have selected xphone");
+ assert.strictEqual(form.$('.o_field_widget[name=product_id] input').val(), "xphone", "should have selected xphone");
- testUtils.form.clickDiscard(form);;
- assert.strictEqual(form.$('a').text(), '', 'the tag a should be empty');
+ testUtils.form.clickDiscard(form);
+ assert.strictEqual(form.$('a[name=product_id]').text(), '', 'the tag a should be empty');
form.destroy();
});
@@ -2103,7 +2103,7 @@ QUnit.module('fields', {}, function () {
// select a new value then cancel the creation of the new one --> restore the previous
testUtils.fields.many2one.clickOpenDropdown('product_id');
testUtils.fields.many2one.clickItem('product_id','o');
- assert.strictEqual(form.$('input').val(), "xphone", "should have selected xphone");
+ assert.strictEqual(form.$('.o_field_many2one input').val(), "xphone", "should have selected xphone");
form.$('.o_field_many2one input').focus().val('new product').trigger('keyup').trigger('blur');
assert.strictEqual($('.modal').length, 1, "there should be one opened modal");
testUtils.dom.click($('.modal .modal-footer .btn:contains(Cancel)'));
@@ -2739,7 +2739,7 @@ QUnit.module('fields', {}, function () {
'The display name field should have the right value');
// Save the whole thing
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
x2mList = form.$('.o_field_x2many_list[name=p]');
diff --git a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js
index af508179830..56c44731be4 100644
--- a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js
+++ b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js
@@ -370,7 +370,7 @@ QUnit.module('fields', {}, function () {
testUtils.fields.editInput($secondTarget, 9);
testUtils.fields.editInput($secondTarget, $secondTarget.val() + 9);
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -397,6 +397,11 @@ QUnit.module('fields', {}, function () {
'</sheet>' +
'</form>',
res_id: 1,
+ intercepts: {
+ load_filters: function (event) {
+ throw new Error('Should not load filters');
+ },
+ },
});
@@ -700,7 +705,7 @@ QUnit.module('fields', {}, function () {
assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "blipyopkawa",
"should have the 3 rows in the new order");
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.deepEqual(_.map(this.data.turtle.records, function (turtle) {
return _.pick(turtle, 'id', 'turtle_foo', 'turtle_int');
@@ -777,7 +782,7 @@ QUnit.module('fields', {}, function () {
testUtils.dom.click(form.$('.o_data_cell').eq(1));
var $cell = form.$('.o_selected_row .o_input[name=turtle_foo]');
testUtils.fields.editSelect($cell, "hop");
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -839,7 +844,7 @@ QUnit.module('fields', {}, function () {
testUtils.dom.click(form.$('.o_data_cell').eq(1));
var $cell = form.$('.o_selected_row .o_input[name=turtle_foo]');
testUtils.fields.editSelect($cell, "hop");
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.deepEqual(form.$('.o_many2many_tags_cell').text().trim().split(/\s+/),
["second", "record", "aaa"],
@@ -960,7 +965,7 @@ QUnit.module('fields', {}, function () {
"should still have the 3 rows in the correct order");
assert.strictEqual(turtleOnchange, 3, "should update all lines");
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -1004,7 +1009,7 @@ QUnit.module('fields', {}, function () {
res_id: 1,
});
- testUtils.dom.click(form.$('.o_pager_next'));
+ testUtils.dom.click(form.$('.o_field_widget[name=turtles] .o_pager_next'));
assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "yopblipkawa",
"should have the 3 rows in the correct order");
@@ -1021,8 +1026,8 @@ QUnit.module('fields', {}, function () {
assert.strictEqual(form.$('.o_data_cell').text(), "blurpkawablip", "should display to record in 'turtle_int' order");
- testUtils.form.clickSave(form);;
- testUtils.dom.click(form.$('.o_pager_next'));
+ testUtils.form.clickSave(form);
+ testUtils.dom.click(form.$('.o_field_widget[name=turtles] .o_pager_next'));
assert.strictEqual(form.$('.o_data_cell:not(.o_handle_cell)').text(), "blurpkawablip",
"should display to record in 'turtle_int' order");
@@ -1098,7 +1103,7 @@ QUnit.module('fields', {}, function () {
testUtils.fields.editInput(form.$('.o_field_one2many .o_list_view tbody tr:first input:first'), 'blurp');
// the domain fail if the widget does not use the allready loaded data.
- testUtils.form.clickDiscard(form);;
+ testUtils.form.clickDiscard(form);
assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "blurp#21#22#23#24#25#26#27#28#29",
"should display the records in order with the changes");
@@ -1130,7 +1135,7 @@ QUnit.module('fields', {}, function () {
assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "#20#39#40#41#42#43#44#45#46#47",
"should display the records in order after resequence (display record with turtle_int=0)");
- testUtils.form.clickDiscard(form);;
+ testUtils.form.clickDiscard(form);
assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "#20#39#40#41#42#43#44#45#46#47",
"should display the records in order after resequence");
@@ -1184,7 +1189,7 @@ QUnit.module('fields', {}, function () {
});
testUtils.form.clickEdit(form);
- testUtils.dom.click(form.$('.o_pager_next'));
+ testUtils.dom.click(form.$('.o_field_widget[name=turtles] .o_pager_next'));
testUtils.dom.click(form.$('.o_field_one2many .o_list_view tbody tr:eq(1) td:first'));
testUtils.fields.editInput(form.$('.o_field_one2many .o_list_view tbody tr:eq(1) input:first'), 'value 1');
@@ -1259,7 +1264,7 @@ QUnit.module('fields', {}, function () {
});
testUtils.form.clickEdit(form);
- testUtils.dom.click(form.$('.o_pager_next'));
+ testUtils.dom.click(form.$('.o_field_widget[name=turtles] .o_pager_next'));
testUtils.dom.click(form.$('.o_field_one2many .o_list_view tbody tr:eq(1) td:first'));
testUtils.fields.editInput(form.$('.o_field_one2many .o_list_view tbody tr:eq(1) input:first'), 'value 1');
@@ -1693,7 +1698,7 @@ QUnit.module('fields', {}, function () {
assert.strictEqual(form.$('tbody tr:first td.o_data_cell:not(.o_handle_cell) input').val(), "blip",
"should edit the correct row");
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "blipMy little Foo Valueyop",
"should still have the 3 rows in the new order");
@@ -1904,7 +1909,7 @@ QUnit.module('fields', {}, function () {
assert.strictEqual(form.$('.o_kanban_record:first span:contains(new record)').length,
0, 'new record should not be on page 1');
// save
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
// delete a record on page one
checkRead = true;
@@ -1920,7 +1925,7 @@ QUnit.module('fields', {}, function () {
assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
'1-40 / 45', "pager range should be correct");
// save
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
// add and delete records in both pages
testUtils.form.clickEdit(form);
@@ -1956,7 +1961,7 @@ QUnit.module('fields', {}, function () {
assert.strictEqual(form.$('.o_kanban_record span:contains(new record page 2)').length,
1, 'new records should be on page 2');
// save
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -2088,7 +2093,7 @@ QUnit.module('fields', {}, function () {
"first row of o2m should be readonly again");
// discard changes
- testUtils.form.clickDiscard(form);;
+ testUtils.form.clickDiscard(form);
assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'new value',
"changes shouldn't have been discarded yet, waiting for user confirmation");
testUtils.dom.click($('.modal .modal-footer .btn-primary'));
@@ -2100,7 +2105,7 @@ QUnit.module('fields', {}, function () {
testUtils.dom.click(form.$('.o_field_one2many tbody td').first());
testUtils.fields.editInput(form.$('.o_field_one2many tbody td').first().find('input'), "new value");
testUtils.dom.click(form.$el);
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
// FIXME: this next test doesn't pass as the save of updates of
// relational data is temporarily disabled
// assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'new value',
@@ -2191,7 +2196,7 @@ QUnit.module('fields', {}, function () {
"should have 1 delete button (another record is supposed to have been unlinked)");
// save and check that the correct command has been generated
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -2251,7 +2256,7 @@ QUnit.module('fields', {}, function () {
"should have 1 remove button (another record is supposed to have been deleted)");
// save and check that the correct command has been generated
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
// FIXME: it would be nice to test that the view is re-rendered correctly,
// but as the relational data isn't re-fetched, the rendering is ok even
@@ -2451,7 +2456,7 @@ QUnit.module('fields', {}, function () {
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
testUtils.dom.click(form.$el);
- assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 5',
+ assert.strictEqual(form.$('.o_field_widget[name=turtles] .o_cp_pager').text().trim(), '1-3 / 5',
"pager should display the correct total");
form.destroy();
});
@@ -2522,7 +2527,7 @@ QUnit.module('fields', {}, function () {
assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'new name',
'the remaining subrecord should be "new name"');
- testUtils.form.clickSave(form);; // save the record
+ testUtils.form.clickSave(form); // save the record
assert.strictEqual(nbWrite, 1, "should have write the changes in DB");
form.destroy();
@@ -2622,7 +2627,7 @@ QUnit.module('fields', {}, function () {
// enter another value and save
testUtils.fields.editInput(form.$('.o_selected_row > td input'), 'gemuse');
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.containsN(form, 'tr.o_data_row', 2, "should have 2 data rows");
assert.strictEqual(form.$('td:contains(kartoffel)').length, 1,
"should have one td with the new value");
@@ -2661,7 +2666,7 @@ QUnit.module('fields', {}, function () {
"should have 3 data rows");
// cancel the edition
- testUtils.form.clickDiscard(form);;
+ testUtils.form.clickDiscard(form);
testUtils.dom.click($('.modal-footer button.btn-primary').first());
assert.containsOnce(form, 'tr.o_data_row',
"should have 1 data rows");
@@ -2754,7 +2759,7 @@ QUnit.module('fields', {}, function () {
// click on Add an item again, then click on save
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.containsNone(form, 'tr.o_data_row',
"should still have 0 data rows");
@@ -2785,13 +2790,13 @@ QUnit.module('fields', {}, function () {
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
// the record currently being added should not count in the pager
- assert.ok(!form.$('.o_cp_pager').is(':visible'),
- "pager should not be visible");
+ assert.isNotVisible(form.$('.o_field_widget[name=turtles] .o_cp_pager'));
+
form.destroy();
});
QUnit.test('editable one2many list, adding line, then discarding', function (assert) {
- assert.expect(1);
+ assert.expect(2);
this.data.turtle.records.push({ id: 4, turtle_foo: 'stephen hawking' });
this.data.partner.records[0].turtles = [1, 2, 3, 4];
@@ -2813,13 +2818,15 @@ QUnit.module('fields', {}, function () {
// add a record, then discard
testUtils.form.clickEdit(form);
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
- testUtils.form.clickDiscard(form);;
+ testUtils.form.clickDiscard(form);
// confirm the discard operation
testUtils.dom.click($('.modal .modal-footer .btn-primary'));
- assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 4',
- "pager should still be visible");
+ assert.isVisible(form.$('.o_field_widget[name=turtles] .o_cp_pager'));
+ assert.strictEqual(form.$('.o_field_widget[name=turtles] .o_cp_pager').text().trim(), '1-3 / 4',
+ "pager should display correct values");
+
form.destroy();
});
@@ -2849,9 +2856,8 @@ QUnit.module('fields', {}, function () {
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
// go on next page. The new record is not valid and should be discarded
- testUtils.dom.click(form.$('.o_pager_next'));
- assert.containsOnce(form, 'tr.o_data_row',
- "should have 1 data rows");
+ testUtils.dom.click(form.$('.o_field_widget[name=turtles] .o_pager_next'));
+ assert.containsOnce(form, 'tr.o_data_row');
form.destroy();
});
@@ -2885,15 +2891,15 @@ QUnit.module('fields', {}, function () {
// go to next page. The new record is not valid, but dirty. we should
// see a confirm dialog
- testUtils.dom.click(form.$('.o_pager_next'));
+ testUtils.dom.click(form.$('.o_field_widget[name=turtles] .o_pager_next'));
- assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 4',
+ assert.strictEqual(form.$('.o_field_widget[name=turtles] .o_cp_pager').text().trim(), '1-3 / 4',
"pager should still display the correct total");
// click on cancel
testUtils.dom.click($('.modal .modal-footer .btn-secondary'));
- assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 4',
+ assert.strictEqual(form.$('.o_field_widget[name=turtles] .o_cp_pager').text().trim(), '1-3 / 4',
"pager should again display the correct total");
assert.containsOnce(form, '.o_field_one2many input.o_field_invalid',
"there should be an invalid input in the one2many");
@@ -2927,16 +2933,14 @@ QUnit.module('fields', {}, function () {
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
// go on next page
- testUtils.dom.click(form.$('.o_pager_next'));
+ testUtils.dom.click(form.$('.o_field_widget[name=turtles] .o_pager_next'));
// discard
- testUtils.form.clickDiscard(form);;
+ testUtils.form.clickDiscard(form);
testUtils.dom.click($('.modal .modal-footer .btn-primary'));
- assert.containsOnce(form, 'tr.o_data_row',
- "should have 1 data row");
- assert.ok(!form.$('.o_cp_pager').is(':visible'),
- "pager should not be visible");
+ assert.containsOnce(form, 'tr.o_data_row');
+ assert.isNotVisible(form.$('.o_field_widget[name=turtles] .o_cp_pager'));
form.destroy();
});
@@ -3079,7 +3083,7 @@ QUnit.module('fields', {}, function () {
"onchange should have been triggered");
// save and check everything is fine
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.strictEqual(form.$('.o_data_row td:contains(aubergine)').length, 1,
"should have one row with turtle_foo value");
assert.strictEqual(form.$('.o_data_row td:contains(9)').length, 1,
@@ -3284,7 +3288,7 @@ QUnit.module('fields', {}, function () {
testUtils.form.clickEdit(form);
testUtils.dom.click(form.$('.o_field_one2many tbody td').first());
testUtils.fields.editInput(form.$('.o_field_one2many tbody td').first().find('input'), "new value");
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'from onchange',
"display name of first record in o2m list should be 'new value'");
@@ -3430,7 +3434,7 @@ QUnit.module('fields', {}, function () {
testUtils.dom.click($('.bootstrap-datetimepicker-widget .month').eq(1));
testUtils.dom.click($('.day:contains(22)'));
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.verifySteps(['read', 'read', 'onchange', 'write', 'read', 'read']);
form.destroy();
@@ -3501,7 +3505,7 @@ QUnit.module('fields', {}, function () {
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
testUtils.fields.editInput(form.$('.o_field_widget[name=display_name]'), 'z');
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -3566,7 +3570,7 @@ QUnit.module('fields', {}, function () {
testUtils.dom.click(form.$('.o_field_one2many .o_list_view tbody tr:first td:first'));
testUtils.fields.editInput(form.$('.o_field_one2many .o_list_view tbody tr:first input:first'), 'blurp');
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -3664,7 +3668,7 @@ QUnit.module('fields', {}, function () {
assert.containsN(form, '.o_field_widget', delta,
"all (non visible) field widgets should have been destroyed");
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.strictEqual(form.$('.o_data_row').text(), "first20second-20",
"should correctly refresh the records after save");
@@ -3958,7 +3962,7 @@ QUnit.module('fields', {}, function () {
"should display 'xpad' in a td");
// save the record
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -4186,7 +4190,7 @@ QUnit.module('fields', {}, function () {
"should not have unselected the row after edition");
testUtils.fields.editInput(form.$('td input.o_field_widget'), 'abc');
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.strictEqual(form.$('td:contains(abc)').length, 1,
"should have a row with the correct value");
@@ -4531,7 +4535,7 @@ QUnit.module('fields', {}, function () {
});
testUtils.dom.click(form.$('td.o_field_x2many_list_row_add a'));
testUtils.fields.editInput(form.$('td input[name="turtle_foo"]'), 'cat');
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -4768,7 +4772,7 @@ QUnit.module('fields', {}, function () {
testUtils.dom.click(form.$('.o_field_one2many .o_list_view tbody tr:eq(0) td:first'));
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.strictEqual(nbFetchSpecialDomain, 1,
"should only fetch special domain once");
form.destroy();
@@ -5068,7 +5072,7 @@ QUnit.module('fields', {}, function () {
assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "firstxenomorphe second record new linexphone first record ",
'should display the name, one2many and many2many value');
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "firstxenomorphe second record new linexphone first record ",
'should display the name, one2many and many2many value after save');
@@ -5263,7 +5267,7 @@ QUnit.module('fields', {}, function () {
// confirm the changes in the modal
testUtils.dom.click($('.modal .modal-footer .btn-primary'));
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -5401,7 +5405,7 @@ QUnit.module('fields', {}, function () {
'My little Foo Value', "should have a value for the foo field");
// save the view to force a create of the new record in the one2many
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.containsOnce(form, '.o_field_widget .o_kanban_view',
"should have one inner kanban view for the one2many field");
assert.strictEqual(form.$('.o_field_widget .o_kanban_view .o_kanban_record:not(.o_kanban_ghost)').length, 1,
@@ -5489,7 +5493,7 @@ QUnit.module('fields', {}, function () {
assert.hasClass(form.$('tr.o_data_row:first'),'o_selected_row',
"first row should be selected");
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -5536,7 +5540,7 @@ QUnit.module('fields', {}, function () {
assert.hasClass(form.$('tr.o_data_row:eq(1)'),'o_selected_row',
"second row should be selected");
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -6049,7 +6053,7 @@ QUnit.module('fields', {}, function () {
testUtils.dom.click(form.$('.o_data_cell:first'));
testUtils.fields.editInput(form.$('.o_field_widget[name=display_name]'), 'abc');
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -6254,16 +6258,13 @@ QUnit.module('fields', {}, function () {
testUtils.form.clickEdit(form);
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
- assert.containsN(form, 'tr.o_data_row', 41,
- "should have 41 data rows on the current page");
- assert.hasClass(form.$('tr.o_data_row').last(),'o_selected_row',
- "last row should be selected");
+ assert.containsN(form, 'tr.o_data_row', 41);
+ assert.hasClass(form.$('tr.o_data_row').last(), 'o_selected_row');
testUtils.fields.editInput(form.$('.o_data_row input[name="turtle_foo"]'), 'rainbow dash');
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
- assert.containsN(form, 'tr.o_data_row', 40,
- "should have 40 data rows on the current page");
+ assert.containsN(form, 'tr.o_data_row', 40);
assert.verifySteps(['read', 'read', 'default_get', 'write', 'read', 'read']);
form.destroy();
@@ -6342,27 +6343,23 @@ QUnit.module('fields', {}, function () {
testUtils.form.clickEdit(form);
- testUtils.dom.click(form.$('.o_pager_next'));
+ testUtils.dom.click(form.$('.o_field_widget[name=turtles] .o_pager_next'));
- assert.containsN(form, 'tr.o_data_row', 2,
- "should have 2 data rows on the current page");
+ assert.containsN(form, 'tr.o_data_row', 2);
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
- assert.containsN(form, 'tr.o_data_row', 3,
- "should have 3 data rows on the current page (2 records and the created line)");
+ assert.containsN(form, 'tr.o_data_row', 3);
- assert.hasClass(form.$('tr.o_data_row').first(),'o_selected_row',
- "first row should be selected");
+ assert.hasClass(form.$('tr.o_data_row').first(), 'o_selected_row');
assert.strictEqual(form.$('tr.o_data_row input').val(), 'default foo',
"selected input should have correct string");
testUtils.fields.editInput(form.$('.o_data_row input[name="turtle_foo"]'), 'rainbow dash');
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
- assert.containsN(form, 'tr.o_data_row', 40,
- "should have 40 data rows on the current page");
+ assert.containsN(form, 'tr.o_data_row', 40);
assert.verifySteps(['read', 'read', 'read', 'default_get', 'write', 'read', 'read']);
form.destroy();
@@ -6571,7 +6568,7 @@ QUnit.module('fields', {}, function () {
assert.strictEqual(form.$('.o_data_cell').text(), '1 record',
"should correctly display the value of the inner o2m");
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -6634,7 +6631,7 @@ QUnit.module('fields', {}, function () {
// we make sure here that when we save, the values are the current
// values displayed in the field.
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -6664,7 +6661,7 @@ QUnit.module('fields', {}, function () {
});
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
testUtils.fields.editInput(form.$('input[name="turtle_foo"]'), 'pinky pie');
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -6730,7 +6727,7 @@ QUnit.module('fields', {}, function () {
"should have added the new row to the nested one2many");
testUtils.dom.clickFirst($('.modal .modal-footer .btn-primary'));
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
form.destroy();
});
@@ -6857,7 +6854,7 @@ QUnit.module('fields', {}, function () {
var inputText = 'ninja';
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
testUtils.fields.editInput(form.$('.o_input[name="turtle_foo"]'), inputText);
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.strictEqual($('.o_data_cell').text(), "blipyopkawa" + inputText);
form.destroy();
@@ -6898,7 +6895,7 @@ QUnit.module('fields', {}, function () {
var inputText = 'ninja';
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
testUtils.fields.editInput(form.$('.o_input[name="turtle_foo"]'), inputText);
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.strictEqual($('.o_data_cell').text(), inputText + "blipyopkawa");
form.destroy();
@@ -7187,7 +7184,7 @@ QUnit.module('fields', {}, function () {
});
assert.strictEqual(form.$('.o_data_row').text(), 'yopblip',
'data has been properly loaded');
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.strictEqual(form.$('.o_data_row').text(), 'yopblip',
'data has been properly saved');
@@ -7414,7 +7411,7 @@ QUnit.module('fields', {}, function () {
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a:eq(1)'));
// click add pasta
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a:eq(2)'));
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.strictEqual($('.o_data_cell').text(), "pizzapasta");
form.destroy();
@@ -7638,7 +7635,7 @@ QUnit.module('fields', {}, function () {
// open first partner so changes from previous action are applied
testUtils.dom.click(form.$('.o_data_row:eq(0)'));
testUtils.dom.click($('.modal .btn-primary'));
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.strictEqual(numUserOnchange, 2,
'there should 2 and only 2 onchange from closing the partner modal');
@@ -7796,7 +7793,7 @@ QUnit.module('fields', {}, function () {
form.$('.o_input[name="turtle_foo"]'),
'ninja',
['input', $.Event('keydown', {which: $.ui.keyCode.TAB, keyCode: $.ui.keyCode.TAB,})]
- );;
+ );
assert.strictEqual(form.$('.o_field_text').text(), 'blipninja',
'current line should be saved');
@@ -7900,11 +7897,11 @@ QUnit.module('fields', {}, function () {
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
- testUtils.fields.editInput(form.$('input'), 'some foo value');
+ testUtils.fields.editInput(form.$('.o_field_widget[name=turtles] input'), 'some foo value');
// click to add a second row to unselect the current one, then save
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
assert.containsOnce(form, '.o_form_readonly',
"form view should be in readonly");
@@ -7977,7 +7974,7 @@ QUnit.module('fields', {}, function () {
'The display name field should have the right value');
// Save the whole thing
- testUtils.form.clickSave(form);;
+ testUtils.form.clickSave(form);
x2mList = form.$('.o_field_x2many_list[name=p]');
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index ab7a603fb80..6c3493828ad 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -615,7 +615,7 @@ QUnit.module('relational_fields', {
list.destroy();
});
- QUnit.test('widget selection, edition and on many2one field', function (assert) {
+ QUnit.test('widget selection, edition and on many2one field', function (assert) {
assert.expect(18);
this.data.partner.onchanges = {product_id: function () {}};
@@ -640,7 +640,7 @@ QUnit.module('relational_fields', {
},
});
- assert.ok(!form.$('select').length, "should not have a select tag in dom");
+ assert.containsNone(form.$('.o_form_view'), 'select');
assert.strictEqual(form.$('.o_field_widget[name=product_id]').text(), 'xphone',
"should have rendered the many2one field correctly");
assert.strictEqual(form.$('.o_field_widget[name=trululu]').text(), '',
@@ -650,11 +650,10 @@ QUnit.module('relational_fields', {
testUtils.form.clickEdit(form);
- assert.containsN(form, 'select', 3,
- "should have 3 select tag in dom");
- assert.strictEqual(form.$('select[name="product_id"] option:contains(xphone)').length, 1,
+ assert.containsN(form.$('.o_form_view'), 'select', 3);
+ assert.containsOnce(form, 'select[name="product_id"] option:contains(xphone)',
"should have fetched xphone option");
- assert.strictEqual(form.$('select[name="product_id"] option:contains(xpad)').length, 1,
+ assert.containsOnce(form, 'select[name="product_id"] option:contains(xpad)',
"should have fetched xpad option");
assert.strictEqual(form.$('select[name="product_id"]').val(), "37",
"should have correct product_id value");
@@ -757,7 +756,7 @@ QUnit.module('relational_fields', {
},
});
- testUtils.fields.editSelect(form.$('select'), "false");
+ testUtils.fields.editSelect(form.$('.o_form_view select'), 'false');
testUtils.form.clickSave(form);
form.destroy();
@@ -1297,11 +1296,11 @@ QUnit.module('relational_fields', {
// update foo, which will trigger an onchange and update timmy
// -> m2mtags input should not have taken the focus
- form.$('input:first').focus();
- testUtils.fields.editInput(form.$('input:first'), 'trigger onchange');
+ form.$('input[name=foo]').focus();
+ testUtils.fields.editInput(form.$('input[name=foo]'), 'trigger onchange');
assert.containsNone(form, '.o_field_many2manytags .badge',
"should contain no tags");
- assert.strictEqual(form.$('input:first').get(0), document.activeElement,
+ assert.strictEqual(form.$('input[name=foo]').get(0), document.activeElement,
"foo input should have kept the focus");
// add a tag -> m2mtags input should still have the focus
@@ -1864,7 +1863,6 @@ QUnit.module('relational_fields', {
QUnit.test('name_create in form dialog', async function (assert) {
assert.expect(2);
-
var form = createView({
View: FormView,
model: 'partner',
@@ -1891,7 +1889,7 @@ QUnit.module('relational_fields', {
testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
await testUtils.fields.many2one.searchAndClickItem('product_id',
- {selector:'.modal', search:'new record'});
+ {selector: '.modal', search: 'new record'});
assert.verifySteps(['name_create']);
@@ -1922,7 +1920,7 @@ QUnit.module('relational_fields', {
res_id: 1,
});
- // Cuurent Form
+ // Current Form
assert.equal(form.$('.o_form_uri.o_field_widget[name=reference]').text(), 'xphone',
'the field reference of the form should have the right value');
diff --git a/addons/web/static/tests/fields/special_fields.js b/addons/web/static/tests/fields/special_fields.js
index 8654c3e0a9f..6acc2be521b 100644
--- a/addons/web/static/tests/fields/special_fields.js
+++ b/addons/web/static/tests/fields/special_fields.js
@@ -209,7 +209,7 @@ QUnit.module('special_fields', {
'</form>',
});
testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('select').length, 1, "should have the select field");
+ assert.containsOnce(form, 'select[name=tz]');
form.destroy();
});
diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js
index 759962e444c..2897b02214c 100644
--- a/addons/web/static/tests/helpers/test_utils.js
+++ b/addons/web/static/tests/helpers/test_utils.js
@@ -24,11 +24,12 @@ var testUtilsMock = require('web.test_utils_mock');
var testUtilsModal = require('web.test_utils_modal');
var testUtilsPivot = require('web.test_utils_pivot');
var tools = require('web.tools');
+
function deprecated(fn, type) {
var msg = `Helper 'testUtils.${fn.name}' is deprecated. ` +
`Please use 'testUtils.${type}.${fn.name}' instead.`;
return tools.deprecated(fn, msg);
-};
+}
// Loading static files cannot be properly simulated when their real content is
// really needed. This is the case for static XML files so we load them here,
@@ -108,6 +109,7 @@ return $.when(
createActionManager: testUtilsCreate.createActionManager,
createDebugManager: testUtilsCreate.createDebugManager,
createAsyncView: testUtilsCreate.createAsyncView,
+ createControlPanel: testUtilsCreate.createControlPanel,
createView: testUtilsCreate.createView,
createModel: testUtilsCreate.createModel,
createParent: testUtilsCreate.createParent,
diff --git a/addons/web/static/tests/helpers/test_utils_create.js b/addons/web/static/tests/helpers/test_utils_create.js
index d4d4e2a6b55..0adb13d466f 100644
--- a/addons/web/static/tests/helpers/test_utils_create.js
+++ b/addons/web/static/tests/helpers/test_utils_create.js
@@ -12,9 +12,9 @@ odoo.define('web.test_utils_create', function (require) {
var ActionManager = require('web.ActionManager');
var config = require('web.config');
-var ControlPanel = require('web.ControlPanel');
-var dom = require('web.dom');
+var ControlPanelView = require('web.ControlPanelView');
var DebugManager = require('web.DebugManager');
+var dom = require('web.dom');
var testUtilsMock = require('web.test_utils_mock');
var Widget = require('web.Widget');
@@ -70,8 +70,13 @@ function createActionManager (params) {
actionManager.destroy = originalDestroy;
widget.destroy();
};
- actionManager.appendTo(widget.$el);
-
+ var fragment = document.createDocumentFragment();
+ actionManager.appendTo(fragment).then(function () {
+ dom.append(widget.$el, fragment, {
+ callbacks: [{ widget: actionManager }],
+ in_DOM: true,
+ });
+ });
return actionManager;
}
@@ -113,32 +118,46 @@ function createAsyncView(params) {
$target = $('body');
$target.addClass('debug');
}
+ // reproduce the DOM environment of views
+ var $webClient = $('<div>').addClass('o_web_client').prependTo($target);
+ var $actionManager = $('<div>').addClass('o_action_manager').appendTo($webClient);
+
// add mock environment: mock server, session, fieldviewget, ...
var mockServer = testUtilsMock.addMockEnvironment(widget, params);
var viewInfo = testUtilsMock.fieldsViewGet(mockServer, params);
+
// create the view
- var viewOptions = {
+ var View = params.View;
+ var viewOptions = _.defaults({}, params.viewOptions, {
modelName: params.model || 'foo',
ids: 'res_id' in params ? [params.res_id] : undefined,
currentId: 'res_id' in params ? params.res_id : undefined,
domain: params.domain || [],
context: params.context || {},
- groupBy: params.groupBy || [],
- };
+ });
+ // patch the View to handle the groupBy given in params, as we can't give it
+ // in init (unlike the domain and context which can be set in the action)
+ testUtilsMock.patch(View, {
+ _updateMVCParams: function () {
+ this._super.apply(this, arguments);
+ this.loadParams.groupedBy = params.groupBy || viewOptions.groupBy || [];
+ },
+ });
if (params.hasSelectors) {
viewOptions.hasSelectors = params.hasSelectors;
}
- _.extend(viewOptions, params.viewOptions);
-
- var view = new params.View(viewInfo, viewOptions);
-
- // reproduce the DOM environment of views
- var $web_client = $('<div>').addClass('o_web_client').prependTo($target);
- var controlPanel = new ControlPanel(widget);
- controlPanel.appendTo($web_client);
- var $content = $('<div>').addClass('o_content').appendTo($web_client);
+ var view;
+ if (viewInfo.type === 'controlpanel' || viewInfo.type === 'search') {
+ // TODO: probably needs to create an helper just for that
+ view = new params.View({
+ viewInfo: viewInfo,
+ modelName: params.model || 'foo',
+ });
+ } else {
+ view = new params.View(viewInfo, viewOptions);
+ }
if (params.interceptsPropagate) {
_.each(params.interceptsPropagate, function (cb, name) {
@@ -155,16 +174,15 @@ function createAsyncView(params) {
// when it will be called the second time (by its parent)
delete view.destroy;
widget.destroy();
- };
- // link the view to the control panel
- view.set_cp_bus(controlPanel.get_bus());
+ testUtilsMock.unpatch(View);
+ };
// render the view in a fragment as they must be able to render correctly
// without being in the DOM
var fragment = document.createDocumentFragment();
return view.appendTo(fragment).then(function () {
- dom.append($content, fragment, {
+ dom.prepend($actionManager, fragment, {
callbacks: [{ widget: view }],
in_DOM: true,
});
@@ -176,7 +194,95 @@ function createAsyncView(params) {
});
});
}
+/**
+ * create a controlPanel from various parameters.
+ *
+ * It returns an instance of ControlPanelController
+ *
+ * @param {Object} [params={}]
+ * @param {Object} [params.action={}]
+ * @param {Object} [params.context={}]
+ * @param {Object} [params.debug=false] if true, the widget will be appended in
+ * the DOM. Also, RPCs and uncaught OdooEvent will be logged
+ * @param {string} [params.domain=[]]
+ * @param {integer} [params.fieldDebounce=0] the debounce value to use for the
+ * duration of the test.
+ * @param {Object} params.intercepts an object with event names as key, and
+ * callback as value. Each key,value will be used to intercept the event.
+ * Note that this is particularly useful if you want to intercept events going
+ * up in the init process of the view, because there are no other way to do it
+ * after this method returns
+ * @param {string} [params.modelName]
+ * @param {string[]} [params.searchMenuTypes = ['filter', 'groupBy', 'favorite']]
+ * determines search menus displayed.
+ * @param {string} [params.template] the QWeb template to render
+ * @param {Object} [params.viewInfo={arch: '<controlpanel/>', fields: {}}]
+ a controlpanel (or search) fieldsview
+ * @param {string} [params.viewInfo.arch]
+ * @param {boolean} [params.context.no_breadcrumbs=false] if set to true,
+ * breadcrumbs won't be rendered
+ * @param {boolean} [params.withBreadcrumbs=true] if set to false,
+ * breadcrumbs won't be rendered
+ * @param {boolean} [params.withSearchBar=true] if set to false, no default
+ * search bar will be rendered
+ *
+ * @returns {Deferred<ControlPanel>} resolves with an instance of the ControlPanelController
+ */
+function createControlPanel(params) {
+ params = params || {};
+ var $target = $('#qunit-fixture');
+ if (params.debug) {
+ $target = $('body');
+ $target.addClass('debug');
+ }
+ // reproduce the DOM environment of a view control panel
+ var $webClient = $('<div>').addClass('o_web_client').prependTo($target);
+ var $actionManager = $('<div>').addClass('o_action_manager').appendTo($webClient);
+ var $action = $('<div>').addClass('o_action').appendTo($actionManager);
+
+ var widget = new Widget();
+ // add mock environment: mock server, session, fieldviewget, ...
+ var mockServer = testUtilsMock.addMockEnvironment(widget, params);
+ if (!params.viewInfo) {
+ try {
+ params.viewInfo = testUtilsMock.fieldsViewGet(mockServer, params);
+ } catch (e) {
+ // if an error occurs we keep params.viewInfo undefined.
+ // It will be set to {arch: '<controlpanel/>', fields: {}} in
+ // ControlPanelView init function.
+ }
+ }
+
+ var viewOptions = _.defaults({}, params.viewOptions, {
+ modelName: params.model,
+ searchMenuTypes: params.searchMenuTypes,
+ viewInfo: params.viewInfo,
+ });
+ var controlPanelView = new ControlPanelView(viewOptions);
+ return controlPanelView.getController(widget).then(function (controlPanel) {
+ // override the controlPanel's 'destroy' so that it calls 'destroy' on
+ // the widget instead, as the widget is the parent of the controlPanel
+ // and the mockServer.
+ controlPanel.__destroy = controlPanel.destroy;
+ controlPanel.destroy = function () {
+ // remove the override to properly destroy the controlPanel and its
+ // children when it will be called the second time (by its parent)
+ delete controlPanel.destroy;
+ widget.destroy();
+ };
+ // render the controlPanel in a fragment as it must be able to render
+ // correctly without being in the DOM
+ var fragment = document.createDocumentFragment();
+ return controlPanel.appendTo(fragment).then(function () {
+ dom.prepend($action, fragment, {
+ callbacks: [{ widget: controlPanel }],
+ in_DOM: true,
+ });
+ return controlPanel;
+ });
+ });
+}
/**
* Create and return an instance of DebugManager with all rpcs going through a
* mock method, assuming that the user has access rights, and is an admin.
@@ -279,6 +385,7 @@ function createView(params) {
return {
createActionManager: createActionManager,
createAsyncView: createAsyncView,
+ createControlPanel: createControlPanel,
createDebugManager: createDebugManager,
createModel: createModel,
createParent: createParent,
diff --git a/addons/web/static/tests/tools/debug_manager_tests.js b/addons/web/static/tests/tools/debug_manager_tests.js
index 6929d55cce5..fde90105bfb 100644
--- a/addons/web/static/tests/tools/debug_manager_tests.js
+++ b/addons/web/static/tests/tools/debug_manager_tests.js
@@ -109,7 +109,7 @@ QUnit.module('DebugManager', {}, function () {
'ir.default': { // We just need this to be defined
fields: {},
},
- }
+ };
var form = testUtils.createView({
View: FormView,
@@ -138,6 +138,7 @@ QUnit.module('DebugManager', {}, function () {
// Simulate update debug manager from web client
var action = {
+ controlPanelFieldsView: {},
views: [{
fieldsView: {
view_id: 1,
diff --git a/addons/web/static/tests/views/abstract_view_tests.js b/addons/web/static/tests/views/abstract_view_tests.js
index 01f1daafeb4..3d4cc423ddd 100644
--- a/addons/web/static/tests/views/abstract_view_tests.js
+++ b/addons/web/static/tests/views/abstract_view_tests.js
@@ -3,9 +3,9 @@ odoo.define('web.abstract_view_tests', function (require) {
var AbstractView = require('web.AbstractView');
var ajax = require('web.ajax');
-var ListView = require('web.ListView');
var testUtils = require('web.test_utils');
+var createActionManager = testUtils.createActionManager;
var createAsyncView = testUtils.createAsyncView;
QUnit.module('Views', {
@@ -112,22 +112,36 @@ QUnit.module('Views', {
defs.c.resolve();
});
- QUnit.test('groupBy attribute can be a string, instead of a list of strings', function (assert) {
- assert.expect(2);
+ QUnit.test('group_by from context can be a string, instead of a list of strings', function (assert) {
+ assert.expect(1);
- var list = testUtils.createView({
- View: ListView,
- model: 'foo',
+ var actionManager = createActionManager({
+ actions: [{
+ id: 1,
+ name: 'Foo',
+ res_model: 'foo',
+ type: 'ir.actions.act_window',
+ views: [[false, 'list']],
+ context: {
+ group_by: 'bar',
+ },
+ }],
+ archs: {
+ 'foo,false,list': '<tree><field name="foo"/><field name="bar"/></tree>',
+ 'foo,false,search': '<search></search>',
+ },
data: this.data,
- arch: '<tree><field name="foo"/><field name="bar"/></tree>',
- groupBy: 'bar',
- mockRPC: function (route, args) {
- assert.strictEqual(args.method, 'read_group');
- assert.deepEqual(args.kwargs.groupby, ['bar']);
+ mockRPC: function(route, args) {
+ if (args.method === 'read_group') {
+ assert.deepEqual(args.kwargs.groupby, ['bar']);
+ }
return this._super.apply(this, arguments);
},
});
- list.destroy();
+
+ actionManager.doAction(1);
+
+ actionManager.destroy();
});
});
diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js
index 9f421e9c6e9..3f4c6376d5b 100644
--- a/addons/web/static/tests/views/calendar_tests.js
+++ b/addons/web/static/tests/views/calendar_tests.js
@@ -253,17 +253,17 @@ QUnit.module('Views', {
actionManager.doAction(1);
// displays month mode by default
- assert.strictEqual(actionManager.controlPanel.$('.breadcrumb-item').text(),
+ assert.strictEqual($('.o_control_panel .breadcrumb-item').text(),
'Meetings Test (Dec 11 – 17, 2016)', "should display the current week");
// switch to day mode
- testUtils.dom.click(actionManager.controlPanel.$('.o_calendar_button_day'));
- assert.strictEqual(actionManager.controlPanel.$('.breadcrumb-item').text(),
+ testUtils.dom.click($('.o_control_panel .o_calendar_button_day'));
+ assert.strictEqual($('.o_control_panel .breadcrumb-item').text(),
'Meetings Test (December 12, 2016)', "should display the current day");
// switch to month mode
- testUtils.dom.click(actionManager.controlPanel.$('.o_calendar_button_month'));
- assert.strictEqual(actionManager.controlPanel.$('.breadcrumb-item').text(),
+ testUtils.dom.click($('.o_control_panel .o_calendar_button_month'));
+ assert.strictEqual($('.o_control_panel .breadcrumb-item').text(),
'Meetings Test (December 2016)', "should display the current month");
actionManager.destroy();
@@ -2058,7 +2058,9 @@ QUnit.module('Views', {
// call destroy function of controller to ensure that it correctly destroys everything
calendar.__destroy();
- assert.strictEqual(instanceNumber, initialInstanceNumber + 3, "every widget must be destroyed exept the parent");
+ // + 1 (parent)
+ assert.strictEqual(instanceNumber, initialInstanceNumber + 1,
+ "every widget must be destroyed exept the parent");
calendar.destroy();
@@ -2116,8 +2118,8 @@ QUnit.module('Views', {
testUtils.mock.unpatch(Dialog);
});
- QUnit.test('calendar is configured to hide the groupby menu', function (assert) {
- assert.expect(2);
+ QUnit.test('calendar is configured to have no groupBy menu', function (assert) {
+ assert.expect(1);
var archs = {
'event,1,calendar': '<calendar class="o_calendar_test" '+
@@ -2144,9 +2146,8 @@ QUnit.module('Views', {
});
actionManager.doAction(1);
- var $groupBy = actionManager.controlPanel.$('span.fa.fa-bars');
- assert.strictEqual($groupBy.length, 1, 'just making sure we have the groupby menu');
- assert.ok(!$groupBy.is(':visible'), 'groupby menu should not be visible');
+ assert.containsNone(actionManager.$('.o_control_panel .o_search_options span.fa.fa-bars'),
+ "the control panel has no groupBy menu");
actionManager.destroy();
});
diff --git a/addons/web/static/tests/views/control_panel_tests.js b/addons/web/static/tests/views/control_panel_tests.js
new file mode 100644
index 00000000000..513cdb32e08
--- /dev/null
+++ b/addons/web/static/tests/views/control_panel_tests.js
@@ -0,0 +1,334 @@
+odoo.define('web.control_panel_tests', function (require) {
+"use strict";
+
+var ControlPanelView = require('web.ControlPanelView');
+var testUtils = require('web.test_utils');
+
+var createControlPanel = testUtils.createControlPanel;
+
+function createControlPanelFactory(arch, fields, params) {
+ params = params || {};
+ arch = arch || "<search></search>";
+ fields = fields || {};
+ var viewInfo = {arch: arch, fields: fields};
+ var controlPanelFactory = new ControlPanelView({viewInfo: viewInfo, context: {}});
+ return controlPanelFactory;
+}
+
+QUnit.module('Views', {
+ beforeEach: function () {
+ this.data = {
+ partner: {
+ fields: {
+ display_name: { string: "Displayed name", type: 'char' },
+ foo: {string: "Foo", type: "char", default: "My little Foo Value", store: true, sortable: true},
+ date_field: {string: "Date", type: "date", store: true, sortable: true},
+ float_field: {string: "Float", type: "float"},
+ bar: {string: "Bar", type: "many2one", relation: 'partner'},
+ },
+ records: [],
+ onchanges: {},
+ },
+ };
+ }
+}, function () {
+ QUnit.module('ControlPanelView');
+
+ QUnit.module('Control Panel Arch Parsing');
+
+ QUnit.test('empty arch', function (assert) {
+ assert.expect(1);
+
+ var controlPanelFactory = createControlPanelFactory();
+ assert.deepEqual(
+ controlPanelFactory.loadParams.groups,
+ [],
+ "there should be no group at all"
+ );
+ });
+
+ QUnit.test('parse one field tag', function (assert) {
+ assert.expect(1);
+ var arch = "<search>" +
+ "<field name=\"bar\"/>" +
+ "</search>";
+ var fields = this.data.partner.fields;
+ var controlPanelFactory = createControlPanelFactory(arch, fields);
+ assert.deepEqual(
+ controlPanelFactory.loadParams.groups,
+ [[{
+ attrs: {
+ name: "bar",
+ string: "Bar"
+ },
+ autoCompleteValues: [],
+ description: "bar",
+ isDefault: false,
+ type: "field"
+ }]],
+ "there should be one group with one field"
+ );
+ });
+
+ QUnit.test('parse one separator tag', function (assert) {
+ assert.expect(1);
+ var arch = "<search>" +
+ "<separator/>" +
+ "</search>";
+ var fields = this.data.partner.fields;
+ var controlPanelFactory = createControlPanelFactory(arch, fields);
+ assert.deepEqual(
+ controlPanelFactory.loadParams.groups,
+ [],
+ "there should be no group at all");
+ });
+
+ QUnit.test('parse one separator tag and one field tag', function (assert) {
+ assert.expect(1);
+ var arch = "<search>" +
+ "<separator/>" +
+ "<field name=\"bar\"/>" +
+ "</search>";
+ var fields = this.data.partner.fields;
+ var controlPanelFactory = createControlPanelFactory(arch, fields);
+ assert.deepEqual(
+ controlPanelFactory.loadParams.groups,
+ [[
+ {
+ attrs: {
+ name: "bar",
+ string: "Bar"
+ },
+ autoCompleteValues: [],
+ description: "bar",
+ isDefault: false,
+ type: "field"
+ }
+ ]],
+ "there should be one group with one field"
+ );
+ });
+
+ QUnit.test('parse one filter tag', function (assert) {
+ assert.expect(1);
+ var arch = "<search>" +
+ "<filter name=\"filter\" string=\"Hello\" " +
+ "domain=\"[]\"/>" +
+ "</search>";
+ var fields = this.data.partner.fields;
+ var controlPanelFactory = createControlPanelFactory(arch, fields);
+ assert.deepEqual(
+ controlPanelFactory.loadParams.groups,
+ [[{
+ description: "Hello",
+ groupNumber: 2,
+ domain: "[]",
+ isDefault: false,
+ type: "filter"
+ }]],
+ "there should be one group with one filter"
+ );
+ });
+
+ QUnit.test('parse one groupBy tag', function (assert) {
+ assert.expect(1);
+ var arch = "<search>" +
+ "<groupBy name=\"groupby\" string=\"Hi\" " +
+ "context=\"{\'group_by\': \'date_field:day\'}\"/>" +
+ "</search>";
+ var fields = this.data.partner.fields;
+ var controlPanelFactory = createControlPanelFactory(arch, fields);
+ assert.deepEqual(
+ controlPanelFactory.loadParams.groups,
+ [[
+ {
+ currentOptionId: false,
+ defaultOptionId: "day",
+ description: "Hi",
+ fieldName: "date_field",
+ fieldType: "date",
+ groupNumber: 2,
+ hasOptions: true,
+ isDefault: false,
+ options: [
+ {
+ description: "Day",
+ groupId: 1,
+ optionId: "day"
+ },
+ {
+ description: "Week",
+ groupId: 1,
+ optionId: "week"
+ },
+ {
+ description: "Month",
+ groupId: 1,
+ optionId: "month"
+ },
+ {
+ description: "Quarter",
+ groupId: 1,
+ optionId: "quarter"
+ },
+ {
+ description: "Year",
+ groupId: 1,
+ optionId: "year"
+ }
+ ],
+ type: "groupBy"
+ }
+ ]],
+ "there should be one group with one groupBy with options"
+ );
+ });
+
+ QUnit.test('parse two filter tags', function (assert) {
+ assert.expect(1);
+ var arch = "<search>" +
+ "<filter name=\"filter_1\" string=\"Hello One\" " +
+ "domain=\"[]\"/>" +
+ "<filter name=\"filter_2\" string=\"Hello Two\" " +
+ "domain=\"[(\'bar\', \'=\', 3)]\"/>" +
+ "</search>";
+ var fields = this.data.partner.fields;
+ var controlPanelFactory = createControlPanelFactory(arch, fields);
+ assert.deepEqual(
+ controlPanelFactory.loadParams.groups,
+ [[
+ {
+ "description": "Hello One",
+ "domain": "[]",
+ "groupNumber": 2,
+ "isDefault": false,
+ "type": "filter"
+ },
+ {
+ "description": "Hello Two",
+ "domain": "[('bar', '=', 3)]",
+ "groupNumber": 2,
+ "isDefault": false,
+ "type": "filter"
+ }
+ ]],
+ 'there should be one group of two filters'
+ );
+ });
+
+ QUnit.test('parse two filter tags separated by a separator', function (assert) {
+ assert.expect(1);
+ var arch = "<search>" +
+ "<filter name=\"filter_1\" string=\"Hello One\" " +
+ "domain=\"[]\"/>" +
+ "<separator/>" +
+ "<filter name=\"filter_2\" string=\"Hello Two\" " +
+ "domain=\"[(\'bar\', \'=\', 3)]\"/>" +
+ "</search>";
+
+ var fields = this.data.partner.fields;
+ var controlPanelFactory = createControlPanelFactory(arch, fields);
+ assert.deepEqual(
+ controlPanelFactory.loadParams.groups,
+ [
+ [
+ {
+ description: "Hello One",
+ domain: "[]",
+ groupNumber: 2,
+ isDefault: false,
+ type: "filter"
+ }
+ ],
+ [
+ {
+ description: "Hello Two",
+ domain: "[('bar', '=', 3)]",
+ groupNumber: 4,
+ isDefault: false,
+ type: "filter"
+ }
+ ]
+ ],
+ "there should be two groups of one filter"
+ );
+ });
+
+ QUnit.test('parse one filter tag and one field', function (assert) {
+ assert.expect(1);
+ var arch = "<search>" +
+ "<filter name=\"filter\" string=\"Hello\" domain=\"[]\"/>" +
+ "<field name=\"bar\"/>" +
+ "</search>";
+ var fields = this.data.partner.fields;
+ var controlPanelFactory = createControlPanelFactory(arch, fields);
+ assert.deepEqual(
+ controlPanelFactory.loadParams.groups,
+ [
+ [
+ {
+ description: "Hello",
+ domain: "[]",
+ groupNumber: 2,
+ isDefault: false,
+ type: "filter"
+ }
+ ],
+ [
+ {
+ attrs: {
+ name: "bar",
+ string: "Bar"
+ },
+ autoCompleteValues: [],
+ description: "bar",
+ isDefault: false,
+ type: "field"
+ }
+ ]
+ ],
+ "there should be one group with a filter and one group with a field"
+ );
+ });
+
+ QUnit.test('parse two field tags', function (assert) {
+ assert.expect(1);
+ var arch = "<search>" +
+ "<field name=\"foo\"/>" +
+ "<field name=\"bar\"/>" +
+ "</search>";
+ var fields = this.data.partner.fields;
+ var controlPanelFactory = createControlPanelFactory(arch, fields);
+ assert.deepEqual(
+ controlPanelFactory.loadParams.groups,
+ [
+ [
+ {
+ attrs: {
+ name: "foo",
+ string: "Foo"
+ },
+ autoCompleteValues: [],
+ description: "foo",
+ isDefault: false,
+ type: "field"
+ }
+ ],
+ [
+ {
+ attrs: {
+ name: "bar",
+ string: "Bar"
+ },
+ autoCompleteValues: [],
+ description: "bar",
+ isDefault: false,
+ type: "field"
+ }
+ ]
+ ],
+ "there should be two groups of a single field"
+ );
+ });
+});
+});
diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js
index 0adafa2161a..a120a6a7547 100644
--- a/addons/web/static/tests/views/form_tests.js
+++ b/addons/web/static/tests/views/form_tests.js
@@ -159,8 +159,7 @@ QUnit.module('Views', {
assert.containsOnce(form, 'label:contains(f3_description)');
assert.containsOnce(form, 'div.o_field_one2many table');
assert.containsOnce(form, 'tbody td:not(.o_list_record_selector) .custom-checkbox input:checked');
- assert.strictEqual(form.get('title'), 'second record',
- "title should be display_name of record");
+ assert.containsOnce(form, '.o_control_panel .breadcrumb:contains(second record)');
assert.containsNone(form, 'label.o_form_label_empty:contains(timmy)');
form.destroy();
@@ -1510,8 +1509,8 @@ QUnit.module('Views', {
testUtils.form.clickCreate(form);
assert.strictEqual(form.mode, 'edit', 'form view should be in edit mode');
- assert.strictEqual(form.$('input:first').val(), "My little Foo Value",
- "should have correct default_get value");
+ assert.strictEqual(form.$('input[name=foo]').val(), "My little Foo Value",
+ "should have correct default_get value");
testUtils.form.clickSave(form);
assert.strictEqual(form.mode, 'readonly', 'form view should be in readonly mode');
assert.strictEqual(this.data.partner.records.length, n + 1, "should have created a record");
@@ -1668,9 +1667,9 @@ QUnit.module('Views', {
return this._super.apply(this, arguments);
},
}).then(function (form) {
- assert.ok(form.$('input').val(), 'aaa',
- 'default value should be correctly displayed');
- assert.strictEqual(nameGetCount, 1, 'should have done one name_get');
+ assert.strictEqual(form.$('.o_field_widget[name=trululu] input').val(), 'aaa',
+ "default value should be correctly displayed");
+ assert.strictEqual(nameGetCount, 1, "should have done one name_get");
form.destroy();
done();
});
@@ -1689,11 +1688,13 @@ QUnit.module('Views', {
res_id: 1,
});
- assert.strictEqual(form.get('title'), 'first record',
+ assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'first record',
"should have the display name of the record as title");
+
testUtils.form.clickCreate(form);
- assert.strictEqual(form.get('title'), _t("New"),
- "should have the display name of the record as title");
+ assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), _t("New"),
+ "should have the display name of the record as title");
+
form.destroy();
});
@@ -1717,13 +1718,13 @@ QUnit.module('Views', {
},
});
- assert.strictEqual(form.get('title'), 'first record',
+ assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'first record',
"should have the display name of the record as title");
testUtils.dom.click(form.sidebar.$('.o_dropdown_toggler_btn:contains(Action)'));
testUtils.dom.click(form.sidebar.$('a:contains(Duplicate)'));
- assert.strictEqual(form.get('title'), 'first record (copy)',
+ assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'first record (copy)',
"should have duplicated the record");
assert.strictEqual(form.mode, "edit", 'should be in edit mode');
@@ -1782,36 +1783,13 @@ QUnit.module('Views', {
},
});
- assert.strictEqual(form.get('title'), 'first record',
+ assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'first record',
"should have the display name of the record as title");
assert.containsNone(form.sidebar, 'a:contains(Duplicate)',
"should not contains a 'Duplicate' action");
form.destroy();
});
- QUnit.test('buttons in footer are moved to $buttons if necessary', function (assert) {
- // not sure about this test...
- assert.expect(2);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="foo"/>' +
- '<footer>' +
- '<button string="Create" type="object" class="infooter"/>' +
- '</footer>' +
- '</form>',
- res_id: 1,
- viewOptions: {footerToButtons: true},
- });
-
- assert.containsOnce(form.$buttons, 'button.infooter', "footer button should be in footer");
- assert.containsNone(form, 'button.infooter', "footer button should not be in form");
- form.destroy();
- });
-
QUnit.test('clicking on stat buttons in edit mode', function (assert) {
assert.expect(9);
@@ -1891,13 +1869,13 @@ QUnit.module('Views', {
},
});
- assert.strictEqual(form.getTitle(), 'second record',
+ assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'second record',
"should have correct display_name");
testUtils.form.clickEdit(form);
testUtils.fields.editInput(form.$('input[name=name]'), 'some other name');
testUtils.dom.click('.oe_stat_button');
- assert.strictEqual(form.getTitle(), 'GOLDORAK',
+ assert.strictEqual(form.$('.o_control_panel .breadcrumb').text(), 'GOLDORAK',
"should have correct display_name");
form.destroy();
@@ -1916,7 +1894,7 @@ QUnit.module('Views', {
arch: '<form string="Partners">' +
'<field name="foo"/>' +
'<button string="Do something" class="btn-primary" name="abc" type="object"/>' +
- '<button string="Discard" class="btn-secondary" special="cancel"/>' +
+ '<button string="Or discard" class="btn-secondary" special="cancel"/>' +
'</form>',
res_id: 1,
mockRPC: function (route, args) {
@@ -1935,13 +1913,13 @@ QUnit.module('Views', {
// make the record dirty
testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
- testUtils.dom.clickFirst(form.$('button'));
+ testUtils.dom.click(form.$('button:contains(Do something)'));
assert.strictEqual(writeCount, 1, "should have triggered a write");
assert.strictEqual(executeActionCount, 1, "should have triggered a execute action");
testUtils.fields.editInput(form.$('input[name=foo]'), 'abcdef');
- testUtils.dom.click(form.$('button').eq(1));
+ testUtils.dom.click(form.$('button:contains(Or discard)'));
assert.strictEqual(writeCount, 1, "should not have triggered a write");
assert.strictEqual(executeActionCount, 2, "should have triggered a execute action");
@@ -1975,7 +1953,7 @@ QUnit.module('Views', {
});
testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
- testUtils.dom.click(form.$('button'));
+ testUtils.dom.click(form.$('.o_form_view button'));
assert.verifySteps(['read', 'write', 'read', 'execute_action']);
form.destroy();
@@ -2124,14 +2102,16 @@ QUnit.module('Views', {
// switch to edit mode
testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('input').val(), 'yop', 'input should contain yop');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'yop',
+ "input should contain yop");
// click on discard
testUtils.form.clickDiscard(form);
- assert.containsNone(document.body, '.modal', 'no confirm modal should be displayed');
- assert.strictEqual(form.$('.o_field_widget').text(), 'yop', 'field in readonly should display yop');
+ assert.containsNone(document.body, '.modal', "no confirm modal should be displayed");
+ assert.strictEqual(form.$('.o_field_widget').text(), 'yop',
+ "field in readonly should display yop");
- assert.strictEqual(nbWrite, 0, 'no write RPC should have been done');
+ assert.strictEqual(nbWrite, 0, "no write RPC should have been done");
form.destroy();
});
@@ -2155,23 +2135,26 @@ QUnit.module('Views', {
// switch to edit mode and edit the foo field
testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('input').val(), 'yop', 'input should contain yop');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'yop', "input should contain yop");
testUtils.fields.editInput(form.$('input[name=foo]'), 'new value');
- assert.strictEqual(form.$('input').val(), 'new value', 'input should contain new value');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'new value',
+ "input should contain new value");
// click on discard and cancel the confirm request
testUtils.form.clickDiscard(form);
assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed");
testUtils.dom.click('.modal-footer .btn-secondary');
- assert.strictEqual(form.$('input').val(), 'new value', 'input should still contain new value');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'new value',
+ "input should still contain new value");
// click on discard and confirm
testUtils.form.clickDiscard(form);
assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed");
testUtils.dom.click('.modal-footer .btn-primary');
- assert.strictEqual(form.$('.o_field_widget').text(), 'yop', 'field in readonly should display yop');
+ assert.strictEqual(form.$('.o_field_widget').text(), 'yop',
+ "field in readonly should display yop");
- assert.strictEqual(nbWrite, 0, 'no write RPC should have been done');
+ assert.strictEqual(nbWrite, 0, "no write RPC should have been done");
form.destroy();
});
@@ -2240,7 +2223,8 @@ QUnit.module('Views', {
testUtils.fields.many2one.clickOpenDropdown('product_id');
testUtils.fields.many2one.clickHighlightedItem('product_id');
- assert.strictEqual(form.$('input').val(), 'xphone', 'input should contain xphone');
+ assert.strictEqual(form.$('.o_field_widget[name=product_id] input').val(), 'xphone',
+ "input should contain xphone");
// click on discard and confirm
testUtils.form.clickDiscard(form);
@@ -2269,11 +2253,13 @@ QUnit.module('Views', {
});
// edit the foo field
- assert.strictEqual(form.$('input').val(), 'ABC', 'input should contain ABC');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'ABC',
+ "input should contain ABC");
testUtils.form.clickDiscard(form);
- assert.containsNone(document.body, '.modal', 'there should not be a confirm modal');
+ assert.containsNone(document.body, '.modal',
+ "there should not be a confirm modal");
form.destroy();
});
@@ -2301,22 +2287,27 @@ QUnit.module('Views', {
});
// edit the foo field
- assert.strictEqual(form.$('input').val(), 'ABC', 'input should contain ABC');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'ABC',
+ "input should contain ABC");
testUtils.fields.editInput(form.$('input[name=foo]'), 'DEF');
// discard the changes and check it has properly been discarded
testUtils.form.clickDiscard(form);
- assert.containsOnce(document.body, '.modal', 'there should be a confirm modal');
- assert.strictEqual(form.$('input').val(), 'DEF', 'input should be DEF');
+ assert.containsOnce(document.body, '.modal', "there should be a confirm modal");
+ assert.strictEqual(form.$('input[name=foo]').val(), 'DEF',
+ "input should be DEF");
testUtils.dom.click('.modal-footer .btn-primary'); // click on confirm
- assert.strictEqual(form.$('input').val(), 'ABC', 'input should now be ABC');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'ABC',
+ "input should now be ABC");
// redirty and discard the field foo (to make sure initial changes haven't been lost)
testUtils.fields.editInput(form.$('input[name=foo]'), 'GHI');
testUtils.form.clickDiscard(form);
- assert.strictEqual(form.$('input').val(), 'GHI', 'input should be GHI');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'GHI',
+ "input should be GHI");
testUtils.dom.click('.modal-footer .btn-primary') // click on confirm
- assert.strictEqual(form.$('input').val(), 'ABC', 'input should now be ABC');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'ABC',
+ "input should now be ABC");
form.destroy();
});
@@ -2345,11 +2336,12 @@ QUnit.module('Views', {
testUtils.dom.click(form.sidebar.$('.o_dropdown_toggler_btn:contains(Action)'));
testUtils.dom.click(form.sidebar.$('a:contains(Duplicate)'));
- assert.strictEqual(form.$('input').val(), 'tralala', 'input should contain ABC');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'tralala',
+ "input should contain ABC");
testUtils.form.clickDiscard(form);
- assert.containsNone(document.body, '.modal', 'there should not be a confirm modal');
+ assert.containsNone(document.body, '.modal', "there should not be a confirm modal");
form.destroy();
});
@@ -2376,30 +2368,32 @@ QUnit.module('Views', {
},
});
- assert.strictEqual(form.pager.$('.o_pager_value').text(), "1", 'pager value should be 1');
- assert.strictEqual(form.pager.$('.o_pager_limit').text(), "2", 'pager limit should be 2');
+ assert.strictEqual(form.$('.o_pager_value').text(), '1', "pager value should be 1");
+ assert.strictEqual(form.$('.o_pager_limit').text(), '2', "pager limit should be 2");
// switch to edit mode
testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('input').val(), 'yop', 'input should contain yop');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'yop', "input should contain yop");
// edit the foo field
testUtils.fields.editInput(form.$('input[name=foo]'), 'new value');
- assert.strictEqual(form.$('input').val(), 'new value', 'input should contain new value');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'new value',
+ "input should contain new value");
// click on the pager to switch to the next record and cancel the confirm request
testUtils.dom.click('.o_pager_next'); // click on next
- assert.containsOnce(document.body, '.modal', 'a confirm modal should be displayed');
+ assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed");
testUtils.dom.click('.modal-footer .btn-secondary'); // click on cancel
- assert.strictEqual(form.$('input').val(), 'new value', 'input should still contain new value');
- assert.strictEqual(form.pager.$('.o_pager_value').text(), "1", 'pager value should still be 1');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'new value',
+ "input should still contain new value");
+ assert.strictEqual(form.$('.o_pager_value').text(), '1', "pager value should still be 1");
// click on the pager to switch to the next record and confirm
testUtils.dom.click('.o_pager_next'); // click on next
- assert.containsOnce(document.body, '.modal', 'a confirm modal should be displayed');
+ assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed");
testUtils.dom.click('.modal-footer .btn-primary'); // click on confirm
- assert.strictEqual(form.$('input').val(), 'blip', 'input should contain blip');
- assert.strictEqual(form.pager.$('.o_pager_value').text(), "2", 'pager value should be 2');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'blip', "input should contain blip");
+ assert.strictEqual(form.$('.o_pager_value').text(), '2', "pager value should be 2");
assert.strictEqual(nbWrite, 0, 'no write RPC should have been done');
form.destroy();
@@ -2422,22 +2416,24 @@ QUnit.module('Views', {
},
res_id: 1,
});
- assert.strictEqual(form.pager.$('.o_pager_value').text(), "1", 'pager value should be 1');
+ assert.strictEqual(form.$('.o_pager_value').text(), '1', "pager value should be 1");
// switch to edit mode
testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('input').val(), 'yop', 'input should contain yop');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'yop', "input should contain yop");
// edit the foo field
testUtils.fields.editInput(form.$('input[name=foo]'), 'new value');
- assert.strictEqual(form.$('input').val(), 'new value', 'input should contain new value');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'new value',
+ "input should contain new value");
testUtils.form.clickSave(form);
// click on the pager to switch to the next record and cancel the confirm request
testUtils.dom.click('.o_pager_next'); // click on next
- assert.containsNone(document.body, '.modal:visible', 'no confirm modal should be displayed');
- assert.strictEqual(form.pager.$('.o_pager_value').text(), "2", 'pager value should be 2');
+ assert.containsNone(document.body, '.modal:visible',
+ "no confirm modal should be displayed");
+ assert.strictEqual(form.$('.o_pager_value').text(), '2', "pager value should be 2");
assert.containsN(form, '.o_priority .fa-star-o', 2,
'priority widget should have been rendered with correct value');
@@ -2448,21 +2444,23 @@ QUnit.module('Views', {
'priority widget should have been updated');
testUtils.dom.click('.o_pager_next'); // click on next
- assert.containsNone(document.body, '.modal:visible', 'no confirm modal should be displayed');
- assert.strictEqual(form.pager.$('.o_pager_value').text(), "1", 'pager value should be 1');
+ assert.containsNone(document.body, '.modal:visible',
+ "no confirm modal should be displayed");
+ assert.strictEqual(form.$('.o_pager_value').text(), '1', "pager value should be 1");
// switch to edit mode
testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('input').val(), 'new value', 'input should contain yop');
+ assert.strictEqual(form.$('input[name=foo]').val(), 'new value',
+ "input should contain yop");
// edit the foo field
testUtils.fields.editInput(form.$('input[name=foo]'), 'wrong value');
testUtils.form.clickDiscard(form);
- assert.containsOnce(document.body, '.modal', 'a confirm modal should be displayed');
+ assert.containsOnce(document.body, '.modal', "a confirm modal should be displayed");
testUtils.dom.click('.modal-footer .btn-primary'); // click on confirm
testUtils.dom.click('.o_pager_next'); // click on next
- assert.strictEqual(form.pager.$('.o_pager_value').text(), "2", 'pager value should be 2');
+ assert.strictEqual(form.$('.o_pager_value').text(), '2', "pager value should be 2");
form.destroy();
});
@@ -2763,15 +2761,13 @@ QUnit.module('Views', {
});
testUtils.form.clickSave(form);
- assert.hasClass(form.$('label'),'o_field_invalid',
- "label should be tagged as invalid");
- assert.hasClass(form.$('input'),'o_field_invalid',
- "input should be tagged as invalid");
+ assert.hasClass(form.$('label.o_form_label'),'o_field_invalid');
+ assert.hasClass(form.$('input[name=foo]'),'o_field_invalid');
+
+ testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
- testUtils.fields.editInput(form.$('input'), "tralala");
+ assert.containsNone(form, '.o_field_invalid');
- assert.containsNone(form, '.o_field_invalid',
- "nothing should be marked as invalid");
form.destroy();
});
@@ -2867,13 +2863,11 @@ QUnit.module('Views', {
testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('input[name=int_field]').val(), '9',
- "'int_field' value should be 9 before the change");
+ assert.strictEqual(form.$('input[name=int_field]').val(), '9');
- testUtils.fields.editInput(form.$('input').first(), "tralala");
+ testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
- assert.strictEqual(form.$('input[name=int_field]').val(), '10',
- "the onchange should have been correctly applied");
+ assert.strictEqual(form.$('input[name=int_field]').val(), '10');
form.destroy();
});
@@ -3103,7 +3097,7 @@ QUnit.module('Views', {
// switch to edit mode
testUtils.form.clickEdit(form);
- testUtils.fields.editInput(form.$('input'), 'let us trigger an onchange');
+ testUtils.fields.editInput(form.$('input[name=foo]'), 'let us trigger an onchange');
var $o2m = form.$('.o_field_one2many');
assert.strictEqual($o2m.find('.o_data_row').length, 2,
"there should be two linked record");
@@ -3149,7 +3143,7 @@ QUnit.module('Views', {
"should have proper initial content");
testUtils.form.clickEdit(form);
- testUtils.fields.editInput(form.$('input'), "tralala");
+ testUtils.fields.editInput(form.$('input[name=foo]'), 'tralala');
assert.strictEqual($('div[name="p"] .o_data_row td').text().trim(), "goldNo records",
"should have proper initial content");
@@ -3177,7 +3171,7 @@ QUnit.module('Views', {
testUtils.form.clickEdit(form);
testUtils.dom.click(form.$('.o_data_cell').first());
- testUtils.fields.editInput(form.$('input'), '');
+ testUtils.fields.editInput(form.$('input[name=display_name]'), '');
testUtils.dom.click(form.$('.fa-trash-o').eq(1));
assert.strictEqual($('.modal').find('.modal-title').first().text(), "Warning",
@@ -3237,7 +3231,7 @@ QUnit.module('Views', {
// switch to edit mode
testUtils.form.clickEdit(form);
- testUtils.fields.editInput(form.$('input'), 'let us trigger an onchange');
+ testUtils.fields.editInput(form.$('input[name=foo]'), 'let us trigger an onchange');
var $m2m = form.$('.o_field_many2many');
assert.strictEqual($m2m.find('.o_data_row').length, 2,
"there should be two linked records");
@@ -3426,24 +3420,22 @@ QUnit.module('Views', {
testUtils.form.clickEdit(form);
- testUtils.fields.editInput(form.$('input').first(), "1");
+ testUtils.fields.editInput(form.$('input[name=foo]'), '1');
assert.strictEqual(onchangeNbr, 0, "no onchange has been called yet");
- testUtils.fields.editInput(form.$('input').first(), "12");
+ testUtils.fields.editInput(form.$('input[name=foo]'), '12');
assert.strictEqual(onchangeNbr, 0, "no onchange has been called yet");
return waitForFinishedOnChange().then(function () {
assert.strictEqual(onchangeNbr, 1, "one onchange has been called");
// add something in the input, then focus another input
- testUtils.fields.editInput(form.$('input').first(), "123");
- form.$('input').first().change();
- assert.strictEqual(onchangeNbr, 2,
- "one onchange has been called immediately");
+ testUtils.fields.editInput(form.$('input[name=foo]'), '123');
+ form.$('input[name=foo]').change();
+ assert.strictEqual(onchangeNbr, 2, "one onchange has been called immediately");
return waitForFinishedOnChange();
}).then(function () {
- assert.strictEqual(onchangeNbr, 2,
- "no extra onchange should have been called");
+ assert.strictEqual(onchangeNbr, 2, "no extra onchange should have been called");
form.destroy();
done();
@@ -3558,7 +3550,7 @@ QUnit.module('Views', {
testUtils.form.clickEdit(form);
// edit a value
- testUtils.fields.editInput(form.$('input').first(), "1234");
+ testUtils.fields.editInput(form.$('input[name=foo]'), '1234');
// discard changes
testUtils.form.clickDiscard(form);
@@ -3599,7 +3591,7 @@ QUnit.module('Views', {
},
});
- testUtils.fields.editInput(form.$('input'), "1234");
+ testUtils.fields.editInput(form.$('input[name=foo]'), '1234');
// save the value and discard directly
testUtils.form.clickSave(form);
@@ -3657,7 +3649,7 @@ QUnit.module('Views', {
});
testUtils.form.clickEdit(form);
- testUtils.fields.editInput(form.$('input:first'), 'trigger an onchange');
+ testUtils.fields.editInput(form.$('input[name=foo]'), 'trigger an onchange');
form.destroy();
});
@@ -3718,7 +3710,7 @@ QUnit.module('Views', {
});
testUtils.form.clickEdit(form);
- testUtils.fields.editInput(form.$('input:first'), 'trigger an onchange');
+ testUtils.fields.editInput(form.$('input[name=foo]'), 'trigger an onchange');
assert.strictEqual(form.$('.o_data_row td:first').text(), 'foo changed',
"onchange should have been correctly applied on field in o2m list");
@@ -3778,7 +3770,7 @@ QUnit.module('Views', {
assert.strictEqual(form.$('.o_data_row td:first').text(), 'My little Foo Value',
"the initial value should be the default one");
- testUtils.fields.editInput(form.$('input:first'), 'trigger an onchange');
+ testUtils.fields.editInput(form.$('input[name=foo]'), 'trigger an onchange');
assert.strictEqual(form.$('.o_data_row td:first').text(), 'foo changed',
"onchange should have been correctly applied on field in o2m list");
@@ -3927,7 +3919,7 @@ QUnit.module('Views', {
});
testUtils.form.clickEdit(form);
- testUtils.fields.editInput(form.$('input:first'), 'coucou');
+ testUtils.fields.editInput(form.$('input[name=foo]'), 'coucou');
form.destroy();
});
@@ -4494,31 +4486,32 @@ QUnit.module('Views', {
}
});
- QUnit.test('footers are not duplicated on rerender', function (assert) {
- assert.expect(2);
+ QUnit.test('buttons in footer are moved to $buttons if necessary', function (assert) {
+ assert.expect(4);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
- arch:
- '<form>' +
- '<field name="foo"/>' +
- '<footer>' +
- '<button>Hello</button>' +
- '</footer>' +
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '<footer>' +
+ '<button string="Create" type="object" class="infooter"/>' +
+ '</footer>' +
'</form>',
res_id: 1,
- viewOptions: {
- footerToButtons: true,
- },
+ viewOptions: {footerToButtons: true},
});
- assert.containsNone(form, 'footer',
- "footer should have been moved outside of the form view");
- form.reload();
- assert.containsNone(form, 'footer',
- "footer should still have been moved outside of the form view");
+ assert.containsOnce(form.$('.o_control_panel'), 'button.infooter');
+ assert.containsNone(form.$('.o_form_view'), 'button.infooter');
+
+ // check that this still works after a reload
+ testUtils.form.reload(form);
+
+ assert.containsOnce(form.$('.o_control_panel'), 'button.infooter');
+ assert.containsNone(form.$('.o_form_view'), 'button.infooter');
+
form.destroy();
});
@@ -4850,7 +4843,7 @@ QUnit.module('Views', {
});
QUnit.test('display toolbar', function (assert) {
- assert.expect(7);
+ assert.expect(8);
var form = createView({
View: FormView,
@@ -4886,22 +4879,16 @@ QUnit.module('Views', {
return this._super.apply(this, arguments);
},
});
- // The toolbar displayed changes if the module 'document' is installed.
- // So if the module is installed, the assertion checks for 3 dropdowns
- // if not only 2 dropdowns are displayed.
- var $dropdowns = $('.o_web_client .o_control_panel .btn-group .o_dropdown_toggler_btn');
- var $actions = $('.o_web_client .o_control_panel .btn-group .dropdown-menu')[1].children;
- if ('attachment_indexation.document' in odoo.__DEBUG__.services) {
- assert.strictEqual($dropdowns.length, 3,
- "there should be 3 dropdowns (print, attachment, action) in the toolbar.");
- $actions = $('.o_web_client .o_control_panel .btn-group .dropdown-menu')[2].children;
- } else {
- assert.strictEqual($dropdowns.length, 2,
- "there should be 2 dropdowns (print, action) in the toolbar.");
- }
- assert.strictEqual($actions.length, 3,
- "there should be 3 actions");
- var $customAction = $('.o_web_client .o_control_panel .btn-group .dropdown-menu:last .dropdown-item:nth(2)');
+
+ var $printMenu = form.$('.o_cp_sidebar .o_dropdown:contains(Print)');
+ assert.isNotVisible($printMenu);
+ var $actionMenu = form.$('.o_cp_sidebar .o_dropdown:contains(Action)');
+ assert.isVisible($actionMenu);
+
+ testUtils.dom.click($actionMenu.find('button')); // open Action menu
+ assert.strictEqual($actionMenu.find('.dropdown-item').length, 3,
+ 'there should be 3 actions');
+ var $customAction = $actionMenu.find('.dropdown-item:last');
assert.strictEqual($customAction.text().trim(), 'Action partner',
"the custom action should have 'Action partner' as name");
testUtils.mock.intercept(form, 'do_action', function (event) {
@@ -4911,7 +4898,6 @@ QUnit.module('Views', {
assert.deepEqual(context.active_ids, [1],
"the active_ids should be an array with 1 inside.");
});
- testUtils.dom.click($dropdowns.last());
testUtils.dom.click($customAction);
form.destroy();
@@ -5020,7 +5006,7 @@ QUnit.module('Views', {
},
});
- testUtils.dom.click(form.$('input:first'));
+ testUtils.dom.click(form.$('.o_field_widget[name=trululu] input'));
form.destroy();
});
@@ -5217,7 +5203,7 @@ QUnit.module('Views', {
// client-side sort on edit
testUtils.form.clickEdit(form);
testUtils.dom.click(form.$('.o_field_one2many tbody tr:eq(1) td:contains(yop)'));
- testUtils.fields.editInput($('.modal input[name="foo"]'), 'zzz')
+ testUtils.fields.editInput($('.modal input[name="foo"]'), 'zzz');
testUtils.dom.click($('.modal-footer button:first'));
assert.ok(form.$('.o_field_one2many tbody tr:eq(0) td:contains(zzz)').length,
"record zzz should be first");
@@ -6690,7 +6676,9 @@ QUnit.module('Views', {
// call destroy function of controller to ensure that it correctly destroys everything
form.__destroy();
- assert.strictEqual(instanceNumber, initialInstanceNumber + 3, "every widget must be destroyed exept the parent");
+ // + 4 (parent)
+ assert.strictEqual(instanceNumber, initialInstanceNumber + 1,
+ "every widget must be destroyed exept the parent");
form.destroy();
@@ -6971,7 +6959,7 @@ QUnit.module('Views', {
// add a row and partially fill it
form.$('.o_field_x2many_list_row_add a').click();
- form.$('input:nth(0)').val('abc').trigger('input');
+ form.$('input[name=display_name]').val('abc').trigger('input');
// click button which will trigger_up 'execute_action' (this will save)
values = {
@@ -7054,14 +7042,13 @@ QUnit.module('Views', {
fieldDebounce: 5000,
});
- testUtils.fields.editInput(form.$('input'), 'some foo value');
+ testUtils.fields.editInput(form.$('input[name=foo]'), 'some foo value');
// manually save the record, to prevent the field widget to notify the model of its new
// value before being requested to
form.saveRecord();
- assert.containsOnce(form, '.o_form_readonly',
- "form view should be in readonly");
- assert.strictEqual(form.$el.text().trim(), 'some foo value',
+ assert.containsOnce(form, '.o_form_readonly', "form view should be in readonly");
+ assert.strictEqual(form.$('.o_form_view').text().trim(), 'some foo value',
"foo field should have correct value");
assert.verifySteps(['default_get', 'create', 'read']);
diff --git a/addons/web/static/tests/views/graph_tests.js b/addons/web/static/tests/views/graph_tests.js
index 30d295e1f8f..73f2179dc8f 100644
--- a/addons/web/static/tests/views/graph_tests.js
+++ b/addons/web/static/tests/views/graph_tests.js
@@ -118,7 +118,8 @@ QUnit.module('Views', {
'<field name="bar"/>' +
'</graph>',
});
- assert.strictEqual(graph.$('label').text(), "Partners", "should have 'Partners as title'");
+ assert.strictEqual(graph.$('.o_graph_renderer label').text(), "Partners",
+ "should have 'Partners as title'");
graph.destroy();
});
@@ -353,7 +354,7 @@ QUnit.module('Views', {
});
});
- QUnit.test('getContext correctly returns mode, measure, groupbys and interval mapping', function (assert) {
+ QUnit.test('getOwnedQueryParams correctly returns mode, measure, groupbys and interval mapping', function (assert) {
var done = assert.async();
assert.expect(4);
@@ -366,11 +367,13 @@ QUnit.module('Views', {
'</graph>',
});
return concurrency.delay(0).then(function () {
- assert.deepEqual(graph.getContext(), {
- graph_mode: 'bar',
- graph_measure: '__count__',
- graph_groupbys: ['product_id'],
- graph_intervalMapping: {},
+ assert.deepEqual(graph.getOwnedQueryParams(), {
+ context: {
+ graph_mode: 'bar',
+ graph_measure: '__count__',
+ graph_groupbys: ['product_id'],
+ graph_intervalMapping: {},
+ }
}, "context should be correct");
testUtils.dom.click(graph.$buttons.find('.dropdown-toggle:contains(Measures)'));
@@ -378,33 +381,39 @@ QUnit.module('Views', {
return concurrency.delay(0);
}).then(function () {
- assert.deepEqual(graph.getContext(), {
- graph_mode: 'bar',
- graph_measure: 'foo',
- graph_groupbys: ['product_id'],
- graph_intervalMapping: {},
+ assert.deepEqual(graph.getOwnedQueryParams(), {
+ context: {
+ graph_mode: 'bar',
+ graph_measure: 'foo',
+ graph_groupbys: ['product_id'],
+ graph_intervalMapping: {},
+ },
}, "context should be correct");
testUtils.dom.click(graph.$buttons.find('button[data-mode="line"]'));
return concurrency.delay(0);
}).then(function () {
- assert.deepEqual(graph.getContext(), {
- graph_mode: 'line',
- graph_measure: 'foo',
- graph_groupbys: ['product_id'],
- graph_intervalMapping: {},
+ assert.deepEqual(graph.getOwnedQueryParams(), {
+ context: {
+ graph_mode: 'line',
+ graph_measure: 'foo',
+ graph_groupbys: ['product_id'],
+ graph_intervalMapping: {},
+ },
}, "context should be correct");
testUtils.graph.reload(graph, {groupBy: ['product_id', 'color_id']}); // change groupbys
return concurrency.delay(0);
}).then(function () {
- assert.deepEqual(graph.getContext(), {
- graph_mode: 'line',
- graph_measure: 'foo',
- graph_groupbys: ['product_id', 'color_id'],
- graph_intervalMapping: {},
+ assert.deepEqual(graph.getOwnedQueryParams(), {
+ context: {
+ graph_mode: 'line',
+ graph_measure: 'foo',
+ graph_groupbys: ['product_id', 'color_id'],
+ graph_intervalMapping: {},
+ },
}, "context should be correct");
graph.destroy();
diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js
index bb66243fed8..c398e9df09f 100644
--- a/addons/web/static/tests/views/kanban_tests.js
+++ b/addons/web/static/tests/views/kanban_tests.js
@@ -155,21 +155,15 @@ QUnit.module('Views', {
'<div><field name="foo"/></div>' +
'</t></templates></kanban>',
groupBy: ['bar'],
- intercepts: {
- env_updated: function (event) {
- assert.deepEqual(event.data.env.ids, envIDs,
- "should notify the environment with the correct ids");
- },
- },
});
// check archive/restore all actions in kanban header's config dropdown
assert.containsOnce(kanban, '.o_kanban_header:first .o_kanban_config .o_column_archive_records');
assert.containsOnce(kanban, '.o_kanban_header:first .o_kanban_config .o_column_unarchive_records');
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
// archive the records of the first column
assert.containsN(kanban, '.o_kanban_group:last .o_kanban_record', 3);
- envIDs = [4];
testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last'));
testUtils.dom.click(kanban.$('.o_kanban_group:last .o_column_archive_records'));
@@ -181,6 +175,8 @@ QUnit.module('Views', {
assert.ok($('.modal').length, 'a confirm modal should be displayed');
testUtils.modal.clickButton('Ok');
assert.containsNone(kanban, '.o_kanban_group:last .o_kanban_record', "last column should not contain any records");
+ envIDs = [4];
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
kanban.destroy();
});
@@ -819,14 +815,10 @@ QUnit.module('Views', {
'<div><field name="foo"/></div>' +
'</t></templates></kanban>',
groupBy: ['bar'],
- intercepts: {
- env_updated: function (event) {
- assert.strictEqual(event.data.env.ids.length, nbRecords,
- "should update the env with the records ids");
- },
- }
});
+ assert.strictEqual(kanban.exportState().resIds.length, nbRecords);
+
// click to add an element and cancel the quick creation by pressing ESC
testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
@@ -867,6 +859,7 @@ QUnit.module('Views', {
"should have created a partner");
assert.strictEqual(_.last(this.data.partner.records).name, "new partner",
"should have correct name");
+ assert.strictEqual(kanban.exportState().resIds.length, nbRecords);
kanban.destroy();
});
@@ -2408,16 +2401,11 @@ QUnit.module('Views', {
}
return this._super(route, args);
},
- intercepts: {
- env_updated: function (event) {
- assert.deepEqual(event.data.env.ids, envIDs,
- "should notify the environment with the correct ids");
- },
- },
});
assert.containsN(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record', 2);
assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2);
assert.containsN(kanban, '.thisiseditable', 4);
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first');
var $group = kanban.$('.o_kanban_group:nth-child(2)');
@@ -2427,6 +2415,8 @@ QUnit.module('Views', {
assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record');
assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 3);
assert.containsN(kanban, '.thisiseditable', 4);
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
+
kanban.destroy();
});
@@ -2637,24 +2627,22 @@ QUnit.module('Views', {
'</t></templates>' +
'</kanban>',
groupBy: ['product_id'],
- intercepts: {
- env_updated: function (event) {
- assert.deepEqual(event.data.env.ids, envIDs,
- "should notify the environment with the correct ids");
- },
- },
});
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
+
// fold the second group and check that the res_ids it contains are no
// longer in the environment
envIDs = [1, 3];
testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:last'));
testUtils.dom.click(kanban.$('.o_kanban_group:last .o_kanban_toggle_fold'));
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
// re-open the second group and check that the res_ids it contains are
// back in the environment
envIDs = [1, 3, 2, 4];
testUtils.dom.click(kanban.$('.o_kanban_group:last'));
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
kanban.destroy();
});
@@ -3182,27 +3170,6 @@ QUnit.module('Views', {
kanban.destroy();
});
- QUnit.test('if view was grouped at start, it stays grouped', function (assert) {
- assert.expect(1);
-
- var kanban = createView({
- View: KanbanView,
- model: 'partner',
- data: this.data,
- arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
- '<field name="product_id"/>' +
- '<templates><t t-name="kanban-box">' +
- '<div><field name="foo"/></div>' +
- '</t></templates>' +
- '</kanban>',
- groupBy: ['product_id'],
- });
- kanban.update({groupBy: []});
-
- assert.hasClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped');
- kanban.destroy();
- });
-
QUnit.test('if view was not grouped at start, it can be grouped and ungrouped', function (assert) {
assert.expect(3);
@@ -3217,11 +3184,13 @@ QUnit.module('Views', {
'</t></templates>' +
'</kanban>',
});
- assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped', "should not be grouped");
+
+ assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped');
kanban.update({groupBy: ['product_id']});
- assert.hasClass(kanban.$('.o_kanban_view'),'o_kanban_grouped', "should be grouped");
+ assert.hasClass(kanban.$('.o_kanban_view'),'o_kanban_grouped');
kanban.update({groupBy: []});
- assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped', "should not be grouped");
+ assert.doesNotHaveClass(kanban.$('.o_kanban_view'), 'o_kanban_grouped');
+
kanban.destroy();
});
@@ -3792,7 +3761,7 @@ QUnit.module('Views', {
});
QUnit.test('resequence columns in grouped by m2o', function (assert) {
- assert.expect(7);
+ assert.expect(6);
this.data.product.fields.sequence = {string: "Sequence", type: "integer"};
var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test
@@ -3807,12 +3776,6 @@ QUnit.module('Views', {
'</t></templates>' +
'</kanban>',
groupBy: ['product_id'],
- intercepts: {
- env_updated: function (event) {
- assert.deepEqual(event.data.env.ids, envIDs,
- "should notify the environment with the correct ids");
- },
- },
});
assert.hasClass(kanban.$('.o_kanban_view'),'ui-sortable',
@@ -3821,6 +3784,7 @@ QUnit.module('Views', {
"should have two columns");
assert.strictEqual(kanban.$('.o_kanban_group:first').data('id'), 3,
"first column should be id 3 before resequencing");
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
// there is a 100ms delay on the d&d feature (jquery sortable) for
// kanban columns, making it hard to test. So we rather bypass the d&d
@@ -3831,6 +3795,7 @@ QUnit.module('Views', {
assert.strictEqual(kanban.$('.o_kanban_group:first').data('id'), 5,
"first column should be id 5 after resequencing");
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
kanban.destroy();
});
@@ -3944,16 +3909,11 @@ QUnit.module('Views', {
}
return this._super.apply(this, arguments);
},
- intercepts:{
- env_updated: function (event) {
- assert.deepEqual(event.data.env.ids, envIDs,
- "should notify the environment with the correct ids");
- },
- },
});
assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 2,
"there should be 2 records in the column");
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
// load more
envIDs = [1, 2, 3, 4]; // id 3 will be loaded
@@ -3961,14 +3921,15 @@ QUnit.module('Views', {
assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 3,
"there should now be 3 records in the column");
-
assert.verifySteps([[2, undefined], [2, undefined], [2, 2]],
"the records should be correctly fetched");
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
// reload
kanban.reload();
assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 3,
"there should still be 3 records in the column after reload");
+ assert.deepEqual(kanban.exportState().resIds, envIDs);
kanban.destroy();
});
@@ -4471,7 +4432,7 @@ QUnit.module('Views', {
});
QUnit.test('kanban with progressbars: correctly update env when archiving records', function (assert) {
- assert.expect(3);
+ assert.expect(2);
// add active field on partner model and make all records active
this.data.partner.fields.active = {string: 'Active', type: 'char', default: true};
@@ -4493,22 +4454,16 @@ QUnit.module('Views', {
'</t></templates>' +
'</kanban>',
groupBy: ['bar'],
- intercepts: {
- env_updated: function (ev) {
- assert.step(ev.data.env.ids);
- },
- },
});
+ assert.deepEqual(kanban.exportState().resIds, [1, 2, 3, 4]);
+
// archive all records of the first column
testUtils.kanban.toggleGroupSettings(kanban.$('.o_kanban_group:first'));
testUtils.dom.click(kanban.$('.o_column_archive_records:visible'));
testUtils.dom.click($('.modal-footer button:first'));
- assert.verifySteps([
- [1, 2, 3, 4],
- [1, 2, 3],
- ]);
+ assert.deepEqual(kanban.exportState().resIds, [1, 2, 3]);
kanban.destroy();
});
@@ -4784,7 +4739,9 @@ QUnit.module('Views', {
// call destroy function of controller to ensure that it correctly destroys everything
kanban.__destroy();
- assert.strictEqual(instanceNumber, initialInstanceNumber + 3, "every widget must be destroyed exept the parent");
+ // + 1 (parent)
+ assert.strictEqual(instanceNumber, initialInstanceNumber + 1,
+ "every widget must be destroyed exept the parent");
kanban.destroy();
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
index 95921062f7b..f5e0088173b 100644
--- a/addons/web/static/tests/views/list_tests.js
+++ b/addons/web/static/tests/views/list_tests.js
@@ -402,7 +402,7 @@ QUnit.module('Views', {
// Ascending order on Date
list.$('th.o_column_sortable:contains("Date")').click();
- var listContext = list.getContext();
+ var listContext = list.getOwnedQueryParams();
assert.deepEqual(listContext,
{
orderedBy: [{
@@ -416,7 +416,7 @@ QUnit.module('Views', {
list.destroy();
});
- QUnit.test('Loading a filter with a sort attribute', function (assert) {
+ QUnit.test('loading a filter with a sort attribute', function (assert) {
assert.expect(2);
this.data.foo.fields.foo.sortable = true;
@@ -431,15 +431,6 @@ QUnit.module('Views', {
'<field name="foo"/>' +
'<field name="date"/>' +
'</tree>',
- context: {
- orderedBy: [{
- name: 'date',
- asc: true,
- }, {
- name: 'foo',
- asc: false,
- }]
- },
mockRPC: function (route, args) {
if (route === '/web/dataset/search_read') {
if (searchReads === 0) {
@@ -453,22 +444,34 @@ QUnit.module('Views', {
}
return this._super.apply(this,arguments);
},
+ intercepts: {
+ load_filters: function (event) {
+ return $.when([
+ {
+ context: "{}",
+ domain: "[]",
+ id: 7,
+ is_default: true,
+ name: "My favorite",
+ sort: "[\"date asc\", \"foo desc\"]",
+ user_id: [2, "Mitchell Admin"],
+ }, {
+ context: "{}",
+ domain: "[]",
+ id: 8,
+ is_default: false,
+ name: "My second favorite",
+ sort: "[\"date desc\", \"foo asc\"]",
+ user_id: [2, "Mitchell Admin"],
+ }
+ ]).then(event.data.on_success);
+ },
+ },
});
- // Simulate loading a filter
- list.update({
- context: {
- orderedBy: [{
- name: 'date',
- asc: false,
- }, {
- name: 'foo',
- asc: true,
- }]
- }
- });
-
- list.destroy()
+ testUtils.dom.click(list.$('.o_control_panel .o_search_options button.o_favorites_menu_button'));
+ testUtils.dom.click(list.$('.o_control_panel .o_search_options .o_favorites_menu .o_menu_item').eq(1));
+ list.destroy();
});
QUnit.test('many2one field rendering', function (assert) {
@@ -1443,7 +1446,7 @@ QUnit.module('Views', {
});
// Change page
- testUtils.dom.click(form.$('.o_pager_next'));
+ testUtils.dom.click(form.$('.o_field_widget[name=o2m] .o_pager_next'));
assert.strictEqual(form.$('tbody tr:first').text(), 'Value 44',
"record 44 should be first");
assert.strictEqual(form.$('tbody tr:eq(4)').text(), 'Value 48',
@@ -1712,10 +1715,9 @@ QUnit.module('Views', {
assert.containsNone(list, '.o_view_nocontent',
"should not have a no content helper displayed");
assert.containsOnce(list, 'table', "should have rendered a table");
- assert.strictEqual(list.$el.css('height'), list.$('div.table-responsive').css('height'),
+ assert.strictEqual(list.$('.o_content').css('height'), list.$('div.table-responsive').css('height'),
"the div for the table should take the full height");
-
assert.hasClass(list.$('tbody tr:eq(0)'),'o_selected_row',
"the date field td should be in edit mode");
assert.strictEqual(list.$('tbody tr:eq(0) td:eq(1)').text().trim(), "",
@@ -2051,15 +2053,12 @@ QUnit.module('Views', {
assert.strictEqual(event.data.res_id, 4,
"'switch_view' event has been triggered");
},
- env_updated: function (event) {
- assert.deepEqual(event.data.env.ids, envIDs,
- "should notify the environment with the correct ids");
- },
},
});
assert.strictEqual(nbRPCs.readGroup, 1, "should have done one read_group");
assert.strictEqual(nbRPCs.searchRead, 0, "should have done no search_read");
+ assert.deepEqual(list.exportState().resIds, envIDs);
// basic rendering tests
assert.containsOnce(list, 'tbody', "there should be 1 tbody");
@@ -2079,6 +2078,7 @@ QUnit.module('Views', {
testUtils.dom.click(list.$('.o_group_header:first'));
assert.strictEqual(nbRPCs.readGroup, 1, "should have done one read_group");
assert.strictEqual(nbRPCs.searchRead, 0, "should have done no search_read");
+ assert.deepEqual(list.exportState().resIds, envIDs);
var $openGroup = list.$('tbody:nth(1)');
assert.strictEqual(list.$('.o_group_name:first').text(), 'Value 1 (4)',
@@ -2101,6 +2101,7 @@ QUnit.module('Views', {
testUtils.dom.click($openGroup.find('.o_group_header:nth(2)'));
assert.strictEqual(nbRPCs.readGroup, 0, "should have done no read_group");
assert.strictEqual(nbRPCs.searchRead, 1, "should have done one search_read");
+ assert.deepEqual(list.exportState().resIds, envIDs);
var $openSubGroup = list.$('tbody:nth(2)');
assert.containsN(list, 'tbody', 4, "there should be 4 tbodys");
@@ -2118,6 +2119,7 @@ QUnit.module('Views', {
testUtils.dom.click(list.$('thead th:last'));
assert.strictEqual(nbRPCs.readGroup, 2, "should have done two read_groups");
assert.strictEqual(nbRPCs.searchRead, 1, "should have done one search_read");
+ assert.deepEqual(list.exportState().resIds, envIDs);
$openSubGroup = list.$('tbody:nth(2)');
assert.containsN(list, 'tbody', 4, "there should be 4 tbodys");
@@ -2132,6 +2134,7 @@ QUnit.module('Views', {
testUtils.dom.click(list.$('.o_group_header:nth(1)'));
assert.strictEqual(nbRPCs.readGroup, 0, "should have done no read_group");
assert.strictEqual(nbRPCs.searchRead, 0, "should have done no search_read");
+ assert.deepEqual(list.exportState().resIds, envIDs);
assert.containsOnce(list, 'tbody', "there should be 1 tbody");
assert.containsN(list, '.o_group_header', 2,
@@ -2202,14 +2205,14 @@ QUnit.module('Views', {
"height of group header shouldn't have changed");
assert.hasClass(list.$('.o_group_header td:last'),'o_group_pager',
"last cell of open group header should have classname 'o_group_header'");
- assert.strictEqual(list.$('.o_pager_value').text(), '1-3',
+ assert.strictEqual(list.$('.o_group_header .o_pager_value').text(), '1-3',
"pager's value should be correct");
assert.containsN(list, '.o_data_row', 3,
"open group should display 3 records");
// go to next page
- testUtils.dom.click(list.$('.o_pager_next'));
- assert.strictEqual(list.$('.o_pager_value').text(), '4-4',
+ testUtils.dom.click(list.$('.o_group_header .o_pager_next'));
+ assert.strictEqual(list.$('.o_group_header .o_pager_value').text(), '4-4',
"pager's value should be correct");
assert.containsOnce(list, '.o_data_row',
"open group should display 1 record");
@@ -2584,7 +2587,7 @@ QUnit.module('Views', {
});
QUnit.test('display toolbar', function (assert) {
- assert.expect(3);
+ assert.expect(6);
var list = createView({
View: ListView,
@@ -2605,13 +2608,20 @@ QUnit.module('Views', {
},
});
- var $dropdowns = $('.o_web_client .o_control_panel .btn-group .o_dropdown_toggler_btn');
- assert.strictEqual($dropdowns.length, 2,
- "there should be 2 dropdowns in the toolbar.");
- var $actions = $('.o_web_client .o_control_panel .btn-group .dropdown-menu')[1].children;
- assert.strictEqual($actions.length, 3,
+ var $printMenu = list.$('.o_cp_sidebar .o_dropdown:contains(Print)');
+ assert.isNotVisible($printMenu);
+ var $actionMenu = list.$('.o_cp_sidebar .o_dropdown:contains(Action)');
+ assert.isNotVisible($actionMenu);
+
+ testUtils.dom.click(list.$('.o_list_record_selector:first input'));
+
+ assert.isNotVisible($printMenu);
+ assert.isVisible($actionMenu);
+
+ testUtils.dom.click($actionMenu.find('button')); // open Action menu
+ assert.strictEqual($actionMenu.find('.dropdown-item').length, 3,
"there should be 3 actions");
- var $customAction = $('.o_web_client .o_control_panel .btn-group .dropdown-item:nth(2)');
+ var $customAction = $actionMenu.find('.dropdown-item:last');
assert.strictEqual($customAction.text().trim(), 'Action event',
"the custom action should have 'Action event' as name");
@@ -3859,7 +3869,9 @@ QUnit.module('Views', {
// call destroy function of controller to ensure that it correctly destroys everything
list.__destroy();
- assert.strictEqual(instanceNumber, initialInstanceNumber + 3, "every widget must be destroyed exept the parent");
+ // + 1 (parent)
+ assert.strictEqual(instanceNumber, initialInstanceNumber + 1,
+ "every widget must be destroyed exept the parent");
list.destroy();
diff --git a/addons/web/static/tests/chrome/search_view_mobile_tests.js b/addons/web/static/tests/views/mobile_control_panel_tests.js
index 05e2cd09a73..05e2cd09a73 100644
--- a/addons/web/static/tests/chrome/search_view_mobile_tests.js
+++ b/addons/web/static/tests/views/mobile_control_panel_tests.js
diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js
index b03ebd0def1..46c28529bed 100644
--- a/addons/web/static/tests/views/pivot_tests.js
+++ b/addons/web/static/tests/views/pivot_tests.js
@@ -156,7 +156,7 @@ QUnit.module('Views', {
_.extend(this.data.partner.fields, {
foo: {string: "Foo", type: "integer", store: true},
foo2: {string: "Foo2", type: "integer", store: true}
- })
+ });
var pivot = createView({
View: PivotView,
@@ -171,7 +171,7 @@ QUnit.module('Views', {
// there should be only one displayed measure as the other one is invisible
assert.containsOnce(pivot, '.o_pivot_measure_row');
// there should be only one measure besides count, as the other one is invisible
- assert.containsN(document.body, '.dropdown-item', 2);
+ assert.containsN(pivot, '.o_cp_left .dropdown-item', 2);
pivot.destroy();
});
@@ -860,7 +860,7 @@ QUnit.module('Views', {
pivot.destroy();
});
- QUnit.test('getContext correctly returns measures and groupbys', function (assert) {
+ QUnit.test('getOwnedQueryParams correctly returns measures and groupbys', function (assert) {
assert.expect(3);
var pivot = createView({
@@ -873,28 +873,34 @@ QUnit.module('Views', {
'</pivot>',
});
- assert.deepEqual(pivot.getContext(), {
- pivot_column_groupby: ['date:day'],
- pivot_measures: ['foo'],
- pivot_row_groupby: [],
+ assert.deepEqual(pivot.getOwnedQueryParams(), {
+ context: {
+ pivot_column_groupby: ['date:day'],
+ pivot_measures: ['foo'],
+ pivot_row_groupby: [],
+ },
}, "context should be correct");
// expand header on field customer
testUtils.dom.click(pivot.$('thead .o_pivot_header_cell_closed:nth(1)'));
testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="customer"]:first'));
- assert.deepEqual(pivot.getContext(), {
- pivot_column_groupby: ['date:day', 'customer'],
- pivot_measures: ['foo'],
- pivot_row_groupby: [],
+ assert.deepEqual(pivot.getOwnedQueryParams(), {
+ context: {
+ pivot_column_groupby: ['date:day', 'customer'],
+ pivot_measures: ['foo'],
+ pivot_row_groupby: [],
+ },
}, "context should be correct");
// expand row on field product_id
testUtils.dom.click(pivot.$('tbody .o_pivot_header_cell_closed'));
testUtils.dom.click(pivot.$('.o_pivot_field_menu .dropdown-item[data-field="product_id"]:first'));
- assert.deepEqual(pivot.getContext(), {
- pivot_column_groupby: ['date:day', 'customer'],
- pivot_measures: ['foo'],
- pivot_row_groupby: ['product_id'],
+ assert.deepEqual(pivot.getOwnedQueryParams(), {
+ context: {
+ pivot_column_groupby: ['date:day', 'customer'],
+ pivot_measures: ['foo'],
+ pivot_row_groupby: ['product_id'],
+ },
}, "context should be correct");
pivot.destroy();
@@ -1195,12 +1201,16 @@ QUnit.module('Views', {
// Set a domain
testUtils.pivot.reload(pivot, {domain: [['product_id', '=', 41]]});
- var expectedContext = {pivot_column_groupby: ['customer'],
- pivot_measures: ['foo'],
- pivot_row_groupby: ['product_id']};
+ var expectedContext = {
+ context: {
+ pivot_column_groupby: ['customer'],
+ pivot_measures: ['foo'],
+ pivot_row_groupby: ['product_id'],
+ },
+ };
// Mock 'save as favorite'
- assert.deepEqual(pivot.getContext(), expectedContext,
+ assert.deepEqual(pivot.getOwnedQueryParams(), expectedContext,
'The pivot view should have the right context');
var $xpadHeader = pivot.$('tbody .o_pivot_header_cell_closed[data-original-title=Product]');
diff --git a/addons/web/static/tests/views/search_filter_menu_tests.js b/addons/web/static/tests/views/search_filter_menu_tests.js
new file mode 100644
index 00000000000..e907d70111e
--- /dev/null
+++ b/addons/web/static/tests/views/search_filter_menu_tests.js
@@ -0,0 +1,167 @@
+odoo.define('web.search_filters_menu_tests', function (require) {
+"use strict";
+
+var FilterMenu = require('web.FilterMenu');
+var testUtils = require('web.test_utils');
+var Domain = require('web.Domain');
+
+function createFilterMenu(filters, fields, params) {
+ params = params || {};
+ var target = params.debug ? document.body : $('#qunit-fixture');
+ var menu = new FilterMenu(null, filters, fields);
+ testUtils.mock.addMockEnvironment(menu, params);
+ menu.appendTo(target);
+ return menu;
+}
+
+QUnit.module('FilterMenu', {
+ beforeEach: function () {
+ this.filters = [
+ {
+ isActive: false,
+ description: 'some filter',
+ domain: '',
+ groupNumber: 1,
+ },
+ ];
+ this.fields = {
+ boolean_field: {string: "Boolean Field", type: "boolean", default: true, searchable: true},
+ date_field: {string: "A date", type: "date", searchable: true},
+ char_field: {string: "Char Field", type: "char", default: "foo", searchable: true, trim: true},
+ };
+ },
+}, function () {
+
+ QUnit.test('simple rendering with no filter', function (assert) {
+ assert.expect(3);
+
+ var filterMenu = createFilterMenu([], this.fields);
+ testUtils.dom.click(filterMenu.$('span.fa-filter'));
+ assert.containsNone(filterMenu, '.dropdown-divider');
+ assert.containsNone(filterMenu, '.o_add_filter_menu');
+ assert.containsOnce(filterMenu, '.o_add_custom_filter',
+ 'should have one element: a add custom filter item');
+ filterMenu.destroy();
+ });
+
+ QUnit.test('simple rendering with a filter', function (assert) {
+ assert.expect(2);
+
+ var filterMenu = createFilterMenu(this.filters, this.fields);
+ assert.containsN(filterMenu, '.dropdown-divider, .dropdown-item, .o_add_custom_filter', 4,
+ 'should have 4 elements: a hidden separator, a filter, a separator, a add custom filter item');
+ assert.containsOnce(filterMenu, '.o_add_custom_filter');
+ filterMenu.destroy();
+ });
+
+ QUnit.test('click on add custom filter opens the submenu', function (assert) {
+ assert.expect(3);
+
+ var filterMenu = createFilterMenu([], this.fields);
+ // open menu dropdown
+ testUtils.dom.click(filterMenu.$('span.fa-filter'));
+ // open add custom filter submenu
+ testUtils.dom.click(filterMenu.$('.o_add_custom_filter'));
+ assert.containsNone(filterMenu, '.dropdown-divider');
+ assert.isVisible(filterMenu.$('.o_add_filter_menu'));
+ assert.containsN(filterMenu, '.dropdown-item, .dropdown-item-text', 3,
+ 'should have 3 elements: a add custom filter item, a proposition, a apply button + add condition button');
+
+ filterMenu.destroy();
+ });
+
+ QUnit.test('removing last prop disable the apply button', function (assert) {
+ assert.expect(2);
+
+ var filterMenu = createFilterMenu([], this.fields);
+ // open menu dropdown and custom filter submenu
+ testUtils.dom.click(filterMenu.$('span.fa-filter'));
+ testUtils.dom.click(filterMenu.$('.o_add_custom_filter'));
+
+ // remove the current unique proposition
+ testUtils.dom.click(filterMenu.$('.o_searchview_extended_delete_prop'));
+
+ assert.containsNone(filterMenu, '.dropdown-divider');
+ assert.containsN(filterMenu, '.dropdown-item, .dropdown-item-text', 2,
+ 'should have 2 elements: a add custom filter item, a apply button + add condition button');
+
+ filterMenu.destroy();
+ });
+
+ QUnit.test('readding a proposition reenable apply button', function (assert) {
+ assert.expect(1);
+
+ var filterMenu = createFilterMenu([], this.fields);
+ // open menu dropdown and custom filter submenu, remove existing prop
+ testUtils.dom.click(filterMenu.$('span.fa-filter'));
+ testUtils.dom.click(filterMenu.$('.o_add_custom_filter'));
+ testUtils.dom.click(filterMenu.$('.o_searchview_extended_delete_prop'));
+ // read a proposition
+ testUtils.dom.click(filterMenu.$('.o_add_condition'));
+ assert.ok(!filterMenu.$('.o_apply_filter').attr('disabled'));
+
+ filterMenu.destroy();
+ });
+
+ QUnit.test('adding a simple filter works', function (assert) {
+ assert.expect(6);
+
+ delete this.fields.date_field;
+ var filterMenu = createFilterMenu([], this.fields, {
+ intercepts: {
+ new_filters: function (ev) {
+ var filter = ev.data.filters[0];
+ assert.strictEqual(filter.type, 'filter');
+ assert.strictEqual(filter.description, 'Boolean Field is true');
+ assert.strictEqual(filter.domain, '[[\"boolean_field\",\"=\",True]]');
+ filterMenu.update([{
+ isActive: true,
+ description: '?',
+ domain: '?',
+ groupNumber: 1,
+ }]);
+ },
+ },
+ });
+ // open menu dropdown and custom filter submenu, remove existing prop
+ testUtils.dom.click(filterMenu.$('span.fa-filter'));
+ testUtils.dom.click(filterMenu.$('.o_add_custom_filter'));
+ // click on apply to activate filter
+ testUtils.dom.click(filterMenu.$('.o_apply_filter'));
+ assert.containsNone(filterMenu, '.o_filter_condition');
+ assert.containsN(filterMenu, '.dropdown-divider', 2);
+ assert.isNotVisible(filterMenu.$('.dropdown-divider').eq(0));
+ filterMenu.destroy();
+ });
+
+ QUnit.skip('commit search with an extended proposition with field char does not cause a crash', function (assert) {
+ assert.expect(0);
+
+ this.fields = {many2one_field: {string: "Trululu", type: "many2one", relation: 'partner', selectable: true, searchable: true}};
+
+ var filterMenu = createFilterMenu([], this.fields, {
+ intercepts: {
+ new_filters: function (ev) {
+ var filter = ev.data.filters[0];
+ Domain.prototype.stringToArray(filter.domain);
+ },
+ }
+ });
+
+ // open menu dropdown and custom filter submenu, select trululu field and enter string "a", then click apply
+ testUtils.dom.click(filterMenu.$('span.fa-filter'));
+ testUtils.dom.click(filterMenu.$('.o_add_custom_filter'));
+ testUtils.fields.editSelect(filterMenu.$('.o_filter_condition select.o_input.o_searchview_extended_prop_field'), 'many2one_field');
+ testUtils.fields.editInput(filterMenu.$('.o_filter_condition .o_searchview_extended_prop_value input'), "a");
+ testUtils.dom.click(filterMenu.$('.o_apply_filter'));
+
+
+ // open custom filter submenu, select trululu field and enter string "'a'", then click apply
+ testUtils.dom.click(filterMenu.$('.o_add_custom_filter'));
+ testUtils.fields.editSelect(filterMenu.$('.o_filter_condition select.o_input.o_searchview_extended_prop_field'), 'many2one_field');
+ testUtils.fields.editInput(filterMenu.$('.o_filter_condition .o_searchview_extended_prop_value input'), '"a"');
+ testUtils.dom.click(filterMenu.$('.o_apply_filter'));
+ filterMenu.destroy();
+ });
+});
+});
diff --git a/addons/web/static/tests/views/search_filters_menu_tests.js b/addons/web/static/tests/views/search_filters_menu_tests.js
index a1a7617b7ad..e9246520f1d 100644
--- a/addons/web/static/tests/views/search_filters_menu_tests.js
+++ b/addons/web/static/tests/views/search_filters_menu_tests.js
@@ -1,27 +1,26 @@
odoo.define('web.search_filters_menu_tests', function (require) {
"use strict";
-var FiltersMenu = require('web.FiltersMenu');
+var FilterMenu = require('web.FilterMenu');
var testUtils = require('web.test_utils');
-function createFiltersMenu(filters, fields, params) {
+function createFilterMenu(filters, fields, params) {
params = params || {};
var target = params.debug ? document.body : $('#qunit-fixture');
- var menu = new FiltersMenu(null, filters, fields);
+ var menu = new FilterMenu(null, filters, fields);
testUtils.mock.addMockEnvironment(menu, params);
menu.appendTo(target);
return menu;
}
-QUnit.module('FiltersMenu', {
+QUnit.module('FilterMenu', {
beforeEach: function () {
this.filters = [
{
isActive: false,
description: 'some filter',
domain: '',
- name: 'red',
- groupId: 1,
+ groupNumber: 1,
},
];
this.fields = {
@@ -35,7 +34,7 @@ QUnit.module('FiltersMenu', {
QUnit.test('simple rendering with no filter', function (assert) {
assert.expect(4);
- var filterMenu = createFiltersMenu([], this.fields);
+ var filterMenu = createFilterMenu([], this.fields);
testUtils.dom.click(filterMenu.$('span.fa-filter'));
assert.containsOnce(filterMenu, '.dropdown-divider');
assert.ok(!filterMenu.$('.dropdown-divider').is(':visible'));
@@ -48,7 +47,7 @@ QUnit.module('FiltersMenu', {
QUnit.test('simple rendering with a filter', function (assert) {
assert.expect(2);
- var filterMenu = createFiltersMenu(this.filters, this.fields);
+ var filterMenu = createFilterMenu(this.filters, this.fields);
assert.containsN(filterMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 5,
'should have 4 elements: a hidden, separator, a filter, a separator, a add custom filter item, a apply button + add condition button');
assert.containsOnce(filterMenu, '.o_add_custom_filter.o_closed_menu');
@@ -58,7 +57,7 @@ QUnit.module('FiltersMenu', {
QUnit.test('click on add custom filter opens the submenu', function (assert) {
assert.expect(3);
- var filterMenu = createFiltersMenu([], this.fields);
+ var filterMenu = createFilterMenu([], this.fields);
// open menu dropdown
testUtils.dom.click(filterMenu.$('span.fa-filter'));
// open add custom filter submenu
@@ -74,7 +73,7 @@ QUnit.module('FiltersMenu', {
QUnit.test('removing last prop disable the apply button', function (assert) {
assert.expect(2);
- var filterMenu = createFiltersMenu([], this.fields);
+ var filterMenu = createFilterMenu([], this.fields);
// open menu dropdown and custom filter submenu
testUtils.dom.click(filterMenu.$('span.fa-filter'));
testUtils.dom.click(filterMenu.$('.o_add_custom_filter'));
@@ -93,82 +92,48 @@ QUnit.module('FiltersMenu', {
QUnit.test('readding a proposition reenable apply button', function (assert) {
assert.expect(1);
- var filterMenu = createFiltersMenu([], this.fields);
-
+ var filterMenu = createFilterMenu([], this.fields);
// open menu dropdown and custom filter submenu, remove existing prop
testUtils.dom.click(filterMenu.$('span.fa-filter'));
testUtils.dom.click(filterMenu.$('.o_add_custom_filter'));
testUtils.dom.click(filterMenu.$('.o_searchview_extended_delete_prop'));
-
// read a proposition
testUtils.dom.click(filterMenu.$('.o_add_condition'));
-
assert.ok(!filterMenu.$('.o_apply_filter').attr('disabled'));
filterMenu.destroy();
});
QUnit.test('adding a simple filter works', function (assert) {
- assert.expect(3);
- delete this.fields.date_field;
-
- var filterMenu = createFiltersMenu([], this.fields);
+ assert.expect(6);
+ delete this.fields.date_field;
+ var filterMenu = createFilterMenu([], this.fields, {
+ debug: true,
+ intercepts: {
+ new_filters: function (ev) {
+ var filter = ev.data.filters[0];
+ assert.strictEqual(filter.type, 'filter');
+ assert.strictEqual(filter.description, 'Boolean Field is true');
+ assert.strictEqual(filter.domain, '[[\"boolean_field\",\"=\",True]]');
+ filterMenu.update([{
+ isActive: true,
+ description: '?',
+ domain: '?',
+ groupNumber: 1,
+ }]);
+ },
+ },
+ });
// open menu dropdown and custom filter submenu, remove existing prop
testUtils.dom.click(filterMenu.$('span.fa-filter'));
testUtils.dom.click(filterMenu.$('.o_add_custom_filter'));
-
// click on apply to activate filter
testUtils.dom.click(filterMenu.$('.o_apply_filter'));
-
assert.hasClass(filterMenu.$('.o_add_custom_filter'), 'o_closed_menu');
assert.containsNone(filterMenu, '.o_filter_condition');
assert.containsOnce(filterMenu, '.dropdown-divider:visible',
'there should be a separator between filters and add custom filter menu');
-
- filterMenu.destroy();
- });
-
- QUnit.test('adding a date filter', function (assert) {
- assert.expect(5);
- var filters = [{
- itemId: 1,
- isActive: false,
- description: 'some filter',
- isPeriod: true,
- name: 'Filter Date',
- fieldName: 'date_field',
- groupId: 1,
- },
- ];
-
- var filterMenu = createFiltersMenu(filters, this.fields, {
- intercepts: {
- menu_item_toggled: function (ev) {
- assert.strictEqual(ev.data.optionId, 'this_quarter',
- "should trigger event with proper period");
- },
- },
- });
-
- // open menu dropdown
- testUtils.dom.click(filterMenu.$('span.fa-filter'));
-
- assert.containsNone(filterMenu, '.o_menu_item ul',
- "there should not be a sub menu item list");
-
- // open date sub menu
- testUtils.dom.click(filterMenu.$('.o_submenu_switcher'));
- assert.containsOnce(filterMenu, '.o_menu_item ul',
- "there should be a sub menu item list");
-
- // click on This Quarter option
- assert.doesNotHaveClass(filterMenu.$('.o_menu_item li[data-option_id="this_quarter"] .dropdown-item'), 'selected',
- "menu item should not be selected");
- testUtils.dom.click(filterMenu.$('.o_menu_item li[data-option_id="this_quarter"] a'));
- assert.hasClass(filterMenu.$('.o_menu_item li[data-option_id="this_quarter"] .dropdown-item'),'selected',
- "menu item should be selected");
-
filterMenu.destroy();
});
});
diff --git a/addons/web/static/tests/views/search_groupby_menu_tests.js b/addons/web/static/tests/views/search_groupby_menu_tests.js
index 63967f57e86..efeb74103d5 100644
--- a/addons/web/static/tests/views/search_groupby_menu_tests.js
+++ b/addons/web/static/tests/views/search_groupby_menu_tests.js
@@ -3,11 +3,13 @@ odoo.define('web.search_groupby_menu_tests', function (require) {
var GroupByMenu = require('web.GroupByMenu');
var testUtils = require('web.test_utils');
+var controlPanelViewParameters = require('web.controlPanelViewParameters');
+var INTERVAL_OPTIONS = controlPanelViewParameters.INTERVAL_OPTIONS;
-function createGroupByMenu(groupbys, fields, params) {
+function createGroupByMenu(groupBys, fields, params) {
params = params || {};
var target = params.debug ? document.body : $('#qunit-fixture');
- var menu = new GroupByMenu(null, groupbys, fields);
+ var menu = new GroupByMenu(null, groupBys, fields);
testUtils.mock.addMockEnvironment(menu, params);
menu.appendTo(target);
return menu;
@@ -15,38 +17,42 @@ function createGroupByMenu(groupbys, fields, params) {
QUnit.module('GroupByMenu', {
beforeEach: function () {
- this.groupbys = [
+ this.groupBys = [
{
- isActive: false,
description: 'some group by',
- fieldName: 'fieldname',
- itemId: 'red',
- groupId: 1,
+ groupNumber: 1,
+ isActive: false,
},
];
this.fields = {
- fieldname: {sortable: true, string: 'Super Date', type: 'date', isDate: true}
+ fieldName: {sortable: true, string: 'Super Date', type: 'date'}
};
},
}, function () {
- QUnit.test('simple rendering', function (assert) {
- assert.expect(2);
+ QUnit.test('simple rendering with no filter and no field', function (assert) {
+ assert.expect(1);
- var groupByMenu = createGroupByMenu(this.groupbys, this.fields);
+ var groupByMenu = createGroupByMenu([], {});
+ // open groupBy menu
testUtils.dom.click(groupByMenu.$('button:first'));
- assert.containsN(groupByMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 2, 'should have 2 elements');
- assert.strictEqual(groupByMenu.$('.dropdown-divider, .dropdown-item, .dropdown-item-text').eq(1).text().trim(), 'some group by',
- 'should have proper filter name');
+ assert.containsNone(groupByMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text',
+ 'should have 0 element');
+
groupByMenu.destroy();
});
- QUnit.test('simple rendering with no filter and no field', function (assert) {
- assert.expect(1);
+ QUnit.test('simple rendering', function (assert) {
+ assert.expect(2);
- var groupByMenu = createGroupByMenu([], {});
+ var groupByMenu = createGroupByMenu(this.groupBys, this.fields);
+ // open groupBy menu
testUtils.dom.click(groupByMenu.$('button:first'));
- assert.containsNone(groupByMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 'should have 0 element');
+ groupByMenu.$('button:first').click();
+ assert.containsN(groupByMenu, '.dropdown-divider, .dropdown-item', 4);
+ assert.strictEqual(groupByMenu.$('.o_menu_item').text().trim(), 'some group by',
+ 'should have proper filter name');
+
groupByMenu.destroy();
});
@@ -55,33 +61,52 @@ QUnit.module('GroupByMenu', {
var groupByMenu = createGroupByMenu(
[],
- {fieldname: {sortable: true, string: 'Super Date', type: 'date', isDate: true}}
+ {fieldName: {sortable: true, string: 'Super Date', type: 'date'}}
);
testUtils.dom.click(groupByMenu.$('button:first'));
assert.containsOnce(groupByMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 'should have 1 element');
+
groupByMenu.destroy();
});
QUnit.test('click on add custom group toggle group selector', function (assert) {
assert.expect(2);
- var groupByMenu = createGroupByMenu([], {fieldname: {sortable: true, string: 'Super Date', type: 'date', isDate: true}});
+ var groupByMenu = createGroupByMenu([],
+ {fieldName: {sortable: true, string: 'Super Date', type: 'date'}}
+ );
testUtils.dom.click(groupByMenu.$('button:first'));
var selector = groupByMenu.$('select.o_group_selector');
assert.ok(!selector.is(":visible"), 'should be invisible');
testUtils.dom.click(groupByMenu.$('.o_add_custom_group'));
selector = groupByMenu.$('select.o_group_selector');
assert.ok(selector.is(":visible"), 'should be visible');
+
groupByMenu.destroy();
});
- QUnit.test('select a group using the group selector add properly add that group to menu', function (assert) {
- assert.expect(2);
+ QUnit.test('select a groupBy of no date type in Add Custom Group menu add properly that groupBy to menu', function (assert) {
+ assert.expect(6);
var groupByMenu = createGroupByMenu(
[],
{
fieldName: {sortable: true, name: 'candlelight', string: 'Candlelight', type: 'boolean'},
+ },
+ {
+ intercepts: {
+ new_groupBy: function (ev) {
+ assert.strictEqual(ev.data.description, 'Candlelight');
+ assert.strictEqual(ev.data.fieldName, 'fieldName');
+ assert.strictEqual(ev.data.fieldType, 'boolean');
+ assert.strictEqual(ev.data.type, 'groupBy');
+ groupByMenu.update([{
+ description: 'Candlelight',
+ groupNumber: 1,
+ isActive: true,
+ }]);
+ },
+ },
}
);
testUtils.dom.click(groupByMenu.$('button:first'));
@@ -93,62 +118,59 @@ QUnit.module('GroupByMenu', {
groupByMenu.destroy();
});
- QUnit.test('click on a groupby filter (not of date type) should activate it', function (assert) {
- assert.expect(5);
-
- this.groupbys = [{
- isActive: false,
- description: 'another group by',
- fieldName: 'float_field',
- itemId: 'green',
- groupId: 1,
- }];
- this.fields = {float_field: {sortable: true, string: 'Super Float', type: 'float'}};
-
- var groupByMenu = createGroupByMenu(this.groupbys, this.fields, {
- intercepts: {
- menu_item_toggled: function (ev) {
- assert.strictEqual(ev.data.itemId, 'green');
- assert.strictEqual(ev.data.isActive, true);
- },
- },
- });
- testUtils.dom.click(groupByMenu.$('button:first'));
- assert.doesNotHaveClass(groupByMenu.$('.o_menu_item:first > .dropdown-item'), 'selected');
- testUtils.dom.click(groupByMenu.$('.o_menu_item a').first());
- assert.hasClass(groupByMenu.$('.o_menu_item:first > .dropdown-item'), 'selected');
- assert.ok(groupByMenu.$('.o_menu_item:first').is(':visible'),
- 'group by filter should still be visible');
- groupByMenu.destroy();
- });
-
- QUnit.test('click on a groupby filter of date type should open menu option', function (assert) {
- assert.expect(4);
-
- var groupByMenu = createGroupByMenu(this.groupbys,
- {fieldname: {sortable: true, string: 'Super Date', type: 'date', isDate: true}});
- testUtils.dom.click(groupByMenu.$('button:first'));
- assert.doesNotHaveClass(groupByMenu.$('.o_menu_item:first > .dropdown-item'), 'selected');
- testUtils.dom.click(groupByMenu.$('.o_menu_item a').first());
- assert.doesNotHaveClass(groupByMenu.$('.o_menu_item:first > .dropdown-item'), 'selected');
- assert.ok(groupByMenu.$('.o_menu_item:first').is(':visible'),
- 'group by filter should still be visible');
- assert.ok(groupByMenu.$('.o_item_option').length, 5);
- groupByMenu.destroy();
- });
+ QUnit.test('select a groupBy of date type in Add Custom Group menu add properly that groupBy to menu', function (assert) {
+ assert.expect(13);
- QUnit.test('click on groupby filter should not change url', function (assert) {
- assert.expect(0);
+ INTERVAL_OPTIONS = INTERVAL_OPTIONS.map(function (option) {
+ return _.extend(option, {description: option.description.toString()});
+ });
- var groupByMenu = createGroupByMenu(this.groupbys,
- {fieldname: {sortable: true, string: 'Super Date', type: 'date', isDate: true}}
+ var groupByMenu = createGroupByMenu(
+ [],
+ this.fields,
+ {
+ intercepts: {
+ new_groupBy: function (ev) {
+ assert.strictEqual(ev.data.description, 'Super Date');
+ assert.strictEqual(ev.data.fieldName, 'fieldName');
+ assert.strictEqual(ev.data.fieldType, 'date');
+ assert.strictEqual(ev.data.type, 'groupBy');
+ assert.strictEqual(ev.data.hasOptions, true);
+ assert.deepEqual(ev.data.options, controlPanelViewParameters.INTERVAL_OPTIONS);
+ assert.strictEqual(ev.data.defaultOptionId, controlPanelViewParameters.DEFAULT_INTERVAL);
+ assert.strictEqual(ev.data.currentOptionId, false);
+ groupByMenu.update([{
+ description: 'Super Date',
+ fieldName: 'fieldName',
+ groupNumber: 1,
+ isActive: true,
+ hasOptions: true,
+ options: controlPanelViewParameters.INTERVAL_OPTIONS,
+ currentOptionId: controlPanelViewParameters.DEFAULT_INTERVAL,
+ }]);
+ },
+ },
+ }
);
- testUtils.dom.click(groupByMenu.$('.o_dropdown_toggler_btn'));
- groupByMenu.$el.click(function (event) {
- // we do not want a click to get out and change the url, for example
- throw new Error('No click should get out of the groupby menu');
- });
- testUtils.dom.click(groupByMenu.$('.o_menu_item a').first());
+ // open groupBy menu
+ testUtils.dom.click(groupByMenu.$('button:first'));
+ // open Add Custom Group submenu
+ testUtils.dom.click(groupByMenu.$('.o_add_custom_group'));
+ // select fieldName
+ assert.strictEqual(groupByMenu.$('select').val(), 'fieldName',
+ 'the select value should be "fieldName"');
+ // create new groupBy of type date
+ groupByMenu.$('button.o_apply_group').click();
+ assert.strictEqual(groupByMenu.$('.o_menu_item > .dropdown-item.selected').length, 1,
+ 'there should be a groupby selected');
+ assert.strictEqual(groupByMenu.$('.o_menu_item .o_submenu_switcher').length, 1,
+ 'there should be options available');
+ // open options submenu
+ groupByMenu.$('.o_menu_item .o_submenu_switcher').click();
+ assert.strictEqual(groupByMenu.$('.o_item_option').length, 5,
+ 'there should be five options available');
+ assert.strictEqual(groupByMenu.$('.o_add_custom_group').length, 0,
+ 'there should be no more a Add Custome Group submenu');
groupByMenu.destroy();
});
diff --git a/addons/web/static/tests/views/search_view_tests.js b/addons/web/static/tests/views/search_view_tests.js
index 0dbddbe1fae..fa3fe817c2a 100644
--- a/addons/web/static/tests/views/search_view_tests.js
+++ b/addons/web/static/tests/views/search_view_tests.js
@@ -3,9 +3,14 @@ odoo.define('web.search_view_tests', function (require) {
var FormView = require('web.FormView');
var testUtils = require('web.test_utils');
+
var createActionManager = testUtils.createActionManager;
-var patchDate = testUtils.mock.patchDate;
+var createControlPanel = testUtils.createControlPanel;
var createView = testUtils.createView;
+var patchDate = testUtils.mock.patchDate;
+
+var controlPanelViewParameters = require('web.controlPanelViewParameters');
+var PERIOD_OPTIONS_IDS = controlPanelViewParameters.PERIOD_OPTIONS.map(function (option) {return option.optionId;});
QUnit.module('Search View', {
beforeEach: function () {
@@ -188,21 +193,6 @@ QUnit.module('Search View', {
'</search>',
};
- this.periodOptions = [
- 'last_7_days',
- 'last_30_days',
- 'last_365_days',
- 'today',
- 'this_week',
- 'this_month',
- 'this_quarter',
- 'this_year',
- 'yesterday',
- 'last_week',
- 'last_month',
- 'last_quarter',
- 'last_year',
- ];
// assuming that the current time is: 2017-03-22:01:00:00
this.periodDomains = [
@@ -265,7 +255,59 @@ QUnit.module('Search View', {
];
},
}, function () {
- QUnit.module('Groupby Menu');
+ QUnit.test('basic rendering', function (assert) {
+ assert.expect(1);
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+ actionManager.doAction(1);
+
+ assert.strictEqual($('.o_searchview input.o_searchview_input')[0], document.activeElement,
+ "searchview input should be focused");
+
+ actionManager.destroy();
+ });
+ QUnit.test('navigation with facets', function (assert) {
+ assert.expect(4);
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+ actionManager.doAction(1);
+
+ // add a facet
+ testUtils.dom.click(actionManager.$('.o_dropdown_toggler_btn:contains(Group By)'));
+ testUtils.dom.click(actionManager.$('.o_menu_item a'));
+ assert.strictEqual(actionManager.$('.o_searchview .o_searchview_facet').length, 1,
+ "there should be one facet");
+ assert.strictEqual(actionManager.$('.o_searchview input.o_searchview_input')[0], document.activeElement,
+ "searchview input should be focused");
+
+ // press left to focus the facet
+ actionManager.$('.o_searchview_input_container').trigger($.Event('keydown', {
+ which: $.ui.keyCode.LEFT,
+ keyCode: $.ui.keyCode.LEFT,
+ }));
+ assert.strictEqual(actionManager.$('.o_searchview .o_searchview_facet')[0], document.activeElement,
+ "the facet should be focused");
+
+ // press right to focus the facet
+ actionManager.$('.o_searchview_input_container').trigger($.Event('keydown', {
+ which: $.ui.keyCode.RIGHT,
+ keyCode: $.ui.keyCode.RIGHT,
+ }));
+ assert.strictEqual(actionManager.$('.o_searchview input.o_searchview_input')[0], document.activeElement,
+ "searchview input should be focused");
+
+ actionManager.destroy();
+ });
+
+ QUnit.module('GroupByMenu');
QUnit.test('click on groupby filter adds a facet', function (assert) {
assert.expect(1);
@@ -275,12 +317,12 @@ QUnit.module('Search View', {
archs: this.archs,
data: this.data,
});
-
actionManager.doAction(1);
testUtils.dom.click($('.o_dropdown_toggler_btn:contains(Group By)'));
testUtils.dom.click($('.o_menu_item a'));
assert.strictEqual($('.o_searchview .o_searchview_facet .o_facet_values span').text().trim(), 'candle',
'should have a facet with candle name');
+
actionManager.destroy();
});
@@ -358,14 +400,14 @@ QUnit.module('Search View', {
data: this.data,
});
actionManager.doAction(4);
- testUtils.dom.click($('div.o_graph_groupbys_menu > button'));
- testUtils.dom.click($('div.o_graph_groupbys_menu .o_menu_item').eq(1));
- testUtils.dom.click($('div.o_graph_groupbys_menu .o_menu_item .o_item_option > .dropdown-item').first());
- assert.doesNotHaveClass($('div.o_graph_groupbys_menu .o_menu_item > .dropdown-item').eq(1), 'selected',
+ testUtils.dom.click($('.o_graph_controller .o_control_panel .o_cp_buttons button').eq(1));
+ testUtils.dom.click($('.o_graph_controller .o_group_by_menu .o_menu_item').eq(1));
+ testUtils.dom.click($('.o_graph_controller .o_group_by_menu .o_menu_item .o_item_option > .dropdown-item').first());
+ assert.doesNotHaveClass($('.o_graph_controller .o_group_by_menu .o_menu_item > .dropdown-item').eq(1), 'selected',
'groupby should be unselected');
testUtils.dom.click($('.o_search_options button span.fa-filter'));
testUtils.dom.click($('.o_filters_menu .o_menu_item a').first());
- assert.doesNotHaveClass($('div.o_graph_groupbys_menu .o_menu_item > .dropdown-item').eq(1), 'selected',
+ assert.doesNotHaveClass($('.o_graph_controller .o_group_by_menu .o_menu_item > .dropdown-item').eq(1), 'selected',
'groupby should be still unselected');
actionManager.destroy();
});
@@ -459,7 +501,7 @@ QUnit.module('Search View', {
actionManager.destroy();
});
- QUnit.module('Filters Menu');
+ QUnit.module('FilterMenu');
QUnit.test('Search date and datetime fields. Support of timezones', function (assert) {
assert.expect(4);
@@ -628,7 +670,10 @@ QUnit.module('Search View', {
var periodOptions = $('.o_menu_item .o_item_option').map(function () {
return $(this).data('option_id');
}).toArray();
- assert.deepEqual(periodOptions, this.periodOptions,
+
+
+
+ assert.deepEqual(periodOptions, PERIOD_OPTIONS_IDS,
"13 period options should be available:");
testUtils.dom.click($('.o_menu_item .o_item_option[data-option_id="last_7_days"]'));
@@ -660,7 +705,6 @@ QUnit.module('Search View', {
data: this.data,
intercepts: {
create_filter: function (ev) {
- console.log(ev.data);
assert.equal(
ev.data.filter.domain,
"['&', " +
@@ -676,11 +720,175 @@ QUnit.module('Search View', {
testUtils.dom.click($('.o_filters_menu .o_menu_item a'));
testUtils.dom.click($('.o_item_option[data-option_id="today"]'));
testUtils.dom.click($('span.fa-star'));
- testUtils.dom.click($('.o_favorites_menu .o_save_search'));
- testUtils.dom.click($('.o_favorites_menu .o_save_name button'));
+ testUtils.dom.click($('.o_favorites_menu .o_add_favorite'));
+ testUtils.fields.editInput($('div.o_favorite_name input'), 'name for favorite');
+ testUtils.dom.click($('.o_favorites_menu .o_save_favorite button'));
actionManager.destroy();
});
+ QUnit.test('save search filter in modal', function (assert) {
+ assert.expect(5);
+ this.data.partner.records.push({
+ id: 7,
+ display_name: "Partner 6",
+ }, {
+ id: 8,
+ display_name: "Partner 7",
+ }, {
+ id: 9,
+ display_name: "Partner 8",
+ }, {
+ id: 10,
+ display_name: "Partner 9",
+ });
+ this.data.partner.fields.date_field.searchable = true;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="bar"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'partner,false,list': '<tree><field name="display_name"/></tree>',
+ 'partner,false,search': '<search><field name="date_field"/></search>',
+ },
+ res_id: 1,
+ });
+
+ testUtils.form.clickEdit(form);
+
+ testUtils.fields.many2one.clickOpenDropdown('bar');
+ testUtils.fields.many2one.clickItem('bar','Search');
+
+ assert.strictEqual($('tr.o_data_row').length, 9, "should display 9 records");
+
+ testUtils.dom.click($('button:contains(Filters)'));
+ testUtils.dom.click($('.o_add_custom_filter:visible'));
+ assert.strictEqual($('.o_filter_condition select.o_searchview_extended_prop_field').val(), 'date_field',
+ "date field should be selected");
+ testUtils.dom.click($('.o_apply_filter'));
+
+ assert.strictEqual($('tr.o_data_row').length, 0, "should display 0 records");
+
+ // Save this search
+ testUtils.mock.intercept(form, 'create_filter', function (event) {
+ assert.strictEqual(event.data.filter.name, "Awesome Test Customer Filter", "filter name should be correct");
+ });
+ testUtils.dom.click($('button:contains(Favorites)'));
+ testUtils.dom.click($('.o_add_favorite'));
+ var filterNameInput = $('.o_favorite_name .o_input[type="text"]:visible');
+ assert.strictEqual(filterNameInput.length, 1, "should display an input field for the filter name");
+ testUtils.fields.editInput(filterNameInput, 'Awesome Test Customer Filter');
+ testUtils.dom.click($('.o_save_favorite button'));
+
+ form.destroy();
+ });
+
+ QUnit.test('save filters created via autocompletion works', function (assert) {
+ assert.expect(2);
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ intercepts: {
+ create_filter: function (ev) {
+ assert.ok(ev.data.filter.domain === "[['foo', 'ilike', 'a']]");
+ },
+ },
+ });
+
+ actionManager.doAction(10);
+
+ $('.o_searchview_input').trigger($.Event('keypress', {
+ which: 97,
+ }));
+
+ $('.o_searchview_input').trigger($.Event('keyup', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+
+ assert.strictEqual($('.o_searchview_input_container .o_facet_values span').text().trim(), "a");
+
+ testUtils.dom.click($('button .fa-star'));
+ testUtils.dom.click($('.o_favorites_menu .o_add_favorite'));
+ testUtils.fields.editInput($('div.o_favorite_name input'), 'name for favorite');
+ testUtils.dom.click($('.o_favorites_menu div.o_save_favorite button'));
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('delete an active favorite remove it both in list of favorite and in search bar', function (assert) {
+ assert.expect(2);
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ intercepts: {
+ load_filters: function (event) {
+ return $.when([{
+ context: "{}",
+ domain: "[]",
+ id: 7,
+ is_default: true,
+ name: "My favorite",
+ sort: "[]",
+ user_id: [2, "Mitchell Admin"],
+ }]).then(event.data.on_success);
+ },
+ delete_filter: function (event) {
+ event.data.on_success();
+ }
+ },
+ });
+
+ actionManager.doAction(6);
+ testUtils.dom.click(actionManager.$('.o_control_panel .o_search_options button.o_favorites_menu_button'));
+ assert.containsOnce(actionManager, '.o_control_panel .o_searchview_input_container .o_facet_values');
+ testUtils.dom.click(actionManager.$('.o_control_panel .o_search_options .o_favorites_menu span.o_trash_button'));
+ testUtils.modal.clickButton('Ok');
+ assert.containsNone(actionManager, '.o_control_panel .o_searchview_input_container .o_facet_values');
+ actionManager.destroy();
+ });
+
+ QUnit.test('default favorite is not activated if key search_disable_custom_filters is set to true', async function (assert) {
+ assert.expect(1);
+
+ var controlPanel = await createControlPanel({
+ model: 'partner',
+ arch: '<controlpanel/>',
+ data: this.data,
+ intercepts: {
+ load_filters: function (event) {
+ return $.when([{
+ context: "{}",
+ domain: "[]",
+ id: 7,
+ is_default: true,
+ name: "My favorite",
+ sort: "[]",
+ user_id: [2, "Mitchell Admin"],
+ }]).then(event.data.on_success);
+ },
+ },
+ context: {
+ search_disable_custom_filters: true,
+ },
+ });
+
+ assert.containsNone(controlPanel, '.o_facet_values');
+ controlPanel.destroy();
+ });
+
+ QUnit.module('Search Arch');
+
QUnit.test('arch order of groups of filters preserved', function (assert) {
assert.expect(12);
@@ -699,6 +907,8 @@ QUnit.module('Search View', {
actionManager.destroy();
});
+ QUnit.module('Autocompletion');
+
QUnit.test('selection via autocompletion modifies appropriately submenus', function (assert) {
assert.expect(4);
@@ -748,42 +958,10 @@ QUnit.module('Search View', {
actionManager.destroy();
});
- QUnit.test('save filters created via autocompletion works', function (assert) {
- assert.expect(2);
-
- var actionManager = createActionManager({
- actions: this.actions,
- archs: this.archs,
- data: this.data,
- intercepts: {
- create_filter: function (ev) {
- assert.ok(ev.data.filter.domain === "[['foo', 'ilike', 'a']]");
- },
- },
- });
-
- actionManager.doAction(10);
-
- $('.o_searchview_input').trigger($.Event('keypress', {
- which: 97,
- }));
-
- $('.o_searchview_input').trigger($.Event('keyup', {
- which: $.ui.keyCode.ENTER,
- keyCode: $.ui.keyCode.ENTER,
- }));
-
- assert.strictEqual($('.o_searchview_input_container .o_facet_values span').text().trim(), "a");
-
- testUtils.dom.click($('button .fa-star'));
- testUtils.dom.click($('.o_favorites_menu a.o_save_search'));
- testUtils.dom.click($('.o_favorites_menu div.o_save_name button'));
-
- actionManager.destroy();
- });
+ QUnit.module('TimeRangeMenu');
QUnit.test('time range menu stays hidden', function (assert) {
- assert.expect(6);
+ assert.expect(4);
var actionManager = createActionManager({
actions: this.actions,
@@ -793,9 +971,8 @@ QUnit.module('Search View', {
actionManager.doAction(1);
- // check that the fifth dropdown is the time range menu and is hidden
- assert.isNotVisible($('.btn-group.o_dropdown').eq(4));
- assert.hasClass($('.btn-group.o_dropdown').eq(4).children().eq(1), 'o_time_range_menu');
+ // check that there is no time range menu
+ assert.containsNone(actionManager, '.o_control_panel .o_search_options .o_time_range_menu');
// check if search view has no facets
assert.strictEqual($('.o_facet_values').length, 0);
@@ -804,9 +981,8 @@ QUnit.module('Search View', {
testUtils.dom.click($('.o_menu_item a').eq(0));
// check that there is a facet
assert.strictEqual($('div.o_facet_values').length, 1);
- // check that the fifth dropdown is the time range menu and is still hidden
- assert.isNotVisible($('.btn-group.o_dropdown').eq(4));
- assert.hasClass($('.btn-group.o_dropdown').eq(4).children().eq(1), 'o_time_range_menu');
+ // check that there is still no time range menu
+ assert.containsNone(actionManager, '.o_control_panel .o_search_options .o_time_range_menu');
actionManager.destroy();
});
@@ -857,7 +1033,7 @@ QUnit.module('Search View', {
var periodOptions = $periodOptions.map(function () {
return $(this).val();
}).toArray();
- assert.deepEqual(periodOptions, this.periodOptions,
+ assert.deepEqual(periodOptions, PERIOD_OPTIONS_IDS,
"13 period options should be available:");
$periodOptions.each(function () {
@@ -908,75 +1084,11 @@ QUnit.module('Search View', {
views: [[false, 'pivot']],
context: {time_ranges: {range: 'today', field: 'date_field'}}
});
-
actionManager.destroy();
});
- QUnit.test('save search filter in modal', function (assert) {
- assert.expect(5);
- this.data.partner.records.push({
- id: 7,
- display_name: "Partner 6",
- }, {
- id: 8,
- display_name: "Partner 7",
- }, {
- id: 9,
- display_name: "Partner 8",
- }, {
- id: 10,
- display_name: "Partner 9",
- });
- this.data.partner.fields.date_field.searchable = true;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<sheet>' +
- '<group>' +
- '<field name="bar"/>' +
- '</group>' +
- '</sheet>' +
- '</form>',
- archs: {
- 'partner,false,list': '<tree><field name="display_name"/></tree>',
- 'partner,false,search': '<search><field name="date_field"/></search>',
- },
- res_id: 1,
- });
-
- testUtils.form.clickEdit(form);
-
- testUtils.fields.many2one.clickOpenDropdown('bar');
- testUtils.fields.many2one.clickItem('bar','Search');
-
- assert.strictEqual($('tr.o_data_row').length, 9, "should display 9 records");
-
- testUtils.dom.click($('button:contains(Filters)'));
- testUtils.dom.click($('.o_add_custom_filter:visible'));
- assert.strictEqual($('.o_filter_condition select.o_searchview_extended_prop_field').val(), 'date_field',
- "date field should be selected");
- testUtils.dom.click($('.o_apply_filter'));
-
- assert.strictEqual($('tr.o_data_row').length, 0, "should display 0 records");
-
- // Save this search
- testUtils.mock.intercept(form, 'create_filter', function (event) {
- assert.strictEqual(event.data.filter.name, "Awesome Test Customer Filter", "filter name should be correct");
- });
- testUtils.dom.click($('button:contains(Favorites)'));
- testUtils.dom.click($('.o_save_search'));
- var filterNameInput = $('.o_save_name .o_input[type="text"]:visible');
- assert.strictEqual(filterNameInput.length, 1, "should display an input field for the filter name");
- testUtils.fields.editInput(filterNameInput, 'Awesome Test Customer Filter');
- testUtils.dom.click($('.o_save_name button'));
-
- form.destroy();
- });
-
QUnit.test('Customizing filter does not close the filter dropdown', function (assert) {
- assert.expect(4);
+ assert.expect(3);
var self = this;
_.each(this.data.partner.records.slice(), function (rec) {
@@ -1002,39 +1114,30 @@ QUnit.module('Search View', {
res_id: 1,
});
- form.$('.o_input').click();
- $('.ui-autocomplete .ui-menu-item:contains(Search More)').mouseenter().click();
-
- var $modal = $('.modal-dialog.modal-lg');
- assert.strictEqual($modal.length, 1, 'Modal Opened');
-
- $modal.find('.o_search_options button:contains(Filters)').click();
-
- var $filterDropDown = $modal.find('.o_dropdown_menu.show');
+ testUtils.fields.many2one.clickOpenDropdown('bar');
+ testUtils.fields.many2one.clickItem('bar', 'Search More');
- $filterDropDown.find('button:contains(Add Custom Filter)').click();
+ assert.containsOnce(document.body, '.modal');
- assert.ok($filterDropDown.find('button:contains(Add Custom Filter)').hasClass('o_open_menu'),
- 'The right dropdown is open');
+ testUtils.dom.click($('.modal .o_filters_menu_button'));
- var $filterInputs = $filterDropDown.find('.o_input');
+ var $filterDropdown = $('.modal .o_filters_menu');
+ testUtils.dom.click($filterDropdown.find('.o_add_custom_filter'));
- assert.strictEqual($filterInputs.length, 3,
- 'The custom filter builder has 3 elements');
+ assert.containsN($filterDropdown, '.o_input', 3);
// We really are interested in the click event
// We do it twice on each input to make sure
// the parent dropdown doesn't react to any of it
- _.each($filterInputs, function (input) {
+ _.each($filterDropdown.find('input'), function (input) {
var $input = $(input);
$input.click();
$input.click();
});
- assert.ok($filterDropDown.is(':visible'));
+ assert.isVisible($filterDropdown);
form.destroy();
});
-
});
});
diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js
index 023a977ad56..1df70b03acc 100644
--- a/addons/web/static/tests/views/view_dialogs_tests.js
+++ b/addons/web/static/tests/views/view_dialogs_tests.js
@@ -80,11 +80,6 @@ QUnit.module('Views', {
},
});
- testUtils.mock.intercept(parent, 'env_updated', function () {
- throw new Error("The environment should not be propagated to the action manager");
- });
-
-
new dialogs.FormViewDialog(parent, {
res_model: 'partner',
res_id: 1,
@@ -155,10 +150,10 @@ QUnit.module('Views', {
mockRPC: function (route, args) {
if (args.method === 'read_group') {
assert.deepEqual(args.kwargs, {
- context: {group_by: "bar"},
- domain: [["display_name","like","a"], ["display_name","ilike","piou"], ["foo","ilike","piou"]],
- fields:["display_name","foo","bar"],
- groupby:["bar"],
+ context: {},
+ domain: [["display_name","like","a"], "&", ["display_name","ilike","piou"], ["foo","ilike","piou"]],
+ fields: ["display_name","foo","bar"],
+ groupby: ["bar"],
orderby: '',
lazy: true
}, "should search with the complete domain (domain + search), and group by 'bar'");
@@ -167,8 +162,8 @@ QUnit.module('Views', {
search++;
assert.deepEqual(args, {
context: {},
- domain: [["display_name","like","a"], ["display_name","ilike","piou"], ["foo","ilike","piou"]],
- fields:["display_name","foo"],
+ domain: [["display_name","like","a"], "&", ["display_name","ilike","piou"], ["foo","ilike","piou"]],
+ fields: ["display_name","foo"],
model: "partner",
limit: 80,
sort: ""
@@ -177,7 +172,7 @@ QUnit.module('Views', {
assert.deepEqual(args, {
context: {},
domain: [["display_name","like","a"]],
- fields:["display_name","foo"],
+ fields: ["display_name","foo"],
model: "partner",
limit: 80,
sort: ""
@@ -423,9 +418,11 @@ QUnit.module('Views', {
assert.expect(4);
testUtils.mock.patch(ListController, {
- getContext: function () {
+ getOwnedQueryParams: function () {
return {
- shouldBeInFilterContext: true,
+ context: {
+ shouldBeInFilterContext: true,
+ },
};
},
});
@@ -471,9 +468,9 @@ QUnit.module('Views', {
// save filter
testUtils.dom.click(dialog.$('.o_dropdown_toggler_btn:contains(Favorites)'));
- testUtils.dom.click(dialog.$('.o_save_search'));
- dialog.$('.o_save_name input[type=text]').val('some name'); // name the filter
- testUtils.dom.click(dialog.$('.o_save_name button'));
+ testUtils.dom.click(dialog.$('.o_add_favorite'));
+ dialog.$('.o_favorite_name input[type=text]').val('some name'); // name the filter
+ testUtils.dom.click(dialog.$('.o_save_favorite button'));
testUtils.mock.unpatch(ListController);
parent.destroy();
diff --git a/addons/web/static/tests/widgets/dropdown_menu_tests.js b/addons/web/static/tests/widgets/dropdown_menu_tests.js
index 83f5d3fecb7..709651c7cb1 100644
--- a/addons/web/static/tests/widgets/dropdown_menu_tests.js
+++ b/addons/web/static/tests/widgets/dropdown_menu_tests.js
@@ -4,10 +4,10 @@ odoo.define('web.dropdown_menu_tests', function (require) {
var DropdownMenu = require('web.DropdownMenu');
var testUtils = require('web.test_utils');
-function createDropdownMenu(dropdownTitle, groups, params) {
+function createDropdownMenu(items, params) {
params = params || {};
var target = params.debug ? document.body : $('#qunit-fixture');
- var menu = new DropdownMenu(null, dropdownTitle, groups);
+ var menu = new DropdownMenu(null, items);
testUtils.mock.addMockEnvironment(menu, params);
menu.appendTo(target);
return menu;
@@ -18,74 +18,69 @@ QUnit.module('Web', {
this.items = [
{
isActive: false,
- isOpen: false,
description: 'Some Item',
- itemId: 1,
+ id: 1,
groupId: 1,
+ groupNumber: 1,
},
{
isActive: true,
description: 'Some other Item',
- itemId: 2,
- options: [],
- isRemovable: true,
+ id: 2,
groupId: 2,
+ groupNumber: 2,
},
];
- this.dropdownHeader = {
- title: "Menu",
- icon: "fa fa-bars",
- };
},
}, function () {
QUnit.module('DropdownMenu');
QUnit.test('simple rendering', function (assert) {
- assert.expect(2);
+ assert.expect(4);
- var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items);
- assert.containsN(dropdownMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 4, 'should have 4 elements counting the dividers');
- assert.strictEqual(dropdownMenu.$('.dropdown-divider, .dropdown-item, .dropdown-item-text').eq(1).text().trim(), 'Some Item',
+ var dropdownMenu = createDropdownMenu(this.items);
+ assert.containsN(dropdownMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 4,
+ 'should have 4 elements counting the dividers');
+ assert.strictEqual(dropdownMenu.$('.o_menu_item').first().text().trim(), 'Some Item',
'first element should have "Some Item" description');
+ assert.ok(!dropdownMenu.$('.o_menu_item > .dropdown-item').eq(0).hasClass('selected'),
+ 'first element should not be selected');
+ assert.ok(dropdownMenu.$('.o_menu_item > .dropdown-item').eq(1).hasClass('selected'),
+ 'second element should be selected');
+
dropdownMenu.destroy();
});
- QUnit.test('click on an item should toggle item', function (assert) {
- assert.expect(9);
+ QUnit.test('click on an item without options should toggle it', function (assert) {
+ assert.expect(7);
- var eventNumber = 0;
-
- var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items, {
+ var self = this;
+ var dropdownMenu = createDropdownMenu(this.items, {
intercepts: {
- menu_item_toggled: function (ev) {
- eventNumber++;
- assert.strictEqual(ev.data.itemId, 1);
- if (eventNumber === 1) {
- assert.strictEqual(ev.data.isActive, true);
- } else {
- assert.strictEqual(ev.data.isActive, false);
- }
+ menu_item_clicked: function (ev) {
+ assert.strictEqual(ev.data.id, 1);
+ self.items[0].isActive = !self.items[0].isActive;
+ dropdownMenu.update(self.items);
},
},
});
testUtils.dom.click(dropdownMenu.$('button:first'));
- assert.doesNotHaveClass(dropdownMenu.$('.o_menu_item:first > .dropdown-item'), 'selected');
+ assert.doesNotHaveClass(dropdownMenu.$('.o_menu_item:first > .dropdown-item').first(), 'selected');
testUtils.dom.click(dropdownMenu.$('.o_menu_item a').first());
assert.hasClass(dropdownMenu.$('.o_menu_item:first > .dropdown-item'), 'selected');
assert.ok(dropdownMenu.$('.o_menu_item:first').is(':visible'),
'item should still be visible');
testUtils.dom.click(dropdownMenu.$('.o_menu_item a').first());
- assert.doesNotHaveClass(dropdownMenu.$('.o_menu_item:first > .dropdown-item'), 'selected');
+ assert.doesNotHaveClass(dropdownMenu.$('.o_menu_item:first > .dropdown-item').first(), 'selected');
assert.ok(dropdownMenu.$('.o_menu_item:first').is(':visible'),
'item should still be visible');
-
dropdownMenu.destroy();
});
QUnit.test('click on an item should not change url', function (assert) {
assert.expect(0);
- var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items);
+ var dropdownMenu = createDropdownMenu(this.items);
testUtils.dom.click(dropdownMenu.$('.o_dropdown_toggler_btn'));
dropdownMenu.$el.click(function () {
// we do not want a click to get out and change the url, for example
@@ -99,10 +94,12 @@ QUnit.module('Web', {
QUnit.test('options rendering', function (assert) {
assert.expect(3);
- this.items[0].options = [{optionId: 1, description: "First Option", groupId: 1}, {optionId: 2, description: "Second Option", groupId: 1}];
-
- var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items);
- // open dropdown
+ this.items[0].hasOptions = true;
+ this.items[0].options = [
+ {optionId: 1, description: "First Option", groupId: 1},
+ {optionId: 2, description: "Second Option", groupId: 1}
+ ];
+ var dropdownMenu = createDropdownMenu(this.items);
testUtils.dom.click(dropdownMenu.$('button:first'));
assert.containsN(dropdownMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 4);
// open options menu
@@ -115,12 +112,16 @@ QUnit.module('Web', {
dropdownMenu.destroy();
});
- QUnit.test('close menu closes also submenu', function (assert) {
+ QUnit.test('close menu closes also submenus', function (assert) {
assert.expect(2);
- this.items[0].options = [{optionId: 1, description: "First Option"}, {optionId: 2, description: "Second Option"}];
-
- var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items);
+ this.items[0].hasOptions = true;
+ this.items[0].options = [
+ {optionId: 1, description: "First Option"},
+ {optionId: 2, description: "Second Option"}
+ ];
+ var dropdownMenu = createDropdownMenu(this.items);
+ // open dropdown menu
testUtils.dom.click(dropdownMenu.$('button:first'));
// open options menu
testUtils.dom.click(dropdownMenu.$('span.fa-caret-right'));
@@ -132,74 +133,59 @@ QUnit.module('Web', {
dropdownMenu.destroy();
});
- QUnit.test('click on an option should toggle options and item states properly', function (assert) {
- assert.expect(22);
-
- this.items[0].options = [{optionId: 1, description: "First Option"}, {optionId: 2, description: "Second Option"}];
+ QUnit.test('click on an option should trigger the event "item_option_clicked" with appropriate data', function (assert) {
+ assert.expect(16);
+ var self = this;
var eventNumber = 0;
-
- var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items, {
+ this.items[0].hasOptions = true;
+ this.items[0].options = [
+ {optionId: 1, description: "First Option"},
+ {optionId: 2, description: "Second Option"}
+ ];
+ var dropdownMenu = createDropdownMenu(this.items, {
intercepts: {
- menu_item_toggled: function (ev) {
+ item_option_clicked: function (ev) {
eventNumber++;
+ assert.strictEqual(ev.data.id, 1);
if (eventNumber === 1) {
- assert.strictEqual(ev.data.itemId, 1);
- assert.strictEqual(ev.data.isActive, true);
assert.strictEqual(ev.data.optionId, 1);
- } else {
- assert.strictEqual(ev.data.itemId, 1);
- assert.strictEqual(ev.data.isActive, false);
- assert.strictEqual(ev.data.optionId, false);
+ self.items[0].isActive = true;
+ self.items[0].currentOptionId = 1;
}
- },
- item_option_changed: function (ev) {
- if (eventNumber === 1) {
- assert.strictEqual(ev.data.itemId, 1);
- assert.strictEqual(ev.data.isActive, true);
+ if (eventNumber === 2) {
+ assert.strictEqual(ev.data.optionId, 2);
+ self.items[0].currentOptionId = 2;
+ }
+ if (eventNumber === 3) {
assert.strictEqual(ev.data.optionId, 2);
+ self.items[0].isActive = false;
+ self.items[0].currentOptionId = false;
}
+ dropdownMenu.update(self.items);
},
},
});
// open dropdown menu
testUtils.dom.click(dropdownMenu.$('button:first'));
- // open options menu
- testUtils.dom.click(dropdownMenu.$('span.fa-caret-right'));
- assert.containsN(dropdownMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 7);
- // Don't forget there is a hidden li.divider element at first place among children
- assert.doesNotHaveClass(dropdownMenu.$('.o_menu_item:nth-child(2) > .dropdown-item'), 'selected');
- assert.doesNotHaveClass(dropdownMenu.$('.o_item_option:nth-child(2) > .dropdown-item'), 'selected');
- assert.doesNotHaveClass(dropdownMenu.$('.o_item_option:nth-child(3) > .dropdown-item'), 'selected');
- testUtils.dom.click(dropdownMenu.$('.o_item_option:first'));
- assert.hasClass(dropdownMenu.$('.o_menu_item:nth-child(2) > .dropdown-item'), 'selected');
- assert.hasClass(dropdownMenu.$('.o_item_option:nth-child(2) > .dropdown-item'), 'selected');
- assert.doesNotHaveClass(dropdownMenu.$('.o_item_option:nth-child(3) > .dropdown-item'), 'selected');
- testUtils.dom.click(dropdownMenu.$('.o_item_option:nth-child(3)'));
- assert.hasClass(dropdownMenu.$('.o_menu_item:nth-child(2) > .dropdown-item'), 'selected');
- assert.doesNotHaveClass(dropdownMenu.$('.o_item_option:nth-child(2) > .dropdown-item'), 'selected');
- assert.hasClass(dropdownMenu.$('.o_item_option:nth-child(3) > .dropdown-item'), 'selected');
- testUtils.dom.click(dropdownMenu.$('.o_item_option:nth-child(3)'));
- assert.doesNotHaveClass(dropdownMenu.$('.o_menu_item:nth-child(2) > .dropdown-item'), 'selected');
- assert.doesNotHaveClass(dropdownMenu.$('.o_item_option:nth-child(2) > .dropdown-item'), 'selected');
- assert.doesNotHaveClass(dropdownMenu.$('.o_item_option:nth-child(3) > .dropdown-item'), 'selected');
- dropdownMenu.destroy();
- });
+ assert.containsN(dropdownMenu, '.dropdown-divider, .o_menu_item', 4);
+ testUtils.dom.clickFirst(dropdownMenu.$('.o_menu_item'));
+ // click on first option
+ testUtils.dom.click(dropdownMenu.$('.o_item_option > .dropdown-item').eq(0));
+ assert.hasClass(dropdownMenu.$('.o_menu_item > .dropdown-item').first(), 'selected');
+ assert.hasClass(dropdownMenu.$('.o_item_option> .dropdown-item').eq(0),'selected');
+ assert.doesNotHaveClass(dropdownMenu.$('.o_item_option > .dropdown-item').eq(1), 'selected');
+ // click on second option
+ testUtils.dom.click($('.o_item_option > .dropdown-item').eq(1));
+ assert.hasClass(dropdownMenu.$('.o_menu_item > .dropdown-item').first(), 'selected');
+ assert.doesNotHaveClass(dropdownMenu.$('.o_item_option > .dropdown-item').eq(0), 'selected');
+ assert.hasClass(dropdownMenu.$('.o_item_option > .dropdown-item').eq(1), 'selected');
+ // click again on second option
+ testUtils.dom.click($('.o_item_option > .dropdown-item').eq(1));
+ assert.doesNotHaveClass(dropdownMenu.$('.o_menu_item > .dropdown-item').first(), 'selected');
+ assert.doesNotHaveClass(dropdownMenu.$('.o_item_option > .dropdown-item').eq(0), 'selected');
+ assert.doesNotHaveClass(dropdownMenu.$('.o_item_option > .dropdown-item').eq(1), 'selected');
- QUnit.test('trash an item should be possible', function (assert) {
- assert.expect(3);
-
- var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items, {
- intercepts: {
- menu_item_deleted: function (ev) {
- assert.strictEqual(ev.data.itemId, 2);
- },
- },
- });
- testUtils.dom.click(dropdownMenu.$('button:first'));
- assert.containsN(dropdownMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 4);
- testUtils.dom.click(dropdownMenu.$('span.o_trash_button'));
- assert.containsN(dropdownMenu, '.dropdown-divider, .dropdown-item, .dropdown-item-text', 2);
dropdownMenu.destroy();
});
});
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index b4c1c5f5d3e..d8f6b34d263 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -204,7 +204,7 @@
<script type="text/javascript" src="/web/static/src/js/chrome/action_manager.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/action_manager_act_window.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/action_manager_report.js"/>
- <script type="text/javascript" src="/web/static/src/js/chrome/control_panel.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/chrome/action_mixin.js"/>
<script type="text/javascript" src="/web/static/src/js/core/browser_detection.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/keyboard_navigation_mixin.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/abstract_web_client.js"></script>
@@ -239,18 +239,9 @@
<script type="text/javascript" src="/web/static/src/js/chrome/user_menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/apps_menu.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/search/search_view.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/search/search_view_mobile.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/search/search_filters.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/search/search_inputs.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/search/favorites_menu.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/search/groupby_menu.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/search/time_range_menu.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/search/time_range_menu_options.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/search/groupby_menu_interface_mixin.js"></script>
+
<script type="text/javascript" src="/web/static/src/js/widgets/dropdown_menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/pie_chart.js"></script>
- <script type="text/javascript" src="/web/static/src/js/views/search/filters_menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/apps.js"></script>
<script type="text/javascript" src="/web/static/src/js/fields/abstract_field.js"></script>
@@ -270,6 +261,26 @@
<script type="text/javascript" src="/web/static/src/js/views/basic/basic_view.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/basic/basic_controller.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/basic/basic_renderer.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/control_panel_controller.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/control_panel_model.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/control_panel_renderer.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/control_panel_view.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/mobile_control_panel_controller.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/mobile_control_panel_renderer.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_facet.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_bar.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/filter_menu.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/groupby_menu.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/time_range_menu.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/favorite_menu.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/control_panel_view_parameters.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_filters.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_filters_registry.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/field_manager_mixin.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/standalone_field_manager_mixin.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/view_registry.js"></script>
@@ -533,6 +544,7 @@
<script type="text/javascript" src="/web/static/tests/views/abstract_controller_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/abstract_view_tests.js"></script>
+ <script type="text/javascript" src="/web/static/tests/views/control_panel_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/form_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/graph_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/list_tests.js"></script>
@@ -543,7 +555,7 @@
<script type="text/javascript" src="/web/static/tests/views/abstract_view_banner_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/kanban_model_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/view_dialogs_tests.js"></script>
- <script type="text/javascript" src="/web/static/tests/views/search_filters_menu_tests.js"></script>
+ <script type="text/javascript" src="/web/static/tests/views/search_filter_menu_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/search_groupby_menu_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/widgets/dropdown_menu_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/search_view_tests.js"></script>
@@ -561,7 +573,6 @@
<script type="text/javascript" src="/web/static/tests/core/dom_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/chrome/action_manager_tests.js"></script>
- <script type="text/javascript" src="/web/static/tests/chrome/search_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/chrome/pager_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/chrome/keyboard_navigation_mixin_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/chrome/user_menu_tests.js"></script>
@@ -614,10 +625,10 @@
<script type="text/javascript" src="/base/static/tests/base_settings_mobile_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/chrome/action_manager_mobile_tests.js"></script>
- <script type="text/javascript" src="/web/static/tests/chrome/search_view_mobile_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/calendar_mobile_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/kanban_mobile_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/form_mobile_tests.js"></script>
+ <script type="text/javascript" src="/web/static/tests/views/mobile_control_panel_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/fields/basic_fields_mobile_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/fields/relational_fields_mobile_tests.js"></script>
</t>
@@ -742,9 +753,6 @@
</t>
<t t-set="head" t-value="head_web + (head or '')"/>
<t t-set="body_classname" t-value="'o_web_client'"/>
- <div class="o_main">
- <main class="o_main_content"/>
- </div>
</t>
</template>
diff --git a/addons/web_editor/static/tests/web_editor_tests.js b/addons/web_editor/static/tests/web_editor_tests.js
index 12a5937988f..1995add777d 100644
--- a/addons/web_editor/static/tests/web_editor_tests.js
+++ b/addons/web_editor/static/tests/web_editor_tests.js
@@ -256,7 +256,7 @@ QUnit.test('html_frame does not crash when saving in edit mode (editor not loade
});
testUtils.form.clickEdit(form);
- testUtils.fields.editInput(form.$('input'), 'trululu');
+ testUtils.fields.editInput(form.$('input.o_input.o_field_char'), 'trululu');
testUtils.form.clickSave(form); // crash without editor fully loaded
assert.verifySteps(['read']);
@@ -308,7 +308,7 @@ QUnit.test('html_frame saving in edit mode (editor and content fully loaded)', f
});
testUtils.form.clickEdit(form);
- testUtils.fields.editInput(form.$('input'), 'trululu');
+ testUtils.fields.editInput(form.$('input.o_input.o_field_char'), 'trululu');
testUtils.form.clickSave(form);
loadDeferred.resolve(); // simulate late loading of html frame