diff --git a/addons/web/static/src/js/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js index c19364fa7a99da5958c89fccdf57c124241f3cad..ef9273bb48498d7229157e316b431a9b10b290c2 100644 --- a/addons/web/static/src/js/chrome/action_manager_act_window.js +++ b/addons/web/static/src/js/chrome/action_manager_act_window.js @@ -689,6 +689,7 @@ ActionManager.include({ }; } var options = {on_close: ev.data.on_closed}; + action.flags = _.extend({}, action.flags, {withSearchPanel: false}); return self.doAction(action, options).then(ev.data.on_success, ev.data.on_fail); }); }, @@ -711,8 +712,10 @@ ActionManager.include({ // only switch to the requested view if the controller that // triggered the request is the current controller var action = this.actions[currentController.actionID]; + var currentControllerState = currentController.widget.exportState(); + action.controllerState = _.extend({}, action.controllerState, currentControllerState); var options = { - controllerState: currentController.widget.exportState(), + controllerState: action.controllerState, currentId: ev.data.res_id, }; if (ev.data.mode) { diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index d8cc6baa8f5a4028473fecd5161f9071bcd84bb6..58a011599d1946dc29f98be6a898eaa2f23e3652 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -28,6 +28,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { open_record: '_onOpenRecord', search: '_onSearch', switch_view: '_onSwitchView', + search_panel_domain_updated: '_onSearchPanelDomainUpdated', }, events: { 'click a[type="action"]': '_onActionClicked', @@ -58,6 +59,11 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { this.viewType = params.viewType; // use a DropPrevious to correctly handle concurrent updates this.dp = new concurrency.DropPrevious(); + + // the following attributes are used when there is a searchPanel + this._searchPanel = params.searchPanel; + this.controlPanelDomain = params.controlPanelDomain || []; + this.searchPanelDomain = this._searchPanel ? this._searchPanel.getDomain() : []; }, /** * Simply renders and updates the url. @@ -66,6 +72,11 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { */ start: function () { var self = this; + if (this._searchPanel) { + this.$('.o_content') + .addClass('o_controller_with_searchpanel') + .prepend(this._searchPanel.$el); + } this.$el.addClass('o_view_controller'); @@ -123,7 +134,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { */ canBeRemoved: function () { // AAB: get rid of this option when on_hashchange mechanism is improved - return this.discardChanges(undefined, { readonlyIfRealDiscard: true }); + return this.discardChanges(undefined, {readonlyIfRealDiscard: true}); }, /** * Discards the changes made on the record associated to the given ID, or @@ -154,6 +165,9 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { if (this._controlPanel) { state.cpState = this._controlPanel.exportState(); } + if (this._searchPanel) { + state.spState = this._searchPanel.exportState(); + } return state; }, /** @@ -180,20 +194,33 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { * @param {Object} [params] This object will simply be given to the update * @returns {Promise} */ - reload: function (params) { + reload: async function (params) { params = params || {}; - var self = this; - var def; + var searchPanelUpdateProm; var controllerState = params.controllerState || {}; var cpState = controllerState.cpState; if (this._controlPanel && cpState) { - def = this._controlPanel.importState(cpState).then(function (searchQuery) { + await this._controlPanel.importState(cpState).then(function (searchQuery) { params = _.extend({}, params, searchQuery); }); } - return Promise.resolve(def).then(function () { - return self.update(params, {}); - }); + var postponeRendering = false; + if (this._searchPanel) { + this.controlPanelDomain = params.domain || this.controlPanelDomain; + if (controllerState.spState) { + this._searchPanel.importState(controllerState.spState); + this.searchPanelDomain = this._searchPanel.getDomain(); + } else { + searchPanelUpdateProm = this._searchPanel.update({searchDomain: this._getSearchDomain()}); + postponeRendering = !params.noRender; + params.noRender = true; // wait for searchpanel to be ready to render + } + params.domain = this.controlPanelDomain.concat(this.searchPanelDomain); + } + await Promise.all([this.update(params, {}), searchPanelUpdateProm]); + if (postponeRendering) { + return this.renderer._render(); + } }, /** * For views that require a pager, this method will be called to allow the @@ -266,6 +293,18 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { // Private //-------------------------------------------------------------------------- + /** + * Return the current search domain. This is the searchDomain used to update + * the searchpanel. It returns the domain coming from the controlpanel. This + * function can be overridden to add sub-domains coming from other parts of + * the interface. + * + * @private + * @returns {Array[]} + */ + _getSearchDomain: function () { + return this.controlPanelDomain; + }, /** * This method is the way a view can notifies the outside world that * something has changed. The main use for this is to update the url, for @@ -302,7 +341,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { if (this.bannerRoute !== undefined) { var self = this; return this.dp - .add(this._rpc({ route: this.bannerRoute })) + .add(this._rpc({route: this.bannerRoute})) .then(function (response) { if (!response.html) { self.$el.removeClass('o_has_banner'); @@ -370,7 +409,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { */ _renderSwitchButtons: function () { var self = this; - var views = _.filter(this.actionViews, { multiRecord: this.isMultiRecord }); + var views = _.filter(this.actionViews, {multiRecord: this.isMultiRecord}); if (views.length <= 1) { return $(); @@ -389,7 +428,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { var $switchButtonsFiltered = config.device.isMobile ? $switchButtons.find('button') : $switchButtons.filter('button'); $switchButtonsFiltered.click(_.debounce(function (event) { var viewType = $(event.target).data('view-type'); - self.trigger_up('switch_view', { view_type: viewType }); + self.trigger_up('switch_view', {view_type: viewType}); }, 200, true)); // set active view's icon as view switcher button's icon in mobile @@ -519,8 +558,8 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { * @private * @param {OdooEvent} ev */ - _onNavigationMove : function (ev) { - switch(ev.data.direction) { + _onNavigationMove: function (ev) { + switch (ev.data.direction) { case 'down' : ev.stopPropagation(); this.giveFocus(); @@ -543,7 +582,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { */ _onOpenRecord: function (ev) { ev.stopPropagation(); - var record = this.model.get(ev.data.id, { raw: true }); + var record = this.model.get(ev.data.id, {raw: true}); this.trigger_up('switch_view', { view_type: 'form', res_id: record.res_id, @@ -565,6 +604,15 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { ev.stopPropagation(); this.reload(_.extend({offset: 0}, ev.data)); }, + /** + * @private + * @param {OdooEvent} ev + * @param {Array[]} ev.data.domain the current domain of the searchPanel + */ + _onSearchPanelDomainUpdated: function (ev) { + this.searchPanelDomain = ev.data.domain; + this.reload({offset: 0}); + }, /** * Intercepts the 'switch_view' event to add the controllerID into the data, * and lets the event bubble up. diff --git a/addons/web/static/src/js/views/abstract_renderer.js b/addons/web/static/src/js/views/abstract_renderer.js index 724b63bae7920ae71de3f8feb8341d7d15b2e144..afaf275ab5b8a8af42a81aa402aeee57e190f443 100644 --- a/addons/web/static/src/js/views/abstract_renderer.js +++ b/addons/web/static/src/js/views/abstract_renderer.js @@ -21,6 +21,7 @@ return mvc.Renderer.extend({ this._super.apply(this, arguments); this.arch = params.arch; this.noContentHelp = params.noContentHelp; + this.withSearchPanel = params.withSearchPanel; }, /** * The rendering is asynchronous. The start @@ -30,6 +31,9 @@ return mvc.Renderer.extend({ */ start: function () { this.$el.addClass(this.arch.attrs.class); + if (this.withSearchPanel) { + this.$el.addClass('o_renderer_with_searchpanel'); + } return Promise.all([this._render(), this._super()]); }, /** diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js index dbb1b3e557cff539b1d70d2ec6f78f7eeccd029c..fc8e73136d8dbd6a1320b2c9cb47a7951bd23b9b 100644 --- a/addons/web/static/src/js/views/abstract_view.js +++ b/addons/web/static/src/js/views/abstract_view.js @@ -28,6 +28,7 @@ var AbstractRenderer = require('web.AbstractRenderer'); var AbstractController = require('web.AbstractController'); var ControlPanelView = require('web.ControlPanelView'); var mvc = require('web.mvc'); +var SearchPanel = require('web.SearchPanel'); var viewUtils = require('web.viewUtils'); var Factory = mvc.Factory; @@ -50,11 +51,14 @@ var AbstractView = Factory.extend({ searchMenuTypes: ['filter', 'groupBy', 'favorite'], // determines if a control panel should be instantiated withControlPanel: true, + // determines if a search panel could be instantiated + withSearchPanel: true, // determines the MVC components to use config: _.extend({}, Factory.prototype.config, { Model: AbstractModel, Renderer: AbstractRenderer, Controller: AbstractController, + SearchPanel: SearchPanel, }), /** @@ -75,7 +79,7 @@ var AbstractView = Factory.extend({ * @param {string} [params.controllerID] * @param {number} [params.count] * @param {number} [params.currentId] - * @param {string} [params.controllerState] + * @param {Object} [params.controllerState] * @param {string} [params.displayName] * @param {Array[]} [params.domain=[]] * @param {Object[]} [params.dynamicFilters] transmitted to the @@ -88,6 +92,7 @@ var AbstractView = Factory.extend({ * @param {string[]} [params.searchQuery.groupBy=[]] * @param {Object} [params.userContext={}] * @param {boolean} [params.withControlPanel=true] + * @param {boolean} [params.withSearchPanel=true] */ init: function (viewInfo, params) { this._super.apply(this, arguments); @@ -110,6 +115,7 @@ var AbstractView = Factory.extend({ this.fields = this.fieldsView.viewFields; this.userContext = params.userContext || {}; this.withControlPanel = this.withControlPanel && params.withControlPanel; + this.withSearchPanel = this.withSearchPanel && this.multi_record && params.withSearchPanel; // the boolean parameter 'isEmbedded' determines if the view should be // considered as a subview. For now this is only used by the graph @@ -180,6 +186,9 @@ var AbstractView = Factory.extend({ withBreadcrumbs: params.withBreadcrumbs, withSearchBar: params.withSearchBar, }; + this.searchPanelParams = { + state: controllerState.spState, + }; }, //-------------------------------------------------------------------------- @@ -191,20 +200,32 @@ var AbstractView = Factory.extend({ */ getController: function (parent) { var self = this; - var def; - if (this.withControlPanel) { - def = this._createControlPanel(parent); + var cpDef = this.withControlPanel && this._createControlPanel(parent); + var spDef; + if (this.withSearchPanel) { + var spProto = this.config.SearchPanel.prototype; + var viewInfo = this.controlPanelParams.viewInfo; + var sections = spProto.computeSearchPanelParams(viewInfo, this.viewType); + if (sections) { + this.searchPanelParams.sections = sections; + this.rendererParams.withSearchPanel = true; + spDef = Promise.resolve(cpDef).then(this._createSearchPanel.bind(this, parent)); + } } + var _super = this._super.bind(this); - return Promise.resolve(def).then(function (controlPanel) { + return Promise.all([cpDef, spDef]).then(function ([controlPanel, searchPanel]) { // get the parent of the model if it already exists, as _super will // set the new controller as parent, which we don't want var modelParent = self.model && self.model.getParent(); - var prom = _super(parent); + var prom = _super(parent); prom.then(function (controller) { if (controlPanel) { controlPanel.setParent(controller); } + if (searchPanel) { + searchPanel.setParent(controller); + } if (modelParent) { // if we already add a model, restore its parent self.model.setParent(modelParent); @@ -243,7 +264,8 @@ var AbstractView = Factory.extend({ * * @private * @param {Widget} parent - * @returns {ControlPanelController} + * @returns {Promise<ControlPanelController>} resolved when the controlPanel + * is ready */ _createControlPanel: function (parent) { var self = this; @@ -256,6 +278,36 @@ var AbstractView = Factory.extend({ }); }); }, + /** + * @private + * @param {Widget} parent + * @returns {Promise<SearchPanel>} resolved when the searchPanel is ready + */ + _createSearchPanel: async function (parent) { + var defaultValues = {}; + Object.keys(this.loadParams.context).forEach((key) => { + let match = /^searchpanel_default_(.*)$/.exec(key); + if (match) { + defaultValues[match[1]] = this.loadParams.context[key]; + } + }); + var controlPanelDomain = this.loadParams.domain; + var searchPanel = new this.config.SearchPanel(parent, { + defaultValues: defaultValues, + fields: this.fields, + model: this.loadParams.modelName, + searchDomain: controlPanelDomain, + sections: this.searchPanelParams.sections, + state: this.searchPanelParams.state, + }); + this.controllerParams.searchPanel = searchPanel; + this.controllerParams.controlPanelDomain = controlPanelDomain; + await searchPanel.appendTo(document.createDocumentFragment()); + + var searchPanelDomain = searchPanel.getDomain(); + this.loadParams.domain = controlPanelDomain.concat(searchPanelDomain); + return searchPanel; + }, /** * @private * @param {Object} [action] @@ -294,6 +346,7 @@ var AbstractView = Factory.extend({ withBreadcrumbs: 'no_breadcrumbs' in context ? !context.no_breadcrumbs : true, withControlPanel: this.withControlPanel, withSearchBar: inline ? false : this.withSearchBar, + withSearchPanel: this.withSearchPanel, }; }, /** diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js index 008bbcf87756342468d5057d76ef00f3bfa1b85d..fde334d79d0da482278e82ebeea96692f145a494 100644 --- a/addons/web/static/src/js/views/basic/basic_view.js +++ b/addons/web/static/src/js/views/basic/basic_view.js @@ -52,8 +52,8 @@ var BasicView = AbstractView.extend({ this.loadParams.fieldsInfo = this.fieldsInfo; this.loadParams.fields = this.fields; this.loadParams.limit = parseInt(this.arch.attrs.limit, 10) || params.limit; - this.loadParams.viewType = this.viewType; this.loadParams.parentID = params.parentID; + this.loadParams.viewType = this.viewType; this.recordID = params.recordID; this.model = params.model; diff --git a/addons/web/static/src/js/views/calendar/calendar_view.js b/addons/web/static/src/js/views/calendar/calendar_view.js index 90db648ffd36fa477a0320dd0f675c49ec42c95a..2ee927a4af09fe3e519d03b22dc3217db59ff494 100644 --- a/addons/web/static/src/js/views/calendar/calendar_view.js +++ b/addons/web/static/src/js/views/calendar/calendar_view.js @@ -23,11 +23,11 @@ var CalendarView = AbstractView.extend({ icon: 'fa-calendar', jsLibs: ['/web/static/lib/fullcalendar/js/fullcalendar.js'], cssLibs: ['/web/static/lib/fullcalendar/css/fullcalendar.css'], - config: { + config: _.extend({}, AbstractView.prototype.config, { Model: CalendarModel, Controller: CalendarController, Renderer: CalendarRenderer, - }, + }), viewType: 'calendar', searchMenuTypes: ['filter', 'favorite'], diff --git a/addons/web/static/src/js/views/control_panel/control_panel_view.js b/addons/web/static/src/js/views/control_panel/control_panel_view.js index 0a140e1f90107df8b9d8f34cfed6fe1dbfb9531a..c15840714db14b4c5948deb257d39beb9fb7c106 100644 --- a/addons/web/static/src/js/views/control_panel/control_panel_view.js +++ b/addons/web/static/src/js/views/control_panel/control_panel_view.js @@ -201,7 +201,15 @@ var ControlPanelView = Factory.extend({ */ _parseSearchArch: function (arch) { var self = this; - var preFilters = _.flatten(arch.children.map(function (child) { + // a searchview arch may contain a 'searchpanel' node, but this isn't + // the concern of the ControlPanelView (the SearchPanel will handle it). + // Ideally, this code should whitelist the tags to take into account + // instead of blacklisting the others, but with the current (messy) + // structure of a searchview arch, it's way simpler to do it that way. + var children = arch.children.filter(function (child) { + return child.tag !== 'searchpanel'; + }); + var preFilters = _.flatten(children.map(function (child) { return child.tag !== 'group' ? self._evalArchChild(child) : child.children.map(self._evalArchChild); 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 4a91bd67f19950dfa32c25421c6e8a45e39eb5ee..f8125675d4e12d2abadd0d7015ba0af547968779 100644 --- a/addons/web/static/src/js/views/graph/graph_view.js +++ b/addons/web/static/src/js/views/graph/graph_view.js @@ -25,11 +25,11 @@ var GraphView = AbstractView.extend({ jsLibs: [ '/web/static/lib/Chart/Chart.js', ], - config: { + config: _.extend({}, AbstractView.prototype.config, { Model: GraphModel, Controller: Controller, Renderer: GraphRenderer, - }, + }), viewType: 'graph', searchMenuTypes: ['filter', 'groupBy', 'timeRange', 'favorite'], diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js index bf39906b8e506b8aa38f2c73ce7106a29f2b4e63..a6816b08b3921bdec3740379caf73ed6c3c78d2e 100644 --- a/addons/web/static/src/js/views/kanban/kanban_controller.js +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -33,7 +33,6 @@ var KanbanController = BasicController.extend({ kanban_load_records: '_onLoadColumnRecords', column_toggle_fold: '_onToggleColumn', kanban_column_records_toggle_active: '_onToggleActiveRecords', - search_panel_domain_updated: '_onSearchPanelDomainUpdated', }), events: _.extend({}, BasicController.prototype.events, { click: '_onClick', @@ -52,22 +51,6 @@ var KanbanController = BasicController.extend({ this.on_create = params.on_create; this.hasButtons = params.hasButtons; this.quickCreateEnabled = params.quickCreateEnabled; - - // the following attributes are used when there is a searchPanel - this._searchPanel = params.searchPanel; - this.controlPanelDomain = params.controlPanelDomain || []; - this.searchPanelDomain = this._searchPanel ? this._searchPanel.getDomain() : []; - }, - /** - * @override - */ - start: function () { - if (this._searchPanel) { - this.$('.o_content') - .addClass('o_kanban_with_searchpanel') - .prepend(this._searchPanel.$el); - } - return this._super.apply(this, arguments); }, //-------------------------------------------------------------------------- @@ -91,32 +74,6 @@ var KanbanController = BasicController.extend({ } return Promise.resolve(); }, - /** - * Override to add the domain coming from the searchPanel (if any) to the - * domain coming from the controlPanel. - * - * @override - */ - update: function (params) { - if (!this._searchPanel) { - return this._super.apply(this, arguments); - } - var self = this; - if (params.domain) { - this.controlPanelDomain = params.domain; - } - // do not re-render the view as soon as records have been fetched, but - // wait for the searchPanel to be ready as well, such that the view - // isn't re-rendered before the searchPanel - params.noRender = true; - params.domain = this.controlPanelDomain.concat(this.searchPanelDomain); - var superProm = this._super.apply(this, arguments); - var searchPanelProm = this._updateSearchPanel(); - return Promise.all([superProm, searchPanelProm]).then(function () { - // searchPanel has been re-rendered, so re-render the view - return self.renderer.render(); - }); - }, //-------------------------------------------------------------------------- // Private @@ -256,13 +213,6 @@ var KanbanController = BasicController.extend({ this.$buttons.find('.o-kanban-button-new').toggleClass('o_hidden', createHidden); } }, - /** - * @private - * @returns {Promise} - */ - _updateSearchPanel: function () { - return this._searchPanel.update({searchDomain: this.controlPanelDomain}); - }, //-------------------------------------------------------------------------- // Handlers @@ -519,15 +469,6 @@ var KanbanController = BasicController.extend({ self._updateEnv(); }); }, - /** - * @private - * @param {OdooEvent} ev - * @param {Array[]} ev.data.domain the current domain of the searchPanel - */ - _onSearchPanelDomainUpdated: function (ev) { - this.searchPanelDomain = ev.data.domain; - this.reload({offset: 0}); - }, /** * @private * @param {OdooEvent} ev diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js index 5be98e4de52508709666c392724995c1c2c87916..ca6f8893cd8607ee00e461b234e84296dfef1d84 100644 --- a/addons/web/static/src/js/views/kanban/kanban_renderer.js +++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js @@ -172,17 +172,6 @@ var KanbanRenderer = BasicRenderer.extend({ this.quickCreate.toggleFold(); this._toggleNoContentHelper(); }, - /** - * Allow the rendering to be triggered from outside. This is used for kanban - * views with a searchPanel, to synchronize updates (the view is updated - * with param 'noRender', so that it is not re-rendered before the - * searchPanel). - * - * @returns {$.Promise} - */ - render: function () { - return this._render(); - }, /** * Updates a given column with its new state. * diff --git a/addons/web/static/src/js/views/kanban/kanban_view.js b/addons/web/static/src/js/views/kanban/kanban_view.js index 65e6fed767b919029effd0085e9ce11a68d8f93f..e31bff931d146fe703505947f2694c58e216641b 100644 --- a/addons/web/static/src/js/views/kanban/kanban_view.js +++ b/addons/web/static/src/js/views/kanban/kanban_view.js @@ -8,8 +8,6 @@ var KanbanController = require('web.KanbanController'); var kanbanExamplesRegistry = require('web.kanban_examples_registry'); var KanbanModel = require('web.KanbanModel'); var KanbanRenderer = require('web.KanbanRenderer'); -var pyUtils = require('web.py_utils'); -var SearchPanel = require('web.SearchPanel'); var utils = require('web.utils'); var _lt = core._lt; @@ -19,12 +17,11 @@ var KanbanView = BasicView.extend({ display_name: _lt("Kanban"), icon: 'fa-th-large', mobile_friendly: true, - config: { + config: _.extend({}, BasicView.prototype.config, { Model: KanbanModel, Controller: KanbanController, Renderer: KanbanRenderer, - SearchPanel: SearchPanel, - }, + }), jsLibs: [], viewType: 'kanban', @@ -32,8 +29,6 @@ var KanbanView = BasicView.extend({ * @constructor */ init: function (viewInfo, params) { - this.searchPanelSections = Object.create(null); - this._super.apply(this, arguments); this.loadParams.limit = this.loadParams.limit || 40; @@ -92,79 +87,12 @@ var KanbanView = BasicView.extend({ this.jsLibs.push('/web/static/lib/jquery.touchSwipe/jquery.touchSwipe.js'); } - this.hasSearchPanel = !_.isEmpty(this.searchPanelSections); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- - /** - * Override to set the controller as parent of the optional searchPanel - * - * @override - */ - getController: function () { - var self = this; - return this._super.apply(this, arguments).then(function (controller) { - if (self.hasSearchPanel) { - self.controllerParams.searchPanel.setParent(controller); - } - return controller; - }); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Override to create the searchPanel (if necessary) with the domain coming - * from the controlPanel - * - * @override - * @private - */ - _createControlPanel: function (parent) { - if (!this.hasSearchPanel) { - return this._super.apply(this, arguments); - } - var self = this; - return this._super.apply(this, arguments).then(function (controlPanel) { - return self._createSearchPanel(parent).then(function () { - return controlPanel; - }); - }); - }, - /** - * @private - * @param {Widget} parent - * @returns {Promise} resolved when the searchPanel is ready - */ - _createSearchPanel: function (parent) { - var self = this; - var defaultValues = {}; - Object.keys(this.loadParams.context).forEach(function (key) { - var match = /^searchpanel_default_(.*)$/.exec(key); - if (match) { - defaultValues[match[1]] = self.loadParams.context[key]; - } - }); - var controlPanelDomain = this.loadParams.domain; - var searchPanel = new this.config.SearchPanel(parent, { - defaultValues: defaultValues, - fields: this.fields, - model: this.loadParams.modelName, - searchDomain: controlPanelDomain, - sections: this.searchPanelSections, - }); - this.controllerParams.searchPanel = searchPanel; - this.controllerParams.controlPanelDomain = controlPanelDomain; - return searchPanel.appendTo(document.createDocumentFragment()).then(function () { - var searchPanelDomain = searchPanel.getDomain(); - self.loadParams.domain = controlPanelDomain.concat(searchPanelDomain); - }); - }, /** * @private * @param {Object} viewInfo @@ -180,59 +108,6 @@ var KanbanView = BasicView.extend({ } return true; }, - /** - * Override to handle nodes with tagname 'searchpanel'. - * - * @override - * @private - */ - _processNode: function (node, fv) { - if (node.tag === 'searchpanel') { - this._processSearchPanelNode(node, fv); - return false; - } - return this._super.apply(this, arguments); - }, - /** - * Populate this.searchPanelSections with category/filter descriptions. - * - * @private - * @param {Object} node - * @param {Object} fv - */ - _processSearchPanelNode: function (node, fv) { - var self = this; - node.children.forEach(function (childNode, index) { - if (childNode.tag !== 'field') { - return; - } - if (childNode.attrs.invisible === "1") { - return; - } - var fieldName = childNode.attrs.name; - var type = childNode.attrs.select === 'multi' ? 'filter' : 'category'; - - var sectionId = _.uniqueId('section_'); - var section = { - color: childNode.attrs.color, - description: childNode.attrs.string || fv.fields[fieldName].string, - fieldName: fieldName, - icon: childNode.attrs.icon, - id: sectionId, - index: index, - type: type, - }; - if (section.type === 'category') { - section.icon = section.icon || 'fa-folder'; - } else if (section.type === 'filter') { - section.disableCounters = !!pyUtils.py_eval(childNode.attrs.disable_counters || '0'); - section.domain = childNode.attrs.domain || '[]'; - section.groupBy = childNode.attrs.groupby; - section.icon = section.icon || 'fa-filter'; - } - self.searchPanelSections[sectionId] = section; - }); - }, /** * @override * @private diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js index 272ce7cb709616b1c41447aaf78c81136a7eccb5..cf58e1abf4b9bd1388b52918275c0a592bdea31e 100644 --- a/addons/web/static/src/js/views/pivot/pivot_view.js +++ b/addons/web/static/src/js/views/pivot/pivot_view.js @@ -22,11 +22,11 @@ var GROUPABLE_TYPES = controlPanelViewParameters.GROUPABLE_TYPES; var PivotView = AbstractView.extend({ display_name: _lt('Pivot'), icon: 'fa-table', - config: { + config: _.extend({}, AbstractView.prototype.config,{ Model: PivotModel, Controller: PivotController, Renderer: PivotRenderer, - }, + }), viewType: 'pivot', searchMenuTypes: ['filter', 'groupBy', 'timeRange', 'favorite'], diff --git a/addons/web/static/src/js/views/qweb/qweb_view.js b/addons/web/static/src/js/views/qweb/qweb_view.js index 45c7340d5c1672c37970f2ce95c568744ae00437..0c858b5eb6cc2d7a4bd2fcb45ba06499cb807648 100644 --- a/addons/web/static/src/js/views/qweb/qweb_view.js +++ b/addons/web/static/src/js/views/qweb/qweb_view.js @@ -178,11 +178,11 @@ var QWebView = AbstractView.extend({ viewType: 'qweb', // groupable? enableTimeRangeMenu: true, - config: { + config: _.extend({}, AbstractView.prototype.config, { Model: Model, Renderer: Renderer, Controller: Controller, - }, + }), /** * init method diff --git a/addons/web/static/src/js/views/kanban/search_panel.js b/addons/web/static/src/js/views/search_panel.js similarity index 81% rename from addons/web/static/src/js/views/kanban/search_panel.js rename to addons/web/static/src/js/views/search_panel.js index 536409ba1997b06161bbd2e3836fa20ff6bc9153..55d2f23c0fa88ddcfda7c6f8eac9cf8228b6aaa2 100644 --- a/addons/web/static/src/js/views/kanban/search_panel.js +++ b/addons/web/static/src/js/views/search_panel.js @@ -8,10 +8,60 @@ odoo.define('web.SearchPanel', function (require) { var core = require('web.core'); var Domain = require('web.Domain'); +var pyUtils = require('web.py_utils'); +var viewUtils = require('web.viewUtils'); var Widget = require('web.Widget'); var qweb = core.qweb; +// defaultViewTypes is the list of view types for which the searchpanel is +// present by default (if not explicitly stated in the 'view_types' attribute +// in the arch) +var defaultViewTypes = ['kanban', 'tree']; + +/** + * Given a <searchpanel> arch node, iterate over its children to generate the + * description of each section (being either a category or a filter). + * + * @param {Object} node a <searchpanel> arch node + * @param {Object} fields the fields of the model + * @returns {Object} + */ +function _processSearchPanelNode(node, fields) { + var sections = {}; + node.children.forEach((childNode, index) => { + if (childNode.tag !== 'field') { + return; + } + if (childNode.attrs.invisible === "1") { + return; + } + var fieldName = childNode.attrs.name; + var type = childNode.attrs.select === 'multi' ? 'filter' : 'category'; + + var sectionId = _.uniqueId('section_'); + var section = { + color: childNode.attrs.color, + description: childNode.attrs.string || fields[fieldName].string, + fieldName: fieldName, + icon: childNode.attrs.icon, + id: sectionId, + index: index, + type: type, + }; + if (section.type === 'category') { + section.icon = section.icon || 'fa-folder'; + } else if (section.type === 'filter') { + section.disableCounters = !!pyUtils.py_eval(childNode.attrs.disable_counters || '0'); + section.domain = childNode.attrs.domain || '[]'; + section.groupBy = childNode.attrs.groupby; + section.icon = section.icon || 'fa-filter'; + } + sections[sectionId] = section; + }); + return sections; +} + var SearchPanel = Widget.extend({ className: 'o_search_panel', events: { @@ -29,8 +79,10 @@ var SearchPanel = Widget.extend({ * default, for each filter and category * @param {Object} params.fields * @param {string} params.model - * @param {Object} params.sections * @param {Array[]} params.searchDomain domain coming from controlPanel + * @param {Object} params.sections + * @param {Object} [params.state] state exported by another searchpanel + * instance */ init: function (parent, params) { this._super.apply(this, arguments); @@ -42,6 +94,7 @@ var SearchPanel = Widget.extend({ return section.type === 'filter'; }); + this.initialState = params.state; this.defaultValues = params.defaultValues || {}; this.fields = params.fields; this.model = params.model; @@ -52,10 +105,16 @@ var SearchPanel = Widget.extend({ */ willStart: function () { var self = this; - var loadProm = this._fetchCategories().then(function () { - return self._fetchFilters().then(self._applyDefaultFilterValues.bind(self)); - }); - return Promise.all([loadProm, this._super.apply(this, arguments)]); + var loadCategoriesProm; + if (this.initialState) { + this.filters = this.initialState.filters; + this.categories = this.initialState.categories; + } else { + loadCategoriesProm = this._fetchCategories().then(function () { + return self._fetchFilters().then(self._applyDefaultFilterValues.bind(self)); + }); + } + return Promise.all([loadCategoriesProm, this._super.apply(this, arguments)]); }, /** * @override @@ -69,6 +128,55 @@ var SearchPanel = Widget.extend({ // Public //-------------------------------------------------------------------------- + /** + * Parse a given search view arch to extract the searchpanel information + * (i.e. a description of each filter/category). Note that according to the + * 'view_types' attribute on the <searchpanel> node, and the given viewType, + * it may return undefined, meaning that no searchpanel should be rendered + * for the current view. + * + * Note that this is static method, called by AbstractView, *before* + * instantiating the SearchPanel, as depending on what it returns, we may + * or may not instantiate a SearchPanel. + * + * @static + * @params {Object} viewInfo the viewInfo of a search view + * @params {string} viewInfo.arch + * @params {Object} viewInfo.fields + * @params {string} viewType the type of the current view (e.g. 'kanban') + * @returns {Object|undefined} + */ + computeSearchPanelParams: function (viewInfo, viewType) { + var searchPanelSections; + if (viewInfo) { + var arch = viewUtils.parseArch(viewInfo.arch); + viewType = viewType === 'list' ? 'tree' : viewType; + arch.children.forEach(function (node) { + if (node.tag === 'searchpanel') { + var attrs = node.attrs; + var viewTypes = defaultViewTypes; + if (attrs.view_types) { + viewTypes = attrs.view_types.split(','); + } + if (viewTypes.indexOf(viewType) !== -1) { + searchPanelSections = _processSearchPanelNode(node, viewInfo.fields); + } + } + }); + } + return searchPanelSections; + }, + /** + * Export the current state (categories and filters) of the searchpanel. + * + * @returns {Object} + */ + exportState: function () { + return { + categories: this.categories, + filters: this.filters, + }; + }, /** * @returns {Array[]} the current searchPanel domain based on active * categories and checked filters @@ -76,6 +184,18 @@ var SearchPanel = Widget.extend({ getDomain: function () { return this._getCategoryDomain().concat(this._getFilterDomain()); }, + /** + * Import a previously exported state (see exportState). + * + * @param {Object} state + * @param {Object} state.filters. + * @param {Object} state.categories + */ + importState: function (state) { + this.filters = state.filters || this.filters; + this.categories = state.categories || this.categories; + this._render(); + }, /** * Reload the filters and re-render. Note that we only reload the filters if * the controlPanel domain or searchPanel domain has changed. @@ -145,10 +265,10 @@ var SearchPanel = Widget.extend({ } }); category.rootIds = _.filter(_.map(values, function (value) { - return value.id; - }), function (valueId) { - var value = category.values[valueId]; - return value.parentId === false; + return value.id; + }), function (valueId) { + var value = category.values[valueId]; + return value.parentId === false; }); // set active value @@ -317,6 +437,7 @@ var SearchPanel = Widget.extend({ */ _getCategoryDomain: function () { var self = this; + function categoryToDomain(domain, categoryId) { var category = self.categories[categoryId]; if (category.activeValueId) { @@ -326,6 +447,7 @@ var SearchPanel = Widget.extend({ } return domain; } + return Object.keys(this.categories).reduce(categoryToDomain, []); }, /** @@ -341,6 +463,7 @@ var SearchPanel = Widget.extend({ */ _getFilterDomain: function () { var self = this; + function getCheckedValueIds(values) { return Object.keys(values).reduce(function (checkedValues, valueId) { if (values[valueId].checked) { @@ -349,6 +472,7 @@ var SearchPanel = Widget.extend({ return checkedValues; }, []); } + function filterToDomain(domain, filterId) { var filter = self.filters[filterId]; if (filter.groups) { @@ -367,6 +491,7 @@ var SearchPanel = Widget.extend({ } return domain; } + return Object.keys(this.filters).reduce(filterToDomain, []); }, /** diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js index e35f09a6cc9f9b1df1fb1620282394686b39b643..1fb202324951159ef25507dec676f0914a00e18d 100644 --- a/addons/web/static/src/js/views/view_dialogs.js +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -367,6 +367,7 @@ var SelectCreateDialog = ViewDialog.extend({ modelName: this.res_model, readonly: true, withBreadcrumbs: false, + withSearchPanel: false, }, this.options.list_view_options)); listView.setController(SelectCreateListController); return listView.getController(this).then(function (controller) { diff --git a/addons/web/static/src/scss/kanban_view.scss b/addons/web/static/src/scss/kanban_view.scss index f80d50ba576f1139a6c0a224197e261d9c396bcd..34c71ebb5515ceff7bdc355fb67e4ac9a3e2dbc1 100644 --- a/addons/web/static/src/scss/kanban_view.scss +++ b/addons/web/static/src/scss/kanban_view.scss @@ -582,101 +582,6 @@ } } -// ------- Kanban with SearchPanel ------- -$o-searchpanel-p: $o-horizontal-padding; -$o-searchpanel-p-small: $o-horizontal-padding*0.5; -$o-searchpanel-p-tiny: $o-searchpanel-p-small*0.5; - -$o-searchpanel-category-default-color: $o-brand-primary; -$o-searchpanel-filter-default-color: #D59244; - -.o_kanban_with_searchpanel { - display: flex; - align-items: flex-start; - - .o_kanban_view { - flex: 1 1 100%; - overflow: auto; // make the kanban renderer and search panel scroll individually - max-height: 100%; - } - .o_search_panel { - flex: 0 0 220px; - overflow: auto; - height: 100%; - padding: $o-searchpanel-p-small $o-searchpanel-p-small $o-searchpanel-p*2 $o-searchpanel-p; - border-right: 1px solid $gray-300; - background-color: white; - - .o_search_panel_category .o_search_panel_section_icon { - color: $o-brand-odoo; - } - .o_search_panel_filter .o_search_panel_section_icon { - color: $o-searchpanel-filter-default-color; - } - - .o_search_panel_label { - cursor: pointer; - user-select: none; - - .o_toggle_fold { - padding: 3px; - } - } - .o_search_panel_section_header { - padding: $o-searchpanel-p-small 0; - } - .list-group-item { - padding: 0 0 $o-searchpanel-p-small 0; - - .list-group-item { - padding: 0 0 0 $custom-control-gutter; - margin-bottom: $o-searchpanel-p-tiny*0.5; - &:first-child { - margin-top: $o-searchpanel-p-tiny*0.5; - } - } - span.o_search_panel_label_title { - color: $headings-color; - @include o-text-overflow(inline-block, calc(100% - 22px)); - } - header.active { - background-color: $list-group-action-active-bg; - } - } - .o_search_panel_category_value { - header { - margin-left: -$o-searchpanel-p-tiny; - padding-left: $o-searchpanel-p-tiny; - } - .o_search_panel_category_value { - position: relative; - padding-left: $o-searchpanel-p; - padding-bottom: $o-searchpanel-p-tiny; - margin-bottom: 0; - - &:before, &:after { - @include o-position-absolute(0, $left: $o-searchpanel-p-tiny); - @include size(1px, 100%); - background: $gray-500; - content: ''; - } - &:after { - top: 10px; - @include size(8px, 1px); - } - &:last-child { - &:before { - height: 11px; - } - &:after { - top: 11px; - } - } - } - } - } -} - // ----------------- Set Cover Dialog ----------------- .modal .o_kanban_cover_container .o_kanban_cover_image { display: inline-block; diff --git a/addons/web/static/src/scss/search_panel.scss b/addons/web/static/src/scss/search_panel.scss new file mode 100644 index 0000000000000000000000000000000000000000..deac73754d123c151a0bcb6220f6a086fb836dbf --- /dev/null +++ b/addons/web/static/src/scss/search_panel.scss @@ -0,0 +1,94 @@ +// ------- View with SearchPanel ------- +$o-searchpanel-p: $o-horizontal-padding; +$o-searchpanel-p-small: $o-horizontal-padding*0.5; +$o-searchpanel-p-tiny: $o-searchpanel-p-small*0.5; + +$o-searchpanel-category-default-color: $o-brand-primary; +$o-searchpanel-filter-default-color: #D59244; + +.o_controller_with_searchpanel { + display: flex; + align-items: flex-start; + + .o_renderer_with_searchpanel { + flex: 1 1 100%; + overflow: auto; // make the renderer and search panel scroll individually + max-height: 100%; + } + .o_search_panel { + flex: 0 0 220px; + overflow: auto; + height: 100%; + padding: $o-searchpanel-p-small $o-searchpanel-p-small $o-searchpanel-p*2 $o-searchpanel-p; + border-right: 1px solid $gray-300; + background-color: white; + + .o_search_panel_category .o_search_panel_section_icon { + color: $o-brand-odoo; + } + .o_search_panel_filter .o_search_panel_section_icon { + color: $o-searchpanel-filter-default-color; + } + + .o_search_panel_label { + cursor: pointer; + user-select: none; + + .o_toggle_fold { + padding: 3px; + } + } + .o_search_panel_section_header { + padding: $o-searchpanel-p-small 0; + } + .list-group-item { + padding: 0 0 $o-searchpanel-p-small 0; + + .list-group-item { + padding: 0 0 0 $custom-control-gutter; + margin-bottom: $o-searchpanel-p-tiny*0.5; + &:first-child { + margin-top: $o-searchpanel-p-tiny*0.5; + } + } + span.o_search_panel_label_title { + color: $headings-color; + @include o-text-overflow(inline-block, calc(100% - 22px)); + } + header.active { + background-color: $list-group-action-active-bg; + } + } + .o_search_panel_category_value { + header { + margin-left: -$o-searchpanel-p-tiny; + padding-left: $o-searchpanel-p-tiny; + } + .o_search_panel_category_value { + position: relative; + padding-left: $o-searchpanel-p; + padding-bottom: $o-searchpanel-p-tiny; + margin-bottom: 0; + + &:before, &:after { + @include o-position-absolute(0, $left: $o-searchpanel-p-tiny); + @include size(1px, 100%); + background: $gray-500; + content: ''; + } + &:after { + top: 10px; + @include size(8px, 1px); + } + &:last-child { + &:before { + height: 11px; + } + &:after { + top: 11px; + } + } + } + } + } +} diff --git a/addons/web/static/tests/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js index 8a6675ecc584b6d9e134e9b7d1f11976ba969301..ac944425a8cafdee038b597628731c833e72c7cb 100644 --- a/addons/web/static/tests/chrome/action_manager_tests.js +++ b/addons/web/static/tests/chrome/action_manager_tests.js @@ -3442,6 +3442,9 @@ QUnit.module('ActionManager', { active_id: 1, active_ids: [1], }, + flags: { + withSearchPanel: false, + }, }); var checkSessionStorage = false; var actionManager = await createActionManager({ diff --git a/addons/web/static/tests/helpers/test_utils_create.js b/addons/web/static/tests/helpers/test_utils_create.js index ef890e92e9086d4a87bc09a452aa4c0bc297a396..b8f42236c2464c4719ffe38311b012213e3f7478 100644 --- a/addons/web/static/tests/helpers/test_utils_create.js +++ b/addons/web/static/tests/helpers/test_utils_create.js @@ -154,6 +154,11 @@ async function createView(params) { modelName: params.model || 'foo', }); } else { + viewOptions.controlPanelFieldsView = testUtilsMock.fieldsViewGet(mockServer, { + arch: params.archs && params.archs[params.model + ',false,search'] || '<search/>', + fields: viewInfo.fields, + model: params.model, + }); view = new params.View(viewInfo, viewOptions); } diff --git a/addons/web/static/tests/views/search_panel_tests.js b/addons/web/static/tests/views/search_panel_tests.js index 9cb66724e78a78de0106d69a5a48d4dc58774f2b..b51ba9c3b3a9d986a0ec41a04626b72eadad6cab 100644 --- a/addons/web/static/tests/views/search_panel_tests.js +++ b/addons/web/static/tests/views/search_panel_tests.js @@ -2,10 +2,12 @@ odoo.define('web.search_panel_tests', function (require) { "use strict"; var AbstractStorageService = require('web.AbstractStorageService'); +var FormView = require('web.FormView'); var KanbanView = require('web.KanbanView'); var RamStorage = require('web.RamStorage'); var testUtils = require('web.test_utils'); +var createActionManager = testUtils.createActionManager; var createView = testUtils.createView; QUnit.module('Views', { @@ -15,15 +17,16 @@ QUnit.module('Views', { fields: { foo: {string: "Foo", type: 'char'}, bar: {string: "Bar", type: 'boolean'}, + int_field: {string: "Int Field", type: 'integer'}, company_id: {string: "company", type: 'many2one', relation: 'company'}, category_id: { string: "category", type: 'many2one', relation: 'category' }, state: { string: "State", type: 'selection', selection: [['abc', "ABC"], ['def', "DEF"], ['ghi', "GHI"]]}, }, records: [ - {id: 1, bar: true, foo: "yop", company_id: 3, state: 'abc', category_id: 6}, - {id: 2, bar: true, foo: "blip", company_id: 5, state: 'def', category_id: 7}, - {id: 3, bar: true, foo: "gnap", company_id: 3, state: 'ghi', category_id: 7}, - {id: 4, bar: false, foo: "blip", company_id: 5, state: 'ghi', category_id: 7}, + {id: 1, bar: true, foo: "yop", int_field: 1, company_id: 3, state: 'abc', category_id: 6}, + {id: 2, bar: true, foo: "blip", int_field: 2, company_id: 5, state: 'def', category_id: 7}, + {id: 3, bar: true, foo: "gnap", int_field: 4, company_id: 3, state: 'ghi', category_id: 7}, + {id: 4, bar: false, foo: "blip", int_field: 8, company_id: 5, state: 'ghi', category_id: 7}, ] }, company: { @@ -48,6 +51,40 @@ QUnit.module('Views', { }, }; + this.actions = [{ + id: 1, + name: 'Partners', + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'kanban'], [false, 'list'], [false, 'pivot'], [false, 'form']], + }, { + id: 2, + name: 'Partners', + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'form']], + }]; + + this.archs = { + 'partner,false,list': '<tree><field name="foo"/></tree>', + 'partner,false,kanban': '<kanban>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + 'partner,false,form': '<form>' + + '<button name="1" type="action" string="multi view"/>' + + '<field name="foo"/>' + + '</form>', + 'partner,false,pivot': '<pivot><field name="int_field" type="measure"/></pivot>', + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="company_id"/>' + + '<field select="multi" name="category_id"/>' + + '</searchpanel>' + + '</search>', + }; + var RamStorageService = AbstractStorageService.extend({ storage: new RamStorage(), }); @@ -57,7 +94,7 @@ QUnit.module('Views', { }, }, function () { - QUnit.module('SearchPanel in Kanban views'); + QUnit.module('SearchPanel'); QUnit.test('basic rendering', async function (assert) { assert.expect(17); @@ -77,15 +114,19 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="company_id"/>' + - '<field select="multi" name="category_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="company_id"/>' + + '<field select="multi" name="category_id"/>' + + '</searchpanel>' + + '</search>', + }, }); - assert.containsOnce(kanban, '.o_content.o_kanban_with_searchpanel > .o_search_panel'); - assert.containsOnce(kanban, '.o_content.o_kanban_with_searchpanel > .o_kanban_view'); + assert.containsOnce(kanban, '.o_content.o_controller_with_searchpanel > .o_search_panel'); + assert.containsOnce(kanban, '.o_content.o_controller_with_searchpanel > .o_kanban_view'); assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 4); @@ -129,11 +170,15 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="company_id" icon="fa-car" color="blue"/>' + - '<field select="multi" name="state" icon="fa-star" color="#000"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="company_id" icon="fa-car" color="blue"/>' + + '<field select="multi" name="state" icon="fa-star" color="#000"/>' + + '</searchpanel>' + + '</search>', + }, }); assert.hasClass(kanban.$('.o_search_panel_section_header:first i'), 'fa-car'); @@ -160,11 +205,15 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="company_id"/>' + - '<field select="multi" invisible="1" name="state"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="company_id"/>' + + '<field select="multi" invisible="1" name="state"/>' + + '</searchpanel>' + + '</search>', + }, mockRPC: function (route, args) { assert.step(args.method || route); return this._super.apply(this, arguments); @@ -195,12 +244,16 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="company_id"/>' + - '<field select="multi" name="category_id"/>' + - '<field name="state"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="company_id"/>' + + '<field select="multi" name="category_id"/>' + + '<field name="state"/>' + + '</searchpanel>' + + '</search>', + } }); assert.containsN(kanban, '.o_search_panel_section', 3); @@ -228,11 +281,15 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="company_id"/>' + - '<field name="state"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="company_id"/>' + + '<field name="state"/>' + + '</searchpanel>' + + '</search>', + }, mockRPC: function (route, args) { if (route === '/web/dataset/search_read') { assert.deepEqual(args.domain, [["state", "=", "ghi"]]); @@ -268,10 +325,14 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="company_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="company_id"/>' + + '</searchpanel>' + + '</search>', + }, domain: [['bar', '=', true]], }); @@ -326,10 +387,14 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="state"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="state"/>' + + '</searchpanel>' + + '</search>', + }, }); // select 'abc' @@ -393,10 +458,14 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="company_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="company_id"/>' + + '</searchpanel>' + + '</search>', + }, mockRPC: function (route, args) { if (route === '/web/dataset/search_read') { assert.deepEqual(args.domain, [['company_id', 'child_of', expectedActiveId]]); @@ -448,10 +517,14 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="company_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="company_id"/>' + + '</searchpanel>' + + '</search>', + }, mockRPC: function (route, args) { if (route === '/web/dataset/search_read') { assert.deepEqual(args.domain, []); @@ -490,11 +563,15 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="company_id"/>' + - '<field name="state"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="company_id"/>' + + '<field name="state"/>' + + '</searchpanel>' + + '</search>', + }, domain: [['bar', '=', true]], }); @@ -557,10 +634,14 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="company_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="company_id"/>' + + '</searchpanel>' + + '</search>', + }, }); // 'All' is selected by default @@ -635,10 +716,14 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="company_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="company_id"/>' + + '</searchpanel>' + + '</search>', + }, }); assert.strictEqual(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold').length, 1, @@ -680,10 +765,14 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="company_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="company_id"/>' + + '</searchpanel>' + + '</search>', + }, }); // unfold agrolait @@ -724,10 +813,14 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="company_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="company_id"/>' + + '</searchpanel>' + + '</search>', + }, domain: [['bar', '=', true]], }); @@ -801,11 +894,15 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="state"/>' + - '<field select="multi" name="company_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="state"/>' + + '<field select="multi" name="company_id"/>' + + '</searchpanel>' + + '</search>', + }, }); // 'All' should be selected by default @@ -872,10 +969,14 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field select="multi" name="company_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field select="multi" name="company_id"/>' + + '</searchpanel>' + + '</search>', + }, }); assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 4); @@ -928,10 +1029,14 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field select="multi" name="company_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field select="multi" name="company_id"/>' + + '</searchpanel>' + + '</search>', + }, domain: [['bar', '=', true]], }); @@ -1027,10 +1132,14 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field select="multi" name="state"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field select="multi" name="state"/>' + + '</searchpanel>' + + '</search>', + }, domain: [['bar', '=', true]], }); @@ -1111,11 +1220,15 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="state"/>' + - '<field select="multi" name="company_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="state"/>' + + '<field select="multi" name="company_id"/>' + + '</searchpanel>' + + '</search>', + }, viewOptions: { limit: 2, }, @@ -1187,10 +1300,14 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field select="multi" name="company_id" groupby="category_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field select="multi" name="company_id" groupby="category_id"/>' + + '</searchpanel>' + + '</search>', + }, domain: [['bar', '=', true]], }); @@ -1305,10 +1422,14 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field select="multi" name="company_id" domain="[(\'parent_id\',\'=\',False)]"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field select="multi" name="company_id" domain="[(\'parent_id\',\'=\',False)]"/>' + + '</searchpanel>' + + '</search>', + }, }); assert.containsN(kanban, '.o_search_panel_filter_value', 2); @@ -1334,10 +1455,14 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field select="multi" name="company_id" groupby="category_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field select="multi" name="company_id" groupby="category_id"/>' + + '</searchpanel>' + + '</search>', + }, }); // groups are opened by default @@ -1405,11 +1530,15 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="category_id"/>' + - '<field select="multi" name="company_id" domain="[[\'category_id\', \'=\', category_id]]"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="category_id"/>' + + '<field select="multi" name="company_id" domain="[[\'category_id\', \'=\', category_id]]"/>' + + '</searchpanel>' + + '</search>', + }, }); // select 'gold' category @@ -1467,11 +1596,15 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field select="multi" name="company_id"/>' + - '<field select="multi" name="state"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field select="multi" name="company_id"/>' + + '<field select="multi" name="state"/>' + + '</searchpanel>' + + '</search>', + }, mockRPC: function (route, args) { if (route === '/web/dataset/search_read') { assert.deepEqual(args.domain, expectedDomain); @@ -1509,10 +1642,14 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field select="multi" name="company_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field select="multi" name="company_id"/>' + + '</searchpanel>' + + '</search>', + }, mockRPC: function (route, args) { if (route === '/web/dataset/search_read') { assert.deepEqual(args.domain, [["company_id", "in", [3]]]); @@ -1543,10 +1680,14 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field select="multi" name="company_id" groupby="category_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field select="multi" name="company_id" groupby="category_id"/>' + + '</searchpanel>' + + '</search>', + }, mockRPC: function (route, args) { if (route === '/web/dataset/search_read') { assert.deepEqual(args.domain, [['company_id', 'in', [5]]]); @@ -1581,11 +1722,15 @@ QUnit.module('Views', { '<field name="foo"/>' + '</div>' + '</t></templates>' + - '<searchpanel>' + - '<field name="company_id"/>' + - '<field select="multi" name="category_id"/>' + - '</searchpanel>' + '</kanban>', + archs: { + 'partner,false,search': '<search>' + + '<searchpanel>' + + '<field name="company_id"/>' + + '<field select="multi" name="category_id"/>' + + '</searchpanel>' + + '</search>', + }, }); var $firstSection = kanban.$('.o_search_panel_section:first'); @@ -1593,6 +1738,296 @@ QUnit.module('Views', { 'AllasustekagrolaithighIDlowID'); kanban.destroy(); }); -}); + QUnit.test('search panel is available on list and kanban by default', async function (assert) { + assert.expect(8); + + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + + await actionManager.doAction(1); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_kanban_view'); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel'); + + await testUtils.dom.click(actionManager.$('.o_cp_switch_pivot')); + assert.containsOnce(actionManager, '.o_content .o_pivot'); + assert.containsNone(actionManager, '.o_content .o_search_panel'); + + await testUtils.dom.click(actionManager.$('.o_cp_switch_list')); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_list_view'); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel'); + + await testUtils.dom.click(actionManager.$('.o_data_row .o_data_cell:first')); + assert.containsOnce(actionManager, '.o_content .o_form_view'); + assert.containsNone(actionManager, '.o_content .o_search_panel'); + + actionManager.destroy(); + }); + + QUnit.test('search panel with view_types attribute', async function (assert) { + assert.expect(6); + + this.archs['partner,false,search'] = '<search>' + + '<searchpanel view_types="kanban,pivot">' + + '<field name="company_id"/>' + + '<field select="multi" name="category_id"/>' + + '</searchpanel>' + + '</search>'; + + + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + + await actionManager.doAction(1); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_kanban_view'); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel'); + + await testUtils.dom.click(actionManager.$('.o_cp_switch_list')); + assert.containsOnce(actionManager, '.o_content .o_list_view'); + assert.containsNone(actionManager, '.o_content .o_search_panel'); + + await testUtils.dom.click(actionManager.$('.o_cp_switch_pivot')); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_pivot'); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel'); + + actionManager.destroy(); + }); + + QUnit.test('search panel state is shared between views', async function (assert) { + assert.expect(16); + + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + services: this.services, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.step(JSON.stringify(args.domain)); + } + return this._super.apply(this, arguments); + }, + }); + + await actionManager.doAction(1); + assert.hasClass(actionManager.$('.o_search_panel_category_value:first header'), 'active'); + assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 4); + + // select 'asustek' company + await testUtils.dom.click(actionManager.$('.o_search_panel_category_value:nth(1) header')); + assert.hasClass(actionManager.$('.o_search_panel_category_value:nth(1) header'), 'active'); + assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 2); + + await testUtils.dom.click(actionManager.$('.o_cp_switch_list')); + assert.hasClass(actionManager.$('.o_search_panel_category_value:nth(1) header'), 'active'); + assert.containsN(actionManager, '.o_data_row', 2); + + // select 'agrolait' company + await testUtils.dom.click(actionManager.$('.o_search_panel_category_value:nth(2) header')); + assert.hasClass(actionManager.$('.o_search_panel_category_value:nth(2) header'), 'active'); + assert.containsN(actionManager, '.o_data_row', 2); + + await testUtils.dom.click(actionManager.$('.o_cp_switch_kanban')); + assert.hasClass(actionManager.$('.o_search_panel_category_value:nth(2) header'), 'active'); + assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 2); + + assert.verifySteps([ + '[]', // initial search_read + '[["company_id","child_of",3]]', // kanban, after selecting the first company + '[["company_id","child_of",3]]', // list + '[["company_id","child_of",5]]', // list, after selecting the other company + '[["company_id","child_of",5]]', // kanban + ]); + + actionManager.destroy(); + }); + + QUnit.test('search panel filters are kept between switch views', async function (assert) { + assert.expect(16); + + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + services: this.services, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.step(JSON.stringify(args.domain)); + } + return this._super.apply(this, arguments); + }, + }); + + await actionManager.doAction(1); + assert.containsNone(actionManager, '.o_search_panel_filter_value input:checked'); + assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 4); + + // select gold filter + await testUtils.dom.click(actionManager.$('.o_search_panel_filter input[type="checkbox"]:nth(0)')); + assert.containsOnce(actionManager, '.o_search_panel_filter_value input:checked'); + assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 1); + + await testUtils.dom.click(actionManager.$('.o_cp_switch_list')); + assert.containsOnce(actionManager, '.o_search_panel_filter_value input:checked'); + assert.containsN(actionManager, '.o_data_row', 1); + + // select silver filter + await testUtils.dom.click(actionManager.$('.o_search_panel_filter input[type="checkbox"]:nth(1)')); + assert.containsN(actionManager, '.o_search_panel_filter_value input:checked', 2); + assert.containsN(actionManager, '.o_data_row', 4); + + await testUtils.dom.click(actionManager.$('.o_cp_switch_kanban')); + assert.containsN(actionManager, '.o_search_panel_filter_value input:checked', 2); + assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 4); + + assert.verifySteps([ + '[]', // initial search_read + '[["category_id","in",[6]]]', // kanban, after selecting the gold filter + '[["category_id","in",[6]]]', // list + '[["category_id","in",[6,7]]]', // list, after selecting the silver filter + '[["category_id","in",[6,7]]]', // kanban + ]); + + actionManager.destroy(); + }); + + QUnit.test('search panel filters are kept when switching to a view with no search panel', async function (assert) { + assert.expect(13); + + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + + await actionManager.doAction(1); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_kanban_view'); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel'); + assert.containsNone(actionManager, '.o_search_panel_filter_value input:checked'); + assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 4); + + // select gold filter + await testUtils.dom.click(actionManager.$('.o_search_panel_filter input[type="checkbox"]:nth(0)')); + assert.containsOnce(actionManager, '.o_search_panel_filter_value input:checked'); + assert.containsN(actionManager, '.o_kanban_record:not(.o_kanban_ghost)', 1); + + // switch to pivot + await testUtils.dom.click(actionManager.$('.o_cp_switch_pivot')); + assert.containsOnce(actionManager, '.o_content .o_pivot'); + assert.containsNone(actionManager, '.o_content .o_search_panel'); + assert.strictEqual(actionManager.$('.o_pivot_cell_value').text(), '15'); + + // switch to list + await testUtils.dom.click(actionManager.$('.o_cp_switch_list')); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_list_view'); + assert.containsOnce(actionManager, '.o_content.o_controller_with_searchpanel .o_search_panel'); + assert.containsOnce(actionManager, '.o_search_panel_filter_value input:checked'); + assert.containsN(actionManager, '.o_data_row', 1); + + actionManager.destroy(); + }); + + QUnit.test('disable search panel onExecuteAction', async function (assert) { + assert.expect(6); + + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + services: this.services, + }); + + await actionManager.doAction(2); + + await testUtils.dom.click(actionManager.$('.o_form_view button:contains("multi view")')); + assert.containsOnce(actionManager, '.o_kanban_view'); + assert.containsNone(actionManager, '.o_search_panel'); + + await testUtils.dom.click(actionManager.$('.o_cp_switch_list')); + assert.containsOnce(actionManager, '.o_list_view'); + assert.containsNone(actionManager, '.o_search_panel'); + + await testUtils.dom.click(actionManager.$('.o_cp_switch_kanban')); + assert.containsOnce(actionManager, '.o_kanban_view'); + assert.containsNone(actionManager, '.o_search_panel'); + + actionManager.destroy(); + }); + + QUnit.test('categories and filters are not reloaded when switching between views', async function (assert) { + assert.expect(8); + + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + services: this.services, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + + await actionManager.doAction(1); + await testUtils.dom.click(actionManager.$('.o_cp_switch_list')); + await testUtils.dom.click(actionManager.$('.o_cp_switch_kanban')); + + assert.verifySteps([ + '/web/action/load', + 'load_views', + 'search_panel_select_range', // kanban: categories + 'search_panel_select_multi_range', // kanban: filters + '/web/dataset/search_read', // kanban: records + '/web/dataset/search_read', // list: records + '/web/dataset/search_read', // kanban: records + ]); + + actionManager.destroy(); + }); + + QUnit.test('search panel is not instanciated in dialogs', async function (assert) { + assert.expect(2); + + this.data.company.records = [ + {id: 1, name: 'Company1'}, + {id: 2, name: 'Company2'}, + {id: 3, name: 'Company3'}, + {id: 4, name: 'Company4'}, + {id: 5, name: 'Company5'}, + {id: 6, name: 'Company6'}, + {id: 7, name: 'Company7'}, + {id: 8, name: 'Company8'}, + ]; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form><field name="company_id"/></form>', + archs: { + 'company,false,list': '<tree><field name="name"/></tree>', + 'company,false,search': '<search>' + + '<field name="name"/>' + + '<searchpanel>' + + '<field name="category_id"/>' + + '</searchpanel>' + + '</search>', + }, + }); + + await testUtils.fields.many2one.clickOpenDropdown('company_id'); + await testUtils.fields.many2one.clickItem('company_id', 'Search More'); + + assert.containsOnce(document.body, '.modal .o_list_view'); + assert.containsNone(document.body, '.modal .o_search_panel'); + + form.destroy(); + }); +}); }); diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 6a225d7a6361defa67e5e9f828b970d02ea6c02f..f17889bf5c2a0b54077970acb84eeb21c4eeead9 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -209,6 +209,7 @@ <link rel="stylesheet" type="text/scss" href="/web/static/src/scss/web_calendar.scss"/> <link rel="stylesheet" type="text/scss" href="/web/static/src/scss/web_calendar_mobile.scss"/> <link rel="stylesheet" type="text/scss" href="/web/static/src/scss/search_view.scss"/> + <link rel="stylesheet" type="text/scss" href="/web/static/src/scss/search_panel.scss"/> <link rel="stylesheet" type="text/scss" href="/web/static/src/scss/search_view_mobile.scss"/> <link rel="stylesheet" type="text/scss" href="/web/static/src/scss/dropdown_menu.scss"/> <link rel="stylesheet" type="text/scss" href="/web/static/src/scss/search_view_extra.scss"/> @@ -314,6 +315,7 @@ <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js"></script> <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_filters.js"></script> <script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_filters_registry.js"></script> + <script type="text/javascript" src="/web/static/src/js/views/search_panel.js"></script> <script type="text/javascript" src="/web/static/src/js/views/field_manager_mixin.js"></script> <script type="text/javascript" src="/web/static/src/js/views/standalone_field_manager_mixin.js"></script> <script type="text/javascript" src="/web/static/src/js/views/view_registry.js"></script> @@ -337,7 +339,6 @@ <script type="text/javascript" src="/web/static/src/js/views/kanban/kanban_renderer.js"></script> <script type="text/javascript" src="/web/static/src/js/views/kanban/kanban_renderer_mobile.js"></script> <script type="text/javascript" src="/web/static/src/js/views/kanban/kanban_view.js"></script> - <script type="text/javascript" src="/web/static/src/js/views/kanban/search_panel.js"></script> <script type="text/javascript" src="/web/static/src/js/views/kanban/quick_create_form_view.js"></script> <script type="text/javascript" src="/web/static/src/js/views/list/list_editable_renderer.js"></script> <script type="text/javascript" src="/web/static/src/js/views/list/list_model.js"></script> diff --git a/addons/web_diagram/static/src/js/diagram_view.js b/addons/web_diagram/static/src/js/diagram_view.js index c8a46a21990c29009bf40ecd7a0d9530f7d90b98..f95a0b69564237d668ecae813c0d5da6897196ba 100644 --- a/addons/web_diagram/static/src/js/diagram_view.js +++ b/addons/web_diagram/static/src/js/diagram_view.js @@ -22,11 +22,11 @@ var DiagramView = BasicView.extend({ '/web_diagram/static/lib/js/jquery.mousewheel.js', '/web_diagram/static/lib/js/raphael.js', ]], - config: { + config: _.extend({}, BasicView.prototype.config, { Model: DiagramModel, Renderer: DiagramRenderer, Controller: DiagramController, - }, + }), viewType: 'diagram', /** diff --git a/doc/reference/views.rst b/doc/reference/views.rst index eb94a707c771411abf7356daae8651c4df70ba95..8b632bba228e7e0f2bf53724c8960dea53703289 100644 --- a/doc/reference/views.rst +++ b/doc/reference/views.rst @@ -1134,57 +1134,6 @@ Possible children of the view element are: * kanban-specific CSS * kanban structures/widgets (vignette, details, ...) -``searchpanel`` - allows to display a search panel on the left of the kanban view. - This tool allows to quickly filter data on the basis of given fields. The fields - are specified as direct children of the ``searchpanel`` with tag name ``field``, - and the following attributes: - - * ``name`` (mandatory) the name of the field to filter on - - * ``select`` determines the behavior and display. Possible values are - - ``one`` (default) at most one value can be selected. Supported field types are - many2one and selection. - - ``multi`` several values can be selected (checkboxes). Supported field - types are many2one, many2many and selection. - - * ``groups``: restricts to specific users - - * ``string``: determines the label to display - - * ``icon``: specifies which icon is used - - * ``color``: determines the icon color - - Additional optional attributes are available in the ``multi`` case: - - * ``domain``: determines conditions that the comodel records have to satisfy. - - A domain might be used to express a dependency on another field (with select="one") - of the search panel. Consider - - .. code-block:: xml - - <searchpanel> - <field name="department_id"/> - <field name="manager_id" select="multi" domain="[('department_id', '=', department_id)]"/> - <searchpanel/> - - In the above example, the range of values for manager_id (manager names) available at screen - will depend on the value currently selected for the field ``department_id``. - - * ``groupby``: field name of the comodel (only available for many2one and many2many fields). Values will be grouped by that field. - - * ``disable_counters``: default is false. If set to true the counters won't be computed. - - This feature has been implemented in case performances would be too bad. - - Another way to solve performance issues is to properly override the - ``search_panel_select_multi_range`` method. - - If you need to extend the Kanban view, see :js:class::`the JS API <KanbanRecord>`. .. _reference/views/calendar: @@ -1951,6 +1900,62 @@ Possible children elements of the search view are: ``group`` can be used to separate groups of filters, more readable than ``separator`` in complex search views +``searchpanel`` + allows to display a search panel on the left of any multi records view. + By default, the list and kanban views have the searchpanel enabled. + The search panel can be activated on other views with the attribute: + + * ``view_types`` a comma separated list of view types on which to enable the search panel + + default: 'tree,kanban' + + This tool allows to quickly filter data on the basis of given fields. The fields + are specified as direct children of the ``searchpanel`` with tag name ``field``, + and the following attributes: + + * ``name`` (mandatory) the name of the field to filter on + + * ``select`` determines the behavior and display. Possible values are + + ``one`` (default) at most one value can be selected. Supported field types are + many2one and selection. + + ``multi`` several values can be selected (checkboxes). Supported field + types are many2one, many2many and selection. + + * ``groups``: restricts to specific users + + * ``string``: determines the label to display + + * ``icon``: specifies which icon is used + + * ``color``: determines the icon color + + Additional optional attributes are available in the ``multi`` case: + + * ``domain``: determines conditions that the comodel records have to satisfy. + + A domain might be used to express a dependency on another field (with select="one") + of the search panel. Consider + + .. code-block:: xml + + <searchpanel> + <field name="department_id"/> + <field name="manager_id" select="multi" domain="[('department_id', '=', department_id)]"/> + <searchpanel/> + + In the above example, the range of values for manager_id (manager names) available at screen + will depend on the value currently selected for the field ``department_id``. + + * ``groupby``: field name of the comodel (only available for many2one and many2many fields). Values will be grouped by that field. + + * ``disable_counters``: default is false. If set to true the counters won't be computed. + + This feature has been implemented in case performances would be too bad. + + Another way to solve performance issues is to properly override the + ``search_panel_select_multi_range`` method. .. _reference/views/search/defaults: diff --git a/odoo/addons/base/models/ir_ui_view.py b/odoo/addons/base/models/ir_ui_view.py index cff628c93497e7b157a5afc742cce9c72dd30295..a110361974a2e4bb93b63682cd3ba074a7d4816e 100644 --- a/odoo/addons/base/models/ir_ui_view.py +++ b/odoo/addons/base/models/ir_ui_view.py @@ -1,19 +1,16 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import ast import collections import copy import datetime import fnmatch import logging -import os import re import time import uuid import itertools from dateutil.relativedelta import relativedelta -from functools import partial from difflib import HtmlDiff from operator import itemgetter @@ -22,7 +19,7 @@ from lxml import etree from lxml.etree import LxmlError from lxml.builder import E -from odoo import api, fields, models, tools, SUPERUSER_ID, _ +from odoo import api, fields, models, tools, _ from odoo.exceptions import ValidationError from odoo.http import request from odoo.modules.module import get_resource_from_path, get_resource_path @@ -31,7 +28,7 @@ from odoo.tools import config, graph, ConstantMapping, SKIPPED_ELEMENT_TYPES, py from odoo.tools.convert import _fix_multiple_roots from odoo.tools.json import scriptsafe as json_scriptsafe from odoo.tools.safe_eval import safe_eval -from odoo.tools.view_validation import valid_view +from odoo.tools.view_validation import valid_view, get_attrs_field_names, field_is_editable from odoo.tools.translate import xml_translate, TRANSLATED_ATTRS from odoo.tools.image import image_data_uri @@ -43,20 +40,6 @@ MOVABLE_BRANDING = ['data-oe-model', 'data-oe-id', 'data-oe-field', 'data-oe-xpa # Note: natural _order has `name`, but only because that makes list browsing easier INHERIT_ORDER = 'priority,id' -# attributes in views that may contain references to field names -ATTRS_WITH_FIELD_NAMES = { - 'context', - 'domain', - 'decoration-bf', - 'decoration-it', - 'decoration-danger', - 'decoration-info', - 'decoration-muted', - 'decoration-primary', - 'decoration-success', - 'decoration-warning', -} - def keep_query(*keep_params, **additional_params): """ @@ -181,7 +164,6 @@ xpath_utils['hasclass'] = _hasclass TRANSLATED_ATTRS_RE = re.compile(r"@(%s)\b" % "|".join(TRANSLATED_ATTRS)) WRONGCLASS = re.compile(r"(@class\s*=|=\s*@class|contains\(@class)") -READONLY = re.compile(r"\breadonly\b") class View(models.Model): @@ -375,7 +357,7 @@ actual arch. # A <data> element is a wrapper for multiple root nodes view_docs = view_docs[0] for view_arch in view_docs: - check = valid_view(view_arch) + check = valid_view(view_arch, env=self.env, model=view.model) if not check: raise ValidationError(_('Invalid view %s definition in %s') % (view.name, view.arch_fs)) if check == "Warning": @@ -876,7 +858,7 @@ actual arch. attrs = {} field = Model._fields.get(node.get('name')) if field: - editable = self.env.context.get('view_is_editable', True) and self._field_is_editable(field, node) + editable = self.env.context.get('view_is_editable', True) and field_is_editable(field, node) children = False views = {} for f in node: @@ -939,6 +921,15 @@ actual arch. if f.tag == 'filter': fields[f.get('name')] = {} + elif node.tag == 'search': + searchpanel = [c for c in node if c.tag == 'searchpanel'] + if searchpanel: + self.with_context( + base_model_name=model, + check_field_names=False, # field validation is a bit more tricky and done apart + view_is_editable=False, + ).postprocess_and_fields(model, searchpanel[0], view_id) + if not self._apply_group(model, node, modifiers, fields): # node must be removed, no need to proceed further with its children return fields @@ -948,6 +939,9 @@ actual arch. orm.transfer_node_to_modifiers(node, modifiers, self._context, in_tree_view) for f in node: + if node.tag == 'search' and f.tag == 'searchpanel': + # searchpanel part has to be validated independently + continue if children or (node.tag == 'field' and f.tag in ('filter', 'separator')): fields.update(self.postprocess(model, f, view_id, in_tree_view, model_fields)) @@ -984,113 +978,6 @@ actual arch. return arch - def _view_is_editable(self, node): - """ Return whether the node is an editable view. """ - return node.tag == 'form' or node.tag == 'tree' and node.get('editable') - - def _field_is_editable(self, field, node): - """ Return whether a field is editable (not always readonly). """ - return ( - (not field.readonly or READONLY.search(str(field.states or ""))) and - (node.get('readonly') != "1" or READONLY.search(node.get('attrs') or "")) - ) - - def get_attrs_symbols(self): - """ Return a set of predefined symbols for evaluating attrs. """ - return { - 'True', 'False', 'None', # those are identifiers in Python 2.7 - 'self', - 'parent', - 'id', - 'uid', - 'context', - 'context_today', - 'active_id', - 'active_ids', - 'allowed_company_ids', - 'current_company_id', - 'active_model', - 'time', - 'datetime', - 'relativedelta', - 'current_date', - 'abs', - 'len', - 'bool', - 'float', - 'str', - 'unicode', - } - - def get_attrs_field_names(self, arch, model, editable): - """ Retrieve the field names appearing in context, domain and attrs, and - return a list of triples ``(field_name, attr_name, attr_value)``. - """ - VIEW_TYPES = {item[0] for item in type(self).type.selection} - symbols = self.get_attrs_symbols() | {None} - result = [] - - def get_name(node): - """ return the name from an AST node, or None """ - if isinstance(node, ast.Name): - return node.id - - def get_subname(get, node): - """ return the subfield name from an AST node, or None """ - if isinstance(node, ast.Attribute) and get(node.value) == 'parent': - return node.attr - - def process_expr(expr, get, key, val): - """ parse `expr` and collect triples """ - for node in ast.walk(ast.parse(expr.strip(), mode='eval')): - name = get(node) - if name not in symbols: - result.append((name, key, val)) - - def process_attrs(expr, get, key, val): - """ parse `expr` and collect field names in lhs of conditions. """ - for domain in safe_eval(expr).values(): - if not isinstance(domain, list): - continue - for arg in domain: - if isinstance(arg, (tuple, list)): - process_expr(str(arg[0]), get, key, expr) - - def process(node, model, editable, get=get_name): - """ traverse `node` and collect triples """ - if node.tag in VIEW_TYPES: - # determine whether this view is editable - editable = editable and self._view_is_editable(node) - elif node.tag in ('field', 'groupby'): - # determine whether the field is editable - field = model._fields.get(node.get('name')) - if field: - editable = editable and self._field_is_editable(field, node) - - for key, val in node.items(): - if not val: - continue - if key in ATTRS_WITH_FIELD_NAMES: - process_expr(val, get, key, val) - elif key == 'attrs': - process_attrs(val, get, key, val) - - if node.tag in ('field', 'groupby') and field and field.relational: - if editable and not node.get('domain'): - domain = field._description_domain(self.env) - # process the field's domain as if it was in the view - if isinstance(domain, str): - process_expr(domain, get, 'domain', domain) - # retrieve subfields of 'parent' - model = self.env[field.comodel_name] - get = partial(get_subname, get) - - for child in node: - process(child, model, editable, get) - - process(arch, model, editable) - return result - @api.model def postprocess_and_fields(self, model, node, view_id): """ Return an architecture and a description of all the fields. @@ -1124,7 +1011,7 @@ actual arch. attrs_fields = [] if self.env.context.get('check_field_names'): editable = self.env.context.get('view_is_editable', True) - attrs_fields = self.get_attrs_field_names(node, Model, editable) + attrs_fields = get_attrs_field_names(self.env, node, Model, editable) fields_def = self.postprocess(model, node, view_id, False, fields) self._postprocess_access_rights(model, node) diff --git a/odoo/addons/base/rng/common.rng b/odoo/addons/base/rng/common.rng index 66ca9a6135b879716038be7781e8cc4d78368d7c..43a46d8a0e5de044e57e667c48e9839cd20b5440 100644 --- a/odoo/addons/base/rng/common.rng +++ b/odoo/addons/base/rng/common.rng @@ -233,6 +233,8 @@ <rng:optional><rng:attribute name="avg"/></rng:optional> <rng:optional><rng:attribute name="select"/></rng:optional> <rng:optional><rng:attribute name="group"/></rng:optional> + <rng:optional><rng:attribute name="color"/></rng:optional> + <rng:optional><rng:attribute name="groupby"/></rng:optional> <rng:optional><rng:attribute name="operator"/></rng:optional> <rng:optional><rng:attribute name="colspan"/></rng:optional> <rng:optional><rng:attribute name="nolabel"/></rng:optional> diff --git a/odoo/addons/base/rng/search_view.rng b/odoo/addons/base/rng/search_view.rng index 74dc4c89abd969483465fbb728d7a6bef9b22842..08776e8e2060e082fab4bd9f130e2c8bb7ca2fc5 100644 --- a/odoo/addons/base/rng/search_view.rng +++ b/odoo/addons/base/rng/search_view.rng @@ -6,6 +6,17 @@ template --> <rng:include href="common.rng"/> + + <rng:define name="searchpanel"> + <rng:element name="searchpanel"> + <rng:ref name="overload"/> + <rng:optional><rng:attribute name="view_types"/></rng:optional> + <rng:zeroOrMore> + <rng:ref name="field" /> + </rng:zeroOrMore> + </rng:element> + </rng:define> + <rng:define name="search"> <rng:element name="search"> <rng:ref name="overload"/> @@ -17,6 +28,7 @@ <rng:ref name="separator"/> <rng:ref name="filter"/> <rng:element name="newline"><rng:empty/></rng:element> + <rng:ref name="searchpanel"/> </rng:choice> </rng:zeroOrMore> </rng:element> diff --git a/odoo/tools/view_validation.py b/odoo/tools/view_validation.py index bde57f96a666f350097d20ed06388af1b3c4f370..2400ef117234c5d20db9201199793fc236c49723 100644 --- a/odoo/tools/view_validation.py +++ b/odoo/tools/view_validation.py @@ -1,11 +1,15 @@ """ View validation code (using assertions, not the RNG schema). """ +import ast import collections import logging import os +import re +from functools import partial from lxml import etree from odoo import tools +from odoo.tools.safe_eval import safe_eval _logger = logging.getLogger(__name__) @@ -13,9 +17,140 @@ _logger = logging.getLogger(__name__) _validators = collections.defaultdict(list) _relaxng_cache = {} -def valid_view(arch): +# attributes in views that may contain references to field names +ATTRS_WITH_FIELD_NAMES = { + 'context', + 'domain', + 'decoration-bf', + 'decoration-it', + 'decoration-danger', + 'decoration-info', + 'decoration-muted', + 'decoration-primary', + 'decoration-success', + 'decoration-warning', +} + +READONLY = re.compile(r"\breadonly\b") + + +def _get_attrs_symbols(): + """ Return a set of predefined symbols for evaluating attrs. """ + return { + 'True', 'False', 'None', # those are identifiers in Python 2.7 + 'self', + 'parent', + 'id', + 'uid', + 'context', + 'context_today', + 'active_id', + 'active_ids', + 'allowed_company_ids', + 'current_company_id', + 'active_model', + 'time', + 'datetime', + 'relativedelta', + 'current_date', + 'abs', + 'len', + 'bool', + 'float', + 'str', + 'unicode', + } + + +def _view_is_editable(node): + """ Return whether the node is an editable view. """ + return node.tag == 'form' or node.tag == 'tree' and node.get('editable') + + +def field_is_editable(field, node): + """ Return whether a field is editable (not always readonly). """ + return ( + (not field.readonly or READONLY.search(str(field.states or ""))) and + (node.get('readonly') != "1" or READONLY.search(node.get('attrs') or "")) + ) + + +def get_attrs_field_names(env, arch, model, editable): + """ Retrieve the field names appearing in context, domain and attrs, and + return a list of triples ``(field_name, attr_name, attr_value)``. + """ + VIEW_TYPES = {item[0] for item in type(env['ir.ui.view']).type.selection} + symbols = _get_attrs_symbols() | {None} + result = [] + + def get_name(node): + """ return the name from an AST node, or None """ + if isinstance(node, ast.Name): + return node.id + + def get_subname(get, node): + """ return the subfield name from an AST node, or None """ + if isinstance(node, ast.Attribute) and get(node.value) == 'parent': + return node.attr + + def process_expr(expr, get, key, val): + """ parse `expr` and collect triples """ + for node in ast.walk(ast.parse(expr.strip(), mode='eval')): + name = get(node) + if name not in symbols: + result.append((name, key, val)) + + def process_attrs(expr, get, key, val): + """ parse `expr` and collect field names in lhs of conditions. """ + for domain in safe_eval(expr).values(): + if not isinstance(domain, list): + continue + for arg in domain: + if isinstance(arg, (tuple, list)): + process_expr(str(arg[0]), get, key, expr) + + def process(node, model, editable, get=get_name): + """ traverse `node` and collect triples """ + if node.tag in VIEW_TYPES: + # determine whether this view is editable + editable = editable and _view_is_editable(node) + elif node.tag in ('field', 'groupby'): + # determine whether the field is editable + field = model._fields.get(node.get('name')) + if field: + editable = editable and field_is_editable(field, node) + + for key, val in node.items(): + if not val: + continue + if key in ATTRS_WITH_FIELD_NAMES: + process_expr(val, get, key, val) + elif key == 'attrs': + process_attrs(val, get, key, val) + + if node.tag in ('field', 'groupby') and field and field.relational: + if editable and not node.get('domain'): + domain = field._description_domain(env) + # process the field's domain as if it was in the view + if isinstance(domain, str): + process_expr(domain, get, 'domain', domain) + # retrieve subfields of 'parent' + model = env[field.comodel_name] + get = partial(get_subname, get) + + for child in node: + if node.tag == 'search' and child.tag == 'searchpanel': + # searchpanel part has to be validated independently + continue + process(child, model, editable, get) + + process(arch, model, editable) + return result + + +def valid_view(arch, **kwargs): for pred in _validators[arch.tag]: - check = pred(arch) + check = pred(arch, **kwargs) if not check: _logger.error("Invalid XML: %s", pred.__doc__) return False @@ -49,7 +184,7 @@ def relaxng(view_type): @validate('calendar', 'diagram', 'gantt', 'graph', 'pivot', 'search', 'tree', 'activity') -def schema_valid(arch): +def schema_valid(arch, **kwargs): """ Get RNG validator and validate RNG file.""" validator = relaxng(arch.tag) if validator and not validator.validate(arch): @@ -60,14 +195,48 @@ def schema_valid(arch): return result return True + +@validate('search') +def valid_searchpanel(arch, **kwargs): + """ There must be at most one ``searchpanel`` node in search view archs. """ + return len(arch.xpath('/search/searchpanel')) <= 1 + + +@validate('search') +def valid_searchpanel_domain_select(arch, **kwargs): + """ In the searchpanel, the attribute ``domain`` can only be used on ``field`` nodes with + ``select`` attribute set to ``multi``. """ + for child in arch.xpath('/search/searchpanel/field'): + if child.get('domain') and child.get('select') != 'multi': + return False + return True + + +@validate('search') +def valid_searchpanel_domain_fields(arch, **kwargs): + """ In the searchpanel, fields used in the ``domain`` attribute must be present inside the + ``searchpanel`` node with ``select`` attribute not set to ``multi``. """ + searchpanel = arch.xpath('/search/searchpanel') + if searchpanel: + env = kwargs['env'] + model = kwargs['model'] + attrs_fields = [r[0] for r in get_attrs_field_names(env, searchpanel[0], env[model], False)] + non_multi_fields = [ + c.get('name') for c in arch.xpath('/search/searchpanel/field') + if c.get('select') != 'multi' + ] + return len(set(attrs_fields) - set(non_multi_fields)) == 0 + return True + + @validate('form') -def valid_page_in_book(arch): +def valid_page_in_book(arch, **kwargs): """A `page` node must be below a `notebook` node.""" return not arch.xpath('//page[not(ancestor::notebook)]') @validate('graph') -def valid_field_in_graph(arch): +def valid_field_in_graph(arch, **kwargs): """ Children of ``graph`` can only be ``field`` """ return all( child.tag == 'field' @@ -76,7 +245,7 @@ def valid_field_in_graph(arch): @validate('tree') -def valid_field_in_tree(arch): +def valid_field_in_tree(arch, **kwargs): """ Children of ``tree`` view must be ``field`` or ``button`` or ``control`` or ``groupby``.""" return all( child.tag in ('field', 'button', 'control', 'groupby') @@ -85,24 +254,24 @@ def valid_field_in_tree(arch): @validate('form', 'graph', 'tree', 'activity') -def valid_att_in_field(arch): +def valid_att_in_field(arch, **kwargs): """ ``field`` nodes must all have a ``@name`` """ return not arch.xpath('//field[not(@name)]') @validate('form') -def valid_att_in_label(arch): +def valid_att_in_label(arch, **kwargs): """ ``label`` nodes must have a ``@for`` """ return not arch.xpath('//label[not(@for) and not(descendant::input)]') @validate('form') -def valid_att_in_form(arch): +def valid_att_in_form(arch, **kwargs): return True @validate('form') -def valid_type_in_colspan(arch): +def valid_type_in_colspan(arch, **kwargs): """A `colspan` attribute must be an `integer` type.""" return all( attrib.isdigit() @@ -111,22 +280,24 @@ def valid_type_in_colspan(arch): @validate('form') -def valid_type_in_col(arch): +def valid_type_in_col(arch, **kwargs): """A `col` attribute must be an `integer` type.""" return all( attrib.isdigit() for attrib in arch.xpath('//@col') ) + @validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') -def valid_alternative_image_text(arch): +def valid_alternative_image_text(arch, **kwargs): """An `img` tag must have an alt value.""" if arch.xpath('//img[not(@alt or @t-att-alt or @t-attf-alt)]'): return "Warning" return True + @validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') -def valid_alternative_icon_text(arch): +def valid_alternative_icon_text(arch, **kwargs): """An icon with fa- class or in a button must have aria-label in its tag, parents, descendants or have text.""" valid_aria_attrs = { 'aria-label', 'aria-labelledby', 't-att-aria-label', 't-attf-aria-label', @@ -166,8 +337,9 @@ def valid_alternative_icon_text(arch): return "Warning" return True + @validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') -def valid_title_icon(arch): +def valid_title_icon(arch, **kwargs): """An icon with fa- class or in a button must have title in its tag, parents, descendants or have text.""" valid_title_attrs = {'title', 't-att-title', 't-attf-title'} valid_t_attrs = {'t-value', 't-raw', 't-field', 't-esc'} @@ -205,8 +377,9 @@ def valid_title_icon(arch): return "Warning" return True + @validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') -def valid_simili_button(arch): +def valid_simili_button(arch, **kwargs): """A simili button must be tagged with "role='button'".""" # Select elements with class 'btn' xpath = '//a[contains(concat(" ", @class), " btn")' @@ -217,8 +390,9 @@ def valid_simili_button(arch): return "Warning" return True + @validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') -def valid_simili_dropdown(arch): +def valid_simili_dropdown(arch, **kwargs): """A simili dropdown must be tagged with "role='menu'".""" xpath = '//*[contains(concat(" ", @class, " "), " dropdown-menu ")' xpath += ' or contains(concat(" ", @t-att-class, " "), " dropdown-menu ")' @@ -228,8 +402,9 @@ def valid_simili_dropdown(arch): return "Warning" return True + @validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') -def valid_simili_progressbar(arch): +def valid_simili_progressbar(arch, **kwargs): """A simili progressbar must be tagged with "role='progressbar'" and have aria-valuenow, aria-valuemin and aria-valuemax attributes.""" # Select elements with class 'btn' @@ -245,8 +420,9 @@ def valid_simili_progressbar(arch): return "Warning" return True + @validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') -def valid_dialog(arch): +def valid_dialog(arch, **kwargs): """A dialog must use role="dialog" and its header, body and footer contents must use <header/>, <main/> and <footer/>.""" # Select elements with class 'btn' xpath = '//*[contains(concat(" ", @class, " "), " modal ")' @@ -278,8 +454,9 @@ def valid_dialog(arch): return "Warning" return True + @validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') -def valid_simili_tabpanel(arch): +def valid_simili_tabpanel(arch, **kwargs): """A tab panel with tab-pane class must have role="tabpanel".""" # Select elements with class 'btn' xpath = '//*[contains(concat(" ", @class, " "), " tab-pane ")' @@ -290,8 +467,9 @@ def valid_simili_tabpanel(arch): return "Warning" return True + @validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') -def valid_simili_tab(arch): +def valid_simili_tab(arch, **kwargs): """A tab link must have role="tab", a link to an id (without #) by aria-controls.""" # Select elements with class 'btn' xpath = '//*[@data-toggle="tab"]' @@ -302,8 +480,9 @@ def valid_simili_tab(arch): return "Warning" return True + @validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') -def valid_simili_tablist(arch): +def valid_simili_tablist(arch, **kwargs): """A tab list with class nav-tabs must have role="tablist".""" # Select elements with class 'btn' xpath = '//*[contains(concat(" ", @class, " "), " nav-tabs ")' @@ -314,8 +493,9 @@ def valid_simili_tablist(arch): return "Warning" return True + @validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') -def valid_focusable_button(arch): +def valid_focusable_button(arch, **kwargs): """A simili button must be with a `button`, an `input` (with type `button`, `submit` or `reset`) or a `a` tag.""" xpath = '//*[contains(concat(" ", @class), " btn")' xpath += ' or contains(concat(" ", @t-att-class), " btn")' @@ -339,16 +519,18 @@ def valid_focusable_button(arch): return "Warning" return True + @validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') -def valid_prohibited_none_role(arch): +def valid_prohibited_none_role(arch, **kwargs): """A role can't be `none` or `presentation`. All your elements must be accessible with screen readers, describe it.""" xpath = '//*[@role="none" or @role="presentation"]' if arch.xpath(xpath): return "Warning" return True + @validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') -def valid_alerts(arch): +def valid_alerts(arch, **kwargs): """An alert (class alert-*) must have an alert, alertdialog or status role. Please use alert and alertdialog only for what expects to stop any activity to be read immediatly.""" xpath = '//*[contains(concat(" ", @class), " alert-")' xpath += ' or contains(concat(" ", @t-att-class), " alert-")'