diff --git a/addons/base_import/static/src/js/import_menu.js b/addons/base_import/static/src/js/import_menu.js index a5c5c5e1293..e5442fac10c 100644 --- a/addons/base_import/static/src/js/import_menu.js +++ b/addons/base_import/static/src/js/import_menu.js @@ -3,7 +3,7 @@ odoo.define('base_import.ImportMenu', function (require) { const DropdownMenuItem = require('web.DropdownMenuItem'); const FavoriteMenu = require('web.FavoriteMenu'); - const { useModel } = require('web.model'); + const { useModel } = require('web/static/src/js/model.js'); /** * Import Records menu @@ -15,7 +15,7 @@ odoo.define('base_import.ImportMenu', function (require) { class ImportMenu extends DropdownMenuItem { constructor() { super(...arguments); - this.model = useModel('controlPanelModel'); + this.model = useModel('searchModel'); } //--------------------------------------------------------------------- @@ -30,8 +30,8 @@ odoo.define('base_import.ImportMenu', function (require) { type: 'ir.actions.client', tag: 'import', params: { - model: this.model.modelName, - context: this.model.actionContext, + model: this.model.config.modelName, + context: this.model.config.context, } }; this.trigger('do-action', {action: action}); diff --git a/addons/board/static/src/js/add_to_board_menu.js b/addons/board/static/src/js/add_to_board_menu.js index 95e15e530c2..709620df156 100644 --- a/addons/board/static/src/js/add_to_board_menu.js +++ b/addons/board/static/src/js/add_to_board_menu.js @@ -5,7 +5,6 @@ odoo.define('board.AddToBoardMenu', function (require) { const Domain = require('web.Domain'); const DropdownMenuItem = require('web.DropdownMenuItem'); const FavoriteMenu = require('web.FavoriteMenu'); - const pyUtils = require('web.py_utils'); const { sprintf } = require('web.utils'); const { useAutofocus } = require('web.custom_hooks'); @@ -48,7 +47,7 @@ odoo.define('board.AddToBoardMenu', function (require) { * @private */ async _addToBoard() { - const searchQuery = this.env.controlPanelModel.getQuery(); + const searchQuery = this.env.searchModel.get('query'); const context = new Context(this.env.action.context); context.add(searchQuery.context); context.add({ @@ -61,7 +60,7 @@ odoo.define('board.AddToBoardMenu', function (require) { }); } let controllerQueryParams; - this.env.controlPanelModel.trigger('get-controller-query-params', params => { + this.env.searchModel.trigger('get-controller-query-params', params => { controllerQueryParams = params || {}; }); controllerQueryParams.context = controllerQueryParams.context || {}; diff --git a/addons/board/static/tests/dashboard_tests.js b/addons/board/static/tests/dashboard_tests.js index 232b20b8ec3..8ba4be76009 100644 --- a/addons/board/static/tests/dashboard_tests.js +++ b/addons/board/static/tests/dashboard_tests.js @@ -840,7 +840,7 @@ QUnit.test('save a action domain to dashboard', async function (assert) { var filter_domain = ["display_name", "ilike", "b"]; // The filter domain already contains the view domain, but is always added by dashboard.., - var expected_domain = ['&', '&', view_domain, view_domain, filter_domain]; + var expected_domain = ['&', view_domain, '&', view_domain, filter_domain]; var actionManager = await createActionManager({ data: this.data, diff --git a/addons/google_spreadsheet/static/src/js/add_to_google_spreadsheet_menu.js b/addons/google_spreadsheet/static/src/js/add_to_google_spreadsheet_menu.js index 4d8ac9552cc..d8d19a06148 100644 --- a/addons/google_spreadsheet/static/src/js/add_to_google_spreadsheet_menu.js +++ b/addons/google_spreadsheet/static/src/js/add_to_google_spreadsheet_menu.js @@ -23,7 +23,7 @@ odoo.define('board.AddToGoogleSpreadsheetMenu', function (require) { * @private */ async _onAddToSpreadsheet() { - const searchQuery = this.env.controlPanelModel.getQuery(); + const searchQuery = this.env.model.get('query'); const listView = this.env.action.views.find(view => view.type === 'list'); const modelName = this.env.action.res_model; const domain = Domain.prototype.arrayToString(searchQuery.domain); diff --git a/addons/lunch/static/src/js/lunch_controller_common.js b/addons/lunch/static/src/js/lunch_controller_common.js index 27ce66ec49c..8cd09316220 100644 --- a/addons/lunch/static/src/js/lunch_controller_common.js +++ b/addons/lunch/static/src/js/lunch_controller_common.js @@ -27,7 +27,6 @@ var LunchControllerCommon = { */ init: function () { this._super.apply(this, arguments); - this.userId = null; this.editMode = false; this.updated = false; this.widgetData = null; @@ -60,29 +59,15 @@ var LunchControllerCommon = { }, }); }, - _fetchWidgetData: function () { - var self = this; - - return this._rpc({ + _fetchWidgetData: async function () { + this.widgetData = await this._rpc({ route: '/lunch/infos', params: { - user_id: this.userId, + user_id: this.searchModel.get('userId'), context: this.context, }, - }).then(function (data) { - self.widgetData = data; - return self.model._updateLocation(data.user_location[0]); }); }, - /** - * Override to add the location domain (coming from the LunchWidget) - * to the searchDomain (coming from the controlPanel). - * - * @override - */ - _getViewDomain: async function () { - return this.model.getLocationDomain(); - }, /** * Renders and appends the lunch banner widget. * @@ -140,28 +125,14 @@ var LunchControllerCommon = { }); }, _onLocationChanged: function (ev) { - var self = this; - ev.stopPropagation(); - - this._rpc({ - route: '/lunch/user_location_set', - params: { - user_id: this.userId, - location_id: ev.data.locationId, - context: this.context, - }, - }).then(function () { - self.model._updateLocation(ev.data.locationId).then(function () { - self.reload(); - }); - }); + this.searchModel.dispatch('setLocationId', ev.data.locationId); }, _onOpenWizard: function (ev) { var self = this; ev.stopPropagation(); - var ctx = this.userId ? {default_user_id: this.userId} : {}; + var ctx = this.searchModel.get('userId') ? {default_user_id: this.searchModel.get('userId')} : {}; var options = { on_close: function () { @@ -197,7 +168,7 @@ var LunchControllerCommon = { this._rpc({ route: '/lunch/pay', params: { - user_id: this.userId, + user_id: this.searchModel.get('userId'), context: this.context, }, }).then(function (isPaid) { @@ -224,13 +195,7 @@ var LunchControllerCommon = { }, _onUserChanged: function (ev) { ev.stopPropagation(); - - var self = this; - - this.userId = ev.data.userId; - this.model._updateUser(ev.data.userId).then(function () { - self.reload(); - }); + this.searchModel.dispatch('updateUserId', ev.data.userId); }, _onUnlinkOrder: function (ev) { var self = this; @@ -239,7 +204,7 @@ var LunchControllerCommon = { this._rpc({ route: '/lunch/trash', params: { - user_id: this.userId, + user_id: this.searchModel.get('userId'), context: this.context, }, }).then(function () { diff --git a/addons/lunch/static/src/js/lunch_kanban_view.js b/addons/lunch/static/src/js/lunch_kanban_view.js index 75fda433b2c..50d347de309 100644 --- a/addons/lunch/static/src/js/lunch_kanban_view.js +++ b/addons/lunch/static/src/js/lunch_kanban_view.js @@ -2,7 +2,6 @@ odoo.define('lunch.LunchKanbanView', function (require) { "use strict"; var LunchKanbanController = require('lunch.LunchKanbanController'); -var LunchModel = require('lunch.LunchModel'); var LunchKanbanRenderer = require('lunch.LunchKanbanRenderer'); var core = require('web.core'); @@ -14,21 +13,16 @@ var _lt = core._lt; var LunchKanbanView = KanbanView.extend({ config: _.extend({}, KanbanView.prototype.config, { Controller: LunchKanbanController, - Model: LunchModel, Renderer: LunchKanbanRenderer, }), display_name: _lt('Lunch Kanban'), - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - /** * @override */ - _getViewDomain: function (parent) { - const model = this.getModel(parent); - return model.getLocationDomain(); + _createSearchModel(params, extraExtensions = {}) { + Object.assign(extraExtensions, { Lunch: {} }); + return this._super(params, extraExtensions); }, }); diff --git a/addons/lunch/static/src/js/lunch_list_view.js b/addons/lunch/static/src/js/lunch_list_view.js index b49d4386ffb..00e8efa5aba 100644 --- a/addons/lunch/static/src/js/lunch_list_view.js +++ b/addons/lunch/static/src/js/lunch_list_view.js @@ -2,7 +2,6 @@ odoo.define('lunch.LunchListView', function (require) { "use strict"; var LunchListController = require('lunch.LunchListController'); -var LunchModel = require('lunch.LunchModel'); var LunchListRenderer = require('lunch.LunchListRenderer'); var core = require('web.core'); @@ -14,7 +13,6 @@ var _lt = core._lt; var LunchListView = ListView.extend({ config: _.extend({}, ListView.prototype.config, { Controller: LunchListController, - Model: LunchModel, Renderer: LunchListRenderer, }), display_name: _lt('Lunch List'), @@ -26,10 +24,11 @@ var LunchListView = ListView.extend({ /** * @override */ - _getViewDomain: function (parent) { - const model = this.getModel(parent); - return model.getLocationDomain(); + _createSearchModel(params, extraExtensions = {}) { + Object.assign(extraExtensions, { Lunch: {} }); + return this._super(params, extraExtensions); }, + }); view_registry.add('lunch_list', LunchListView); diff --git a/addons/lunch/static/src/js/lunch_model_extension.js b/addons/lunch/static/src/js/lunch_model_extension.js new file mode 100644 index 00000000000..fccd82fdce9 --- /dev/null +++ b/addons/lunch/static/src/js/lunch_model_extension.js @@ -0,0 +1,100 @@ +odoo.define("lunch/static/src/js/lunch_model_extension.js", function (require) { + "use strict"; + + const ActionModel = require("web/static/src/js/views/action_model.js"); + + class LunchModelExtension extends ActionModel.Extension { + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * @override + * @returns {any} + */ + get(property) { + switch (property) { + case "domain": return this.getDomain(); + case "userId": return this.state.userId; + } + } + + /** + * @override + */ + async load() { + await this._updateLocationId(); + } + + /** + * @override + */ + prepareState() { + Object.assign(this.state, { + locationId: null, + userId: null, + }); + } + + //--------------------------------------------------------------------- + // Actions / Getters + //--------------------------------------------------------------------- + + /** + * @returns {Array[] | null} + */ + getDomain() { + if (this.state.locationId) { + return [["is_available_at", "in", [this.state.locationId]]]; + } + return null; + } + + /** + * @param {number} locationId + * @returns {Promise} + */ + async setLocationId(locationId) { + this.state.locationId = locationId; + await this.env.services.rpc({ + route: "/lunch/user_location_set", + params: { + context: this.env.session.user_context, + location_id: this.state.locationId, + user_id: this.state.userId, + }, + }); + } + + /** + * @param {number} userId + * @returns {Promise} + */ + async updateUserId(userId) { + this.state.userId = userId; + await this._updateLocationId(); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @returns {Promise} + */ + async _updateLocationId() { + this.state.locationId = await this.env.services.rpc({ + route: "/lunch/user_location_get", + params: { + context: this.env.session.user_context, + user_id: this.state.userId, + }, + }); + } + } + + ActionModel.registry.add("Lunch", LunchModelExtension, 20); + + return LunchModelExtension; +}); diff --git a/addons/lunch/views/lunch_templates.xml b/addons/lunch/views/lunch_templates.xml index 2b2ea02973a..ff943deb685 100644 --- a/addons/lunch/views/lunch_templates.xml +++ b/addons/lunch/views/lunch_templates.xml @@ -7,7 +7,6 @@ - @@ -15,6 +14,7 @@ + diff --git a/addons/mail/static/src/widgets/discuss/discuss.js b/addons/mail/static/src/widgets/discuss/discuss.js index 6f55834895f..2537a4d6513 100644 --- a/addons/mail/static/src/widgets/discuss/discuss.js +++ b/addons/mail/static/src/widgets/discuss/discuss.js @@ -49,7 +49,7 @@ const DiscussWidget = AbstractAction.extend({ // control panel attributes this.action = action; this.actionManager = parent; - this.controlPanelModelConfig.modelName = 'mail.message'; + this.searchModelConfig.modelName = 'mail.message'; this.discuss = undefined; this.options = options; diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index e72fabaff2a..ed939d80836 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -32,6 +32,7 @@ This module provides the core of the Odoo Web Client. "static/src/xml/pivot.xml", "static/src/xml/rainbow_man.xml", "static/src/xml/report.xml", + "static/src/xml/search_panel.xml", "static/src/xml/web_calendar.xml", ], 'bootstrap': True, # load translations for login screen diff --git a/addons/web/static/src/js/chrome/abstract_action.js b/addons/web/static/src/js/chrome/abstract_action.js index 982b750ab56..fc5c1fd4873 100644 --- a/addons/web/static/src/js/chrome/abstract_action.js +++ b/addons/web/static/src/js/chrome/abstract_action.js @@ -9,8 +9,8 @@ odoo.define('web.AbstractAction', function (require) { */ var ActionMixin = require('web.ActionMixin'); +const ActionModel = require('web/static/src/js/views/action_model.js'); var ControlPanel = require('web.ControlPanel'); -var ControlPanelModel = require('web.ControlPanelModel'); var Widget = require('web.Widget'); const { ComponentWrapper } = require('web.OwlCompatibility'); @@ -35,7 +35,7 @@ var AbstractAction = Widget.extend(ActionMixin, { * For example, the Discuss application adds the following line in its * constructor:: * - * this.controlPanelModelConfig.modelName = 'mail.message'; + * this.searchModelConfig.modelName = 'mail.message'; * * @type boolean */ @@ -76,12 +76,16 @@ var AbstractAction = Widget.extend(ActionMixin, { this._super(parent); this._title = action.display_name || action.name; + this.searchModelConfig = { + context: Object.assign({}, action.context), + domain: action.domain || [], + env: owl.Component.env, + searchMenuTypes: this.searchMenuTypes, + }; + this.extensions = {}; if (this.hasControlPanel) { - this.controlPanelModelConfig = { + this.extensions.ControlPanel = { actionId: action.id, - actionContext: action.context || {}, - actionDomain: action.domain || [], - env: owl.Component.env, withSearchBar: this.withSearchBar, }; @@ -102,23 +106,36 @@ var AbstractAction = Widget.extend(ActionMixin, { * @override */ willStart: async function () { - const proms = [this._super(...arguments)]; + const superPromise = this._super(...arguments); if (this.hasControlPanel) { if (this.loadControlPanel) { - const { context, searchMenuTypes } = this.controlPanelProps; - const { modelName } = this.controlPanelModelConfig; - const options = { load_filters: searchMenuTypes.includes('favorite') }; - const args = [modelName, context || {}, this.viewId, 'search', options]; - const loadFieldViewPromise = this.loadFieldView(...args); - const {arch, fields, favoriteFilters } = await loadFieldViewPromise; - this.controlPanelModelConfig.viewInfo = {arch, fields, favoriteFilters }; + const { context, modelName } = this.searchModelConfig; + const options = { load_filters: this.searchMenuTypes.includes('favorite') }; + const { arch, fields, favoriteFilters } = await this.loadFieldView( + modelName, + context || {}, + this.viewId, + 'search', + options + ); + const archs = { search: arch }; + const { ControlPanel: controlPanelInfo } = ActionModel.extractArchInfo(archs); + Object.assign(this.extensions.ControlPanel, { + archNodes: controlPanelInfo.children, + favoriteFilters, + fields, + }); this.controlPanelProps.fields = fields; } - this._controlPanelModel = new ControlPanelModel(this.controlPanelModelConfig); - this.controlPanelProps.controlPanelModel = this._controlPanelModel; - proms.push(this._controlPanelModel.isReady); } - return Promise.all(proms); + this.searchModel = new ActionModel(this.extensions, this.searchModelConfig); + if (this.hasControlPanel) { + this.controlPanelProps.searchModel = this.searchModel; + } + return Promise.all([ + superPromise, + this.searchModel.load(), + ]); }, /** * @override @@ -147,9 +164,9 @@ var AbstractAction = Widget.extend(ActionMixin, { */ on_attach_callback: function () { ActionMixin.on_attach_callback.call(this); + this.searchModel.on('search', this, this._onSearch); if (this.hasControlPanel) { - this._controlPanelModel.on('search', this, this._onSearch); - this._controlPanelModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams); + this.searchModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams); } }, /** @@ -157,9 +174,9 @@ var AbstractAction = Widget.extend(ActionMixin, { */ on_detach_callback: function () { ActionMixin.on_detach_callback.call(this); + this.searchModel.off('search', this); if (this.hasControlPanel) { - this._controlPanelModel.off('search', this); - this._controlPanelModel.off('get-controller-query-params', this); + this.searchModel.off('get-controller-query-params', this); } }, 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 f6c5661eb8e..749feacb3d6 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 @@ -488,6 +488,7 @@ ActionManager.include({ // can't switch to an unknown view return Promise.reject(); } + var currentController = this.getCurrentController(); var index; if (currentController.actionID !== action.jsID) { @@ -546,7 +547,7 @@ ActionManager.include({ return Promise.resolve(controller.widget.willRestore()).then(function () { viewOptions = _.extend({}, viewOptions, { breadcrumbs: self._getBreadcrumbs(self.controllerStack.slice(0, index)), - shouldUpdateControlPanel: true, + shouldUpdateSearchComponents: true, }); return controller.widget.reload(viewOptions).then(function () { return controller; diff --git a/addons/web/static/src/js/control_panel/comparison_menu.js b/addons/web/static/src/js/control_panel/comparison_menu.js index 4504370e7c4..dee1c982d2e 100644 --- a/addons/web/static/src/js/control_panel/comparison_menu.js +++ b/addons/web/static/src/js/control_panel/comparison_menu.js @@ -3,7 +3,7 @@ odoo.define("web.ComparisonMenu", function (require) { const DropdownMenu = require("web.DropdownMenu"); const { FACET_ICONS } = require("web.searchUtils"); - const { useModel } = require("web.model"); + const { useModel } = require("web/static/src/js/model.js"); /** * "Comparison" menu @@ -15,7 +15,7 @@ odoo.define("web.ComparisonMenu", function (require) { class ComparisonMenu extends DropdownMenu { constructor() { super(...arguments); - this.model = useModel("controlPanelModel"); + this.model = useModel('searchModel'); } //--------------------------------------------------------------------- @@ -33,7 +33,7 @@ odoo.define("web.ComparisonMenu", function (require) { * @override */ get items() { - return this.model.getFiltersOfType("comparison"); + return this.model.get('filters', f => f.type === 'comparison'); } /** diff --git a/addons/web/static/src/js/control_panel/control_panel.js b/addons/web/static/src/js/control_panel/control_panel.js index b315893076a..aca28bc0eb2 100644 --- a/addons/web/static/src/js/control_panel/control_panel.js +++ b/addons/web/static/src/js/control_panel/control_panel.js @@ -2,15 +2,15 @@ odoo.define('web.ControlPanel', function (require) { "use strict"; const ActionMenus = require('web.ActionMenus'); - const ControlPanelModel = require('web.ControlPanelModel'); const ComparisonMenu = require('web.ComparisonMenu'); + const ActionModel = require('web/static/src/js/views/action_model.js'); const FavoriteMenu = require('web.FavoriteMenu'); const FilterMenu = require('web.FilterMenu'); const GroupByMenu = require('web.GroupByMenu'); const patchMixin = require('web.patchMixin'); const Pager = require('web.Pager'); const SearchBar = require('web.SearchBar'); - const { useModel } = require('web.model'); + const { useModel } = require('web/static/src/js/model.js'); const { Component, hooks } = owl; const { useRef, useSubEnv } = hooks; @@ -105,14 +105,14 @@ odoo.define('web.ControlPanel', function (require) { useSubEnv({ action: this.props.action, - controlPanelModel: this.props.controlPanelModel, + searchModel: this.props.searchModel, view: this.props.view, }); // Connect to the model // TODO: move this in enterprise whenever possible - if (this.env.controlPanelModel) { - this.model = useModel('controlPanelModel'); + if (this.env.searchModel) { + this.model = useModel('searchModel'); } // Reference hooks @@ -135,7 +135,7 @@ odoo.define('web.ControlPanel', function (require) { } async willUpdateProps(nextProps) { - // Note: action and controlPanelModel are not likely to change during + // Note: action and searchModel are not likely to change during // the lifespan of a ControlPanel instance, so we only need to update // the view information. if ('view' in nextProps) { @@ -185,6 +185,7 @@ odoo.define('web.ControlPanel', function (require) { return formattedFields; } } + ControlPanel.modelExtension = "ControlPanel"; ControlPanel.components = { SearchBar, @@ -202,7 +203,7 @@ odoo.define('web.ControlPanel', function (require) { ControlPanel.props = { action: Object, breadcrumbs: Array, - controlPanelModel: ControlPanelModel, + searchModel: ActionModel, cp_content: { type: Object, optional: 1 }, fields: Object, pager: { validate: p => typeof p === 'object' || p === null, optional: 1 }, @@ -214,7 +215,6 @@ odoo.define('web.ControlPanel', function (require) { withBreadcrumbs: Boolean, withSearchBar: Boolean, }; - ControlPanel.template = 'web.ControlPanel'; return patchMixin(ControlPanel); diff --git a/addons/web/static/src/js/control_panel/control_panel_model.js b/addons/web/static/src/js/control_panel/control_panel_model_extension.js similarity index 84% rename from addons/web/static/src/js/control_panel/control_panel_model.js rename to addons/web/static/src/js/control_panel/control_panel_model_extension.js index d438e5175cb..ef443fbb70a 100644 --- a/addons/web/static/src/js/control_panel/control_panel_model.js +++ b/addons/web/static/src/js/control_panel/control_panel_model_extension.js @@ -1,17 +1,17 @@ -odoo.define('web.ControlPanelModel', function (require) { +odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js", function (require) { "use strict"; + const ActionModel = require("web/static/src/js/views/action_model.js"); const Domain = require('web.Domain'); - const { Model } = require('web.model'); - const { parseArch } = require('web.viewUtils'); const pyUtils = require('web.py_utils'); - const { DEFAULT_INTERVAL, DEFAULT_PERIOD, FACET_ICONS, + const { DEFAULT_INTERVAL, DEFAULT_PERIOD, getComparisonOptions, getIntervalOptions, getPeriodOptions, constructDateDomain, rankInterval, yearSelected } = require('web.searchUtils'); const FAVORITE_PRIVATE_GROUP = 1; const FAVORITE_SHARED_GROUP = 2; + const DISABLE_FAVORITE = "search_disable_custom_filters"; let filterId = 1; let groupId = 1; @@ -140,54 +140,100 @@ odoo.define('web.ControlPanelModel', function (require) { * The query elements indicates what are the active filters and 'how' they are active. * The key groupId has been added for simplicity. It could have been removed from query elements * since the information is available on the corresponding filters. - * @extends Model + * @extends ActionModel.Extension */ - class ControlPanelModel extends Model { + class ControlPanelModelExtension extends ActionModel.Extension { /** * @param {Object} config * @param {(string|number)} config.actionId * @param {Object} config.env * @param {string} config.modelName - * @param {Object} [config.importedState] - * @param {Array[]} [config.actionDomain=[]] - * @param {Object} [config.actionContext={}] + * @param {Object} [config.context={}] + * @param {Object[]} [config.archNodes=[]] * @param {Object[]} [config.dynamicFilters=[]] * @param {string[]} [config.searchMenuTypes=[]] - * @param {Object} [config.viewInfo={}] + * @param {Object} [config.favoriteFilters={}] + * @param {Object} [config.fields={}] * @param {boolean} [config.withSearchBar=true] - * */ - constructor(config) { - super(); + constructor() { + super(...arguments); - this._setProperties(config); - - if (this.withSearchBar) { - if (config.importedState) { - this.importState(config.importedState); - } else { - this._prepareInitialState(); + this.actionContext = Object.assign({}, this.config.context); + this.searchMenuTypes = this.config.searchMenuTypes || []; + this.favoriteFilters = this.config.favoriteFilters || []; + this.fields = this.config.fields || {}; + this.searchDefaults = []; + for (const key in this.actionContext) { + const match = /^search_default_(.*)$/.exec(key); + if (match) { + const val = this.actionContext[key]; + if (val) { + this.searchDefaults[match[1]] = val; + } + delete this.actionContext[key]; } } + this.labelPromises = []; - this.isReady = Promise.all(this.labelPromises); - } - - //--------------------------------------------------------------------- - // Getters - //--------------------------------------------------------------------- - - /** - * @returns {(Object | undefined)} - */ - get activeComparison() { - return this.state.query.find(queryElem => queryElem.type === 'comparison'); + this.referenceMoment = moment(); + this.optionGenerators = getPeriodOptions(this.referenceMoment); + this.intervalOptions = getIntervalOptions(); + this.comparisonOptions = getComparisonOptions(); } //--------------------------------------------------------------------- // Public //--------------------------------------------------------------------- + /** + * @override + * @returns {any} + */ + get(property, ...args) { + switch (property) { + case "context": return this.getContext(); + case "domain": return this.getDomain(); + case "facets": return this._getFacets(); + case "filters": return this._getFilters(...args); + case "groupBy": return this.getGroupBy(); + case "orderedBy": return this.getOrderedBy(); + case "timeRanges": return this.getTimeRanges(); + } + } + + /** + * @override + */ + async load() { + await Promise.all(this.labelPromises); + } + + /** + * @override + */ + prepareState() { + Object.assign(this.state, { + filters: {}, + query: [], + }); + if (this.config.withSearchBar !== false) { + this._addFilters(); + this._activateDefaultFilters(); + } + } + + //--------------------------------------------------------------------- + // Actions / Getters + //--------------------------------------------------------------------- + + /** + * @returns {Object | undefined} + */ + get activeComparison() { + return this.state.query.find(queryElem => queryElem.type === 'comparison'); + } + /** * Activate a filter of type 'field' with given filterId with * 'autocompleteValues' value, label, and operator. @@ -321,117 +367,59 @@ odoo.define('web.ControlPanelModel', function (require) { } /** - * Return the state of the control panel model (the filters and the - * current query). This state can then be used in an other control panel - * model (with same key modelName). See importedState. * @returns {Object} */ - exportState() { - return JSON.parse(JSON.stringify(this.state)); - } - - /** - * Called by search bar to render the facets - * @returns {Object[]} - */ - getFacets() { - const accept = type => { - if (['groupBy', 'comparison'].includes(type) && !this.searchMenuTypes.includes(type)) { - return false; - } - return true; - }; - + getContext() { const groups = this._getGroups(); - const facets = []; - for (const group of groups) { - const { activities, type, id } = group; - if (accept(type)) { - const facet = { - groupId: id, - separator: type === 'groupBy' ? ">" : this.env._t("or"), - values: this._getFacetDescriptions(activities, type), - }; - const icon = FACET_ICONS[type]; - if (icon) { - facet.icon = icon; - } else { - facet.title = activities[0].filter.description; - } - facets.push(facet); - } - } - return facets; + return this._getContext(groups); } /** - * Return an array containing enriched copies of the filters of the provided type. - * @param {string} type - * @returns {Object[]} + * @returns {Array[]} */ - getFiltersOfType(type) { - const filters = Object.values(this.state.filters).reduce( - (filters, filter) => { - if (filter.type === type && !filter.invisible) { - const filterQueryElements = this.state.query.filter( - queryElem => queryElem.filterId === filter.id - ); - const enrichedFilter = this._enrichFilterCopy(filter, filterQueryElements); - if (enrichedFilter) { - filters.push(enrichedFilter); - } - } - return filters; - }, - [] - ); - if (type === 'favorite') { - filters.sort((f1, f2) => f1.groupNumber - f2.groupNumber); + getDomain() { + const groups = this._getGroups(); + const userContext = this.env.session.user_context; + try { + return Domain.prototype.stringToArray(this._getDomain(groups), userContext); + } catch (err) { + throw new Error( + `${this.env._t("Control panel model extension failed to evaluate domain")}:/n${JSON.stringify(err)}` + ); } - return filters; } /** - * Principal objects used by controllers/models to fetch data. - * @returns {Object} An object called search query with keys domain, groupBy, - * context, orderedBy, and (optionally) timeRanges. + * @returns {string[]} */ - getQuery() { + getGroupBy() { + const groups = this._getGroups(); + return this._getGroupBy(groups); + } + + /** + * @returns {string[]} + */ + getOrderedBy() { + const groups = this._getGroups(); + return this._getOrderedBy(groups); + } + + /** + * @returns {Object} + */ + getTimeRanges() { const requireEvaluation = true; - const groups = this._getGroups(); - const query = { - context: this._getContext(groups), - domain: this._getDomain(groups, requireEvaluation), - orderedBy: this._getOrderedBy(groups) - }; - if (this.searchMenuTypes.includes('groupBy')) { - const groupBy = this._getGroupBy(groups); - query.groupBy = groupBy; - } else { - query.groupBy = []; - } - if (this.searchMenuTypes.includes('comparison')) { - const timeRanges = this._getTimeRanges(requireEvaluation); - query.timeRanges = timeRanges || {}; - } - return query; + return this._getTimeRanges(requireEvaluation); } /** - * Allow to reuse the state of a previous control panel model with same modelName. - * This is mainly used when switching views. - * @param {Object} state + * Used to call dispatch and trigger a 'search'. */ - importState(state) { - Object.assign(this.state, state); + search() { + /* ... */ } - /** - * This function won't do anything: its purpose is to call the dispatch - * method to trigger a 'search' event + reload the components. - */ - search() { } - /** * Activate/Deactivate a filter of type 'comparison' with provided id. * At most one filter of type 'comparison' can be activated at every time. @@ -514,27 +502,6 @@ odoo.define('web.ControlPanelModel', function (require) { } } - /** - * TODO: the way it is done could be improved, but the actual state of the - * searchView doesn't allow to do much better. - * - * Update the domain of the search view by adding and/or removing filters. - * @param {Object[]} newFilters list of filters to add, described by - * objects with keys domain (the domain as an Array), description (the text - * to display in the facet) and type with value 'filter'. - * @param {number[]} filtersToRemove list of filter ids to remove - * (previously added ones) - * @returns {number[]} list of added filter ids (to pass as filtersToRemove - * for a further call to this function) - */ - updateFilters(newFilters, filtersToRemove) { - const newFilterIdS = this.createNewFilters(newFilters); - this.state.query = this.state.query.filter( - queryElem => !filtersToRemove.includes(queryElem.filterId) - ); - return newFilterIdS; - } - //--------------------------------------------------------------------- // Private //--------------------------------------------------------------------- @@ -573,9 +540,10 @@ odoo.define('web.ControlPanelModel', function (require) { /** * This function populates the 'filters' object at initialization. * The filters come from: - * - config.viewInfo.arch (types 'comparison', 'filter', 'groupBy', 'field'), + * - config.archNodes (types 'comparison', 'filter', 'groupBy', 'field'), * - config.dynamicFilters (type 'filter'), - * - config.viewInfo.favoriteFilters (type 'favorite'), + * - config.favoriteFilters (type 'favorite'), + * - code itself (type 'timeRange') * @private */ _addFilters() { @@ -610,12 +578,12 @@ odoo.define('web.ControlPanelModel', function (require) { * Returns the active comparison timeRanges object. * @private * @param {Object} comparisonFilter - * @returns {Object | undefined} + * @returns {Object | null} */ _computeTimeRanges(comparisonFilter) { const { filterId } = this.activeComparison; if (filterId !== comparisonFilter.id) { - return; + return null; } const { dateFilterId, comparisonOptionId } = comparisonFilter; const { @@ -674,11 +642,12 @@ odoo.define('web.ControlPanelModel', function (require) { } /** - * Add filters of type 'filter' determined by the key array this.dynamicFilters. + * Add filters of type 'filter' determined by the key array dynamicFilters. * @private */ _createGroupOfDynamicFilters() { - const pregroup = this.dynamicFilters.map(filter => { + const dynamicFilters = this.config.dynamicFilters || []; + const pregroup = dynamicFilters.map(filter => { return { description: filter.description, domain: JSON.stringify(filter.domain), @@ -690,14 +659,17 @@ odoo.define('web.ControlPanelModel', function (require) { } /** - * Add filters of type 'favorite' determined by array this.favoriteFilters. + * Add filters of type 'favorite' determined by the array this.favoriteFilters. * @private */ _createGroupOfFavorites() { + const activateFavorite = DISABLE_FAVORITE in this.actionContext ? + !this.actionContext[DISABLE_FAVORITE] : + true; this.favoriteFilters.forEach(irFilter => { const favorite = this._irFilterToFavorite(irFilter); this._createGroupOfFilters([favorite]); - if (this.activateDefaultFavorite && favorite.isDefault) { + if (activateFavorite && favorite.isDefault) { this.defaultFavoriteId = favorite.id; } }); @@ -733,10 +705,7 @@ odoo.define('web.ControlPanelModel', function (require) { * @private */ _createGroupOfFiltersFromArch() { - const children = this.parsedArch.children.filter( - child => child instanceof Object && child.tag !== 'searchpanel' - ); - const preFilters = children.reduce( + const preFilters = this.config.archNodes.reduce( (preFilters, child) => { if (child.tag === 'group') { return [...preFilters, ...child.children.map(c => this._evalArchChild(c))]; @@ -778,22 +747,6 @@ odoo.define('web.ControlPanelModel', function (require) { // and others are passive (require input(s) to become determined) // What is the right place to process the attrs? }; - if (preFilter.attrs && JSON.parse(preFilter.attrs.modifiers || '{}').invisible) { - filter.invisible = true; - - var preFilterFieldName = null; - if (preFilter.tag == 'filter' && preFilter.attrs.date) { - preFilterFieldName = preFilter.attrs.date; - } else if (preFilter.tag == 'groupBy') { - preFilterFieldName = preFilter.attrs.fieldName; - } - if (preFilterFieldName && !this.fields[preFilterFieldName]) { - // In some case when a field is limited to specific groups - // on the model, we need to ensure to discard related filter - // as it may still be present in the view (in 'invisible' state) - return; - } - } if (filter.type === 'filter' || filter.type === 'groupBy') { filter.groupNumber = groupNumber; } @@ -806,7 +759,7 @@ odoo.define('web.ControlPanelModel', function (require) { this._createGroupOfFilters(pregroupOfGroupBys); } const dateFilters = Object.values(this.state.filters).filter( - (filter) => filter.isDateFilter + (filter) => filter.isDateFilter ); if (dateFilters.length) { this._createGroupOfComparisons(dateFilters); @@ -814,26 +767,14 @@ odoo.define('web.ControlPanelModel', function (require) { } /** - * The parent of the control panel (an action or a controller) is not - * necessarily a component. So we need to notify it through a - * mechanism different from __notifyComponents. Here we use the fact - * that the controlPanelModel is an (owl) EventBus to communicate with - * the parent. - * @private - */ - _dispatch() { - this.trigger('search', this.getQuery()); - } - - /** - * Returns undefined or a copy of the provided filter with additional information + * Returns null or a copy of the provided filter with additional information * used only outside of the control panel model, like in search bar or in the - * various menus. The value undefined is returned if the filter should not appear + * various menus. The value null is returned if the filter should not appear * for some reason. * @private * @param {Object} filter * @param {Object[]} filterQueryElements - * @returns {Object|undefined} + * @returns {Object | null} */ _enrichFilterCopy(filter, filterQueryElements) { const isActive = Boolean(filterQueryElements.length); @@ -854,7 +795,7 @@ odoo.define('web.ControlPanelModel', function (require) { queryElem => queryElem.filterId === dateFilterId ); if (!dateFilterIsActive) { - return; + return null; } break; } @@ -924,6 +865,9 @@ odoo.define('web.ControlPanelModel', function (require) { filter.isDefault = attrs.isDefault; } filter.description = attrs.string || attrs.help || attrs.name || attrs.domain || 'Ω'; + if (JSON.parse(attrs.modifiers || '{}').invisible) { + filter.invisible = true; + } switch (filter.type) { case 'filter': if (attrs.context) { @@ -972,7 +916,7 @@ odoo.define('web.ControlPanelModel', function (require) { filter.defaultRank = -10; filter.defaultAutocompleteValue = attrs.defaultAutocompleteValue; } - if (attrs.widget) { // FIXME: drop support in master + if (attrs.widget) { filter.widget = attrs.widget; } break; @@ -993,8 +937,8 @@ odoo.define('web.ControlPanelModel', function (require) { */ _favoriteToIrFilter(favorite) { const irFilter = { - action_id: this.actionId, - model_id: this.modelName, + action_id: this.config.actionId, + model_id: this.config.modelName, }; // ir.filter fields @@ -1069,7 +1013,7 @@ odoo.define('web.ControlPanelModel', function (require) { * @private * @returns {Object} */ - _getContext(groups, withActionContext = true) { + _getContext(groups) { const types = ['filter', 'favorite', 'field']; const contexts = groups.reduce( (contexts, group) => { @@ -1080,9 +1024,6 @@ odoo.define('web.ControlPanelModel', function (require) { }, [] ); - if (withActionContext) { - contexts.unshift(this.actionContext); - } const evaluationContext = this.env.session.user_context; try { return pyUtils.eval('contexts', contexts, evaluationContext); @@ -1118,35 +1059,97 @@ odoo.define('web.ControlPanelModel', function (require) { * of type 'filter', 'favorite', and 'field'. * @private * @param {Object[]} groups - * @param {boolean} [evaluation=true] * @returns {string} */ - _getDomain(groups, evaluation = true) { + _getDomain(groups) { const types = ['filter', 'favorite', 'field']; - const domains = groups.reduce( - (domains, group) => { - if (types.includes(group.type)) { - domains.push(this._getGroupDomain(group)); - } - return domains; - }, - [] - ); - let filterDomain = pyUtils.assembleDomains(domains, 'AND'); + const domains = []; + for (const group of groups) { + if (types.includes(group.type)) { + domains.push(this._getGroupDomain(group)); + } + } + return pyUtils.assembleDomains(domains, 'AND'); + } - if (evaluation) { - const userContext = this.env.session.user_context; - try { - return pyUtils.eval('domains', [this.actionDomain, filterDomain], userContext); - } catch (err) { - throw new Error( - this.env._t("Failed to evaluate search domain") + ":\n" + - JSON.stringify(err) - ); + /** + * Get the filter description to use in the search bar as a facet. + * @private + * @param {Object} activity + * @param {Object} activity.filter + * @param {Object[]} activity.filterQueryElements + * @returns {string} + */ + _getFacetDescriptions(activities, type) { + const facetDescriptions = []; + if (type === 'field') { + for (const queryElem of activities[0].filterQueryElements) { + facetDescriptions.push(queryElem.label); + } + } else if (type === 'groupBy') { + for (const { filter, filterQueryElements } of activities) { + if (filter.hasOptions) { + for (const queryElem of filterQueryElements) { + const option = this.intervalOptions.find( + o => o.id === queryElem.optionId + ); + facetDescriptions.push(filter.description + ': ' + option.description); + } + } else { + facetDescriptions.push(filter.description); + } } } else { - return filterDomain; + let facetDescription; + for (const { filter, filterQueryElements } of activities) { + // filter, favorite and comparison + facetDescription = filter.description; + if (filter.isDateFilter) { + const description = this._getDateFilterDomain( + filter, filterQueryElements, 'description' + ); + facetDescription += `: ${description}`; + } + facetDescriptions.push(facetDescription); + } } + return facetDescriptions; + } + + /** + * @returns {Object[]} + */ + _getFacets() { + const facets = this._getGroups().map(({ activities, type, id }) => { + const values = this._getFacetDescriptions(activities, type); + const title = activities[0].filter.description; + return { groupId: id, title, type, values }; + }); + return facets; + } + + /** + * Return an array containing enriched copies of the filters of the provided type. + * @param {Function} predicate + * @returns {Object[]} + */ + _getFilters(predicate) { + const filters = []; + Object.values(this.state.filters).forEach(filter => { + if ((!predicate || predicate(filter)) && !filter.invisible) { + const filterQueryElements = this.state.query.filter( + queryElem => queryElem.filterId === filter.id + ); + const enrichedFilter = this._enrichFilterCopy(filter, filterQueryElements); + if (enrichedFilter) { + filters.push(enrichedFilter); + } + } + }); + if (filters.some(f => f.type === 'favorite')) { + filters.sort((f1, f2) => f1.groupNumber - f2.groupNumber); + } + return filters; } /** @@ -1358,6 +1361,36 @@ odoo.define('web.ControlPanelModel', function (require) { return selectedOptionIds; } + /** + * Returns the last timeRanges object found in the query. + * TimeRanges objects can be associated with filters of type 'favorite' + * or 'comparison'. + * @private + * @param {boolean} [evaluation=false] + * @returns {Object | null} + */ + _getTimeRanges(evaluation) { + let timeRanges; + for (const queryElem of this.state.query.slice().reverse()) { + const filter = this.state.filters[queryElem.filterId]; + if (filter.type === 'comparison') { + timeRanges = this._computeTimeRanges(filter); + break; + } else if (filter.type === 'favorite' && filter.comparison) { + timeRanges = filter.comparison; + break; + } + } + if (timeRanges) { + if (evaluation) { + timeRanges.range = Domain.prototype.stringToArray(timeRanges.range); + timeRanges.comparisonRange = Domain.prototype.stringToArray(timeRanges.comparisonRange); + } + return timeRanges; + } + return null; + } + /** * Returns a filter of type 'favorite' starting from an ir_filter comming from db. * @private @@ -1496,8 +1529,10 @@ odoo.define('web.ControlPanelModel', function (require) { } else if (fieldType === 'many2one') { const updateLabel = label => { const queryElem = this.state.query.find(({ filterId }) => filterId === id); - queryElem.label = label; - defaultAutocompleteValue.label = label; + if (queryElem) { + queryElem.label = label; + defaultAutocompleteValue.label = label; + } }; const promise = this.env.services.rpc({ args: [defaultAutocompleteValue.value], @@ -1513,14 +1548,6 @@ odoo.define('web.ControlPanelModel', function (require) { } } - /** - * @private - */ - _prepareInitialState() { - this._addFilters(); - this._activateDefaultFilters(); - } - /** * Compute the search Query and save it as an ir_filter in db. * No evaluation of domains is done in order to keep them dynamic. @@ -1535,17 +1562,13 @@ odoo.define('web.ControlPanelModel', function (require) { const userContext = this.env.session.user_context; let controllerQueryParams; - this.trigger( - 'get-controller-query-params', - p => { - controllerQueryParams = p; - } - ); + this.config.trigger("get-controller-query-params", params => { + controllerQueryParams = params; + }); controllerQueryParams = controllerQueryParams || {}; controllerQueryParams.context = controllerQueryParams.context || {}; - const withoutActiveContext = false; - const queryContext = this._getContext(groups, withoutActiveContext); + const queryContext = this._getContext(groups); const context = pyUtils.eval( 'contexts', [userContext, controllerQueryParams.context, queryContext] @@ -1555,7 +1578,7 @@ odoo.define('web.ControlPanelModel', function (require) { } const requireEvaluation = false; - const domain = this._getDomain(groups, requireEvaluation); + const domain = this._getDomain(groups); const groupBys = this._getGroupBy(groups); const timeRanges = this._getTimeRanges(requireEvaluation); const orderedBy = controllerQueryParams.orderedBy ? @@ -1585,128 +1608,30 @@ odoo.define('web.ControlPanelModel', function (require) { return preFilter; } - /** - * Get the filter description to use in the search bar as a facet. - * @private - * @param {Object} activity - * @param {Object} activity.filter - * @param {Object[]} activity.filterQueryElements - * @returns {string} - */ - _getFacetDescriptions(activities, type) { - const facetDescriptions = []; - if (type === 'field') { - for (const queryElem of activities[0].filterQueryElements) { - facetDescriptions.push(queryElem.label); - } - } else if (type === 'groupBy') { - for (const { filter, filterQueryElements } of activities) { - if (filter.hasOptions) { - for (const queryElem of filterQueryElements) { - const option = this.intervalOptions.find( - o => o.id === queryElem.optionId - ); - facetDescriptions.push(filter.description + ': ' + option.description); - } - } else { - facetDescriptions.push(filter.description); - } - } - } else { - let facetDescription; - for (const { filter, filterQueryElements } of activities) { - // filter, favorite and comparison - facetDescription = filter.description; - if (filter.isDateFilter) { - const description = this._getDateFilterDomain( - filter, filterQueryElements, 'description' - ); - facetDescription += `: ${description}`; - } - facetDescriptions.push(facetDescription); - } - } - return facetDescriptions; - } + //--------------------------------------------------------------------- + // Static + //--------------------------------------------------------------------- /** - * Returns the last timeRanges object found in the query. - * TimeRanges objects can be associated with filters of type 'favorite' - * or 'comparison'. - * @private - * @param {boolean} [evaluation=false] - * @returns {(Object|undefined)} + * @override + * @returns {{ attrs: Object, children: Object[] }} */ - _getTimeRanges(evaluation) { - let timeRanges; - for (const queryElem of this.state.query.slice().reverse()) { - const filter = this.state.filters[queryElem.filterId]; - if (filter.type === 'comparison') { - timeRanges = this._computeTimeRanges(filter); - break; - } else if (filter.type === 'favorite' && filter.comparison) { - timeRanges = filter.comparison; - break; - } - } - if (timeRanges) { - if (evaluation) { - timeRanges.range = Domain.prototype.stringToArray(timeRanges.range); - timeRanges.comparisonRange = Domain.prototype.stringToArray(timeRanges.comparisonRange); - } - return timeRanges; - } - } - - /** - * Using the constructor parameter object config, set most of the properties - * of the control panel model. - * @private - * @param {Object} config - */ - _setProperties(config) { - this.state = { - filters: {}, - query: [], + static extractArchInfo(archs) { + const { attrs, children } = archs.search; + const controlPanelInfo = { + attrs, + children: [], }; - this.env = config.env; - this.modelName = config.modelName; - this.actionDomain = config.actionDomain || []; - this.actionContext = config.actionContext || {}; - this.actionId = config.actionId; - this.withSearchBar = 'withSearchBar' in config ? config.withSearchBar : true; - this.searchMenuTypes = config.searchMenuTypes || []; - - this.searchDefaults = []; - for (const key in this.actionContext) { - const match = /^search_default_(.*)$/.exec(key); - if (match) { - const val = this.actionContext[key]; - if (val) { - this.searchDefaults[match[1]] = val; - } - delete this.actionContext[key]; + for (const child of children) { + if (child.tag !== "searchpanel") { + controlPanelInfo.children.push(child); } } - this.labelPromises = []; - - const viewInfo = config.viewInfo || {}; - - this.parsedArch = parseArch(viewInfo.arch || ''); - this.fields = viewInfo.fields || {}; - this.favoriteFilters = viewInfo.favoriteFilters || []; - this.activateDefaultFavorite = 'search_disable_custom_filters' in this.actionContext ? - !this.actionContext.search_disable_custom_filters : - true; - - this.dynamicFilters = config.dynamicFilters || []; - - this.referenceMoment = moment(); - this.optionGenerators = getPeriodOptions(this.referenceMoment); - this.intervalOptions = getIntervalOptions(); - this.comparisonOptions = getComparisonOptions(); + return controlPanelInfo; } } - return ControlPanelModel; + ActionModel.registry.add("ControlPanel", ControlPanelModelExtension, 10); + + return ControlPanelModelExtension; }); diff --git a/addons/web/static/src/js/control_panel/custom_favorite_item.js b/addons/web/static/src/js/control_panel/custom_favorite_item.js index b7b3a34ef81..b7adb031467 100644 --- a/addons/web/static/src/js/control_panel/custom_favorite_item.js +++ b/addons/web/static/src/js/control_panel/custom_favorite_item.js @@ -4,7 +4,7 @@ odoo.define('web.CustomFavoriteItem', function (require) { const DropdownMenuItem = require('web.DropdownMenuItem'); const FavoriteMenu = require('web.FavoriteMenu'); const { useAutofocus } = require('web.custom_hooks'); - const { useModel } = require('web.model'); + const { useModel } = require('web/static/src/js/model.js'); const { useRef } = owl.hooks; @@ -39,7 +39,7 @@ odoo.define('web.CustomFavoriteItem', function (require) { this.shareAllUsersId = `o_favorite_share_all_users_${favId}`; this.descriptionRef = useRef('description'); - this.model = useModel('controlPanelModel'); + this.model = useModel('searchModel'); this.interactive = true; Object.assign(this.state, { description: this.env.action.name || "", @@ -65,7 +65,7 @@ odoo.define('web.CustomFavoriteItem', function (require) { }); return this.descriptionRef.el.focus(); } - const favorites = this.model.getFiltersOfType('favorite'); + const favorites = this.model.get('filters', f => f.type === 'favorite'); if (favorites.some(f => f.description === this.state.description)) { this.env.services.notification.notify({ message: this.env._t("Filter with same name already exists."), diff --git a/addons/web/static/src/js/control_panel/custom_filter_item.js b/addons/web/static/src/js/control_panel/custom_filter_item.js index 592a948d135..98080f6a418 100644 --- a/addons/web/static/src/js/control_panel/custom_filter_item.js +++ b/addons/web/static/src/js/control_panel/custom_filter_item.js @@ -7,7 +7,7 @@ odoo.define('web.CustomFilterItem', function (require) { const { FIELD_OPERATORS, FIELD_TYPES } = require('web.searchUtils'); const field_utils = require('web.field_utils'); const patchMixin = require('web.patchMixin'); - const { useModel } = require('web.model'); + const { useModel } = require('web/static/src/js/model.js'); /** * Filter generator menu @@ -46,7 +46,7 @@ odoo.define('web.CustomFilterItem', function (require) { constructor() { super(...arguments); - this.model = useModel('controlPanelModel'); + this.model = useModel('searchModel'); this.canBeOpened = true; this.state.conditions = []; diff --git a/addons/web/static/src/js/control_panel/custom_group_by_item.js b/addons/web/static/src/js/control_panel/custom_group_by_item.js index 8082608d3ff..20a1c3c7eb7 100644 --- a/addons/web/static/src/js/control_panel/custom_group_by_item.js +++ b/addons/web/static/src/js/control_panel/custom_group_by_item.js @@ -2,7 +2,7 @@ odoo.define('web.CustomGroupByItem', function (require) { "use strict"; const DropdownMenuItem = require('web.DropdownMenuItem'); - const { useModel } = require('web.model'); + const { useModel } = require('web/static/src/js/model.js'); /** * Group by generator menu @@ -20,7 +20,7 @@ odoo.define('web.CustomGroupByItem', function (require) { this.canBeOpened = true; this.state.fieldName = this.props.fields[0].name; - this.model = useModel('controlPanelModel'); + this.model = useModel('searchModel'); } //--------------------------------------------------------------------- diff --git a/addons/web/static/src/js/control_panel/favorite_menu.js b/addons/web/static/src/js/control_panel/favorite_menu.js index 066b30f30d5..ff09109fe59 100644 --- a/addons/web/static/src/js/control_panel/favorite_menu.js +++ b/addons/web/static/src/js/control_panel/favorite_menu.js @@ -5,7 +5,7 @@ odoo.define('web.FavoriteMenu', function (require) { const DropdownMenu = require('web.DropdownMenu'); const { FACET_ICONS } = require("web.searchUtils"); const Registry = require('web.Registry'); - const { useModel } = require('web.model'); + const { useModel } = require('web/static/src/js/model.js'); /** * 'Favorites' menu @@ -22,7 +22,7 @@ odoo.define('web.FavoriteMenu', function (require) { constructor() { super(...arguments); - this.model = useModel('controlPanelModel'); + this.model = useModel('searchModel'); this.state.deletedFavorite = false; } @@ -41,7 +41,7 @@ odoo.define('web.FavoriteMenu', function (require) { * @override */ get items() { - const favorites = this.model.getFiltersOfType('favorite'); + const favorites = this.model.get('filters', f => f.type === 'favorite'); const registryMenus = this.constructor.registry.values().reduce( (menus, Component) => { if (Component.shouldBeDisplayed(this.env)) { @@ -91,7 +91,7 @@ odoo.define('web.FavoriteMenu', function (require) { * @private */ async _onRemoveFavorite() { - await this.model.dispatch('deleteFavorite', this.state.deletedFavorite.id); + this.model.dispatch('deleteFavorite', this.state.deletedFavorite.id); this.state.deletedFavorite = false; } } diff --git a/addons/web/static/src/js/control_panel/filter_menu.js b/addons/web/static/src/js/control_panel/filter_menu.js index 2341def75b0..da37eb2359b 100644 --- a/addons/web/static/src/js/control_panel/filter_menu.js +++ b/addons/web/static/src/js/control_panel/filter_menu.js @@ -4,7 +4,7 @@ odoo.define('web.FilterMenu', function (require) { const CustomFilterItem = require('web.CustomFilterItem'); const DropdownMenu = require('web.DropdownMenu'); const { FACET_ICONS } = require("web.searchUtils"); - const { useModel } = require('web.model'); + const { useModel } = require('web/static/src/js/model.js'); /** * 'Filters' menu @@ -20,7 +20,7 @@ odoo.define('web.FilterMenu', function (require) { constructor() { super(...arguments); - this.model = useModel('controlPanelModel'); + this.model = useModel('searchModel'); } //--------------------------------------------------------------------- @@ -38,7 +38,7 @@ odoo.define('web.FilterMenu', function (require) { * @override */ get items() { - return this.model.getFiltersOfType('filter'); + return this.model.get('filters', f => f.type === 'filter'); } /** diff --git a/addons/web/static/src/js/control_panel/groupby_menu.js b/addons/web/static/src/js/control_panel/groupby_menu.js index 97bad1a2ce7..546952ad50d 100644 --- a/addons/web/static/src/js/control_panel/groupby_menu.js +++ b/addons/web/static/src/js/control_panel/groupby_menu.js @@ -4,7 +4,7 @@ odoo.define('web.GroupByMenu', function (require) { const CustomGroupByItem = require('web.CustomGroupByItem'); const DropdownMenu = require('web.DropdownMenu'); const { FACET_ICONS, GROUPABLE_TYPES } = require('web.searchUtils'); - const { useModel } = require('web.model'); + const { useModel } = require('web/static/src/js/model.js'); /** * 'Group by' menu @@ -24,7 +24,7 @@ odoo.define('web.GroupByMenu', function (require) { .filter(field => this._validateField(field)) .sort(({ string: a }, { string: b }) => a > b ? 1 : a < b ? -1 : 0); - this.model = useModel('controlPanelModel'); + this.model = useModel('searchModel'); } //--------------------------------------------------------------------- @@ -42,7 +42,7 @@ odoo.define('web.GroupByMenu', function (require) { * @override */ get items() { - return this.model.getFiltersOfType('groupBy'); + return this.model.get('filters', f => f.type === 'groupBy'); } /** diff --git a/addons/web/static/src/js/control_panel/search_bar.js b/addons/web/static/src/js/control_panel/search_bar.js index 3082c38c055..22f6ee6a60b 100644 --- a/addons/web/static/src/js/control_panel/search_bar.js +++ b/addons/web/static/src/js/control_panel/search_bar.js @@ -4,7 +4,7 @@ odoo.define('web.SearchBar', function (require) { const Domain = require('web.Domain'); const field_utils = require('web.field_utils'); const { useAutofocus } = require('web.custom_hooks'); - const { useModel } = require('web.model'); + const { useModel } = require('web/static/src/js/model.js'); const CHAR_FIELDS = ['char', 'html', 'many2many', 'many2one', 'one2many', 'text']; const { Component, hooks } = owl; @@ -42,14 +42,14 @@ odoo.define('web.SearchBar', function (require) { this.focusOnUpdate = useAutofocus(); this.inputRef = useRef('search-input'); - this.model = useModel('controlPanelModel'); + this.model = useModel('searchModel'); this.state = useState({ sources: [], focusedItem: 0, inputValue: "", }); - this.autoCompleteSources = this.model.getFiltersOfType('field').map( + this.autoCompleteSources = this.model.get('filters', f => f.type === 'field').map( filter => this._createSource(filter) ); this.noResultItem = [null, this.env._t("(no result)")]; @@ -60,15 +60,15 @@ odoo.define('web.SearchBar', function (require) { mounted() { // 'search' will always patch the search bar, 'focus' will never. - this.env.controlPanelModel.on('search', this, this.focusOnUpdate); - this.env.controlPanelModel.on('focus-control-panel', this, () => { + this.env.searchModel.on('search', this, this.focusOnUpdate); + this.env.searchModel.on('focus-control-panel', this, () => { this.inputRef.el.focus(); }); } willUnmount() { - this.env.controlPanelModel.off('search', this); - this.env.controlPanelModel.off('focus-control-panel', this); + this.env.searchModel.off('search', this); + this.env.searchModel.off('focus-control-panel', this); } //--------------------------------------------------------------------- @@ -376,9 +376,8 @@ odoo.define('web.SearchBar', function (require) { this.state.focusedItem = this.state.sources.indexOf(currentItem.parent); // Priority 3: Do nothing (navigation inside text). } else if (ev.target.selectionStart === 0) { - const facets = this.model.getFacets(); // Priority 4: navigate to rightmost facet. - this._focusFacet(facets.length - 1); + this._focusFacet(this.model.get("facets").length - 1); } break; case 'ArrowRight': @@ -408,7 +407,7 @@ odoo.define('web.SearchBar', function (require) { break; case 'Backspace': if (!this.state.inputValue.length) { - const facets = this.model.getFacets(); + const facets = this.model.get("facets"); if (facets.length) { this._onFacetRemove(facets[facets.length - 1]); } diff --git a/addons/web/static/src/js/core/registry.js b/addons/web/static/src/js/core/registry.js index 6089ccc827e..1ab16236e59 100644 --- a/addons/web/static/src/js/core/registry.js +++ b/addons/web/static/src/js/core/registry.js @@ -1,147 +1,154 @@ -odoo.define('web.Registry', function (require) { -"use strict"; +odoo.define("web.Registry", function (require) { + "use strict"; -var Class = require('web.Class'); - -/** - * The registry is really pretty much only a mapping from some keys to some - * values. The Registry class only add a few simple methods around that to make - * it nicer and slightly safer. - * - * Note that registries have a fundamental problem: the value that you try to - * get in a registry might not have been added yet, so of course, you need to - * make sure that your dependencies are solid. For this reason, it is a good - * practice to avoid using the registry if you can simply import what you need - * with the 'require' statement. - * - * However, on the flip side, sometimes you cannot just simply import something - * because we would have a dependency cycle. In that case, registries might - * help. - */ -var Registry = Class.extend({ - /** - * @constructor - * @param {Object} [mapping] the initial data in the registry - */ - init: function (mapping) { - this.map = Object.create(mapping || null); - this._scoreMapping = Object.create(null); - this._sortedKeys = null; - this.listeners = []; // listening callbacks on newly added items. - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- + const { sortBy } = require("web.utils"); /** - * Add a key (and a value) to the registry. + * The registry is really pretty much only a mapping from some keys to some + * values. The Registry class only add a few simple methods around that to make + * it nicer and slightly safer. * - * Notify the listeners on newly added item in the registry. + * Note that registries have a fundamental problem: the value that you try to + * get in a registry might not have been added yet, so of course, you need to + * make sure that your dependencies are solid. For this reason, it is a good + * practice to avoid using the registry if you can simply import what you need + * with the 'require' statement. * - * @param {string} key - * @param {any} value - * @param {number} [score] if given, this value will be used to order keys - * @returns {Registry} can be used to chain add calls. + * However, on the flip side, sometimes you cannot just simply import something + * because we would have a dependency cycle. In that case, registries might + * help. */ - add: function (key, value, score) { - this._scoreMapping[key] = score === undefined ? key : score; - this._sortedKeys = null; - this.map[key] = value; - _.each(this.listeners, function (callback) { - callback(key, value); - }); - return this; - }, - /** - * Check if the registry contains the key - * - * @param {string} key - * @returns {boolean} - */ - contains: function (key) { - return (key in this.map); - }, - /** - * Returns the content of the registry (an object mapping keys to values) - * - * @returns {Object} - */ - entries: function () { - var entries = {}; - var keys = this.keys(); - for (var i = 0; i < keys.length; i++) { - entries[keys[i]] = this.map[keys[i]]; + class Registry { + /** + * @function predicate + * @param {any} value + * @returns {boolean} + */ + /** + * @param {Object} [mapping] the initial data in the registry + * @param {predicate} [predicate=(() => true)] predicate that each + * added value must pass to be registered. + */ + constructor(mapping, predicate = () => true) { + this.map = Object.create(mapping || null); + this._scoreMapping = Object.create(null); + this._sortedKeys = null; + this.listeners = []; // listening callbacks on newly added items. + this.predicate = predicate; } - return entries; - }, - /** - * Returns the value associated to the given key. - * - * @param {string} key - * @returns {any} - */ - get: function (key) { - return this.map[key]; - }, - /** - * Tries a number of keys, and returns the first object matching one of - * the keys. - * - * @param {string[]} keys a sequence of keys to fetch the object for - * @returns {any} the first result found matching an object - */ - getAny: function (keys) { - for (var i=0; i this._scoreMapping[key] || 0 + ); + } + return this._sortedKeys; + } + + /** + * @function onAddCallback + * @param {string} key + * @param {any} value + */ + /** + * Register a callback to execute when items are added to the registry. + * @param {onAddCallback} callback function with parameters (key, value). + */ + onAdd(callback) { + this.listeners.push(callback); + } + + /** + * Return the list of values in map object + * @returns {string[]} + */ + values() { + return this.keys().map((key) => this.map[key]); + } + } + + return Registry; }); - -return Registry; - -}); - diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js index 17ce0e080b2..a5a5f4eb465 100644 --- a/addons/web/static/src/js/core/utils.js +++ b/addons/web/static/src/js/core/utils.js @@ -571,6 +571,31 @@ var utils = { str = "" + str; return new Array(size - str.length + 1).join('0') + str; }, + /** + * @param {any[]} arr + * @param {Function} fn + * @returns {any[]} + */ + partitionBy(arr, fn) { + let lastGroup = false; + let lastValue; + return arr.reduce((acc, cur) => { + let curVal = fn(cur); + if (lastGroup) { + if (curVal === lastValue) { + lastGroup.push(cur); + } else { + lastGroup = false; + } + } + if (!lastGroup) { + lastGroup = [cur]; + acc.push(lastGroup); + } + lastValue = curVal; + return acc; + }, []); + }, /** * Patch a class and return a function that remove the patch * when called. diff --git a/addons/web/static/src/js/fields/field_registry.js b/addons/web/static/src/js/fields/field_registry.js index 925b405051e..ef174702579 100644 --- a/addons/web/static/src/js/fields/field_registry.js +++ b/addons/web/static/src/js/fields/field_registry.js @@ -1,18 +1,12 @@ odoo.define('web.field_registry', function (require) { -"use strict"; + "use strict"; -var Registry = require('web.Registry'); + const Registry = require('web.Registry'); -var FieldRegistry = Registry.extend({ - add: function (key, value, score) { - if (value.prototype instanceof owl.Component) { - throw new Error("This registry should not contain any Component. Use 'web.field_registry_owl' instead."); - } - return this._super(...arguments); - }, -}); - -return new FieldRegistry(); + return new Registry( + null, + (value) => !(value.prototype instanceof owl.Component) + ); }); odoo.define('web._field_registry', function (require) { diff --git a/addons/web/static/src/js/fields/field_registry_owl.js b/addons/web/static/src/js/fields/field_registry_owl.js index 907ea38a9d7..635db621829 100644 --- a/addons/web/static/src/js/fields/field_registry_owl.js +++ b/addons/web/static/src/js/fields/field_registry_owl.js @@ -3,16 +3,10 @@ odoo.define('web.field_registry_owl', function (require) { const Registry = require('web.Registry'); - const FieldRegistry = Registry.extend({ - add: function (key, value, score) { - if (!(value.prototype instanceof owl.Component)) { - throw new Error("This registry should only contain subclasses of Component"); - } - return this._super(...arguments); - }, - }); - - return new FieldRegistry(); + return new Registry( + null, + (value) => value.prototype instanceof owl.Component + ); }); odoo.define('web._field_registry_owl', function (require) { diff --git a/addons/web/static/src/js/model.js b/addons/web/static/src/js/model.js index 8e3842e5175..b86599eb5fc 100644 --- a/addons/web/static/src/js/model.js +++ b/addons/web/static/src/js/model.js @@ -1,53 +1,27 @@ -odoo.define('web.model', function () { +odoo.define("web/static/src/js/model.js", function (require) { "use strict"; + const { groupBy, partitionBy } = require("web.utils"); + const Registry = require("web.Registry"); + const { Component, core } = owl; const { EventBus, Observer } = core; - - // TODO: remove this if made available in owl.utils - function partitionBy(arr, fn) { - let lastGroup = false; - let lastValue; - return arr.reduce((acc, cur) => { - let curVal = fn(cur); - if (lastGroup) { - if (curVal === lastValue) { - lastGroup.push(cur); - } - else { - lastGroup = false; - } - } - if (!lastGroup) { - lastGroup = [cur]; - acc.push(lastGroup); - } - lastValue = curVal; - return acc; - }, []); - } + const isNotNull = (val) => val !== null && val !== undefined; /** - * Model - * - * The purpose of the class Model and the associated hook useModel - * is to offer something similar to an owl store but with no automatic - * notification (and rendering) of components when the 'state' used in the model - * would change. Instead, one should call the __notifyComponents function whenever - * it is useful to alert registered component. Nevertheless, - * when calling a method throught the dispatch method, a notifcation - * does take place automatically, and registered components (via useModel) are rendered. - * - * it is highly expected that this class will change in a near future. We don't have - * the necessary hindsight to be sure its actual form is good. - * @extends EventBus + * Feature extension of the class Model. + * @see {Model} */ - class Model extends EventBus { - - constructor() { - super(); - this.rev = 1; - this.mapping = {}; // could be a weak map + class ModelExtension { + /** + * @param {Object} config + * @param {Object} config.env + */ + constructor(config) { + this.config = config; + this.env = this.config.env; + this._shouldLoad = true; + this.state = {}; } //--------------------------------------------------------------------- @@ -55,23 +29,362 @@ odoo.define('web.model', function () { //--------------------------------------------------------------------- /** - * Call the base model method with given name with the arguments - * determined by the dispatch extra arguments. - * - * @param {string} action - * @param {...any} args - * @returns {Promise} + * Used by the parent model to initiate a load action. The actual + * loading of the extension is determined by the "_shouldLoad" property. */ - async dispatch(action, ...args) { - const result = await this[action](...args); - // TODO try to put it in promise.resolve() - this._dispatch(...arguments); - let rev = this.rev; - await Promise.resolve(); - if (rev === this.rev) { - await this._notifyComponents(); + async callLoad() { + if (this._shouldLoad) { + this._shouldLoad = false; + await this.load(); + } + } + + /** + * Exports the current state of the extension. + * @returns {Object} + */ + exportState() { + return this.state; + } + + /** + * Meant to return the result of the appropriate getter or do nothing + * if not concerned by the given property. + * @abstract + * @param {string} property + * @param {...any} args + * @returns {null} + */ + get() { + return null; + } + + /** + * Imports the given state after parsing it. If no state is given the + * extension will prepare a new state and will need to be loaded. + * @param {Object} [state] + */ + importState(state) { + this._shouldLoad = !state; + if (this._shouldLoad) { + this.prepareState(); + } else { + Object.assign(this.state, state); + } + } + + /** + * Called and awaited on initial model load. + * @abstract + * @returns {Promise} + */ + async load() { + /* ... */ + } + + /** + * Called on initialization if no imported state for the extension is + * found. + * @abstract + */ + prepareState() { + /* ... */ + } + + /** + * Called and awaited after an action dispatch. + * @abstract + * @returns {Promise} + */ + async reloadAfterDispatch() { + /* ... */ + } + } + /** + * The layer of an extension indicates with which other extensions this one + * will be loaded. This property must be overridden in case the model + * depends on other extensions to be loaded first. + */ + ModelExtension.layer = 0; + + /** + * Model + * + * The purpose of the class Model and the associated hook useModel + * is to offer something similar to an owl store but with no automatic + * notification (and rendering) of components when the 'state' used in the + * model would change. Instead, one should call the "__notifyComponents" + * function whenever it is useful to alert registered component. + * Nevertheless, when calling a method through the 'dispatch' method, a + * notification does take place automatically, and registered components + * (via useModel) are rendered. + * + * It is highly expected that this class will change in a near future. We + * don't have the necessary hindsight to be sure its actual form is good. + * + * The following snippets show a typical use case of the model system: a + * search model with a control panel extension feature. + * + *------------------------------------------------------------------------- + * MODEL AND EXTENSIONS DEFINITION + *------------------------------------------------------------------------- + * + * 1. Definition of the main model + * @see Model + * ``` + * class ActionModel extends Model { + * // ... + * } + * ``` + * + * 2. Definition of the model extension + * @see ModelExtension + * ``` + * class ControlPanelModelExtension extends ActionModel.Extension { + * // ... + * } + * ``` + * + * 3. Registration of the extension into the main model + * @see Registry() + * ``` + * ActionModel.registry.add("SearchPanel", ControlPanelModelExtension, 10); + * ``` + * + *------------------------------------------------------------------------- + * ON VIEW/ACTION INIT + *------------------------------------------------------------------------- + * + * 4. Creation of the core model and its extensions + * @see Model.prototype.constructor() + * ``` + * const extensions = { + * SearchPanel: { + * // ... + * } + * } + * const searchModelConfig = { + * // ... + * }; + * const actionModel = new ActionModel(extensions, searchModelConfig); + * ``` + * + * 5. Loading of all extensions' asynchronous data + * @see Model.prototype.load() + * ``` + * await actionModel.load(); + * ``` + * + * 6. Subscribing to the model changes + * @see useModel() + * ``` + * class ControlPanel extends Component { + * constructor() { + * super(...arguments); + * // env must contain the actionModel + * this.actionModel = useModel('actionModel'); + * } + * } + * ``` + * + *------------------------------------------------------------------------- + * MODEL USAGE ON RUNTIME + *------------------------------------------------------------------------- + * + * Case: dispatch an action + * @see Model.prototype.dispatch() + * ``` + * actionModel.dispatch("updateProperty", value); + * ``` + * + * Case: call a getter + * @see Model.prototype.get() + * ``` + * const result = actionModel.get("property"); + * ``` + * + * @abstract + * @extends EventBus + */ + class Model extends EventBus { + /** + * Instantiated extensions are determined by the `extensions` argument: + * - keys are the extensions names as added in the registry + * - values are the local configurations given to each extension + * The extensions are grouped by the sequence number they where + * registered with in the registry. Extensions being on the same level + * will be loaded in parallel; this means that all extensions belonging + * to the same group are awaited before loading the next group. + * @param {Object} [extensions={}] + * @param {Object} [globalConfig={}] global configuration: can be + * accessed by itself and each of the added extensions. + * @param {Object} [globalConfig.env] + * @param {string} [globalConfig.importedState] + */ + constructor(extensions = {}, globalConfig = {}) { + super(); + + this.config = globalConfig; + this.env = this.config.env; + + this.dispatching = false; + this.extensions = []; + this.externalState = {}; + this.mapping = {}; + this.rev = 1; + + const { name, registry } = this.constructor; + if (!registry || !(registry instanceof Registry)) { + throw new Error(`Unimplemented registry on model "${name}".`); + } + // Order, group and sequencially instantiate all extensions + const registryExtensions = Object.entries(registry.entries()); + const extensionNameLayers = registryExtensions.map( + ([name, { layer }]) => ({ name, layer }) + ); + const groupedNameLayers = groupBy(extensionNameLayers, "layer"); + for (const groupNameLayers of Object.values(groupedNameLayers)) { + for (const { name } of groupNameLayers) { + if (name in extensions) { + this.addExtension(name, extensions[name]); + } + } + } + this.importState(this.config.importedState); + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * Method used internally to instantiate all extensions. Can also be + * called externally to add extensions after model instantiation. + * @param {string} extensionName + * @param {Object} extensionConfig + */ + addExtension(extensionName, extensionConfig) { + const { name, registry } = this.constructor; + const Extension = registry.get(extensionName); + if (!Extension) { + throw new Error(`Unknown model extension "${extensionName}" in model "${name}"`); + } + // Extension config = this.config ∪ extension.config + const get = this.__get.bind(this, Extension.name); + const trigger = this.trigger.bind(this); + const config = Object.assign({ get, trigger }, this.config, extensionConfig); + const extension = new Extension(config); + if (!(Extension.layer in this.extensions)) { + this.extensions[Extension.layer] = []; + } + this.extensions[Extension.layer].push(extension); + } + + /** + * Returns the result of the first related method on any instantiated + * extension. This method must be overridden if multiple extensions + * return a value with a common method (and dispatchAll does not + * suffice). After the dispatch of the action, all models are partially + * reloaded (@see ModelExtension.reloadAfterDispatch()) and components + * are notified afterwards. + */ + dispatch(method, ...args) { + const isInitialDispatch = !this.dispatching; + this.dispatching = true; + for (const extension of this.extensions.flat()) { + if (method in extension) { + extension[method](...args); + } + } + if (isInitialDispatch) { + this.dispatching = false; + (async () => { + // Calls 'after dispatch' hooks + // Purpose: fetch updated data from the server. This is + // considered a loading action and is thus performed by groups + // instead of loading all extensions at once. + for (let layer = 0; layer < this.extensions.length; layer++) { + const afterDispatchPromises = []; + for (const extension of this.extensions[layer]) { + afterDispatchPromises.push(extension.reloadAfterDispatch()); + } + await Promise.all(afterDispatchPromises); + } + // Notifies subscribed components + // Purpose: re-render components bound by 'useModel' + let rev = this.rev; + await Promise.resolve(); + if (rev === this.rev) { + this._notifyComponents(); + } + })(); + } + } + + /** + * Stringifies and exports an object holding the exported state of each + * active extension. + * @returns {string} + */ + exportState() { + const exported = {}; + for (const extension of this.extensions.flat()) { + exported[extension.constructor.name] = extension.exportState(); + } + const fullState = Object.assign({}, this.externalState, exported); + return JSON.stringify(fullState); + } + + /** + * Returns the result of the first related getter on any instantiated + * extension. This method must be overridden if multiple extensions + * share a common getter (and getAll does not make the job). + * @param {string} property + * @param {...any} args + * @returns {any} + */ + get(property, ...args) { + for (const extension of this.extensions.flat()) { + const result = extension.get(property, ...args); + if (isNotNull(result)) { + return result; + } + } + return null; + } + + /** + * Parses the given stringified state object and imports each state + * part to its related extension. + * @param {string} [stringifiedState="null"] + */ + importState(stringifiedState = "null") { + const state = JSON.parse(stringifiedState) || {}; + Object.assign(this.externalState, state); + for (const extension of this.extensions.flat()) { + extension.importState(state[extension.constructor.name]); + } + } + + /** + * Must be called after construction and state preparation/import. + * Waits for all asynchronous work needed by the model extensions to be + * ready. + * /!\ The current model extensions do not require a smarter system at + * the moment (therefore using layers instead of dependencies). It + * should be changed if at some point an extension needs another + * specific extension to be loaded instead of a whole batch (with the + * current system some promises will be waited needlessly). + * @returns {Promise} + */ + async load() { + for (let layer = 0; layer < this.extensions.length; layer++) { + const layerPromises = []; + for (const extension of this.extensions[layer]) { + layerPromises.push(extension.callLoad()); + } + await Promise.all(layerPromises); } - return result; } //--------------------------------------------------------------------- @@ -79,38 +392,66 @@ odoo.define('web.model', function () { //--------------------------------------------------------------------- /** + * Returns the list of the results of all extensions providing a getter + * for the given property returning a non-null value, excluding the + * extension whose name is equal to "excluded". This method is given to + * each extension in the "config" object bound to the model scope and + * having the extension name bound as the first argument. * @private - * @param {string} action - * @param {...any} args + * @param {string} excluded + * @param {string} property + * @param {...any} args + * @returns {any[]} */ - _dispatch(action, ...args) { } + __get(excluded, property, ...args) { + const results = []; + for (const extension of this.extensions.flat()) { + if (extension.constructor.name !== excluded) { + const result = extension.get(property, ...args); + if (isNotNull(result)) { + results.push(result); + } + } + } + return results; + } /** - * see Context method in owl.js for explanation + * @see Context.__notifyComponents() in owl.js for explanation * @private */ async _notifyComponents() { const rev = ++this.rev; const subscriptions = this.subscriptions.update; - const groups = partitionBy(subscriptions, s => (s.owner ? s.owner.__owl__.depth : -1)); + const groups = partitionBy(subscriptions, (s) => + s.owner ? s.owner.__owl__.depth : -1 + ); for (let group of groups) { - const proms = group.map(sub => sub.callback.call(sub.owner, rev)); + const proms = group.map((sub) => + sub.callback.call(sub.owner, rev) + ); Component.scheduler.flush(); await Promise.all(proms); } } } + Model.Extension = ModelExtension; + /** - * This is more or less the hook 'useContextWithCB' from owl only slightly simplified. + * This is more or less the hook 'useContextWithCB' from owl only slightly + * simplified. * * @param {string} modelName + * @returns {model} */ function useModel(modelName) { const component = Component.current; const model = component.env[modelName]; if (!(model instanceof Model)) { - throw new Error(`No Model found when connecting '${component.constructor.name}'`); + throw new Error(`No Model found when connecting '${ + component.name + }'`); } const mapping = model.mapping; @@ -134,7 +475,7 @@ odoo.define('web.model', function () { return renderFn(comp, params); }; - model.on('update', component, async modelRev => { + model.on("update", component, async (modelRev) => { if (mapping[componentId] < modelRev) { mapping[componentId] = modelRev; await component.render(); @@ -142,7 +483,7 @@ odoo.define('web.model', function () { }); const __destroy = component.__destroy; - component.__destroy = parent => { + component.__destroy = (parent) => { model.off("update", component); __destroy.call(component, parent); }; diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index 8a48c201fa6..5128fa6d9a8 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -16,7 +16,6 @@ var ActionMixin = require('web.ActionMixin'); var ajax = require('web.ajax'); var concurrency = require('web.concurrency'); const { ComponentWrapper } = require('web.OwlCompatibility'); -const ControlPanel = require('web.ControlPanel'); var mvc = require('web.mvc'); var session = require('web.session'); @@ -25,7 +24,6 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { custom_events: _.extend({}, ActionMixin.custom_events, { navigation_move: '_onNavigationMove', open_record: '_onOpenRecord', - search_panel_domain_updated: '_onSearchPanelDomainUpdated', switch_view: '_onSwitchView', }), events: { @@ -37,14 +35,13 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { * @param {Object[]} params.actionViews * @param {string} params.activeActions * @param {string} params.bannerRoute - * @param {Array[]} params.controlPanelDomain - * @param {ControlPanelModel} [params.controlPanelModel] - * @param {Object} [params.controlPanelProps] + * @param {Object} [params.controlPanel] * @param {string} params.controllerID an id to ease the communication with * upstream components * @param {string} params.displayName * @param {Object} params.initialState * @param {string} params.modelName + * @param {ActionModel} [params.searchModel] * @param {string} [params.searchPanel] * @param {string} params.viewType * @param {boolean} [params.withControlPanel] @@ -64,18 +61,20 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { this.dp = new concurrency.DropPrevious(); this.withControlPanel = params.withControlPanel; + this.withSearchPanel = params.withSearchPanel; + if (params.searchModel) { + this.searchModel = params.searchModel; + } if (this.withControlPanel) { - this.controlPanelProps = params.controlPanelProps; - this._controlPanelModel = params.controlPanelModel; + const { Component, props } = params.controlPanel; + this.ControlPanel = Component; + this.controlPanelProps = props; } - - this.withSearchPanel = params.withSearchPanel && params.searchPanel; - // the following attributes are used when there is a searchPanel if (this.withSearchPanel) { - this._searchPanel = params.searchPanel; + const { Component, props } = params.searchPanel; + this.SearchPanel = Component; + this.searchPanelProps = props; } - this.controlPanelDomain = params.controlPanelDomain || []; - this.searchPanelDomain = this._searchPanel ? this._searchPanel.getDomain() : []; }, /** @@ -84,23 +83,23 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { * @returns {Promise} */ start: async function () { - if (this.withSearchPanel) { - this.$('.o_content') - .addClass('o_controller_with_searchpanel') - .prepend(this._searchPanel.$el); - } this.$el.addClass('o_view_controller'); - this.renderButtons(); const promises = [this._super(...arguments)]; if (this.withControlPanel) { this._updateControlPanelProps(this.initialState); - this._controlPanelWrapper = new ComponentWrapper(this, ControlPanel, this.controlPanelProps); + this._controlPanelWrapper = new ComponentWrapper(this, this.ControlPanel, this.controlPanelProps); this._controlPanelWrapper.env.bus.on('focus-view', this, () => this._giveFocus()); promises.push(this._controlPanelWrapper.mount(this.el, { position: 'first-child' })); } + if (this.withSearchPanel) { + this._searchPanelWrapper = new ComponentWrapper(this, this.SearchPanel, this.searchPanelProps); + const content = this.el.querySelector(':scope .o_content'); + content.classList.add('o_controller_with_searchpanel'); + promises.push(this._searchPanelWrapper.mount(content, { position: 'first-child' })); + } await Promise.all(promises); - await this._update(this.initialState, { shouldUpdateControlPanel: false }); + await this._update(this.initialState, { shouldUpdateSearchComponents: false }); this.updateButtons(); }, /** @@ -118,12 +117,9 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { */ on_attach_callback: function () { ActionMixin.on_attach_callback.call(this); - if (this.withSearchPanel) { - this._searchPanel.on_attach_callback(); - } + this.searchModel.on('search', this, this._onSearch); if (this.withControlPanel) { - this._controlPanelModel.on('search', this, this._onSearch); - this._controlPanelModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams); + this.searchModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams); } if (!(this.renderer instanceof owl.Component)) { this.renderer.on_attach_callback(); @@ -134,9 +130,9 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { */ on_detach_callback: function () { ActionMixin.on_detach_callback.call(this); + this.searchModel.off('search', this); if (this.withControlPanel) { - this._controlPanelModel.off('search', this); - this._controlPanelModel.off('get-controller-query-params', this); + this.searchModel.off('get-controller-query-params', this); } if (!(this.renderer instanceof owl.Component)) { this.renderer.on_detach_callback(); @@ -177,20 +173,32 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { }, /** * Export the state of the controller containing information that is shared - * between different controllers of a same action (like the current - * searchQuery of the controlPanel). + * between different controllers of a same action (like the current search + * model state or the states of some components). * * @returns {Object} */ - exportState: function () { - var state = {}; - if (this.withControlPanel) { - state.cpState = this._controlPanelModel.exportState(); - } + exportState() { + const exported = { + searchModel: this.searchModel.exportState(), + }; if (this.withSearchPanel) { - state.spState = this._searchPanel.exportState(); + const searchPanel = this._searchPanelWrapper.componentRef.comp; + exported.searchPanel = searchPanel.exportState(); + } + return exported; + }, + /** + * Parses and imports a previously exported state. + * + * @param {Object} state + */ + importState(state) { + this.searchModel.importState(state.searchModel); + if (this.withSearchPanel) { + const searchPanel = this._searchPanelWrapper.componentRef.comp; + searchPanel.importState(state.searchPanel); } - return state; }, /** * The use of this method is discouraged. It is still snakecased, because @@ -207,40 +215,17 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { /** * Short helper method to reload the view * - * @param {Object} [params={}] This object will simply be given to the update + * @param {Object} [params={}] + * @param {Object} [params.controllerState={}] * @returns {Promise} */ reload: async function (params = {}) { - const controllerState = params.controllerState || {}; - const cpState = controllerState.cpState; - if (this.withControlPanel && cpState) { - this._controlPanelModel.importState(cpState); - const searchQuery = this._controlPanelModel.getQuery(); - params = Object.assign({}, params, searchQuery); - } - let postponeRendering = false; - if (this.withSearchPanel) { - if (params.domain) { - this.controlPanelDomain = params.domain; - } - if (controllerState.spState) { - this._searchPanel.importState(controllerState.spState); - } else { - const viewDomain = await this._getViewDomain(); - await this._searchPanel.update({ - searchDomain: this.controlPanelDomain, - viewDomain, - }); - postponeRendering = !params.noRender; - params.noRender = true; // wait for searchpanel to be ready to render - } - this.searchPanelDomain = this._searchPanel.getDomain(); - params.domain = this.controlPanelDomain.concat(this.searchPanelDomain); - } - await this.update(params, {}); - if (postponeRendering) { - return this._updateRendererState(false); + if (params.controllerState) { + this.importState(params.controllerState); + await this.searchModel.load(); + Object.assign(params, this.searchModel.get('query')); } + return this.update(params, {}); }, /** * This is the main entry point for the controller. Changes from the search @@ -287,15 +272,6 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { _getPagingInfo: function (state) { return null; }, - /** - * Get the domain defined by the view. It is meant to be overridden. - * - * @private - * @returns {Promise} - */ - _getViewDomain: async function () { - return []; - }, /** * Meant to be overriden to return a proper object. * @private @@ -409,7 +385,8 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { * @private * @param {Object} state the state given by the model * @param {Object} [params={}] - * @param {Object} [params.shouldUpdateControlPanel] + * @param {Array} [params.breadcrumbs] + * @param {Object} [params.shouldUpdateSearchComponents] * @returns {Promise} */ async _update(state, params) { @@ -418,12 +395,17 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { this.renderButtons(); } const promises = [this._renderBanner()]; - if (this.withControlPanel && params.shouldUpdateControlPanel !== false) { - this._updateControlPanelProps(state); - if (params.breadcrumbs) { - this.controlPanelProps.breadcrumbs = params.breadcrumbs; + if (params.shouldUpdateSearchComponents !== false) { + if (this.withControlPanel) { + this._updateControlPanelProps(state); + if (params.breadcrumbs) { + this.controlPanelProps.breadcrumbs = params.breadcrumbs; + } + promises.push(this.updateControlPanel()); + } + if (this.withSearchPanel) { + this._updateSearchPanel(); } - promises.push(this.updateControlPanel()); } this._pushState(); await Promise.all(promises); @@ -476,6 +458,15 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { } return this.renderer.updateState(state, params); }, + /** + * @private + * @param {Object} [newProps={}] + * @return {Promise} + */ + async _updateSearchPanel(newProps) { + Object.assign(this.searchPanelProps, newProps); + await this._searchPanelWrapper.update(this.searchPanelProps); + }, //-------------------------------------------------------------------------- // Handlers @@ -551,7 +542,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { switch (ev.data.direction) { case 'up': ev.stopPropagation(); - this._controlPanelModel.trigger('focus-control-panel'); + this.searchModel.trigger('focus-control-panel'); break; case 'down': ev.stopPropagation(); @@ -593,15 +584,6 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { _onSearch: function (searchQuery) { this.reload(_.extend({ offset: 0, groupsOffset: 0 }, searchQuery)); }, - /** - * @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_view.js b/addons/web/static/src/js/views/abstract_view.js index 6504314ddd1..04b1b781848 100644 --- a/addons/web/static/src/js/views/abstract_view.js +++ b/addons/web/static/src/js/views/abstract_view.js @@ -23,12 +23,13 @@ odoo.define('web.AbstractView', function (require) { * in most case discarded. */ +const ActionModel = require("web/static/src/js/views/action_model.js"); var AbstractModel = require('web.AbstractModel'); var AbstractRenderer = require('web.AbstractRenderer'); var AbstractController = require('web.AbstractController'); -const ControlPanelModel = require('web.ControlPanelModel'); +const ControlPanel = require('web.ControlPanel'); +const SearchPanel = require("web/static/src/js/views/search_panel.js"); var mvc = require('web.mvc'); -var SearchPanel = require('web.SearchPanel'); var viewUtils = require('web.viewUtils'); const { Component } = owl; @@ -60,7 +61,8 @@ var AbstractView = Factory.extend({ Model: AbstractModel, Renderer: AbstractRenderer, Controller: AbstractController, - SearchPanel: SearchPanel, + ControlPanel, + SearchPanel, }), /** @@ -116,10 +118,6 @@ var AbstractView = Factory.extend({ this.arch = this.fieldsView.arch; this.fields = this.fieldsView.viewFields; this.userContext = params.userContext || {}; - this.withControlPanel = this.withControlPanel && params.withControlPanel; - const searchPanelDisabled = 'search_panel' in params.context && !params.search_panel; - this.withSearchPanel = this.withSearchPanel && this.multi_record && - params.withSearchPanel && !searchPanelDisabled; // the boolean parameter 'isEmbedded' determines if the view should be // considered as a subview. For now this is only used by the graph @@ -147,8 +145,6 @@ var AbstractView = Factory.extend({ isEmbedded: isEmbedded, modelName: params.modelName, viewType: this.viewType, - withControlPanel: this.withControlPanel, - withSearchPanel: this.withSearchPanel, }; var controllerState = params.controllerState || {}; @@ -183,49 +179,32 @@ var AbstractView = Factory.extend({ this._updateMVCParams(params.searchQuery); } - if (this.withControlPanel) { - this.controlPanelModelConfig = { - env: Component.env, - actionId: action.id, - actionContext: Object.assign({}, this.loadParams.context || {}), - actionDomain: this.loadParams.domain || [], - modelName: params.modelName, - // control initialization - activateDefaultFavorite: params.activateDefaultFavorite, - dynamicFilters: params.dynamicFilters, - viewInfo: params.controlPanelFieldsView, - withSearchBar: params.withSearchBar, - // used to avoid timeRanges in query - searchMenuTypes: params.searchMenuTypes, - // avoid work to initialize - importedState: controllerState.cpState, - }; + this.withControlPanel = this.withControlPanel && params.withControlPanel; + this.withSearchPanel = this.withSearchPanel && + this.multi_record && params.withSearchPanel && + !('search_panel' in params.context && !params.search_panel); - const controlPanelModel = new ControlPanelModel(this.controlPanelModelConfig); - - const controlPanelProps = { - action, - breadcrumbs: params.breadcrumbs, - controlPanelModel, - fields: this.fields, - searchMenuTypes: params.searchMenuTypes, - view: this.fieldsView, - views: action.views && action.views.filter(v => v.multiRecord === this.multi_record), - withBreadcrumbs: params.withBreadcrumbs, - withSearchBar: params.withSearchBar, - }; - this.controllerParams.controlPanelModel = controlPanelModel; - this.controllerParams.controlPanelProps = controlPanelProps; + const searchModelParams = Object.assign({}, params, { action }); + if (this.withControlPanel || this.withSearchPanel) { + const { arch, fields, favoriteFilters } = params.controlPanelFieldsView || {}; + const archInfo = ActionModel.extractArchInfo({ search: arch }, this.viewType); + const controlPanelInfo = archInfo[this.config.ControlPanel.modelExtension]; + const searchPanelInfo = archInfo[this.config.SearchPanel.modelExtension]; + this.withSearchPanel = this.withSearchPanel && Boolean(searchPanelInfo); + Object.assign(searchModelParams, { + fields, + favoriteFilters, + controlPanelInfo, + searchPanelInfo, + }); } - - if (this.withSearchPanel) { - this.searchPanelParams = { - arch: (params.controlPanelFieldsView || {}).arch, - defaultNoFilter: params.searchPanelDefaultNoFilter, - fields: this.fields, - model: this.loadParams.modelName, - state: controllerState.spState, - }; + const searchModel = this._createSearchModel(searchModelParams); + this.controllerParams.searchModel = searchModel; + if (this.controllerParams.controlPanel) { + this.controllerParams.controlPanel.props.searchModel = searchModel; + } + if (this.controllerParams.searchPanel) { + this.controllerParams.searchPanel.props.searchModel = searchModel; } }, @@ -233,34 +212,107 @@ var AbstractView = Factory.extend({ // Public //-------------------------------------------------------------------------- + /** + * @param {Object} params + * @param {Object} extraExtensions + * @returns {ActionModel} + */ + _createSearchModel: function (params, extraExtensions) { + // Search model + common config + const { fields, favoriteFilters, controlPanelInfo, searchPanelInfo } = params; + const extensions = Object.assign({}, extraExtensions); + const importedState = params.controllerState || {}; + + // Control panel params + if (this.withControlPanel) { + // Control panel (Model) + const ControlPanelComponent = this.config.ControlPanel; + extensions[ControlPanelComponent.modelExtension] = { + actionId: params.action.id, + // control initialization + activateDefaultFavorite: params.activateDefaultFavorite, + archNodes: controlPanelInfo.children, + dynamicFilters: params.dynamicFilters, + favoriteFilters, + withSearchBar: params.withSearchBar, + }; + this.controllerParams.withControlPanel = true; + // Control panel (Component) + const controlPanelProps = { + action: params.action, + breadcrumbs: params.breadcrumbs, + fields: this.fields, + searchMenuTypes: params.searchMenuTypes, + view: this.fieldsView, + views: params.action.views && params.action.views.filter( + v => v.multiRecord === this.multi_record + ), + withBreadcrumbs: params.withBreadcrumbs, + withSearchBar: params.withSearchBar, + }; + this.controllerParams.controlPanel = { + Component: ControlPanelComponent, + props: controlPanelProps, + }; + } + + // Search panel params + if (this.withSearchPanel) { + // Search panel (Model) + const SearchPanelComponent = this.config.SearchPanel; + const defaultValues = {}; + Object.keys(this.loadParams.context).forEach((key) => { + let match = /^searchpanel_default_(.*)$/.exec(key); + if (match) { + defaultValues[match[1]] = this.loadParams.context[key]; + } + }); + extensions[SearchPanelComponent.modelExtension] = { + archNodes: searchPanelInfo.children, + defaultNoFilter: params.searchPanelDefaultNoFilter, + defaultValues, + }; + this.controllerParams.withSearchPanel = true; + this.rendererParams.withSearchPanel = true; + // Search panel (Component) + const searchPanelProps = { + importedState: importedState.searchPanel, + }; + if (searchPanelInfo.attrs.class) { + searchPanelProps.className = searchPanelInfo.attrs.class; + } + this.controllerParams.searchPanel = { + Component: SearchPanelComponent, + props: searchPanelProps, + }; + } + + const searchModel = new ActionModel(extensions, { + env: Component.env, + modelName: params.modelName, + context: Object.assign({}, this.loadParams.context), + domain: this.loadParams.domain || [], + importedState: importedState.searchModel, + searchMenuTypes: params.searchMenuTypes, + searchQuery: params.searchQuery, + fields, + }); + + return searchModel; + }, + /** * @override */ - getController: async function (parent) { + getController: async function () { const _super = this._super.bind(this); - if (this.withControlPanel) { - await this.controllerParams.controlPanelModel.isReady; - const query = this.controllerParams.controlPanelModel.getQuery(); - this._updateMVCParams(query); - } - let searchPanel = false; - if (this.withSearchPanel) { - const spProto = this.config.SearchPanel.prototype; - const { arch, fields } = this.searchPanelParams; - const searchPanelParams = spProto.computeSearchPanelParams(arch, fields, this.viewType); - if (searchPanelParams.sections) { - this.searchPanelParams.sections = searchPanelParams.sections; - this.rendererParams.withSearchPanel = true; - searchPanel = await this._createSearchPanel(parent, searchPanelParams); - } - } + await this.controllerParams.searchModel.load(); + const query = this.controllerParams.searchModel.get('query'); + this._updateMVCParams(query); // get the parent of the model if it already exists, as _super will // set the new controller as parent, which we don't want const modelParent = this.model && this.model.getParent(); const controller = await _super(...arguments); - if (searchPanel) { - searchPanel.setParent(controller); - } if (modelParent) { // if we already add a model, restore its parent this.model.setParent(modelParent); @@ -292,36 +344,6 @@ var AbstractView = Factory.extend({ // Private //-------------------------------------------------------------------------- - /** - * @private - * @param {Widget} parent - * @returns {Promise} resolved when the searchPanel is ready - */ - _createSearchPanel: async function (parent, params) { - 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; - const viewDomain = await this._getViewDomain(parent); - var spParams = _.extend({}, this.searchPanelParams, { - defaultValues: defaultValues, - searchDomain: controlPanelDomain, - viewDomain, - classes: params.classes || [], - }); - var searchPanel = new this.config.SearchPanel(parent, spParams); - 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] @@ -363,17 +385,6 @@ var AbstractView = Factory.extend({ withSearchPanel: this.withSearchPanel, }; }, - /** - * Get the domain defined by the view. It is meant to be overridden. The parent - * is provided in case some subcomponents need to be retrieved. - * - * @private - * @param {Object} parent - * @returns {Promise} - */ - _getViewDomain: async function (parent) { - return []; - }, /** * Processes a fieldsView. In particular, parses its arch. * diff --git a/addons/web/static/src/js/views/action_model.js b/addons/web/static/src/js/views/action_model.js new file mode 100644 index 00000000000..382aa30531b --- /dev/null +++ b/addons/web/static/src/js/views/action_model.js @@ -0,0 +1,190 @@ +odoo.define("web/static/src/js/views/action_model.js", function (require) { + "use strict"; + + const Domain = require("web.Domain"); + const { FACET_ICONS } = require("web.searchUtils"); + const { Model } = require("web/static/src/js/model.js"); + const { parseArch } = require("web.viewUtils"); + const pyUtils = require("web.py_utils"); + const Registry = require("web.Registry"); + + const isNotNull = (value) => value !== null && value !== undefined; + const isObject = (obj) => typeof obj === "object" && obj !== null; + + /** + * @extends Model.Extension + */ + class ActionModelExtension extends Model.Extension { + + //--------------------------------------------------------------------- + // Static + //--------------------------------------------------------------------- + + /** + * @abstract + * @param {Object} archs + * @param {string | null} [viewType=null] + * @returns {null} + */ + static extractArchInfo() { + return null; + } + } + + /** + * @extends Model + */ + class ActionModel extends Model { + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * @override + */ + get(property) { + switch (property) { + case "query": return this.config.searchQuery || this._getQuery(); + case "facets": return this._getFacets(); + } + return super.get(...arguments); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @override + */ + __get(excluded, property) { + const results = super.__get(...arguments); + switch (property) { + case "domain": return [this.config.domain, ...results]; + case "context": return [this.config.context, ...results]; + } + return results; + } + + /** + * Validates and formats all facets given by the extensions. This is + * done here rather than in the search bar because the searchMenuTypes + * are available only to the model. + * @private + * @returns {Object[]} + */ + _getFacets() { + const types = this.config.searchMenuTypes || []; + const isValidType = (type) => ( + !['groupBy', 'comparison'].includes(type) || types.includes(type) + ); + const facets = []; + for (const extension of this.extensions.flat()) { + for (const facet of extension.get("facets") || []) { + if (!isValidType(facet.type)) { + continue; + } + facet.separator = facet.type === 'groupBy' ? ">" : this.env._t("or"); + if (facet.type in FACET_ICONS) { + facet.icon = FACET_ICONS[facet.type]; + } + facets.push(facet); + } + } + return facets; + } + + /** + * @typedef TimeRanges + * @property {string} fieldName + * @property {string} comparisonRangeId + * @property {Array[]} range + * @property {string} rangeDescription + * @property {Array[]} comparisonRange + * @property {string} comparisonRangeDescription + */ + /** + * @typedef Query + * @property {Object} context + * @property {Array[]} domain + * @property {string[]} groupBy + * @property {string[]} orderedBy + * @property {TimeRanges?} timeRanges + */ + /** + * @private + * @returns {Query} + */ + _getQuery() { + const evalContext = this.env.session.user_context; + const contexts = this.__get(null, "context"); + const domains = this.__get(null, "domain"); + const query = { + context: pyUtils.eval("contexts", contexts, evalContext), + domain: Domain.prototype.normalizeArray( + pyUtils.eval("domains", domains, evalContext) + ), + orderedBy: this.get("orderedBy") || [], + }; + const searchMenuTypes = this.config.searchMenuTypes || []; + if (searchMenuTypes.includes("groupBy")) { + query.groupBy = this.get("groupBy") || []; + } else { + query.groupBy = []; + } + if (searchMenuTypes.includes("comparison")) { + query.timeRanges = this.get("timeRanges") || {}; + } + return query; + } + + /** + * @override + */ + _notifyComponents() { + this.trigger("search", this.get("query")); + super._notifyComponents(...arguments); + } + + //--------------------------------------------------------------------- + // Static + //--------------------------------------------------------------------- + + /** + * @param {Object} archs + * @param {string | null} [viewType=null] + * @returns {Object} + */ + static extractArchInfo(archs, viewType = null) { + const parsedArchs = {}; + if (!archs.search) { + archs.search = ""; + } + for (const key in archs) { + const { attrs, children } = parseArch(archs[key]); + const objectChildren = children.filter(isObject); + parsedArchs[key] = { + attrs, + children: objectChildren, + }; + } + const archInfo = {}; + for (const key of this.registry.keys()) { + const extension = this.registry.get(key); + const result = extension.extractArchInfo(parsedArchs, viewType); + if (isNotNull(result)) { + archInfo[key] = result; + } + } + return archInfo; + } + } + + ActionModel.Extension = ActionModelExtension; + ActionModel.registry = new Registry(null, + (value) => value.prototype instanceof ActionModel.Extension + ); + + return ActionModel; +}); 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 f7028d93d08..e872fff811f 100644 --- a/addons/web/static/src/js/views/kanban/kanban_controller.js +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -101,7 +101,7 @@ var KanbanController = BasicController.extend({ */ _addQuickCreate(groupId) { this._removeSampleData(async () => { - await this.update({ shouldUpdateControlPanel: false }, { reload: false }); + await this.update({ shouldUpdateSearchComponents: false }, { reload: false }); return this.renderer.addQuickCreate(groupId); }); }, diff --git a/addons/web/static/src/js/views/search_panel.js b/addons/web/static/src/js/views/search_panel.js index 84c1889cc5e..bd6bda889ab 100644 --- a/addons/web/static/src/js/views/search_panel.js +++ b/addons/web/static/src/js/views/search_panel.js @@ -1,789 +1,219 @@ -odoo.define('web.SearchPanel', function (require) { -"use strict"; +odoo.define("web/static/src/js/views/search_panel.js", function (require) { + "use strict"; -/** - * This file defines the SearchPanel widget for Kanban. It allows to - * filter/manage data easily. - */ + const { Model, useModel } = require("web/static/src/js/model.js"); + const patchMixin = require("web.patchMixin"); -const core = require('web.core'); -const Domain = require('web.Domain'); -const pyUtils = require('web.py_utils'); -const { sortBy } = require('web.utils'); -const viewUtils = require('web.viewUtils'); -const Widget = require('web.Widget'); - -const 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) -const defaultViewTypes = ['kanban', 'tree']; -let nextSectionId = 1; - -const SEARCH_PANEL_DEFAULT_LIMIT = 200; - -/** - * Given a arch node, iterate over its children to generate the - * description of each section (being either a category or a filter). - * - * @param {Object} node a arch node - * @param {Object} fields the fields of the model - * @returns {Object} - */ -function _processSearchPanelNode(node, fields) { - const sections = {}; - node.children.forEach(({ attrs, tag }, index) => { - if (tag !== 'field' || attrs.invisible === "1") { - return; - } - const fieldName = attrs.name; - const type = attrs.select === 'multi' ? 'filter' : 'category'; - - const sectionId = `section_${nextSectionId++}`; - const section = { - color: attrs.color, - description: attrs.string || fields[fieldName].string, - enableCounters: !!pyUtils.py_eval(attrs.enable_counters || '0'), - expand: !!pyUtils.py_eval(attrs.expand || '0'), - fieldName, - icon: attrs.icon, - id: sectionId, - index, - limit: attrs.limit ? - pyUtils.py_eval(attrs.limit) : - SEARCH_PANEL_DEFAULT_LIMIT, - type, - }; - if (section.type === 'category') { - section.icon = section.icon || 'fa-folder'; - section.hierarchize = !!pyUtils.py_eval(attrs.hierarchize || '1'); - } else if (section.type === 'filter') { - section.domain = attrs.domain || '[]'; - section.groupBy = attrs.groupby; - section.icon = section.icon || 'fa-filter'; - } - sections[sectionId] = section; - }); - return sections; -} - -const SearchPanel = Widget.extend({ - className: 'o_search_panel', - events: { - 'click .o_search_panel_category_value header': '_onCategoryValueClicked', - 'change .o_search_panel_filter_value > header > div > input': '_onFilterValueChanged', - 'change .o_search_panel_filter_group > header > div > input': '_onFilterGroupChanged', - }, + const { Component, hooks } = owl; + const { useState, useSubEnv } = hooks; /** - * @constructor - * @param {Object} params - * @param {Object} [params.defaultValues={}] the value(s) to activate by - * default, for each filter and category - * @param {boolean} [params.defaultNoFilter=false] if true, select 'All' as - * value for each category that has no value specified in defaultValues - * (instead of looking in the localStorage for the last selected value) - * @param {Object} params.fields - * @param {string} params.model - * @param {Array[]} params.searchDomain domain coming from controlPanel - * @param {Array[]} params.viewDomain domain coming from the view (ill advised) - * @param {Object} params.sections - * @param {Object} [params.state] state exported by another searchpanel - * instance + * Search panel + * + * Represent an extension of the search interface located on the left side of + * the view. It is divided in sections defined in a "" node located + * inside of a "" arch. Each section is represented by a list of different + * values (categories or ungrouped filters) or groups of values (grouped filters). + * Its state is directly affected by its model (@see SearchPanelModelExtension). + * @extends Component */ - init(parent, params) { - this._super(...arguments); + class SearchPanel extends Component { + constructor() { + super(...arguments); - this.categories = {}; - this.filters = {}; - for (const section of Object.values(params.sections)) { - const key = section.type === 'category' ? 'categories' : 'filters'; - this[key][section.id] = section; - } + useSubEnv({ searchModel: this.props.searchModel }); - this.initialState = params.state; - this.scrollTop = this.initialState && this.initialState.scrollTop || null; - this.defaultValues = params.defaultValues || {}; - if (params.defaultNoFilter) { - Object.values(this.categories).forEach(({ fieldName }) => { - this.defaultValues[fieldName] = this.defaultValues[fieldName] || false; + this.state = useState({ + active: {}, + expanded: {}, }); - } - this.fields = params.fields; - this.model = params.model; - this.className = params.classes.concat(['o_search_panel']).join(' '); - this.internalChangeOccurred = false; - this.searchDomain = params.searchDomain; - this.viewDomain = params.viewDomain; - }, - /** - * @override - */ - async willStart() { - const _super = this._super; - if (this.initialState) { - this.filters = this.initialState.filters; - this.categories = this.initialState.categories; - } else { - await this._fetchCategories({ initialLoad: true }); - await this._fetchFilters(); - await this._applyDefaultFilterValues(); - } - return _super.call(this, ...arguments); - }, - /** - * @override - */ - start() { - this._render(); - return this._super(...arguments); - }, - /** - * Called each time the searchPanel is attached into the DOM. - */ - on_attach_callback() { - if (this.scrollTop !== null) { - this.el.scrollTop = this.scrollTop; - } - }, + this.model = useModel("searchModel"); + this.scrollTop = 0; + this.hasImportedState = false; - //------------------------------------------------------------------------- - // 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 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 - * @param {Object} viewInfo the viewInfo of a search view - * @param {string} viewInfo.arch - * @param {Object} viewInfo.fields - * @param {string} viewType the type of the current view (e.g. 'kanban') - * @returns {Object} - */ - computeSearchPanelParams(arch, fields, viewType) { - const searchPanelParams = {}; - if (arch && fields) { - const parsedArch = viewUtils.parseArch(arch); - const searchPanelNode = parsedArch.children.find(child => child.tag === 'searchpanel'); - if (searchPanelNode) { - const { attrs } = searchPanelNode; - const type = viewType === 'list' ? 'tree' : viewType; - let viewTypes = defaultViewTypes; - if (attrs.view_types) { - viewTypes = attrs.view_types.split(','); - } - if (attrs.class) { - searchPanelParams.classes = attrs.class.split(' '); - } - if (viewTypes.includes(type)) { - searchPanelParams.sections = _processSearchPanelNode(searchPanelNode, fields); - } - } - } - return searchPanelParams; - }, - /** - * Export the current state (categories and filters) of the searchpanel. - * - * @returns {Object} - */ - exportState() { - return { - categories: this.categories, - filters: this.filters, - scrollTop: this.el ? this.el.scrollTop : null, - }; - }, - /** - * @returns {Array[]} the current searchPanel domain based on active - * categories and checked filters - */ - getDomain() { - 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(state) { - this.categories = state.categories || this.categories; - this.filters = state.filters || this.filters; - this.scrollTop = state.scrollTop; - this._render(); - }, - /** - * Reload the filters and categories and re-render. Note that we only reload them - * if the controlPanel domain or searchPanel domain has changed. - * - * @param {Object} params - * @param {Array[]} params.searchDomain domain coming from controlPanel - * @param {Array[]} params.viewDomain domain coming from view - * @returns {Promise} - */ - async update(params) { - const currentDomain = JSON.stringify([...this.searchDomain, ...this.viewDomain]); - const newDomain = JSON.stringify([...params.searchDomain, ...params.viewDomain]); - const externalDomainUpdated = currentDomain !== newDomain; - if (this.internalChangeOccurred || externalDomainUpdated) { - this.searchDomain = params.searchDomain; - this.viewDomain = params.viewDomain; - await this._fetchCategories({ externalDomainUpdated }); - await this._fetchFilters(); - this.internalChangeOccurred = false; - } - return this._render(); - }, - - //------------------------------------------------------------------------- - // Private - //------------------------------------------------------------------------- - - /** - * Set active values for each filter (coming from context). This needs to be - * done only once, at widget startup. - * - * @private - */ - _applyDefaultFilterValues() { - Object.values(this.filters).forEach(filter => { - const defaultValues = this.defaultValues[filter.fieldName] || []; - defaultValues.forEach(value => { - if (filter.values[value]) { - filter.values[value].checked = true; - } - }); - Object.values(filter.groups || []).forEach(this._updateFilterGroupState); - }); - }, - /** - * @private - * @param {string} categoryId - * @param {Object} result - */ - _createCategoryTree(categoryId, result) { - const category = this.categories[categoryId]; - let { error_msg, parent_field: parentField, values } = result; - if (error_msg) { - category.errorMsg = error_msg; - values = []; - } - if (category.hierarchize) { - category.parentField = parentField; + this.importState(this.props.importedState); } - const unfoldedIds = Object.values(category.values || {}) - .filter(c => c.folded === false) - .map(c => c.id); + async willStart() { + this._expandDefaultValue(); + this._updateActiveValues(); + } - category.values = {}; - values.forEach(value => { - category.values[value.id] = Object.assign({}, value, { - childrenIds: [], - folded: !unfoldedIds.includes(value.id), - parentId: value[parentField] || false, - }); - }); - values.forEach(value => { - const { parentId: parentCategoryId } = category.values[value.id]; - if (parentCategoryId && parentCategoryId in category.values) { - category.values[parentCategoryId].childrenIds.push(value.id); - } - }); - category.rootIds = []; - for (const value of values) { - const { parentId } = category.values[value.id]; - if (!parentId) { - category.rootIds.push(value.id); + mounted() { + this._updateGroupHeadersChecked(); + if (this.hasImportedState) { + this.el.scroll({ top: this.scrollTop }); } } - // set active value - const validValues = [...Object.values(category.values).map(v => v.id), false]; - const value = this._getCategoryDefaultValue(category, validValues); - category.activeValueId = validValues.includes(value) ? value : false; - - // unfold ancestor values of active value to make it is visible - if (category.activeValueId) { - const parentValueIds = this._getAncestorValueIds(category, category.activeValueId); - parentValueIds.forEach(parentValue => { - category.values[parentValue].folded = false; - }); - } - }, - /** - * @private - * @param {string} filterId - * @param {Object} result - */ - _createFilterTree(filterId, result) { - const filter = this.filters[filterId]; - let { error_msg, values } = result; - if (error_msg) { - filter.errorMsg = error_msg; - values = []; + async willUpdateProps() { + this._updateActiveValues(); } - // restore checked property - values.forEach(value => { - const oldValue = filter.values && filter.values[value.id]; - value.checked = oldValue && oldValue.checked || false; - }); + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- - filter.values = {}; - const groupIds = []; - if (filter.groupBy) { - const groups = {}; - values.forEach(value => { - const groupId = JSON.stringify(value.group_id); - if (!groups[groupId]) { - if (groupId !== 'false') { - groupIds.push(groupId); - } - groups[groupId] = { - folded: false, - id: groupId, - name: value.group_name, - values: {}, - tooltip: value.group_tooltip, - sequence: value.group_sequence, - hex_color: value.group_hex_color, - sortedValueIds: [], - }; - // restore former checked and folded state - const oldGroup = filter.groups && filter.groups[groupId]; - groups[groupId].state = oldGroup && oldGroup.state || false; - groups[groupId].folded = oldGroup && oldGroup.folded || false; - } - groups[groupId].values[value.id] = value; - groups[groupId].sortedValueIds.push(value.id); - }); - filter.groups = groups; - filter.sortedGroupIds = sortBy(groupIds, - id => groups[id].sequence || groups[id].name - ); - Object.values(filter.groups).forEach(group => { - Object.assign(filter.values, group.values); - }); - } else { - values.forEach(value => { - filter.values[value.id] = value; - }); - filter.sortedValueIds = values.map(value => value.id); + exportState() { + const exported = { + expanded: this.state.expanded, + scrollTop: this.el.scrollTop, + }; + return JSON.stringify(exported); } - }, - /** - * Fetch values for each category at startup. At reload a category is only - * fetched if the situation requires it. - * - * @private - * @param {Object} [options={}] - * @param {boolean} [options.initialLoad] - * @param {boolean} [options.externalDomainUpdated] - * @returns {Promise} resolved when all categories have been fetched - */ - _fetchCategories(options = {}) { - const proms = []; - for (const category of Object.values(this.categories)) { - const { enableCounters, expand, hierarchize, limit } = category; - if (options.initialLoad || enableCounters || (options.externalDomainUpdated && !expand)) { - const prom = this._rpc({ - method: 'search_panel_select_range', - model: this.model, - args: [category.fieldName], - kwargs: { - category_domain: this._getCategoryDomain(category.id), - enable_counters: enableCounters, - expand, - filter_domain: this._getFilterDomain(), - hierarchize, - limit, - search_domain: this.searchDomain, - }, - }).then( - (result) => this._createCategoryTree(category.id, result) - ); - proms.push(prom); - } - } - return Promise.all(proms); - }, - /** - * Fetch values for each filter. This is done at startup, and at each reload - * (when the controlPanel or searchPanel domain changes). - * - * @private - * @returns {Promise} resolved when all filters have been fetched - */ - _fetchFilters() { - const evalContext = {}; - for (const category of Object.values(this.categories)) { - evalContext[category.fieldName] = category.activeValueId; - } - const categoryDomain = this._getCategoryDomain(); - const proms = []; - for (const filter of Object.values(this.filters)) { - const { enableCounters, expand, groupBy, limit } = filter; - const prom = this._rpc({ - method: 'search_panel_select_multi_range', - model: this.model, - args: [filter.fieldName], - kwargs: { - category_domain: categoryDomain, - comodel_domain: Domain.prototype.stringToArray(filter.domain, evalContext), - enable_counters: enableCounters, - filter_domain: this._getFilterDomain(filter.id), - expand, - group_by: groupBy || false, - group_domain: this._getGroupDomain(filter), - limit, - search_domain: [...this.searchDomain, ...this.viewDomain], - }, - }).then( - (result) => this._createFilterTree(filter.id, result) - ); - proms.push(prom); - } - return Promise.all(proms); - }, - /** - * @private - * @param {Object} category - * @param {Array} validValues - * @returns {(number|boolean)} id of the default item of the category or false - */ - _getCategoryDefaultValue(category, validValues) { - // set active value from context - const value = this.defaultValues[category.fieldName]; - // if not set in context, or set to an unknown value, set active value - // from localStorage - if (!validValues.includes(value)) { - const storageKey = this._getLocalStorageKey(category); - return this.call('local_storage', 'getItem', storageKey); - } - return value; - }, - /** - * Compute and return the domain based on the current active categories. - * - * @private - * @param {string} [excludedCategoryId] - * @returns {Array[]} - */ - _getCategoryDomain(excludedCategoryId) { - const domain = []; - for (const category of Object.values(this.categories)) { - if (category.id === excludedCategoryId) { - continue; - } - if (category.activeValueId) { - const field = this.fields[category.fieldName]; - const op = (field.type === 'many2one' && category.parentField) ? 'child_of' : '='; - domain.push([category.fieldName, op, category.activeValueId]); - } - } - return domain; - }, - /** - * Compute and return the domain based on the current checked filters. - * The values of a single filter are combined using a simple rule: checked values within - * a same group are combined with an 'OR' (this is expressed as single condition using a list) - * and groups are combined with an 'AND' (expressed by concatenation of conditions). - * If a filter has no groups, its checked values are implicitely considered as forming - * a group (and grouped using an 'OR'). - * - * @private - * @param {string} [excludedFilterId] - * @returns {Array[]} - */ - _getFilterDomain(excludedFilterId) { - const domain = []; - function addCondition(fieldName, checkedValues) { - if (checkedValues.length) { - const ids = checkedValues.map(v => v.id); - domain.push([fieldName, 'in', ids]); + importState(stringifiedState) { + this.hasImportedState = Boolean(stringifiedState); + if (this.hasImportedState) { + const state = JSON.parse(stringifiedState); + this.state.expanded = state.expanded; + this.scrollTop = state.scrollTop; } } - for (const filter of Object.values(this.filters)) { - if (filter.id === excludedFilterId) { - continue; - } - const { fieldName } = filter; - let checkedValues; - if (filter.groups) { - for (const group of Object.values(filter.groups)) { - checkedValues = Object.values(group.values).filter(v => v.checked); - addCondition(fieldName, checkedValues); - } - } else if (filter.values) { - checkedValues = Object.values(filter.values).filter(v => v.checked); - addCondition(fieldName, checkedValues); - } - } - return domain; - }, - /** - * Returns a domain or an object of domains used to complement - * the filter domains to accurately describe the constraints on - * records when computing record counts associated with the filter - * values (if a groupBy is provided). The idea is that the checked values - * within a group should not impact the counts for the other values - * in the same group. - * - * @private - * @param {string} filter - * @returns {(Array{}|Array[]|undefined)} - */ - _getGroupDomain(filter) { - const { fieldName, groups, enableCounters } = filter; - const { type: fieldType } = this.fields[fieldName]; + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- - if (!enableCounters || !groups) { - switch (fieldType) { - case 'many2one': return []; - case 'many2many': return {}; - default: return; + /** + * Expands category values holding the default value of a category. + * @private + */ + _expandDefaultValue() { + if (this.hasImportedState) { + return; } - } - - let groupDomain; - if (fieldType === 'many2one') { - for (const group of Object.values(groups)) { - const valueIds = []; - let active = false; - for (const value of Object.values(group.values || {})) { - const { id, checked } = value; - valueIds.push(id); - if (checked) { - active = true; - } - } - if (active) { - if (groupDomain) { - groupDomain = Domain.FALSE_DOMAIN; - break; - } else { - groupDomain = [[fieldName, 'in', valueIds]]; - } - } - } - } else if (fieldType === 'many2many') { - const checkedValueIds = {}; - for (const group of Object.values(groups)) { - for (const value of Object.values(group.values || {})) { - const { id, checked } = value; - if (checked) { - if (!checkedValueIds[group.id]) { - checkedValueIds[group.id] = []; - } - checkedValueIds[group.id].push(id); - } - } - } - groupDomain = {}; - for (const gId in checkedValueIds) { - const ids = checkedValueIds[gId]; - for (const group of Object.values(groups)) { - if (gId !== group.id) { - if (!groupDomain[group.id]) { - groupDomain[group.id] = []; - } - groupDomain[group.id].push( - [fieldName, 'in', ids] - ); + const categories = this.model.get("sections", s => s.type === "category"); + for (const category of categories) { + this.state.expanded[category.id] = {}; + if (category.activeValueId) { + const ancestorIds = this._getAncestorValueIds(category, category.activeValueId); + for (const ancestorId of ancestorIds) { + this.state.expanded[category.id][ancestorId] = true; } } } } - return groupDomain; - }, - /** - * The active id of each category is stored in the localStorage, s.t. it - * can be restored afterwards (when the action is reloaded, for instance). - * This function returns the key in the sessionStorage for a given category. - * - * @param {Object} category - * @returns {string} - */ - _getLocalStorageKey(category) { - return 'searchpanel_' + this.model + '_' + category.fieldName; - }, - /** - * @private - * @param {Object} category - * @param {number} categoryValueId - * @returns {number[]} list of ids of the ancestors of the given value in - * the given category - */ - _getAncestorValueIds(category, categoryValueId) { - const { parentId } = category.values[categoryValueId]; - if (parentId) { - return [parentId].concat(this._getAncestorValueIds(category, parentId)); + + /** + * @private + * @param {Object} category + * @param {number} categoryValueId + * @returns {number[]} list of ids of the ancestors of the given value in + * the given category. + */ + _getAncestorValueIds(category, categoryValueId) { + const { parentId } = category.values.get(categoryValueId); + return parentId ? [...this._getAncestorValueIds(category, parentId), parentId] : []; } - return []; - }, - /** - * Compute the current searchPanel domain based on categories and filters, - * and notify environment of the domain change. - * - * Note that this assumes that the environment will update the searchPanel. - * This is done as such to ensure the coordination between the reloading of - * the searchPanel and the reloading of the data. - * - * @private - */ - _notifyDomainUpdated() { - this.internalChangeOccurred = true; - this.trigger_up('search_panel_domain_updated', { - domain: this.getDomain(), - }); - }, - /** - * @private - */ - _render() { - this.$el.empty(); - // sort categories and filters according to their index - const categories = Object.values(this.categories); - const filters = Object.values(this.filters); - const sections = categories.concat(filters).sort((s1, s2) => s1.index - s2.index); - - sections.forEach(section => { - if (Object.keys(section.values).length || section.errorMsg) { - if (section.type === 'category') { - this.$el.append(this._renderCategory(section)); + /** + * Prevent unnecessary calls to the model by ensuring a different category + * is clicked. + * @private + * @param {Object} category + * @param {Object} value + */ + async _toggleCategory(category, value) { + if (value.childrenIds.length) { + const categoryState = this.state.expanded[category.id]; + if (categoryState[value.id] && category.activeValueId === value.id) { + delete categoryState[value.id]; } else { - this.$el.append(this._renderFilter(section)); + categoryState[value.id] = true; } } - }); - }, - /** - * @private - * @param {Object} category - * @returns {string} - */ - _renderCategory(category) { - return qweb.render('SearchPanel.Category', { category }); - }, - /** - * @private - * @param {Object} filter - * @returns {HTMLElement} - */ - _renderFilter(filter) { - const filterElement = $(qweb.render('SearchPanel.Filter', { filter }))[0]; - - // set group inputs in indeterminate state when necessary - Object.keys(filter.groups || {}).forEach(groupId => { - const group = filter.groups[groupId]; - const { state } = group; - // group 'false' is not displayed - if (groupId !== 'false' && state === 'indeterminate') { - const sel = `.o_search_panel_filter_group[data-group-id="${groupId}"] input`; - filterElement.querySelector(sel).indeterminate = true; + if (category.activeValueId !== value.id) { + this.state.active[category.id] = value.id; + this.model.dispatch("toggleCategoryValue", category.id, value.id); } - }); + } - return filterElement; - }, - /** - * Updates the state property of a given filter's group according to the - * checked property of its values. - * - * @private - * @param {Object} group - */ - _updateFilterGroupState(group) { - if (Object.values(group.values).some(v => v.checked)) { - if (Object.values(group.values).some(v => !v.checked)) { - group.state = 'indeterminate'; - } else { - group.state = 'checked'; + /** + * @private + * @param {number} filterId + * @param {{ values: Map }} group + */ + _toggleFilterGroup(filterId, { values }) { + const valueIds = []; + const checked = [...values.values()].every( + (value) => this.state.active[filterId][value.id] + ); + values.forEach(({ id }) => { + valueIds.push(id); + this.state.active[filterId][id] = !checked; + }); + this.model.dispatch("toggleFilterValues", filterId, valueIds, !checked); + } + + /** + * @private + * @param {number} filterId + * @param {Object} [group] + * @param {number} valueId + * @param {MouseEvent} ev + */ + _toggleFilterValue(filterId, valueId, { currentTarget }) { + this.state.active[filterId][valueId] = currentTarget.checked; + this._updateGroupHeadersChecked(); + this.model.dispatch("toggleFilterValues", filterId, [valueId]); + } + + _updateActiveValues() { + for (const section of this.model.get("sections")) { + if (section.type === "category") { + this.state.active[section.id] = section.activeValueId; + } else { + this.state.active[section.id] = {}; + if (section.groups) { + for (const group of section.groups.values()) { + for (const value of group.values.values()) { + this.state.active[section.id][value.id] = value.checked; + } + } + } + for (const value of section.values.values()) { + this.state.active[section.id][value.id] = value.checked; + } + } } - } else { - group.state = 'unchecked'; } - }, - //------------------------------------------------------------------------- - // Handlers - //------------------------------------------------------------------------- - - /** - * @private - * @param {MouseEvent} ev - */ - _onCategoryValueClicked(ev) { - ev.stopPropagation(); - const item = ev.currentTarget.closest('.o_search_panel_category_value'); - const category = this.categories[item.dataset.categoryId]; - const valueId = !isNaN(item.dataset.id) ? - Number(item.dataset.id) : - item.dataset.id || false; - const hasChanged = category.activeValueId !== valueId; - category.activeValueId = valueId; - const value = category.values[category.activeValueId]; - if (value) { - value.folded = value.folded ? false : !hasChanged; - } - if (hasChanged) { - const storageKey = this._getLocalStorageKey(category); - this.call('local_storage', 'setItem', storageKey, valueId); - this._notifyDomainUpdated(); - } else { - this._render(); - } - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onFilterGroupChanged(ev) { - ev.stopPropagation(); - const item = ev.target.closest('.o_search_panel_filter_group'); - const filter = this.filters[item.dataset.filterId]; - const group = filter.groups[item.dataset.groupId]; - group.state = group.state === 'checked' ? 'unchecked' : 'checked'; - Object.values(group.values).forEach(value => { - value.checked = group.state === 'checked'; - }); - this._notifyDomainUpdated(); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onFilterValueChanged(ev) { - ev.stopPropagation(); - const item = ev.target.closest('.o_search_panel_filter_value'); - const filter = this.filters[item.dataset.filterId]; - const value = filter.values[item.dataset.valueId]; - value.checked = !value.checked; - const groupId = JSON.stringify(value.group_id); - const group = filter.groups && filter.groups[groupId]; - if (group) { - this._updateFilterGroupState(group); - } - this._notifyDomainUpdated(); - }, -}); - -return SearchPanel; + /** + * Updates the "checked" or "indeterminate" state of each of the group + * headers according to the state of their values. + * @private + */ + _updateGroupHeadersChecked() { + const groups = this.el.querySelectorAll(":scope .o_search_panel_filter_group"); + for (const group of groups) { + const header = group.querySelector(":scope .o_search_panel_group_header input"); + const vals = [...group.querySelectorAll(":scope .o_search_panel_filter_value input")]; + header.checked = false; + header.indeterminate = false; + if (vals.every((v) => v.checked)) { + header.checked = true; + } else if (vals.some((v) => v.checked)) { + header.indeterminate = true; + } + } + } + } + SearchPanel.modelExtension = "SearchPanel"; + SearchPanel.props = { + className: { type: String, optional: 1 }, + importedState: { + type: Object, + optional: true, + shape: { + scrollTop: { type: Number, optional: 1 }, + expanded: { type: Object, optional: 1 }, + }, + }, + searchModel: Model, + }; + SearchPanel.template = "web.SearchPanel"; + + return patchMixin(SearchPanel); }); diff --git a/addons/web/static/src/js/views/search_panel_model_extension.js b/addons/web/static/src/js/views/search_panel_model_extension.js new file mode 100644 index 00000000000..3641c425ba7 --- /dev/null +++ b/addons/web/static/src/js/views/search_panel_model_extension.js @@ -0,0 +1,730 @@ +odoo.define("web/static/src/js/views/search_panel_model_extension.js", function (require) { + "use strict"; + + const ActionModel = require("web/static/src/js/views/action_model.js"); + const { sortBy } = require("web.utils"); + const Domain = require("web.Domain"); + const pyUtils = require("web.py_utils"); + + // 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). + const DEFAULT_VIEW_TYPES = ["kanban", "tree"]; + const DEFAULT_LIMIT = 200; + let nextSectionId = 1; + + /** + * @param {Section} section + * @returns {boolean} + */ + function hasValues({ errorMsg, groups, type, values }) { + if (errorMsg) { + return true; + } else if (groups) { + return [...groups.values()].some((g) => g.values.size); + } else if (type === "category") { + return values && values.size > 1; // false item ignored + } else { + return values && values.size > 0; + } + } + + /** + * @typedef Section + * @prop {string} color + * @prop {string} description + * @prop {boolean} enableCounters + * @prop {boolean} expand + * @prop {string} fieldName + * @prop {string} icon + * @prop {number} id + * @prop {number} index + * @prop {number} limit + * @prop {string} type + */ + + /** + * @typedef {Section} Category + * @prop {boolean} hierarchize + */ + + /** + * @typedef {Section} Filter + * @prop {string} domain + * @prop {string} groupBy + */ + + /** + * @function sectionPredicate + * @param {Section} section + * @returns {boolean} + */ + + /** + * @property {{ sections: Map }} state + * @extends ActionModel.Extension + */ + class SearchPanelModelExtension extends ActionModel.Extension { + constructor() { + super(...arguments); + + this.searchDomain = null; + this.DEFAULT_VALUE_INDEX = 0; + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * @override + */ + exportState() { + const state = super.exportState(); + state.sections = [...state.sections]; + for (const [id, section] of state.sections) { + section.values = [...section.values]; + if (section.groups) { + section.groups = [...section.groups]; + for (const [id, group] of section.groups) { + group.values = [...group.values]; + } + } + } + return state; + } + + /** + * @override + * @returns {any} + */ + get(property, ...args) { + switch (property) { + case "domain": return this.getDomain(); + case "sections": return this.getSections(...args); + } + } + + /** + * @override + */ + importState(importedState) { + super.importState(...arguments); + if (importedState) { + this.state.sections = new Map(this.state.sections); + for (const section of this.state.sections.values()) { + section.values = new Map(section.values); + if (section.groups) { + section.groups = new Map(section.groups); + for (const group of section.groups.values()) { + group.values = new Map(group.values); + } + } + } + } + } + + /** + * @override + */ + async load() { + await this._fetchSections({ initialLoad: true }); + } + + /** + * @override + */ + prepareState() { + Object.assign(this.state, { sections: new Map() }); + this._createSectionsFromArch(); + } + + /** + * @override + */ + async reloadAfterDispatch() { + await this._fetchSections(); + } + + //--------------------------------------------------------------------- + // Actions / Getters + //--------------------------------------------------------------------- + + /** + * Returns the concatenation of the category domain ad the filter + * domain. + * @returns {Array[]} + */ + getDomain() { + return Domain.prototype.normalizeArray([ + ...this._getCategoryDomain(), + ...this._getFilterDomain(), + ]); + } + + /** + * Returns a sorted list of a copy of all sections. This list can be + * filtered by a given predicate. + * @param {sectionPredicate} [predicate] used to determine + * which subsets of sections is wanted + * @returns {Section[]} + */ + getSections(predicate) { + let sections = [...this.state.sections.values()].map((section) => + Object.assign({}, section, { empty: !hasValues(section) }) + ); + if (predicate) { + sections = sections.filter(predicate); + } + return sections.sort((s1, s2) => s1.index - s2.index); + } + + /** + * Sets the active value id of a given category. This will also update + * the local storage value. + * @param {number} sectionId + * @param {number} valueId + */ + toggleCategoryValue(sectionId, valueId) { + const category = this.state.sections.get(sectionId); + category.activeValueId = valueId; + const storageKey = `searchpanel_${this.config.modelName}_${category.fieldName}`; + this.env.services.local_storage.setItem(storageKey, valueId); + // FIXME this should be removed (in another commit): should no longer (re)store + // active category in localStorage + } + + /** + * Toggles a the filter value of a given section. The value will be set + * to "forceTo" if provided, else it will be its own opposed value. + * @param {number} sectionId + * @param {number[]} valueIds + * @param {boolean} [forceTo=null] + */ + toggleFilterValues(sectionId, valueIds, forceTo = null) { + const section = this.state.sections.get(sectionId); + for (const valueId of valueIds) { + const value = section.values.get(valueId); + value.checked = forceTo === null ? !value.checked : forceTo; + } + } + + //--------------------------------------------------------------------- + // Internal getters + //--------------------------------------------------------------------- + + /** + * Shorthand access to sections of type "category". + * @returns {Category[]} + */ + get categories() { + return [...this.state.sections.values()].filter(s => s.type === "category"); + } + + /** + * Shorthand access to sections of type "filter". + * @returns {Filter[]} + */ + get filters() { + return [...this.state.sections.values()].filter(s => s.type === "filter"); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Sets active values for each filter (coming from context). This needs + * to be done only once at initialization. + * @private + */ + _applyDefaultFilterValues() { + for (const { fieldName, values } of this.filters) { + const defaultValues = this.config.defaultValues[fieldName] || []; + for (const valueId of defaultValues) { + const value = values.get(valueId); + if (value) { + value.checked = true; + } + } + } + } + + /** + * @private + * @param {string} sectionId + * @param {Object} result + */ + _createCategoryTree(sectionId, result) { + const category = this.state.sections.get(sectionId); + + let { error_msg, parent_field: parentField, values, } = result; + if (error_msg) { + category.errorMsg = error_msg; + values = []; + } + if (category.hierarchize) { + category.parentField = parentField; + } + + const allItem = { + childrenIds: [], + display_name: this.env._t("All"), + id: false, + bold: true, + parentId: false, + }; + category.values = new Map([[false, allItem]]); + for (const value of values) { + category.values.set( + value.id, + Object.assign({}, value, { + childrenIds: [], + parentId: value[parentField] || false, + }) + ); + } + for (const value of values) { + const { parentId } = category.values.get(value.id); + if (parentId && category.values.has(parentId)) { + category.values.get(parentId).childrenIds.push(value.id); + } + } + // collect rootIds + category.rootIds = [false]; + for (const value of values) { + const { parentId } = category.values.get(value.id); + if (!parentId) { + category.rootIds.push(value.id); + } + } + // Set active value from context + category.activeValueId = this._getCategoryDefaultValue(values, category); + } + + /** + * @private + * @param {string} sectionId + * @param {Object} result + */ + _createFilterTree(sectionId, result) { + const filter = this.state.sections.get(sectionId); + + let { error_msg, values, } = result; + if (error_msg) { + filter.errorMsg = error_msg; + values = []; + } + + // restore checked property + values.forEach((value) => { + const oldValue = filter.values && filter.values.get(value.id); + value.checked = (oldValue && oldValue.checked) || false; + }); + + filter.values = new Map(); + const groupIds = []; + if (filter.groupBy) { + const groups = new Map(); + for (const value of values) { + const groupId = value.group_id; + if (!groups.has(groupId)) { + if (groupId) { + groupIds.push(groupId); + } + groups.set(groupId, { + id: groupId, + name: value.group_name, + values: new Map(), + tooltip: value.group_tooltip, + sequence: value.group_sequence, + hex_color: value.group_hex_color, + }); + // restore former checked state + const oldGroup = + filter.groups && filter.groups.get(groupId); + groups.get(groupId).state = + (oldGroup && oldGroup.state) || false; + } + groups.get(groupId).values.set(value.id, value); + } + filter.groups = groups; + filter.sortedGroupIds = sortBy( + groupIds, + (id) => groups.get(id).sequence || groups.get(id).name + ); + for (const group of filter.groups.values()) { + for (const [valueId, value] of group.values) { + filter.values.set(valueId, value); + } + } + } else { + for (const value of values) { + filter.values.set(value.id, value); + } + } + } + + /** + * Adds a section in this.state.sections for each visible field found + * in the search panel arch. + * @private + */ + _createSectionsFromArch() { + this.config.archNodes.forEach(({ attrs, tag }, index) => { + if (tag !== "field" || attrs.invisible === "1") { + return; + } + const type = attrs.select === "multi" ? "filter" : "category"; + const section = { + color: attrs.color, + description: + attrs.string || this.config.fields[attrs.name].string, + enableCounters: !!pyUtils.py_eval( + attrs.enable_counters || "0" + ), + expand: !!pyUtils.py_eval(attrs.expand || "0"), + fieldName: attrs.name, + icon: attrs.icon, + id: nextSectionId++, + index, + limit: pyUtils.py_eval(attrs.limit || String(DEFAULT_LIMIT)), + type, + }; + if (type === "category") { + section.icon = section.icon || "fa-folder"; + section.hierarchize = !!pyUtils.py_eval( + attrs.hierarchize || "1" + ); + } else { + section.domain = attrs.domain || "[]"; + section.groupBy = attrs.groupby; + section.icon = section.icon || "fa-filter"; + } + this.state.sections.set(section.id, section); + }); + } + + /** + * Fetches values for each category at startup. At reload a category is + * only fetched if needed. + * @private + * @param {{ + * initialLoad: boolean, + * searchDomainHasChanged: boolean + * }} options + * @returns {Promise} resolved when all categories have been fetched + */ + async _fetchCategories({ initialLoad, searchDomainHasChanged }) { + const proms = []; + for (const category of this.categories) { + const { enableCounters, expand, hierarchize, limit } = category; + if (initialLoad || enableCounters || (searchDomainHasChanged && !expand)) { + const prom = this.env.services.rpc({ + method: "search_panel_select_range", + model: this.config.modelName, + args: [category.fieldName], + kwargs: { + category_domain: this._getCategoryDomain( + category.id + ), + enable_counters: enableCounters, + expand, + filter_domain: this._getFilterDomain(), + hierarchize, + limit, + search_domain: this.searchDomain, + }, + }).then(result => this._createCategoryTree(category.id, result)); + proms.push(prom); + } + } + await Promise.all(proms); + } + + /** + * Fetches values for each filter. This is done at startup and at each + * reload if needed. + * @private + * @param {{ + * initialLoad: boolean, + * searchDomainHasChanged: boolean + * }} options + * @returns {Promise} resolved when all filters have been fetched + */ + async _fetchFilters({ initialLoad, searchDomainHasChanged }) { + const evalContext = {}; + for (const category of this.categories) { + evalContext[category.fieldName] = category.activeValueId; + } + const categoryDomain = this._getCategoryDomain(); + const proms = []; + for (const filter of this.filters) { + const { enableCounters, expand, groupBy, limit } = filter; + if (initialLoad || enableCounters || (searchDomainHasChanged && !expand)) { + const prom = this.env.services.rpc({ + method: "search_panel_select_multi_range", + model: this.config.modelName, + args: [filter.fieldName], + kwargs: { + category_domain: categoryDomain, + comodel_domain: Domain.prototype.stringToArray( + filter.domain, + evalContext + ), + enable_counters: enableCounters, + filter_domain: this._getFilterDomain(filter.id), + expand, + group_by: groupBy || false, + group_domain: this._getGroupDomain(filter), + limit, + search_domain: this.searchDomain, + }, + }).then(result => this._createFilterTree(filter.id, result)); + proms.push(prom); + } + } + await Promise.all(proms); + } + + /** + * Fetches categories and filters at startup or if needed. + * @private + * @param {Object} [options={}] + * @param {boolean} [options.initialLoad=false] + */ + async _fetchSections(options = {}) { + const searchDomain = this._getExternalDomain(); + if (!options.initialLoad) { + options.searchDomainHasChanged = + JSON.stringify(this.searchDomain) !== JSON.stringify(searchDomain); + } + this.searchDomain = searchDomain; + await this._fetchCategories(options); + await this._fetchFilters(options); + if (options.initialLoad) { + this._applyDefaultFilterValues(); + } + } + + /** + * Computes and returns the domain based on the current active + * categories. If "excludedCategoryId" is provided, the category with + * that id is not taken into account in the domain computation. + * @private + * @param {string} [excludedCategoryId] + * @returns {Array[]} + */ + _getCategoryDomain(excludedCategoryId) { + const domain = []; + for (const category of this.categories) { + if ( + category.id === excludedCategoryId || + category.activeValueId === false + ) { + continue; + } + if (category.activeValueId) { + const field = this.config.fields[category.fieldName]; + const operator = + field.type === "many2one" && category.parentField ? "child_of" : "="; + domain.push([ + category.fieldName, + operator, + category.activeValueId, + ]); + } + } + return domain; + } + + /** + * @private + * @param {Object[]} values + * @param {Category} category + * @returns {number | false} + */ + _getCategoryDefaultValue(values, { fieldName }) { + const validValues = [false, ...values.map((val) => val.id)]; + const defaultValue = this.config.defaultValues[fieldName]; + if (validValues.includes(defaultValue)) { + return defaultValue; + } + if (this.config.defaultNoFilter) { + return false; + } + // If not set in context, or set to an unknown value, set active value + // from localStorage + const storageKey = `searchpanel_${this.config.modelName}_${fieldName}`; + const storageValue = this.env.services.local_storage.getItem(storageKey); + if (validValues.includes(storageValue)) { + return storageValue; + } + // If still not a valid value, get the search panel default value + // from the given valid values. + return validValues[ + Math.min(validValues.length - 1, this.DEFAULT_VALUE_INDEX) + ]; + } + + /** + * Returns the domain retrieved from the other model extensions. + * @private + * @returns {Array[]} + */ + _getExternalDomain() { + const domains = this.config.get("domain"); + const domain = domains.reduce((acc, dom) => [...acc, ...dom], []); + return Domain.prototype.normalizeArray(domain); + } + + /** + * Computes and returns the domain based on the current checked + * filters. The values of a single filter are combined using a simple + * rule: checked values within a same group are combined with an "OR" + * operator (this is expressed as single condition using a list) and + * groups are combined with an "AND" operator (expressed by + * concatenation of conditions). + * If a filter has no group, its checked values are implicitely + * considered as forming a group (and grouped using an "OR"). + * If excludedFilterId is provided, the filter with that id is not + * taken into account in the domain computation. + * @private + * @param {string} [excludedFilterId] + * @returns {Array[]} + */ + _getFilterDomain(excludedFilterId) { + const domain = []; + + function addCondition(fieldName, valueMap) { + const ids = []; + for (const [valueId, value] of valueMap) { + if (value.checked) { + ids.push(valueId); + } + } + if (ids.length) { + domain.push([fieldName, "in", ids]); + } + } + + for (const filter of this.filters) { + if (filter.id === excludedFilterId) { + continue; + } + const { fieldName, groups, values } = filter; + if (groups) { + for (const group of groups.values()) { + addCondition(fieldName, group.values); + } + } else if (values) { + addCondition(fieldName, values); + } + } + return domain; + } + + /** + * Returns a domain or an object of domains used to complement + * the filter domains to accurately describe the constrains on + * records when computing record counts associated to the filter + * values (if a groupBy is provided). The idea is that the checked + * values within a group should not impact the counts for the other + * values in the same group. + * @private + * @param {string} filter + * @returns {Object | Array[] | null} + */ + _getGroupDomain(filter) { + const { fieldName, groups, enableCounters } = filter; + const { type: fieldType } = this.config.fields[fieldName]; + + if (!enableCounters || !groups) { + return { + many2one: [], + many2many: {}, + }[fieldType]; + } + let groupDomain = null; + if (fieldType === "many2one") { + for (const group of groups.values()) { + const valueIds = []; + let active = false; + for (const [valueId, value] of group.values) { + const { checked } = value; + valueIds.push(valueId); + if (checked) { + active = true; + } + } + if (active) { + if (groupDomain) { + groupDomain = [[0, "=", 1]]; + break; + } else { + groupDomain = [[fieldName, "in", valueIds]]; + } + } + } + } else if (fieldType === "many2many") { + const checkedValueIds = new Map(); + groups.forEach(({ values }, groupId) => { + values.forEach(({ checked }, valueId) => { + if (checked) { + if (!checkedValueIds.has(groupId)) { + checkedValueIds.set(groupId, []); + } + checkedValueIds.get(groupId).push(valueId); + } + }); + }); + groupDomain = {}; + for (const [gId, ids] of checkedValueIds.entries()) { + for (const groupId of groups.keys()) { + if (gId !== groupId) { + const key = JSON.stringify(groupId); + if (!groupDomain[key]) { + groupDomain[key] = []; + } + groupDomain[key].push([fieldName, "in", ids]); + } + } + } + } + return groupDomain; + } + + //--------------------------------------------------------------------- + // Static + //--------------------------------------------------------------------- + + /** + * @override + * @returns {{ attrs: Object, children: Object[] } | null} + */ + static extractArchInfo(archs, viewType) { + const { children } = archs.search; + const spNode = children.find(c => c.tag === "searchpanel"); + const isObject = (obj) => typeof obj === "object" && obj !== null; + if (spNode) { + const actualType = viewType === "list" ? "tree" : viewType; + const { view_types } = spNode.attrs; + const viewTypes = view_types ? + view_types.split(",") : + DEFAULT_VIEW_TYPES; + if (viewTypes.includes(actualType)) { + return { + attrs: spNode.attrs, + children: spNode.children.filter(isObject), + }; + } + } + return null; + } + } + SearchPanelModelExtension.layer = 1; + + ActionModel.registry.add("SearchPanel", SearchPanelModelExtension, 30); + + return SearchPanelModelExtension; +}); diff --git a/addons/web/static/src/scss/search_panel.scss b/addons/web/static/src/scss/search_panel.scss index c40686804ea..079154803c3 100644 --- a/addons/web/static/src/scss/search_panel.scss +++ b/addons/web/static/src/scss/search_panel.scss @@ -16,116 +16,128 @@ $o-searchpanel-filter-default-color: #D59244; 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 { + 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_filter .o_search_panel_section_icon { - color: $o-searchpanel-filter-default-color; - } + .o_search_panel_category .o_search_panel_section_icon { + color: $o-brand-odoo; + } - .o_toggle_fold { - text-align: center; - width: 1.5rem; - } + .o_search_panel_filter .o_search_panel_section_icon { + color: $o-searchpanel-filter-default-color; + } - .o_search_panel_label { - align-items: center; - cursor: pointer; - display: flex; - justify-content: space-between; - user-select: none; - } + .o_toggle_fold { + text-align: center; + width: 1.5rem; + } - .o_search_panel_section_header { - padding: $o-searchpanel-p-small 0; - cursor: default; - } + .o_search_panel_label { + align-items: center; + cursor: pointer; + display: flex; + justify-content: space-between; + user-select: none; + width: 100%; + } - .list-group { - padding-bottom: $o-searchpanel-p-tiny; - } + .o_search_panel_section_header { + cursor: default; + padding: $o-searchpanel-p-small 0; + } + + .list-group { + padding-bottom: $o-searchpanel-p-tiny; + } + + .list-group-item { + padding: 0; .list-group-item { - padding: 0; + padding: 0 0 0 $custom-control-gutter; + margin-bottom: $o-searchpanel-p-tiny*0.5; + } - .list-group-item { - padding: 0 0 0 $custom-control-gutter; - margin-bottom: $o-searchpanel-p-tiny*0.5; - } + .o_search_panel_label_title { + color: $headings-color; + width: 100%; + @include o-text-overflow; + } - span.o_search_panel_label_title { - color: $headings-color; - @include o-text-overflow; - } + header.active { + background-color: $o-searchpanel-active-bg; + } + } - header.active { - background-color: $o-searchpanel-active-bg; - } + .o_search_panel_category_value { + cursor: pointer; + + header { + align-items: center; + display: flex; + justify-content: space-between; + padding: 4px 6px 4px 0px; + } + + .o_search_panel_label.o_with_counters { + overflow: hidden; } .o_search_panel_category_value { - cursor: pointer; + margin-bottom: 0; + padding-left: $o-searchpanel-p; + position: relative; - header { - align-items: center; - display: flex; - justify-content: space-between; - padding: 4px 6px 4px 0px; - } - .o_search_panel_label.o_with_counters { - overflow: hidden; - } - .o_search_panel_category_value { - position: relative; - padding-left: $o-searchpanel-p; - margin-bottom: 0; - - &:before, &:after { - @include o-position-absolute(0, $left: $o-searchpanel-p-tiny); - @include size(1px, 100%); - background: $gray-500; - content: ''; - margin-left: 4px; - } - &:after { - top: 12px; - @include size(8px, 1px); - } - &:last-child:before { - height: 12px; - } - } - } - - .o_search_panel_filter_value, - .o_search_panel_filter_group { - cursor: pointer; - padding-bottom: $o-searchpanel-p-small; - - .o_search_panel_label, - .o_search_panel_label_title { - padding-right: 6px; - } - } - - .o_search_panel_filter_group { - - header { - display: flex; + &:before, + &:after { + content: ''; + background: $gray-500; + margin-left: 4px; + @include o-position-absolute(0, $left: $o-searchpanel-p-tiny); + @include size(1px, 100%); } - .o_search_panel_label.o_with_counters { - justify-content: flex-start; + &:after { + top: 12px; + @include size(8px, 1px); + } + + &:last-child:before { + height: 12px; } } } + + .o_search_panel_group_header .custom-control { + width: 100%; + } + + .o_search_panel_filter_value, + .o_search_panel_filter_group { + cursor: pointer; + padding-bottom: $o-searchpanel-p-small; + + .o_search_panel_label, + .o_search_panel_label_title { + padding-right: 6px; + } + } + + .o_search_panel_filter_group { + + header { + display: flex; + } + + .o_search_panel_label.o_with_counters { + justify-content: flex-start; + } + } } diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index abe3856b91e..1e75a8dacc0 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -62,7 +62,7 @@ class="o_group_by_menu" fields="fields" /> -
-