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 @@ + + + + + +
  • +
  • + + + + + + +
  • + + + + + +
    + + +
    + + +
  • +
  • + Add Custom Group +
  • +
  • + +
  • +
  • + +
  • +
    + + +
  • +
  • + Add Custom Filter +
  • +
  • + + +
  • +
    + + +
    @@ -1050,25 +1119,12 @@
    - + -
    - - -
    +
  • -
    - - -
    - -
  • -
  • - -
  • -
    +