diff --git a/addons/web/static/src/js/chrome/abstract_action.js b/addons/web/static/src/js/chrome/abstract_action.js
index 8c06d887431..5b0f7e3aeca 100644
--- a/addons/web/static/src/js/chrome/abstract_action.js
+++ b/addons/web/static/src/js/chrome/abstract_action.js
@@ -10,7 +10,7 @@ odoo.define('web.AbstractAction', function (require) {
* 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 this AbstractAction class.
+ * 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
@@ -51,6 +51,13 @@ var AbstractAction = Widget.extend({
canBeRemoved: function () {
return $.when();
},
+ /**
+ * This function is called when the current context (~state) of the action
+ * should be known. For instance, if the action is a view controller,
+ * this may be useful to reinstantiate the view in the same state.
+ */
+ getContext: function () {
+ },
/**
* Gives the focus to the action
*/
diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js
index 9853f279794..fe95ada0a7f 100644
--- a/addons/web/static/src/js/views/abstract_view.js
+++ b/addons/web/static/src/js/views/abstract_view.js
@@ -76,6 +76,7 @@ var AbstractView = Class.extend({
* @param {string[]} params.domain
* @param {string[]} params.groupBy
* @param {number} [params.currentId]
+ * @param {boolean} params.isEmbedded
* @param {number[]} [params.ids]
* @param {boolean} [params.withControlPanel]
* @param {boolean} [params.action.flags.headless]
@@ -99,6 +100,10 @@ var AbstractView = Class.extend({
}
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.isEmbedded = params.isEmbedded || false;
this.rendererParams = {
arch: this.arch,
@@ -114,6 +119,7 @@ var AbstractView = Class.extend({
duplicate: this.arch.attrs.duplicate ? JSON.parse(this.arch.attrs.duplicate) : true,
},
groupable: this.groupable,
+ isEmbedded: this.isEmbedded,
controllerID: params.controllerID,
};
// AAB: these params won't be necessary as soon as the ControlPanel will
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 77e3f1e7a40..e9f933023b1 100644
--- a/addons/web/static/src/js/views/graph/graph_controller.js
+++ b/addons/web/static/src/js/views/graph/graph_controller.js
@@ -4,13 +4,15 @@ odoo.define('web.GraphController', function (require) {
* Odoo Graph view
*---------------------------------------------------------*/
-var AbstractController = require('web.AbstractController');
var core = require('web.core');
+var AbstractController = require('web.AbstractController');
+var GroupByMenuInterfaceMixin = require('web.GroupByMenuInterfaceMixin');
var qweb = core.qweb;
-var GraphController = AbstractController.extend({
+var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{
className: 'o_graph',
+
/**
* @override
* @param {Widget} parent
@@ -18,10 +20,23 @@ var GraphController = AbstractController.extend({
* @param {GraphRenderer} renderer
* @param {Object} params
* @param {string[]} params.measures
+ * @param {boolean} params.isEmbedded
+ * @param {string[]} params.groupableFields,
*/
init: function (parent, model, renderer, params) {
+ GroupByMenuInterfaceMixin.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.
},
/**
* @todo check if this can be removed (mostly duplicate with
@@ -53,6 +68,12 @@ var GraphController = AbstractController.extend({
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,
};
},
/**
@@ -66,13 +87,18 @@ var GraphController = AbstractController.extend({
*/
renderButtons: function ($node) {
if ($node) {
- var context = {measures: _.pairs(_.omit(this.measures, '__count__'))};
+ var context = {
+ measures: _.pairs(_.omit(this.measures, '__count__')),
+ };
this.$buttons = $(qweb.render('GraphView.buttons', context));
this.$measureList = this.$buttons.find('.o_graph_measures_list');
this.$buttons.find('button').tooltip();
this.$buttons.click(this._onButtonClick.bind(this));
this._updateButtons();
this.$buttons.appendTo($node);
+ if (this.isEmbedded) {
+ this._addGroupByMenu($node, this.groupableFields);
+ }
}
},
@@ -80,10 +106,22 @@ var GraphController = AbstractController.extend({
// Private
//--------------------------------------------------------------------------
+ /*
+ * override
+ *
+ * @private
+ * @param {string[]} groupbys
+ */
+ _setGroupby: function (groupbys) {
+ this.update({groupBy: groupbys});
+ },
+
/**
* @todo remove this and directly calls update. Update should be overridden
* and modified to call _updateButtons
*
+ * private
+ *
* @param {string} mode one of 'pie', 'line' or 'bar'
*/
_setMode: function (mode) {
@@ -102,6 +140,8 @@ var GraphController = AbstractController.extend({
});
},
/**
+ * override
+ *
* @private
*/
_update: function () {
@@ -133,17 +173,20 @@ var GraphController = AbstractController.extend({
/**
* Do what need to be done when a button from the control panel is clicked.
*
+ * @private
* @param {MouseEvent} event
*/
_onButtonClick: function (event) {
var $target = $(event.target);
+ var parent;
+ var field;
if ($target.hasClass('o_graph_button')) {
this._setMode($target.data('mode'));
} else if ($target.parents('.o_graph_measures_list').length) {
event.preventDefault();
event.stopPropagation();
- var parent = $target.parent();
- var field = parent.data('field');
+ parent = $target.parent();
+ field = parent.data('field');
this._setMeasure(field);
}
},
diff --git a/addons/web/static/src/js/views/graph/graph_model.js b/addons/web/static/src/js/views/graph/graph_model.js
index 2ffbd4c4cf8..dd7d0efa28f 100644
--- a/addons/web/static/src/js/views/graph/graph_model.js
+++ b/addons/web/static/src/js/views/graph/graph_model.js
@@ -51,6 +51,8 @@ return AbstractModel.extend({
* @param {string[]} params.groupBys a list of valid field names
* @param {Object} params.context
* @param {string[]} params.domain
+ * @param {Object} params.intervalMapping object linking fieldNames with intervals.
+ * this could be useful to simplify the code. For now this parameter is not used.
* @returns {Deferred} The deferred does not return a handle, we don't need
* to keep track of various entities.
*/
@@ -62,6 +64,12 @@ return AbstractModel.extend({
this.chart = {
data: [],
groupedBy: params.groupedBy.length ? params.groupedBy : groupBys,
+ // this parameter is not used anywhere 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.
+ intervalMapping: params.intervalMapping,
measure: params.context.graph_measure || params.measure,
mode: params.context.graph_mode || params.mode,
domain: params.domain,
@@ -97,6 +105,9 @@ return AbstractModel.extend({
if ('groupBy' in params) {
this.chart.groupedBy = params.groupBy.length ? params.groupBy : this.initialGroupBys;
}
+ if ('intervalMapping' in params) {
+ this.chart.intervalMapping = params.intervalMapping;
+ }
if ('measure' in params) {
this.chart.measure = params.measure;
}
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 54ff6642b15..07c486cf5b7 100644
--- a/addons/web/static/src/js/views/graph/graph_view.js
+++ b/addons/web/static/src/js/views/graph/graph_view.js
@@ -16,6 +16,9 @@ var GraphRenderer = require('web.GraphRenderer');
var _t = core._t;
var _lt = core._lt;
+var GROUPABLE_TYPES =
+ ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime'];
+
var GraphView = AbstractView.extend({
display_name: _lt('Graph'),
icon: 'fa-bar-chart',
@@ -43,18 +46,22 @@ var GraphView = AbstractView.extend({
var measure;
var groupBys = [];
var measures = {__count__: {string: _t("Count"), type: "integer"}};
+ var groupableFields = {};
+ var intervalMapping = {};
this.fields.__count__ = {string: _t("Count"), type: "integer"};
this.arch.children.forEach(function (field) {
- var name = field.attrs.name;
- if (field.attrs.interval) {
- name += ':' + field.attrs.interval;
+ var fieldName = field.attrs.name;
+ var interval = field.attrs.interval;
+ if (interval) {
+ intervalMapping[fieldName] = interval;
+ fieldName = fieldName + ':' + interval;
}
if (field.attrs.type === 'measure') {
- measure = name;
- measures[name] = self.fields[name];
+ measure = fieldName;
+ measures[fieldName] = self.fields[fieldName];
} else {
- groupBys.push(name);
+ groupBys.push(fieldName);
}
});
@@ -64,15 +71,20 @@ var GraphView = AbstractView.extend({
_.contains(params.additionalMeasures, name)) {
measures[name] = field;
}
+ if (_.contains(GROUPABLE_TYPES, field.type)) {
+ groupableFields[name] = field;
+ }
}
});
this.controllerParams.measures = measures;
+ this.controllerParams.groupableFields = groupableFields;
this.rendererParams.stacked = this.arch.attrs.stacked !== "False";
this.loadParams.mode = this.arch.attrs.type || 'bar';
this.loadParams.measure = measure || '__count__';
this.loadParams.groupBys = groupBys || [];
+ this.loadParams.intervalMapping = intervalMapping;
this.loadParams.fields = this.fields;
},
});
diff --git a/addons/web/static/src/js/views/search/filter_menu.js b/addons/web/static/src/js/views/search/filter_menu.js
deleted file mode 100644
index 61a5f2fafc8..00000000000
--- a/addons/web/static/src/js/views/search/filter_menu.js
+++ /dev/null
@@ -1,103 +0,0 @@
-odoo.define('web.FilterMenu', function (require) {
-"use strict";
-
-var search_filters = require('web.search_filters');
-var search_inputs = require('web.search_inputs');
-var Widget = require('web.Widget');
-
-return Widget.extend({
- template: 'SearchView.FilterMenu',
- events: {
- 'click .o_add_filter': function (event) {
- event.preventDefault();
- this.toggle_custom_filter_menu();
- },
- 'click li': function (event) {
- event.preventDefault();
- event.stopImmediatePropagation();
- },
- 'hidden.bs.dropdown': function () {
- this.toggle_custom_filter_menu(false);
- },
- 'click .o_add_condition': 'append_proposition',
- 'click .o_apply_filter': 'commit_search',
- 'keyup .o_searchview_extended_prop_value': function (ev) {
- if (ev.which === $.ui.keyCode.ENTER) {
- this.commit_search();
- }
- },
- },
- init: function (parent, filters, fields) {
- this._super(parent);
- this.filters = filters || [];
- this.searchview = parent;
- this.propositions = [];
- this.custom_filters_open = false;
- this.fields = _.pick(fields, function (field, name) {
- return field.selectable !== false && name !== 'id';
- });
- this.fields.id = { string: 'ID', type: 'id', searchable: true };
- },
- start: function () {
- var self = this;
- this.$menu = this.$('.o_filters_menu');
- this.$add_filter = this.$('.o_add_filter');
- this.$apply_filter = this.$('.o_apply_filter');
- this.$add_filter_menu = this.$('.o_add_filter_menu');
- _.each(this.filters, function (group) {
- if (group.is_visible()) {
- group.insertBefore(self.$add_filter);
- $('
').insertBefore(self.$add_filter);
- }
- });
- },
- toggle_custom_filter_menu: function (is_open) {
- var self = this;
- this.custom_filters_open = !_.isUndefined(is_open) ? is_open : !this.custom_filters_open;
- var def;
- if (this.custom_filters_open && !this.propositions.length) {
- def = this.append_proposition();
- }
- $.when(def).then(function () {
- self.$add_filter
- .toggleClass('o_closed_menu', !self.custom_filters_open)
- .toggleClass('o_open_menu', self.custom_filters_open);
- self.$add_filter_menu.toggle(self.custom_filters_open);
- self.$('.o_filter_condition').toggle(self.custom_filters_open);
- });
- },
- append_proposition: function () {
- var prop = new search_filters.ExtendedSearchProposition(this, this.fields);
- this.propositions.push(prop);
- this.$apply_filter.prop('disabled', false);
- return prop.insertBefore(this.$add_filter_menu);
- },
- remove_proposition: function (prop) {
- this.propositions = _.without(this.propositions, prop);
- if (!this.propositions.length) {
- this.$apply_filter.prop('disabled', true);
- }
- prop.destroy();
- },
- commit_search: function () {
- var filters = _.invoke(this.propositions, 'get_filter'),
- filters_widgets = _.map(filters, function (filter) {
- return new search_inputs.Filter(filter, this);
- }),
- filter_group = new search_inputs.FilterGroup(filters_widgets, this.searchview),
- facets = filters_widgets.map(function (filter) {
- return filter_group.make_facet([filter_group.make_value(filter)]);
- });
- filter_group.insertBefore(this.$add_filter);
- $(' ').insertBefore(this.$add_filter);
- this.searchview.query.add(facets, {silent: true});
- this.searchview.query.trigger('reset');
-
- _.invoke(this.propositions, 'destroy');
- this.propositions = [];
- this.append_proposition();
- this.toggle_custom_filter_menu(false);
- },
-});
-
-});
diff --git a/addons/web/static/src/js/views/search/filters_menu.js b/addons/web/static/src/js/views/search/filters_menu.js
index 6305e635082..778e199df44 100644
--- a/addons/web/static/src/js/views/search/filters_menu.js
+++ b/addons/web/static/src/js/views/search/filters_menu.js
@@ -1,105 +1,208 @@
-odoo.define('web.FilterMenu', function (require) {
+odoo.define('web.FiltersMenu', function (require) {
"use strict";
var config = require('web.config');
+var core = require('web.core');
+var DropdownMenu = require('web.DropdownMenu');
var search_filters = require('web.search_filters');
-var search_inputs = require('web.search_inputs');
-var Widget = require('web.Widget');
-return Widget.extend({
- template: 'SearchView.FilterMenu',
- events: {
- 'click .o_add_filter': function (event) {
- event.preventDefault();
- this.toggle_custom_filter_menu();
- },
- 'click li': function (event) {
- event.preventDefault();
- event.stopImmediatePropagation();
- },
- 'hidden.bs.dropdown': function () {
- this.toggle_custom_filter_menu(false);
- },
- 'click .o_add_condition': 'append_proposition',
- 'click .o_apply_filter': 'commit_search',
- 'keyup .o_searchview_extended_prop_value': function (ev) {
- if (ev.which === $.ui.keyCode.ENTER) {
- this.commit_search();
- }
- },
+var QWeb = core.qweb;
+var _t = core._t;
+
+
+
+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',
+ }),
+
+ /**
+ * @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;
+ * 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 'field_get' of a model: mapping from field name
+ * to an object with its properties
+ */
init: function (parent, filters, fields) {
- this._super(parent);
+ // determines where the filter menu is displayed and its style
this.isMobile = config.device.isMobile;
- this.filters = filters || [];
- this.searchview = parent;
+ // determines list of options used by filter of type 'date'
+ // TO DO: simplify optionIds below
+ this.intervalOptions = [
+ {description: _t('Day'), optionId: '__interval__day__'},
+ {description: _t('Week'), optionId: '__interval__week__'},
+ {description: _t('Month'), optionId: '__interval__month__'},
+ {description: _t('Year'), optionId: '__interval__year__'},
+ ];
+ // determines the default option used in case
+ // it has not been provided for a filter
+ this.defaultOptionId = "__interval__month__";
+ // determines when the 'Add custom filter' submenu is open
+ this.generatorMenuIsOpen = false;
this.propositions = [];
- this.custom_filters_open = false;
this.fields = _.pick(fields, function (field, name) {
return field.selectable !== false && name !== 'id';
});
- this.fields.id = { string: 'ID', type: 'id', searchable: true };
+ this.fields.id = {string: 'ID', type: 'id', searchable: true};
+ var dropdownHeader = {
+ category: 'filterCategory',
+ title: 'Filters',
+ icon: 'fa fa-filter',
+ symbol: this.isMobile ? 'fa fa-chevron-right pull-right mt4' : 'caret'
+ };
+ 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 () {
- var self = this;
- this.$menu = this.$('.o_filters_menu');
- this.$add_filter = this.$('.o_add_filter');
- this.$apply_filter = this.$('.o_apply_filter');
- this.$add_filter_menu = this.$('.o_add_filter_menu');
- _.each(this.filters, function (group) {
- if (group.is_visible()) {
- group.insertBefore(self.$add_filter);
- $(' ').insertBefore(self.$add_filter);
- }
- });
+ this.$menu = this.$('ul.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');
},
- toggle_custom_filter_menu: function (is_open) {
- var self = this;
- this.custom_filters_open = !_.isUndefined(is_open) ? is_open : !this.custom_filters_open;
- var def;
- if (this.custom_filters_open && !this.propositions.length) {
- def = this.append_proposition();
- }
- $.when(def).then(function () {
- self.$add_filter
- .toggleClass('o_closed_menu', !self.custom_filters_open)
- .toggleClass('o_open_menu', self.custom_filters_open);
- self.$add_filter_menu.toggle(self.custom_filters_open);
- self.$('.o_filter_condition').toggle(self.custom_filters_open);
- });
- },
- append_proposition: function () {
+
+ //--------------------------------------------------------------------------
+ // 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.$apply_filter.prop('disabled', false);
- return prop.insertBefore(this.$add_filter_menu);
+ this.$('.o_apply_filter').prop('disabled', false);
+ return prop.insertBefore(this.$addFilterMenu);
},
- remove_proposition: function (prop) {
- this.propositions = _.without(this.propositions, prop);
- if (!this.propositions.length) {
- this.$apply_filter.prop('disabled', true);
- }
- prop.destroy();
- },
- commit_search: function () {
- var filters = _.invoke(this.propositions, 'get_filter'),
- filters_widgets = _.map(filters, function (filter) {
- return new search_inputs.Filter(filter, this);
- }),
- filter_group = new search_inputs.FilterGroup(filters_widgets, this.searchview),
- facets = filters_widgets.map(function (filter) {
- return filter_group.make_facet([filter_group.make_value(filter)]);
+ /**
+ * 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');
+ var groupId = _.uniqueId('__group__');
+ var toSendToSearchView = [];
+ filters.forEach(function (filter) {
+ var filterName = _.uniqueId('__filter__');
+ var filterItem = {
+ itemId: filterName,
+ description: filter.attrs.string,
+ groupId: groupId,
+ isActive: true,
+ };
+ self._prepareItem(filterItem);
+ toSendToSearchView.push({
+ itemId: filterName,
+ groupId: groupId,
+ filter: filter,
});
- filter_group.insertBefore(this.$add_filter);
- $(' ').insertBefore(this.$add_filter);
- this.searchview.query.add(facets, {silent: true});
- this.searchview.query.trigger('reset');
-
+ self.items.push(filterItem);
+ });
+ this._renderMenuItems();
+ this.trigger_up('new_filter', toSendToSearchView);
_.invoke(this.propositions, 'destroy');
this.propositions = [];
- this.append_proposition();
- this.toggle_custom_filter_menu(false);
+ // this._appendProposition();
+ this._toggleCustomFilterMenu();
+ },
+ /**
+ * 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
index 9fe6951efc2..4e3c4eb573d 100644
--- a/addons/web/static/src/js/views/search/groupby_menu.js
+++ b/addons/web/static/src/js/views/search/groupby_menu.js
@@ -3,77 +3,232 @@ odoo.define('web.GroupByMenu', function (require) {
var config = require('web.config');
var core = require('web.core');
-var search_inputs = require('web.search_inputs');
-var Widget = require('web.Widget');
+var DropdownMenu = require('web.DropdownMenu');
var QWeb = core.qweb;
-return Widget.extend({
- template: 'SearchView.GroupByMenu',
- events: {
- 'click li': function (event) {
- event.stopImmediatePropagation();
- },
- 'hidden.bs.dropdown': function () {
- this.toggle_add_menu(false);
- },
- 'click .o_add_custom_group a': function (event) {
- event.preventDefault();
- this.toggle_add_menu();
- },
- },
- init: function (parent, groups, fields) {
+var GROUPABLE_TYPES = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime'];
+
+var GroupByMenu = DropdownMenu.extend({
+ events: _.extend({}, DropdownMenu.prototype.events,
+ {
+ 'click .o_add_custom_group a': '_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; (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)
+ * defaultOptionId: string refers to an optionId (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._super(parent);
- this.searchview = parent;
- this.isMobile = config.device.isMobile;
- this.groups = groups || [];
+ 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'},
+ {description: 'Week', optionId: 'week'},
+ {description: 'Month', optionId: 'month'},
+ {description: 'Quarter', optionId: 'quarter'},
+ {description: 'Year', optionId: 'year'},
+ ];
+ // determines the default option used in case
+ // it has not been provided for a groupby
+ this.defaultOptionId = "month";
this.groupableFields = [];
- var groupable_types = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime'];
_.each(fields, function (field, name) {
- if (field.sortable && _.contains(groupable_types, field.type)) {
- self.groupableFields.push(_.extend({}, field, {name: name}));
+ if (field.sortable && _.contains(GROUPABLE_TYPES, field.type)) {
+ self.groupableFields.push(_.extend({}, field, {
+ name: name,
+ isDate: _.contains(['date', 'datetime'], field.type),
+ }));
}
});
- self.groupableFields = _.sortBy(this.groupableFields, 'string');
- },
- start: function () {
- var self = this;
- this.$menu = this.$('.o_group_by_menu');
- var divider = this.$menu.find('.divider');
- _.invoke(this.groups, 'insertBefore', divider);
- if (this.groups.length) {
- divider.show();
- }
- this.$add_group = this.$menu.find('.o_add_custom_group');
- this.$menu.append(QWeb.render('GroupByMenuSelector', this));
- this.$add_group_menu = this.$('.o_add_group');
- this.$group_selector = this.$('.o_group_selector');
- this.$('.o_select_group').click(function () {
- self.toggle_add_menu(false);
- var field = self.$group_selector.find(':selected').data('name');
- self.add_groupby_to_menu(field);
+ 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 btn-sm dropdown-toggle'},
+ };
+ }
+ var dropdownHeader = {
+ category: 'groupByCategory',
+ title: 'Group By',
+ icon: 'fa fa-bars',
+ symbol: this.isMobile ? 'fa fa-chevron-right pull-right mt4' : 'caret',
+ style: style,
+ };
+ this._super(parent, dropdownHeader, groupbys, options);
},
- toggle_add_menu: function (is_open) {
- this.$add_group
- .toggleClass('o_closed_menu', !(_.isUndefined(is_open)) ? !is_open : undefined)
- .toggleClass('o_open_menu', is_open);
- this.$add_group_menu.toggle(is_open);
- if (this.$add_group.hasClass('o_open_menu')) {
- this.$group_selector.focus();
+
+ /**
+ * render the template used to add a new custom groupby and append it
+ * to the basic dropdown menu
+ *
+ * @private
+ */
+ start: function () {
+ this.$menu = this.$('ul.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 'this.defaultOptionId'
+ *
+ * @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: this.groupId,
+ isActive: true,
+ };
+ var eventData = _.clone(groupby);
+ this._prepareItem(groupby);
+ if (groupby.hasOptions) {
+ groupby.currentOptionId = groupby.defaultOptionId;
+ eventData.optionId = groupby.currentOptionId;
+ }
+ 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;
+ item.defaultOptionId = item.defaultOptionId || this.defaultOptionId;
+ }
+ // the idea is that we have only one group in the groupby menu
+ // like in the search view.
+ if (!this.groupId) {
+ this.groupId = item.groupId;
+ }
+ // 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();
}
},
- add_groupby_to_menu: function (field_name) {
- var filter = new search_inputs.Filter({attrs:{
- context:"{'group_by':'" + field_name + "''}",
- name: _.find(this.groupableFields, {name: field_name}).string,
- }}, this.searchview);
- var group = new search_inputs.FilterGroup([filter], this.searchview),
- divider = this.$('.divider').show();
- group.insertBefore(divider);
- group.toggle(filter);
+
+ //--------------------------------------------------------------------------
+ // 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();
},
});
-});
\ No newline at end of file
+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
new file mode 100644
index 00000000000..9e0e1621c27
--- /dev/null
+++ b/addons/web/static/src/js/views/search/groupby_menu_interface_mixin.js
@@ -0,0 +1,116 @@
+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,
+ });
+ });
+ 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_filters.js b/addons/web/static/src/js/views/search/search_filters.js
index 582af7e70ef..4455a7b9bdf 100644
--- a/addons/web/static/src/js/views/search/search_filters.js
+++ b/addons/web/static/src/js/views/search/search_filters.js
@@ -16,7 +16,12 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext
'change .o_searchview_extended_prop_op': 'operator_changed',
'click .o_searchview_extended_delete_prop': function (e) {
e.stopPropagation();
- this.getParent().remove_proposition(this);
+ this.trigger_up('remove_proposition');
+ },
+ 'keyup .o_searchview_extended_prop_value': function (ev) {
+ if (ev.which === $.ui.keyCode.ENTER) {
+ this.trigger_up('confirm_proposition');
+ }
},
},
/**
diff --git a/addons/web/static/src/js/views/search/search_inputs.js b/addons/web/static/src/js/views/search/search_inputs.js
index d937a6e246e..3366fe91ecb 100644
--- a/addons/web/static/src/js/views/search/search_inputs.js
+++ b/addons/web/static/src/js/views/search/search_inputs.js
@@ -515,7 +515,7 @@ var FilterGroup = Input.extend(/** @lends instance.web.search.FilterGroup# */{
* @param {Array} filters elements of the group
* @param {instance.web.SearchView} parent parent in which the filters are contained
*/
- init: function (filters, parent) {
+ init: function (filters, parent, intervalMapping) {
// 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) &&
@@ -523,11 +523,12 @@ var FilterGroup = Input.extend(/** @lends instance.web.search.FilterGroup# */{
if (!f.attrs.context) { return false; }
var c = pyeval.eval('context', f.attrs.context);
return !_.isEmpty(c.group_by);})) {
- return new GroupbyGroup(filters, parent);
+ return new GroupbyGroup(filters, parent, intervalMapping);
}
this._super(parent);
this.filters = filters;
this.searchview = parent;
+ this.intervalMapping = intervalMapping;
this.searchview.query.on('add remove change reset', this.proxy('search_change'));
},
start: function () {
@@ -560,6 +561,7 @@ var FilterGroup = Input.extend(/** @lends instance.web.search.FilterGroup# */{
},
make_facet: function (values) {
return {
+ cat: 'filterCategory',
category: _t("Filter"),
icon: this.icon,
separator: _t(" or "),
@@ -610,8 +612,23 @@ var FilterGroup = Input.extend(/** @lends instance.web.search.FilterGroup# */{
* @return {Array} enabled filters in this group
*/
get_groupby: function (facet) {
+ var self = this;
return facet.values.chain()
- .map(function (f) { return f.get('value').attrs.context; })
+ .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();
@@ -683,14 +700,18 @@ var FilterGroup = Input.extend(/** @lends instance.web.search.FilterGroup# */{
facet: self.make_facet([facet_value])
};
}));
+ },
+ updateIntervalMapping: function (intervalMapping) {
+ this.intervalMapping = intervalMapping;
}
+
});
var GroupbyGroup = FilterGroup.extend({
icon: 'fa-bars',
completion_label: _lt("Group by: %s"),
init: function (filters, parent) {
- this._super(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
@@ -713,6 +734,7 @@ var GroupbyGroup = FilterGroup.extend({
},
make_facet: function (values) {
return {
+ cat: 'groupByCategory',
category: _t("Group By"),
icon: this.icon,
separator: " > ",
diff --git a/addons/web/static/src/js/views/search/search_view.js b/addons/web/static/src/js/views/search/search_view.js
index 8fc3cd73a2b..e307ca2a240 100644
--- a/addons/web/static/src/js/views/search/search_view.js
+++ b/addons/web/static/src/js/views/search/search_view.js
@@ -5,7 +5,7 @@ var AutoComplete = require('web.AutoComplete');
var config = require('web.config');
var core = require('web.core');
var FavoriteMenu = require('web.FavoriteMenu');
-var FilterMenu = require('web.FilterMenu');
+var FiltersMenu = require('web.FiltersMenu');
var GroupByMenu = require('web.GroupByMenu');
var pyeval = require('web.pyeval');
var search_inputs = require('web.search_inputs');
@@ -167,7 +167,8 @@ var FacetView = Widget.extend({
template: 'SearchView.FacetView',
events: {
'focus': function () { this.trigger('focused', this); },
- 'blur': function () { this.trigger('blurred', this); },
+ 'blur': function () {
+ this.trigger('blurred', this); },
'click': function (e) {
if ($(e.target).hasClass('o_facet_remove')) {
this.model.destroy();
@@ -186,9 +187,10 @@ var FacetView = Widget.extend({
}
}
},
- init: function (parent, model) {
+ init: function (parent, model, intervalMapping) {
this._super(parent);
this.model = model;
+ this.intervalMapping = intervalMapping;
this.model.on('change', this.model_changed, this);
},
destroy: function () {
@@ -203,7 +205,9 @@ var FacetView = Widget.extend({
if (index > 0) {
$(' ', {html: self.model.get('separator') || _t(" or ")}).addClass('o_facet_values_sep').appendTo($e);
}
- return new FacetValueView(self, value).appendTo($e);
+ var couple = _.findWhere(self.intervalMapping, {groupby: value.attributes.value});
+ var interval = couple ? couple.interval : undefined;
+ return new FacetValueView(self, value, interval).appendTo($e);
}));
});
},
@@ -214,9 +218,22 @@ var FacetView = Widget.extend({
var FacetValueView = Widget.extend({
template: 'SearchView.FacetView.Value',
- init: function (parent, model) {
+ init: function (parent, model, interval) {
this._super(parent);
this.model = model;
+
+ var intervalDescription = {
+ day: 'Day',
+ week: 'Week',
+ month: 'Month',
+ quarter: 'Quarter',
+ year: 'Year',
+ };
+ // to do: put a test on interval
+ if (interval) {
+ var intervalLabel = intervalDescription[interval];
+ this.intervalLabel = _t(intervalLabel);
+ }
this.model.on('change', this.model_changed, this);
},
destroy: function () {
@@ -257,6 +274,12 @@ var SearchView = Widget.extend({
}
},
},
+ custom_events: {
+ 'menu_item_toggled': '_onItemToggled',
+ 'item_option_changed': '_onItemOptionChanged',
+ 'new_groupby': '_onNewGroupby',
+ 'new_filter': '_onNewFilter',
+ },
defaults: _.extend({}, Widget.prototype.defaults, {
hidden: false,
disable_filters: false,
@@ -288,6 +311,16 @@ var SearchView = Widget.extend({
this.title = this.options.action && this.options.action.name;
this.action = this.options.action || {};
this.search_fields = [];
+
+ this.selectedGroupIds = {
+ groupByCategory: [],
+ filterCategory: []
+ };
+ this.groupsMapping = [];
+ this.groupbysMapping = [];
+ this.filtersMapping = [];
+ this.intervalMapping = [];
+
this.filters = [];
this.groupbys = [];
var visibleSearchMenu = this.call('local_storage', 'getItem', 'visible_search_menu');
@@ -297,7 +330,7 @@ var SearchView = Widget.extend({
this.headless = this.options.hidden && _.isEmpty(this.search_defaults);
this.$buttons = this.options.$buttons;
- this.filter_menu = undefined;
+ this.filters_menu = undefined;
this.groupby_menu = undefined;
this.favorite_menu = undefined;
},
@@ -312,6 +345,7 @@ var SearchView = Widget.extend({
return $.when(this._super(), def);
},
start: function () {
+ var self= this;
if (this.headless) {
this.do_hide();
}
@@ -324,24 +358,29 @@ var SearchView = Widget.extend({
this.$('.o_searchview_more')
.toggleClass('fa-search-minus', this.visible_filters)
.toggleClass('fa-search-plus', !this.visible_filters);
- var menu_defs = [];
+ var def;
this.prepare_search_inputs();
var $buttons = this._getButtonsElement();
if ($buttons) {
- if (!this.options.disable_filters) {
- this.filter_menu = new FilterMenu(this, this.filters, this.fields);
- menu_defs.push(this.filter_menu.appendTo($buttons));
- }
- if (!this.options.disable_groupby) {
- this.groupby_menu = new GroupByMenu(this, this.groupbys, this.fields);
- menu_defs.push(this.groupby_menu.appendTo($buttons));
- }
if (!this.options.disable_favorites) {
this.favorite_menu = new FavoriteMenu(this, this.query, this.dataset.model, this.action, this.favorite_filters);
- menu_defs.push(this.favorite_menu.appendTo($buttons));
+ def = this.favorite_menu.appendTo($buttons);
}
}
- return $.when.apply($, menu_defs).then(this.set_default_filters.bind(this));
+ return $.when(def)
+ .then(this.set_default_filters.bind(this))
+ .then(function () {
+ var menu_defs = [];
+ 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);
+ });
},
on_attach_callback: function () {
this._focusInput();
@@ -416,10 +455,18 @@ var SearchView = Widget.extend({
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) {
@@ -517,21 +564,56 @@ var SearchView = Widget.extend({
.$el.focus();
},
/**
- * @param {openerp.web.search.SearchQuery | undefined} collection Undefined if event is change
- * @param {openerp.web.search.Facet} model
- * @param {Object} [options]
*/
- renderFacets: function (collection, model, options) {
+ renderFacets: function () {
var self = this;
var started = [];
_.invoke(this.input_subviews, 'destroy');
this.input_subviews = [];
+ var selectedGroupIds = {
+ groupByCategory: [],
+ filterCategory: [],
+ };
+
this.query.each(function (facet) {
- var f = new FacetView(this, facet);
+
+ if (facet.attributes.cat === "groupByCategory") {
+ selectedGroupIds.groupByCategory = selectedGroupIds.groupByCategory.concat(
+ _.compact(
+ _.uniq(
+ _.map(facet.attributes.values, function (value) {
+ var groupby = value.value;
+ var groupbyDescription = _.findWhere(self.groupbysMapping, {groupby: groupby});
+ if (groupbyDescription) {
+ return groupbyDescription.groupId;
+ }
+ })
+ )
+ )
+ );
+ }
+ if (facet.attributes.cat === "filterCategory") {
+ selectedGroupIds.filterCategory = selectedGroupIds.filterCategory.concat(
+ _.uniq(
+ _.compact(
+ _.map(facet.attributes.values, function (value) {
+ var filter = value.value;
+ var filterDescription = _.findWhere(self.filtersMapping, {filter: filter});
+ if (filterDescription) {
+ return filterDescription.groupId;
+ }
+ })
+ )
+ )
+ );
+ }
+
+ var f = new FacetView(this, facet, this.intervalMapping);
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);
@@ -547,6 +629,12 @@ var SearchView = Widget.extend({
// not what you expect when you just selected a filter.
_.last(self.input_subviews).$el.focus();
}
+ if (self.groupby_menu) {
+ self._unsetUnusedGroupbys(selectedGroupIds.groupByCategory);
+ }
+ if (self.filters_menu) {
+ self._unsetUnusedFilters(selectedGroupIds.filterCategory);
+ }
});
},
childFocused: function () {
@@ -584,6 +672,8 @@ var SearchView = Widget.extend({
var context = pyeval.eval('context', item.attrs.context);
if (context.group_by) {
category = 'group_by';
+ item.attrs.fieldName = context.group_by.split(':')[0];
+ item.attrs.defaultInterval = context.group_by.split(':')[1];
}
} catch (e) {}
}
@@ -601,7 +691,7 @@ var SearchView = Widget.extend({
return current_group.push(new search_inputs.Filter(filter.item, self));
}
if (current_group.length) {
- var group = new search_inputs.FilterGroup(current_group, self);
+ var group = new search_inputs.FilterGroup(current_group, self, self.intervalMapping);
categories[current_category].push(group);
current_group = [];
}
@@ -652,12 +742,12 @@ var SearchView = Widget.extend({
attrs: {domain: filter.domain, help: filter.help},
};
var filterWidget = new search_inputs.Filter(filter);
- var filterGroup = new search_inputs.FilterGroup([filterWidget], self);
+ var filterGroup = new search_inputs.FilterGroup([filterWidget], self, self.intervalMapping);
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});
@@ -684,6 +774,85 @@ var SearchView = Widget.extend({
_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__');
+ filters.push({
+ isActive: !!self.search_defaults[filter.attrs.name],
+ description: filter.attrs.string || filter.attrs.help || filter.attrs.name || filter.attrs.domain || 'Ω',
+ itemId: filterId,
+ domain: filter.attrs.domain,
+ groupId: groupId,
+ });
+ self.filtersMapping.push({filterId: filterId, filter: filter, groupId: groupId});
+ }
+ });
+ 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 fieldName = groupby.attrs.fieldName;
+ var defaultOptionId = groupby.attrs.defaultInterval;
+ var groupbyId = _.uniqueId('__groupby__');
+ groupbys.push({
+ isActive: !!self.search_defaults[groupby.attrs.name],
+ description: groupby.attrs.string,
+ itemId: groupbyId,
+ fieldName: fieldName,
+ groupId: groupId,
+ defaultOptionId: defaultOptionId,
+ });
+ if (self._fieldIsDate(fieldName)) {
+ self.intervalMapping.push({groupby: groupby, interval: defaultOptionId || 'month'});
+
+ }
+ 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);
+ },
+
+
+ _fieldIsDate: function (fieldName) {
+ if (_.contains(['date', 'datetime'], this.fields[fieldName].type)) {
+ return true;
+ }
+ return false;
+ },
+
/**
* Processes a fieldsView in place. In particular, parses its arch.
*
@@ -699,6 +868,154 @@ var SearchView = Widget.extend({
fv.arch = utils.xml_to_json(doc, true);
return fv;
},
+ /**
+ * @private
+ * @param {string[]]} selectedGroupIds
+ */
+ _unsetUnusedGroupbys: function (selectedGroupIds) {
+ var groupIds = this.selectedGroupIds.groupByCategory.reduce(
+ function (acc, id) {
+ if (!_.contains(selectedGroupIds, id)) {
+ acc.push(id);
+ }
+ return acc;
+ },
+ []
+ );
+ this.selectedGroupIds.groupByCategory = selectedGroupIds;
+ this.groupby_menu.unsetGroups(groupIds);
+ },
+ /**
+ * @private
+ * @param {string[]]} selectedGroupIds
+ */
+ _unsetUnusedFilters: function (selectedGroupIds) {
+ var groupIds = this.selectedGroupIds.filterCategory.reduce(
+ function (acc, id) {
+ if (!_.contains(selectedGroupIds, id)) {
+ acc.push(id);
+ }
+ return acc;
+ },
+ []
+ );
+ this.selectedGroupIds.filterCategory = selectedGroupIds;
+ this.filters_menu.unsetGroups(groupIds);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ *
+ * @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;
+ group.toggle(filter);
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onNewGroupby: function (event) {
+ var attrs = {
+ context:"{'group_by':'" + event.data.fieldName + "''}",
+ name: event.data.description,
+ fieldName: event.data.fieldName,
+ };
+
+ 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);
+ group.toggle(groupby);
+ 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',
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onNewFilter: 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);
+ filters.forEach(function (filter) {
+ group.toggle(filter, {silent: true});
+ });
+ this.query.trigger('reset');
+
+
+ this.groupsMapping.push({
+ groupId: groupId,
+ group: group,
+ category: 'Filters',
+ });
+ },
+ /**
+ *
+ * @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;
+ group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group;
+ group.toggle(filter);
+ }
+ },
});
_.extend(SearchView, {
diff --git a/addons/web/static/src/js/widgets/dropdown_menu.js b/addons/web/static/src/js/widgets/dropdown_menu.js
new file mode 100644
index 00000000000..d7e4bff32a4
--- /dev/null
+++ b/addons/web/static/src/js/widgets/dropdown_menu.js
@@ -0,0 +1,243 @@
+odoo.define('web.DropdownMenu', function (require) {
+"use strict";
+
+var core = require('web.core');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+
+// interface IItem {
+// itemId: string;
+// description: string;
+// groupId: string;
+// isActive: boolean; (optional)
+// isOpen: boolean; (optional)
+// isRemovable: boolean; (optional)
+// 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)
+// }
+
+var DropdownMenu = Widget.extend({
+ template: 'web.DropdownMenu',
+ events: {
+ 'click li.o_menu_item': '_onItemClick',
+ 'click li.o_item_option': '_onOptionClick',
+ 'click li.o_menu_item span.o_submenu_switcher': '_openItemOptions',
+ 'click span.o_trash_button': '_onTrashClick',
+ 'hidden.bs.dropdown': '_onBootstrapClose',
+ },
+
+ /**
+ * @param {Widget} parent
+ * @param {Object} dropdowHeader object with keys 'title' and 'icon'
+ * @param {Object} items values are arrays of items (see interface IItems)
+ * @param {Object} itemGenerator
+ */
+ init: function (parent, dropdownHeader, items) {
+ this._super(parent);
+ this.dropdownCategory = dropdownHeader.category;
+ this.dropdownTitle = dropdownHeader.title;
+ this.dropdownIcon = dropdownHeader.icon;
+ this.dropdownSymbol = dropdownHeader.symbol || 'caret';
+ // 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-sm btn-default dropdown-toggle'},
+ };
+ this.items = _.sortBy(items, 'groupId');
+ _.each(this.items, this._prepareItem.bind(this));
+ },
+
+ start: function () {
+ this.$menu = this.$('.o_dropdown_menu');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {Array[number]} groupIds
+ */
+ unsetGroups: function (groupIds) {
+ var self = this;
+ _.each(self.items, function (item) {
+ if (_.contains(groupIds, item.groupId)) {
+ item.isActive = false;
+ item.currentOptionId = 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('DropdownMenu.MenuItems', {widget: this});
+ this.$el.find('.o_menu_item,.divider[data-removable="1"]').remove();
+ this.$('ul.o_dropdown_menu').prepend($(newMenuItems));
+ },
+ /**
+ * @private
+ * @param {Object} item
+ */
+ _prepareItem: function (item) {
+ item.isOpen = item.isOpen || false;
+ item.isRemovable = item.isRemovable || false;
+ item.hasOptions = item.options && item.options.length !== 0 ? true : false;
+ item.defaultOptionId = item.hasOptions ?
+ (item.defaultOptionId || item.options[0].optionId) :
+ false;
+ item.currentOptionId = item.isActive ? (item.currentOptionId || item.defaultOptionId ) : false;
+ },
+ /**
+ * @private
+ * @param {string} itemId
+ */
+ _toggleMenuItem: function (itemId) {
+ var item = _.findWhere(this.items, {itemId: itemId});
+ item.isActive = !item.isActive;
+ var eventData = {
+ category: this.dropdownCategory,
+ itemId: itemId,
+ isActive: item.isActive,
+ groupId: item.groupId,
+ };
+ if (item.hasOptions) {
+ if (item.isActive) {
+ item.currentOptionId = item.defaultOptionId;
+ eventData.optionId = item.currentOptionId;
+ } else {
+ item.currentOptionId = false;
+ }
+ }
+ this._renderMenuItems();
+ this.trigger_up('menu_item_toggled', eventData);
+ },
+ /**
+ * @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);
+ }
+ },
+ /**
+ * @private
+ * @param {string} itemId
+ */
+ _toggleMenuOptions: function (itemId) {
+ var item = _.findWhere(this.items, {itemId: itemId});
+ item.isOpen = !item.isOpen;
+ this._renderMenuItems();
+ },
+
+ //--------------------------------------------------------------------------
+ // 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);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _openItemOptions: function (event) {
+ // we preventdefault the event here to avoid changing the hash # in the
+ // url.
+ event.preventDefault();
+ event.stopPropagation();
+ var itemId = $(event.currentTarget).data('id');
+ this._toggleMenuOptions(itemId);
+ },
+});
+
+return DropdownMenu;
+
+});
\ No newline at end of file
diff --git a/addons/web/static/src/scss/dropdown.scss b/addons/web/static/src/scss/dropdown.scss
index adffd21bde5..7db8b2fcb3c 100644
--- a/addons/web/static/src/scss/dropdown.scss
+++ b/addons/web/static/src/scss/dropdown.scss
@@ -45,7 +45,7 @@
top: 0;
left: 100%;
}
- &:hover > .dropdown-menu {
+ &:hover .dropdown-menu {
display: block;
}
}
diff --git a/addons/web/static/src/scss/dropdown_menu.scss b/addons/web/static/src/scss/dropdown_menu.scss
new file mode 100644
index 00000000000..c32779271bc
--- /dev/null
+++ b/addons/web/static/src/scss/dropdown_menu.scss
@@ -0,0 +1,50 @@
+.o_dropdown_menu {
+
+ > li.divider:first-child {
+ display: none;
+ }
+
+ max-height: calc(100vh - 125px);
+ overflow: auto;
+ .o_submenu_switcher {
+ float:right;
+ width: 25px;
+ text-align: center;
+ }
+ .o_trash_button {
+ float:right;
+ + .o_submenu_switcher {
+ margin-right: 6px;
+ }
+ }
+ ul {
+ list-style-type: none;
+ padding-left: 20px;
+ }
+
+ li.o_menu_item {
+ position: relative;
+ > a {
+ color: $o-main-text-color;
+ padding-right: 25px;
+ &:hover {
+ color: darken($o-main-text-color, 10%);
+ }
+ }
+ &.selected {
+ > a {
+ color: darken($o-main-text-color, 10%);
+ font-weight: bold;
+ &:before {
+ font-family: FontAwesome;
+ position: absolute;
+ left: 4px;
+ content: "\f00c";
+ }
+ }
+ }
+ .o_item_option {
+ padding-left: 20px;
+ }
+ }
+}
diff --git a/addons/web/static/src/scss/graph_view.scss b/addons/web/static/src/scss/graph_view.scss
index 4666d68ae61..adbf8e88dd1 100644
--- a/addons/web/static/src/scss/graph_view.scss
+++ b/addons/web/static/src/scss/graph_view.scss
@@ -15,6 +15,6 @@
// ------------------------------------------------------------------
// Control panel buttons
// ------------------------------------------------------------------
-.o_graph_measures_list {
+.o_graph_measures_list, .o_graph_groupbys_list {
@include o-selected-li;
}
diff --git a/addons/web/static/src/scss/search_groupby_menu.scss b/addons/web/static/src/scss/search_groupby_menu.scss
new file mode 100644
index 00000000000..4092b4b5fb7
--- /dev/null
+++ b/addons/web/static/src/scss/search_groupby_menu.scss
@@ -0,0 +1,16 @@
+// Group By
+.o_group_by_menu {
+ @include o-selected-li;
+
+ max-height: calc(100vh - 125px);
+ overflow: auto;
+
+ .o_add_group { // add button
+ @include o-search-options-dropdown-custom-li;
+ }
+
+ .o_submenu_switcher {
+ position: absolute;
+ right: 0px;
+ }
+}
diff --git a/addons/web/static/src/scss/search_view.scss b/addons/web/static/src/scss/search_view.scss
index be6cb0b3f5b..03fe29916fc 100644
--- a/addons/web/static/src/scss/search_view.scss
+++ b/addons/web/static/src/scss/search_view.scss
@@ -122,105 +122,92 @@
}
}
-// Searchview dropdowns
-.o_search_options {
- // Open and closed carets in dropdowns
- .o_closed_menu {
- position: relative;
- a:before {
- @include o-caret-right;
- @include o-position-absolute($top: 50%, $left: 10px);
- margin-top: (-$caret-width-base);
- }
- }
- .o_open_menu {
- position: relative;
- a:before {
- @include o-caret-down;
- @include o-position-absolute($top: 50%, $left: 10px);
- margin-top: (-$caret-width-base);
- }
- }
- // Filters
- .o_filters_menu {
- @include o-selected-li;
- max-height: calc(100vh - 125px);
- overflow: auto;
-
- .o_filter_condition {
- @include o-search-options-dropdown-custom-li;
- margin-bottom: 8px;
- .o_or_filter { // or between conditions
- display: none; // hidden for the first condition
- @include o-position-absolute($top: 7px, $left: -18px);
- }
- }
- .o_filter_condition + .o_filter_condition {
- .o_or_filter {
- display: block; // displayed from second condition
- }
- }
- .o_searchview_extended_prop_op {
- margin: 3px 0px; // vertical gap between selects
- }
- .o_searchview_extended_prop_value { // date fields: datepicker position
- .datepickerbutton {
- @include o-position-absolute(3px, -20px);
- cursor: pointer;
- }
- }
- .o_searchview_extended_delete_prop { // delete condition button position
- @include o-position-absolute(3px, -18px);
- cursor: pointer;
- }
- .o_add_filter_menu { // apply and add buttons
- display: none; // Needed by the webclient
- @include o-search-options-dropdown-custom-li;
- }
- }
-
- // Group By
- .o_group_by_menu {
- @include o-selected-li;
-
- max-height: calc(100vh - 125px);
- overflow: auto;
-
- .divider {display: none;} // hide the divider by default (no group)
- .o_add_group { // add button
- display: none; // Needed by the webclient
- @include o-search-options-dropdown-custom-li;
- }
- }
-
- // Favorites
- .o_favorites_menu {
- max-height: 75vh;
- overflow-y: auto;
- @include o-selected-li;
-
- max-height: calc(100vh - 125px);
- overflow: auto;
-
- .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);
- margin-top: -6px;
- cursor: pointer;
- }
- }
- .o-searchview-custom-public a:after {
- font-family: FontAwesome;
- content: "\f0c0";
- margin-left: 5px;
- font-weight: normal;
- }
- .o_save_name { // save button
- display: none; // hidden by default
- @include o-search-options-dropdown-custom-li;
- }
+// Open and closed carets in dropdowns
+.o_closed_menu {
+ position: relative;
+ a:before {
+ @include o-caret-right;
+ @include o-position-absolute($top: 50%, $left: 10px);
+ margin-top: (-$caret-width-base);
+ }
+}
+.o_open_menu {
+ position: relative;
+ a:before {
+ @include o-caret-down;
+ @include o-position-absolute($top: 50%, $left: 10px);
+ margin-top: (-$caret-width-base);
+ }
+}
+
+// Filters
+.o_filters_menu {
+ @include o-selected-li;
+
+ max-height: calc(100vh - 125px);
+ overflow: auto;
+
+ .o_filter_condition {
+ @include o-search-options-dropdown-custom-li;
+ margin-bottom: 8px;
+ .o_or_filter { // or between conditions
+ display: none; // hidden for the first condition
+ @include o-position-absolute($top: 7px, $left: -18px);
+ }
+ }
+ .o_filter_condition + .o_filter_condition {
+ .o_or_filter {
+ display: block; // displayed from second condition
+ }
+ }
+ .o_searchview_extended_prop_op {
+ margin: 3px 0px; // vertical gap between selects
+ }
+ .o_searchview_extended_prop_value { // date fields: datepicker position
+ .datepickerbutton {
+ @include o-position-absolute(3px, -20px);
+ cursor: pointer;
+ }
+ }
+ .o_searchview_extended_delete_prop { // delete condition button position
+ @include o-position-absolute(3px, -18px);
+ cursor: pointer;
+ }
+ .o_add_filter_menu { // apply and add buttons
+ display: none; // Needed by the webclient
+ @include o-search-options-dropdown-custom-li;
+ }
+}
+
+
+
+// Favorites
+.o_favorites_menu {
+ max-height: 75vh;
+ overflow-y: auto;
+ @include o-selected-li;
+
+ max-height: calc(100vh - 125px);
+ overflow: auto;
+
+ .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);
+ margin-top: -6px;
+ cursor: pointer;
+ }
+ }
+ .o-searchview-custom-public a:after {
+ font-family: FontAwesome;
+ content: "\f0c0";
+ margin-left: 5px;
+ font-weight: normal;
+ }
+ .o_save_name { // save button
+ display: none; // hidden by default
+ @include o-search-options-dropdown-custom-li;
}
}
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index 458e15dd2be..328f1ab93b5 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -1018,6 +1018,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
- Filters
-
-
-
+
-
-
- Group By
-
-
-
-
-
-
-
-
-
-
- Apply
-
-
+
Favorites
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index b9aa2d30253..9b42c518891 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -425,9 +425,8 @@ QUnit.module('relational_fields', {
assert.strictEqual($('tr.o_data_row').length, 9, "should display 9 records");
- $('.o_searchview_more').click(); // Magnifying class for more filters
$('button:contains(Filters)').click();
- $('.o_add_filter').click(); // Add a custom filter, datetime field is selected
+ $('.o_add_custom_filter:visible').click(); // Add a custom filter, datetime field is selected
assert.strictEqual($('li.o_filter_condition select.o_searchview_extended_prop_field').val(), 'datetime',
"datetime field should be selected");
$('.o_apply_filter').click();
@@ -11132,11 +11131,12 @@ QUnit.module('relational_fields', {
},
});
+
var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
form.$('.o_field_many2one input').click();
$dropdown.find('.o_m2o_dropdown_option:contains(Search)').mouseenter().click(); // Open Search More
- $('.modal .o_searchview_more').click(); // Magnifying class for more filters
+ $('.modal .o_search_options .fa-bars').click();
$('.modal .o_search_options .o_group_by_menu a:contains(Bar)').click(); // group by 'Bar'
$('.modal .o_group_header:first').click();
diff --git a/addons/web/static/tests/views/graph_tests.js b/addons/web/static/tests/views/graph_tests.js
index 028eb4cb3a3..5f7227815c1 100644
--- a/addons/web/static/tests/views/graph_tests.js
+++ b/addons/web/static/tests/views/graph_tests.js
@@ -248,7 +248,7 @@ QUnit.module('Views', {
});
});
- QUnit.test('getContext correctly returns mode, measure and groupbys', function (assert) {
+ QUnit.test('getContext correctly returns mode, measure, groupbys and interval mapping', function (assert) {
var done = assert.async();
assert.expect(4);
@@ -265,6 +265,7 @@ QUnit.module('Views', {
graph_mode: 'bar',
graph_measure: '__count__',
graph_groupbys: ['product_id'],
+ graph_intervalMapping: {},
}, "context should be correct");
graph.$buttons.find('li[data-field="foo"] a').click(); // change measure
@@ -275,6 +276,7 @@ QUnit.module('Views', {
graph_mode: 'bar',
graph_measure: 'foo',
graph_groupbys: ['product_id'],
+ graph_intervalMapping: {},
}, "context should be correct");
graph.$buttons.find('button[data-mode="line"]').click(); // change mode
@@ -285,6 +287,7 @@ QUnit.module('Views', {
graph_mode: 'line',
graph_measure: 'foo',
graph_groupbys: ['product_id'],
+ graph_intervalMapping: {},
}, "context should be correct");
graph.update({groupBy: ['product_id', 'color_id']}); // change groupbys
@@ -295,6 +298,7 @@ QUnit.module('Views', {
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/search_filters_menu_tests.js b/addons/web/static/tests/views/search_filters_menu_tests.js
new file mode 100644
index 00000000000..84bc1df86d8
--- /dev/null
+++ b/addons/web/static/tests/views/search_filters_menu_tests.js
@@ -0,0 +1,135 @@
+odoo.define('web.search_filters_menu_tests', function (require) {
+"use strict";
+
+var FiltersMenu = require('web.FiltersMenu');
+var testUtils = require('web.test_utils');
+
+function createFiltersMenu(filters, fields, params) {
+ params = params || {};
+ var target = params.debug ? document.body : $('#qunit-fixture');
+ var menu = new FiltersMenu(null, filters, fields);
+ testUtils.addMockEnvironment(menu, params);
+ menu.appendTo(target);
+ return menu;
+}
+
+QUnit.module('FiltersMenu', {
+ beforeEach: function () {
+ this.filters = [
+ {
+ isActive: false,
+ description: 'some filter',
+ domain: '',
+ name: 'red',
+ groupId: 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(4);
+
+ var filterMenu = createFiltersMenu([], this.fields);
+ filterMenu.$('span.fa-filter').click();
+ assert.strictEqual(filterMenu.$('.divider').length, 1);
+ assert.ok(!filterMenu.$('.divider').is(':visible'));
+ assert.strictEqual(filterMenu.$('li').length, 3,
+ 'should have 3 li: a hidden divider, a add custom filter item, a apply button + add condition button');
+ assert.strictEqual(filterMenu.$('li.o_add_custom_filter.o_closed_menu').length, 1);
+ filterMenu.destroy();
+ });
+
+ QUnit.test('simple rendering with a filter', function (assert) {
+ assert.expect(2);
+
+ var filterMenu = createFiltersMenu(this.filters, this.fields);
+ assert.strictEqual(filterMenu.$('li').length, 5,
+ 'should have 4 li: a hidden, separator, a filter, a separator, a add custom filter item, a apply button + add condition button');
+ assert.strictEqual(filterMenu.$('li.o_add_custom_filter.o_closed_menu').length, 1);
+ filterMenu.destroy();
+ });
+
+ QUnit.test('click on add custom filter opens the submenu', function (assert) {
+ assert.expect(3);
+
+ var filterMenu = createFiltersMenu([], this.fields);
+ // open menu dropdown
+ filterMenu.$('span.fa-filter').click();
+ // open add custom filter submenu
+ filterMenu.$('li.o_add_custom_filter').click();
+ assert.ok(filterMenu.$('li.o_add_custom_filter').hasClass('o_open_menu'));
+ assert.ok(filterMenu.$('li.o_add_filter_menu').is(':visible'));
+ assert.strictEqual(filterMenu.$('li').length, 4,
+ 'should have 3 li: a hidden divider, 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 = createFiltersMenu([], this.fields);
+ // open menu dropdown and custom filter submenu
+ filterMenu.$('span.fa-filter').click();
+ filterMenu.$('li.o_add_custom_filter').click();
+
+ // remove the current unique proposition
+ filterMenu.$('.o_searchview_extended_delete_prop').click();
+
+ assert.ok(filterMenu.$('.o_apply_filter').attr('disabled'));
+
+ assert.strictEqual(filterMenu.$('li').length, 3,
+ 'should have 3 li: a hidden separator, 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 = createFiltersMenu([], this.fields);
+
+ // open menu dropdown and custom filter submenu, remove existing prop
+ filterMenu.$('span.fa-filter').click();
+ filterMenu.$('li.o_add_custom_filter').click();
+ filterMenu.$('.o_searchview_extended_delete_prop').click();
+
+ // read a proposition
+ filterMenu.$('.o_add_condition').click();
+
+ 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);
+
+ // open menu dropdown and custom filter submenu, remove existing prop
+ filterMenu.$('span.fa-filter').click();
+ filterMenu.$('li.o_add_custom_filter').click();
+
+ // click on apply to activate filter
+ filterMenu.$('.o_apply_filter').click();
+
+ assert.ok(filterMenu.$('.o_add_custom_filter').hasClass('o_closed_menu'));
+ assert.strictEqual(filterMenu.$('.o_filter_condition').length, 0);
+ assert.strictEqual(filterMenu.$('.divider:visible').length, 1,
+ 'there should be a separator between filters and add custom filter menu');
+
+ 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
new file mode 100644
index 00000000000..e403c08a97c
--- /dev/null
+++ b/addons/web/static/tests/views/search_groupby_menu_tests.js
@@ -0,0 +1,133 @@
+odoo.define('web.search_groupby_menu_tests', function (require) {
+"use strict";
+
+var GroupByMenu = require('web.GroupByMenu');
+var testUtils = require('web.test_utils');
+
+function createGroupByMenu(groupbys, fields, params) {
+ params = params || {};
+ var target = params.debug ? document.body : $('#qunit-fixture');
+ var menu = new GroupByMenu(null, groupbys, fields);
+ testUtils.addMockEnvironment(menu, params);
+ menu.appendTo(target);
+ return menu;
+}
+
+QUnit.module('GroupByMenu', {
+ beforeEach: function () {
+ this.groupbys = [
+ {
+ isActive: false,
+ description: 'some group by',
+ fieldName: 'fieldname',
+ itemId: 'red',
+ groupId: 1,
+ },
+ ];
+ this.fields = {
+ fieldname: {sortable: true, string: 'Super Date', type: 'date', isDate: true}
+ };
+ },
+}, function () {
+
+ QUnit.test('simple rendering', function (assert) {
+ assert.expect(2);
+
+ var groupByMenu = createGroupByMenu(this.groupbys, this.fields);
+ groupByMenu.$('button:first').click();
+ assert.strictEqual(groupByMenu.$('li').length, 2, 'should have 2 li');
+ assert.strictEqual(groupByMenu.$('li:nth-child(2)').text().trim(), 'some group by',
+ 'should have proper filter name');
+ groupByMenu.destroy();
+ });
+
+ QUnit.test('simple rendering with no filter and no field', function (assert) {
+ assert.expect(1);
+
+ var groupByMenu = createGroupByMenu([], {});
+ groupByMenu.$('button:first').click();
+ assert.strictEqual(groupByMenu.$('li').length, 0, 'should have 0 li');
+ groupByMenu.destroy();
+ });
+
+ QUnit.test('simple rendering with no filter but fields', function (assert) {
+ assert.expect(1);
+
+ var groupByMenu = createGroupByMenu(
+ [],
+ {fieldname: {sortable: true, string: 'Super Date', type: 'date', isDate: true}}
+ );
+ groupByMenu.$('button:first').click();
+ assert.strictEqual(groupByMenu.$('li').length, 1, 'should have 1 li');
+ 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}});
+ groupByMenu.$('button:first').click();
+ var selector = groupByMenu.$('select.o_group_selector');
+ assert.ok(!selector.is(":visible"), 'should be invisible');
+ groupByMenu.$('.o_add_custom_group a').click();
+ 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);
+
+ var groupByMenu = createGroupByMenu(
+ [],
+ {
+ fieldName: {sortable: true, name: 'candlelight', string: 'Candlelight', type: 'boolean'},
+ }
+ );
+ groupByMenu.$('button:first').click();
+ groupByMenu.$('li.o_add_custom_group a').click();
+ assert.strictEqual(groupByMenu.$('select').val(), 'fieldName',
+ 'the select value should be "fieldName"');
+ groupByMenu.$('button.o_apply_group').click();
+ assert.strictEqual(groupByMenu.$('li.o_menu_item.selected').length, 1, 'there should be a groupby selected');
+ groupByMenu.destroy();
+ });
+
+ QUnit.test('click on groupby filter should activate it', function (assert) {
+ assert.expect(5);
+
+ var groupByMenu = createGroupByMenu(this.groupbys,
+ {fieldname: {sortable: true, string: 'Super Date', type: 'date', isDate: true}}, {
+ intercepts: {
+ menu_item_toggled: function(ev) {
+ assert.strictEqual(ev.data.itemId, 'red');
+ assert.strictEqual(ev.data.isActive, true);
+ },
+ },
+ });
+ groupByMenu.$('button:first').click();
+ assert.ok(!groupByMenu.$('.o_menu_item:first').hasClass('selected'));
+ groupByMenu.$('.o_menu_item a').first().click();
+ assert.ok(groupByMenu.$('.o_menu_item:first').hasClass('selected'));
+ assert.ok(groupByMenu.$('.o_menu_item:first').is(':visible'),
+ 'group by filter should still be visible');
+
+ groupByMenu.destroy();
+ });
+
+ QUnit.test('click on groupby filter should not change url', function (assert) {
+ assert.expect(0);
+
+ var groupByMenu = createGroupByMenu(this.groupbys,
+ {fieldname: {sortable: true, string: 'Super Date', type: 'date', isDate: true}}
+ );
+ 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');
+ });
+ groupByMenu.$('.o_menu_item a').first().click();
+
+ groupByMenu.destroy();
+ });
+});
+});
diff --git a/addons/web/static/tests/views/search_view_tests.js b/addons/web/static/tests/views/search_view_tests.js
new file mode 100644
index 00000000000..391b3ed46f5
--- /dev/null
+++ b/addons/web/static/tests/views/search_view_tests.js
@@ -0,0 +1,299 @@
+odoo.define('web.search_view_tests', function (require) {
+"use strict";
+
+var testUtils = require('web.test_utils');
+var createActionManager = testUtils.createActionManager;
+
+QUnit.module('Search View', {
+ beforeEach: function () {
+ this.data = {
+ partner: {
+ fields: {
+ date_field: {string: "Date", type: "date", store: true, sortable: true},
+ Birthday: {string: "Birthday", type: "date", store: true, sortable: true},
+ foo: {string: "Foo", type: "char"},
+ bar: {string: "Bar", type: "many2one", relation: 'partner'},
+ float_field: {string: "Float", type: "float"},
+ },
+ records: [
+ {id: 1, display_name: "First record", foo: "yop", bar: 2, date_field: "2017-01-25", Birthday: "1983-07-15", float_field: 1},
+ {id: 2, display_name: "Second record", foo: "blip", bar: 1, date_field: "2017-01-24", Birthday: "1982-06-04",float_field: 2},
+ {id: 3, display_name: "Third record", foo: "gnap", bar: 1, date_field: "2017-01-13", Birthday: "1985-09-13",float_field: 1.618},
+ {id: 4, display_name: "Fourth record", foo: "plop", bar: 2, date_field: "2017-02-25", Birthday: "1983-05-05",float_field: -1},
+ {id: 5, display_name: "Fifth record", foo: "zoup", bar: 2, date_field: "2016-01-25", Birthday: "1800-01-01",float_field: 13},
+ ],
+ },
+ pony: {
+ fields: {
+ name: {string: 'Name', type: 'char'},
+ },
+ records: [
+ {id: 4, name: 'Twilight Sparkle'},
+ {id: 6, name: 'Applejack'},
+ {id: 9, name: 'Fluttershy'}
+ ],
+ },
+ };
+
+ this.actions = [{
+ id: 1,
+ name: 'Partners Action 1',
+ res_model: 'partner',
+ type: 'ir.actions.act_window',
+ views: [[false, 'list']],
+ }, {
+ id: 2,
+ name: 'Partners Action 2',
+ res_model: 'partner',
+ type: 'ir.actions.act_window',
+ views: [[false, 'list']],
+ search_view_id: [2, 'search'],
+ }, {
+ id: 3,
+ name: 'Partners Action 3',
+ res_model: 'partner',
+ type: 'ir.actions.act_window',
+ views: [[false, 'kanban']],
+ search_view_id: [2, 'search'],
+ }, {
+ id: 4,
+ name: 'Partners Action 4',
+ res_model: 'partner',
+ type: 'ir.actions.act_window',
+ views: [[false, 'graph']],
+ search_view_id: [3, 'search'],
+ }, {
+ id: 5,
+ name: 'Partners Action 5',
+ res_model: 'partner',
+ type: 'ir.actions.act_window',
+ views: [[2, 'list']],
+ search_view_id: [4, 'search'],
+ }
+ ];
+
+ this.archs = {
+ // list views
+ 'partner,false,list': ' ',
+ 'partner,2,list': ' ',
+
+ // kanban views
+ 'partner,false,kanban': '' +
+ '
' +
+ ' ',
+
+ // graph views
+ 'partner,false,graph': '' +
+ ' ' +
+ ' ' +
+ ' ',
+
+ // search views
+ 'partner,false,search': ''+
+ ' ' +
+ ' ' +
+ ' ',
+ 'partner,2,search': ''+
+ ' ' +
+ ' ' +
+ ' ',
+ 'partner,3,search': ''+
+ ' ' +
+ ' ' +
+ ' ',
+ 'partner,4,search': ''+
+ ' ' +
+ ' ' +
+ ' ',
+ };
+ },
+}, function () {
+ QUnit.module('Groupby Menu');
+
+ QUnit.test('click on groupby filter adds a facet', function (assert) {
+ assert.expect(1);
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+
+ actionManager.doAction(1);
+ $('span.fa-bars').prev().click();
+ $('li.o_menu_item a').click();
+ assert.strictEqual($('.o_searchview .o_searchview_facet .o_facet_values span').text().trim(), 'candle',
+ 'should have a facet with candle name');
+ actionManager.destroy();
+ });
+
+ QUnit.test('remove a "Group By" facet properly unchecks groupbys in groupby menu', function (assert) {
+ assert.expect(2);
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+
+ actionManager.doAction(1);
+ $('span.fa-bars').prev().click();
+ $('li.o_menu_item a').click();
+ assert.strictEqual($('.o_searchview .o_searchview_facet .o_facet_values span').text().trim(), 'candle',
+ 'should have a facet with candle name');
+ $('.o_facet_remove:first').click();
+ assert.strictEqual($('.o_searchview .o_searchview_facet .o_facet_values span').length, 0,
+ 'there should be no facet');
+ actionManager.destroy();
+ });
+
+ QUnit.test('change option of a "Group By" does not remove groupy in facet "Group By"', function (assert) {
+ assert.expect(3);
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+
+ actionManager.doAction(2);
+ $('span.fa-bars').click();
+ $('.o_submenu_switcher').click();
+ $('.o_item_option:first').click();
+ assert.strictEqual($('.o_searchview .o_searchview_facet .o_facet_values span').length, 1,
+ 'should have a facet');
+ $('.o_item_option:nth-child(2)').click();
+ assert.strictEqual($('.o_searchview .o_searchview_facet .o_facet_values span').length, 1,
+ 'should have a facet');
+ $('.o_item_option:nth-child(2)').click();
+ assert.strictEqual($('.o_searchview .o_searchview_facet .o_facet_values span').length, 0,
+ 'should have no facet');
+ actionManager.destroy();
+ });
+
+ QUnit.test('select and unselect quickly groupby does not crash', function (assert) {
+ assert.expect(1);
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+
+ actionManager.doAction(3);
+ $('span.fa-bars').click();
+ $('.o_menu_item:first').click();
+ $('.o_menu_item:first').click();
+ assert.strictEqual($('.o_searchview .o_searchview_facet .o_facet_values span').length, 0,
+ 'should have a facet');
+ actionManager.destroy();
+ });
+
+ QUnit.test('groupby selected within graph subview are not deleted when modifying search view content', function (assert) {
+ assert.expect(2);
+
+ this.actions[3].flags = {isEmbedded: true};
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+
+ actionManager.doAction(4);
+ $('.o_graph_buttons div.o_graph_groupbys_menu > button').click();
+ $('.o_graph_buttons div.o_graph_groupbys_menu .o_menu_item').click();
+ assert.ok(!$('.o_graph_buttons div.o_graph_groupbys_menu .o_menu_item').hasClass('selected'),
+ 'groupby should be unselected');
+ $('.o_search_options button span.fa-filter').click();
+ $('.o_filters_menu .o_menu_item a').click();
+ assert.ok(!$('.o_graph_buttons div.o_graph_groupbys_menu .o_menu_item').hasClass('selected'),
+ 'groupby should be still unselected');
+ actionManager.destroy();
+ });
+
+ QUnit.test('group by a date field using interval works', function (assert) {
+ assert.expect(9);
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+
+ actionManager.doAction(5);
+ // open menu 'Group By'
+ $('.o_search_options .fa-bars').click();
+ // Activate the groupby 'Date'
+ $('.o_group_by_menu .o_menu_item').click();
+ // by default, data should be grouped by the field 'Date' using the interval 'day'
+ assert.strictEqual($('div.o_facet_values span').text().trim(),'Date: Day');
+ assert.strictEqual($('.o_content tr.o_group_header').length, 5);
+ // open submenu with interval options
+ $('.o_group_by_menu .o_menu_item .o_submenu_switcher').click();
+ // select option 'month'
+ $('.o_group_by_menu .o_menu_item .o_item_option:nth-child(3)').click();
+ // data should be grouped by the field 'Date' using the interval 'month'
+ assert.strictEqual($('div.o_facet_values span').text().trim(),'Date: Month');
+ assert.strictEqual($('.o_content tr.o_group_header').length, 3);
+ // deactivate option 'month'
+ $('.o_group_by_menu .o_menu_item .o_item_option:nth-child(3)').click();
+ // no groupby is applied
+ assert.strictEqual($('div.o_facet_values span').length, 0);
+ // open 'Add custom Groupby' menu
+ $('.o_group_by_menu .o_add_custom_group a').click();
+ // click on 'Apply' button
+ $('.o_group_by_menu .o_generator_menu button').click();
+ // data should be grouped by the field 'Birthday' using the interval 'month'
+ assert.strictEqual($('div.o_facet_values span').text().trim(),'Birthday: Month');
+ assert.strictEqual($('.o_content tr.o_group_header').length, 5);
+ // open submenu with interval options
+ $('.o_group_by_menu .o_menu_item .o_submenu_switcher').eq(1).click();
+ // select option 'year'
+ $('.o_group_by_menu .o_menu_item .o_item_option').eq(9).click();
+ // data should be grouped by the field 'Birthday' using the interval 'year'
+ assert.strictEqual($('div.o_facet_values span').text().trim(),'Birthday: Year');
+ assert.strictEqual($('.o_content tr.o_group_header').length, 4);
+ actionManager.destroy();
+ });
+
+ QUnit.module('Filters Menu');
+
+ QUnit.test('add a custom filter works', function (assert) {
+ assert.expect(1);
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+
+ actionManager.doAction(1);
+ $('span.fa-filter').click();
+ $('li.o_add_custom_filter').click();
+ $('.o_apply_filter').click();
+ assert.strictEqual($('.o_searchview .o_searchview_facet .o_facet_values span').text().trim(), 'ID is \"0\"',
+ 'should have a facet with candle name');
+ actionManager.destroy();
+ });
+
+ QUnit.test('deactivate a new custom filter works', function (assert) {
+ assert.expect(1);
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+
+ actionManager.doAction(1);
+ $('span.fa-filter').click();
+ $('li.o_add_custom_filter').click();
+ $('.o_apply_filter').click();
+ $('li.o_menu_item').click();
+ assert.strictEqual($('.o_searchview .o_searchview_facet .o_facet_values span').length, 0,
+ 'no facet should be in the search view');
+ actionManager.destroy();
+ });
+});
+});
\ No newline at end of file
diff --git a/addons/web/static/tests/widgets/dropdown_menu_tests.js b/addons/web/static/tests/widgets/dropdown_menu_tests.js
new file mode 100644
index 00000000000..ae8426c5b4c
--- /dev/null
+++ b/addons/web/static/tests/widgets/dropdown_menu_tests.js
@@ -0,0 +1,201 @@
+odoo.define('web.dropdown_menu_tests', function (require) {
+"use strict";
+
+var DropdownMenu = require('web.DropdownMenu');
+var testUtils = require('web.test_utils');
+
+function createDropdownMenu(dropdownTitle, groups, params) {
+ params = params || {};
+ var target = params.debug ? document.body : $('#qunit-fixture');
+ var menu = new DropdownMenu(null, dropdownTitle, groups);
+ testUtils.addMockEnvironment(menu, params);
+ menu.appendTo(target);
+ return menu;
+}
+
+
+QUnit.module('Web', {
+ beforeEach: function () {
+ this.items = [
+ {
+ isActive: false,
+ isOpen: false,
+ description: 'Some Item',
+ itemId: 1,
+ groupId: 1,
+ },
+ {
+ isActive: true,
+ description: 'Some other Item',
+ itemId: 2,
+ options: [],
+ isRemovable: true,
+ groupId: 2,
+ },
+ ];
+ this.dropdownHeader = {
+ title: "Menu",
+ icon: "fa fa-bars",
+ };
+ },
+}, function () {
+ QUnit.module('DropdownMenu');
+
+ QUnit.test('simple rendering', function (assert) {
+ assert.expect(2);
+
+ var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items);
+ assert.strictEqual(dropdownMenu.$('li').length, 4, 'should have 4 li counting the dividers');
+ assert.strictEqual(dropdownMenu.$('li:nth-child(2)').text().trim(), 'Some Item',
+ 'first li should have "Some Item" description');
+ dropdownMenu.destroy();
+ });
+
+ QUnit.test('click on an item should toggle item', function (assert) {
+ assert.expect(9);
+
+ var eventNumber = 0;
+
+ var dropdownMenu = createDropdownMenu(this.dropdownHeader, 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);
+ }
+ },
+ },
+ });
+ dropdownMenu.$('button:first').click();
+ assert.ok(!dropdownMenu.$('.o_menu_item:first').hasClass('selected'));
+ dropdownMenu.$('.o_menu_item a').first().click();
+ assert.ok(dropdownMenu.$('.o_menu_item:first').hasClass('selected'));
+ assert.ok(dropdownMenu.$('.o_menu_item:first').is(':visible'),
+ 'item should still be visible');
+ dropdownMenu.$('.o_menu_item a').first().click();
+ assert.ok(!dropdownMenu.$('.o_menu_item:first').hasClass('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);
+ dropdownMenu.$el.click(function() {
+ // 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 dropdown menu');
+ });
+ dropdownMenu.$('.o_menu_item a').first().click();
+
+ dropdownMenu.destroy();
+ });
+
+ QUnit.test('options rendering', function (assert) {
+ assert.expect(3);
+
+ this.items[0].options = [{optionId: 1, description: "First Option"}, {optionId: 2, description: "Second Option"}];
+
+ var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items);
+ // open dropdown
+ dropdownMenu.$('button:first').click();
+ assert.strictEqual(dropdownMenu.$('li').length, 4);
+ dropdownMenu.$('span.fa-caret-down').click();
+ assert.strictEqual(dropdownMenu.$('li').length, 6);
+ dropdownMenu.$('span.fa-caret-down').click();
+ assert.strictEqual(dropdownMenu.$('li').length, 4);
+
+ dropdownMenu.destroy();
+ });
+
+ QUnit.test('close menu closes also submenu', 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);
+ dropdownMenu.$('button:first').click();
+ dropdownMenu.$('span.fa-caret-down').click();
+ assert.strictEqual(dropdownMenu.$('li').length, 6);
+ dropdownMenu.$('button:first').click();
+ dropdownMenu.$('button:first').click();
+ assert.strictEqual(dropdownMenu.$('li').length, 4);
+
+ 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"}];
+
+ var eventNumber = 0;
+
+ var dropdownMenu = createDropdownMenu(this.dropdownHeader, this.items, {
+ intercepts: {
+ menu_item_toggled: function(ev) {
+ eventNumber++;
+ 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);
+ }
+ },
+ item_option_changed: function(ev) {
+ if (eventNumber === 1) {
+ assert.strictEqual(ev.data.itemId, 1);
+ assert.strictEqual(ev.data.isActive, true);
+ assert.strictEqual(ev.data.optionId, 2);
+ }
+ },
+ },
+ });
+ dropdownMenu.$('button:first').click();
+ dropdownMenu.$('span.fa-caret-down').click();
+ assert.strictEqual(dropdownMenu.$('li').length, 6);
+ assert.ok(!dropdownMenu.$('.o_menu_item:first').hasClass('selected'));
+ assert.ok(!dropdownMenu.$('.o_item_option:first').hasClass('selected'));
+ assert.ok(!dropdownMenu.$('.o_item_option:nth-child(2)').hasClass('selected'));
+ dropdownMenu.$('li.o_item_option:first').click();
+ assert.ok(dropdownMenu.$('.o_menu_item:first').hasClass('selected'));
+ assert.ok(dropdownMenu.$('.o_item_option:first').hasClass('selected'));
+ assert.ok(!dropdownMenu.$('.o_item_option:nth-child(2)').hasClass('selected'));
+ dropdownMenu.$('li.o_item_option:nth-child(2)').click();
+ assert.ok(dropdownMenu.$('.o_menu_item:first').hasClass('selected'));
+ assert.ok(!dropdownMenu.$('.o_item_option:first').hasClass('selected'));
+ assert.ok(dropdownMenu.$('.o_item_option:nth-child(2)').hasClass('selected'));
+ dropdownMenu.$('li.o_item_option:nth-child(2)').click();
+ assert.ok(!dropdownMenu.$('.o_menu_item:first').hasClass('selected'));
+ assert.ok(!dropdownMenu.$('.o_item_option:first').hasClass('selected'));
+ assert.ok(!dropdownMenu.$('.o_item_option:nth-child(2)').hasClass('selected'));
+ dropdownMenu.destroy();
+ });
+
+ 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);
+ },
+ },
+ });
+ dropdownMenu.$('button:first').click();
+ assert.strictEqual(dropdownMenu.$('li').length, 4);
+ dropdownMenu.$('span.o_trash_button').click();
+ assert.strictEqual(dropdownMenu.$('li').length, 2);
+ dropdownMenu.destroy();
+ });
+
+});
+});
\ No newline at end of file
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index 9fd8aa4dcb4..ea0fb57a38d 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -156,6 +156,8 @@
+
+
@@ -212,11 +214,13 @@
-
+
+
+
@@ -522,6 +526,10 @@
+
+
+
+