From e1aac71769c8d07a358b2d8717efa28248e18a34 Mon Sep 17 00:00:00 2001 From: Mathieu Duckerts-Antoine Date: Mon, 11 Jun 2018 15:45:30 +0200 Subject: [PATCH] [IMP] web: add support for interval selection in groupbys In this commit, we extend the search view to allow the selection of some intervals for date or datetime fields in groupby menu. The valid intervals are day, week, month, quarter, and year. It is also possible to add a button 'Group By' in the control panel owned by a graph view in case it is declared 'embedded' (e.g. in a dashboard view). Note that have three new generic reusable widgets have been introduced in this commit: DropdownMenu, GroupByMenu, and FiltersMenu. --- .../static/src/js/chrome/abstract_action.js | 9 +- .../web/static/src/js/views/abstract_view.js | 6 + .../src/js/views/graph/graph_controller.js | 53 ++- .../static/src/js/views/graph/graph_model.js | 11 + .../static/src/js/views/graph/graph_view.js | 24 +- .../static/src/js/views/search/filter_menu.js | 103 ----- .../src/js/views/search/filters_menu.js | 263 +++++++++---- .../src/js/views/search/groupby_menu.js | 273 ++++++++++--- .../search/groupby_menu_interface_mixin.js | 116 ++++++ .../src/js/views/search/search_filters.js | 7 +- .../src/js/views/search/search_inputs.js | 30 +- .../static/src/js/views/search/search_view.js | 367 ++++++++++++++++-- .../static/src/js/widgets/dropdown_menu.js | 243 ++++++++++++ addons/web/static/src/scss/dropdown.scss | 2 +- addons/web/static/src/scss/dropdown_menu.scss | 50 +++ addons/web/static/src/scss/graph_view.scss | 2 +- .../static/src/scss/search_groupby_menu.scss | 16 + addons/web/static/src/scss/search_view.scss | 183 ++++----- addons/web/static/src/xml/base.xml | 108 ++++-- .../tests/fields/relational_fields_tests.js | 6 +- addons/web/static/tests/views/graph_tests.js | 6 +- .../tests/views/search_filters_menu_tests.js | 135 +++++++ .../tests/views/search_groupby_menu_tests.js | 133 +++++++ .../static/tests/views/search_view_tests.js | 299 ++++++++++++++ .../tests/widgets/dropdown_menu_tests.js | 201 ++++++++++ addons/web/views/webclient_templates.xml | 10 +- 26 files changed, 2231 insertions(+), 425 deletions(-) delete mode 100644 addons/web/static/src/js/views/search/filter_menu.js create mode 100644 addons/web/static/src/js/views/search/groupby_menu_interface_mixin.js create mode 100644 addons/web/static/src/js/widgets/dropdown_menu.js create mode 100644 addons/web/static/src/scss/dropdown_menu.scss create mode 100644 addons/web/static/src/scss/search_groupby_menu.scss create mode 100644 addons/web/static/tests/views/search_filters_menu_tests.js create mode 100644 addons/web/static/tests/views/search_groupby_menu_tests.js create mode 100644 addons/web/static/tests/views/search_view_tests.js create mode 100644 addons/web/static/tests/widgets/dropdown_menu_tests.js 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 @@
    - + -
    - - -
    +
  • -
    - - -
    - -
  • -
  • - -
  • -
    +