From 20caa733c656c6f76cd075829e7854b2b2a22bf9 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Mon, 13 Jul 2020 12:58:36 +0000 Subject: [PATCH 1/8] [IMP] web: TestUtils findItem Extraction of the findItem function from the control panel test utils to the dom utils. This function helps find an item matching a given selector and: - having the given text as its inner text OR - being the item at the given index An error is thrown if no item matches the predicate --- addons/web/static/tests/helpers/test_utils.js | 1 + .../tests/helpers/test_utils_control_panel.js | 56 +++---------------- .../static/tests/helpers/test_utils_dom.js | 42 ++++++++++++++ 3 files changed, 52 insertions(+), 47 deletions(-) diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js index 1a1f38f7f09..1aa2e1f0fcf 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -177,6 +177,7 @@ odoo.define('web.test_utils', async function (require) { triggerMouseEvent: testUtilsDom.triggerMouseEvent, triggerPositionalMouseEvent: testUtilsDom.triggerPositionalMouseEvent, dragAndDrop: testUtilsDom.dragAndDrop, + find: testUtilsDom.findItem, getNode: testUtilsDom.getNode, openDatepicker: testUtilsDom.openDatepicker, click: testUtilsDom.click, diff --git a/addons/web/static/tests/helpers/test_utils_control_panel.js b/addons/web/static/tests/helpers/test_utils_control_panel.js index 1f5bff32fd6..2dda198017e 100644 --- a/addons/web/static/tests/helpers/test_utils_control_panel.js +++ b/addons/web/static/tests/helpers/test_utils_control_panel.js @@ -1,47 +1,9 @@ odoo.define('web.test_utils_control_panel', function (require) { "use strict"; - const { click, triggerEvent, getNode } = require('web.test_utils_dom'); + const { click, findItem, getNode, triggerEvent } = require('web.test_utils_dom'); const { editInput, editSelect, editAndTrigger } = require('web.test_utils_fields'); - //------------------------------------------------------------------------- - // Private functions - //------------------------------------------------------------------------- - - /** - * Helper method to retrieve a distinct item from a collection of elements defined - * by the given 'selector' string. It can either be the index of the item or its - * inner text. - * @private - * @param {HTMLElement} el - * @param {string} selector - * @param {(number|string)} elFinder - */ - function findItem(el, selector, elFinder) { - const elements = [...el.querySelectorAll(selector)]; - if (!elements.length) { - throw new Error(`No element found with selector "${selector}".`); - } - switch (typeof elFinder) { - case 'number': - const element = elements[elFinder]; - if (!element) { - throw new Error(`No element with selector "${selector}" at index ${elFinder}.`); - } - return element; - case 'string': - const stringMatch = elements.find( - e => e.innerText.trim().toLowerCase() === elFinder.toLowerCase() - ); - if (!stringMatch) { - throw new Error(`No element with selector "${selector}" containing "${elFinder}".`); - } - return stringMatch; - default: - throw new Error(`Invalid provided element finder: must be a number|string|function.`); - } - } - //------------------------------------------------------------------------- // Exported functions //------------------------------------------------------------------------- @@ -52,7 +14,7 @@ odoo.define('web.test_utils_control_panel', function (require) { * @returns {Promise} */ async function toggleMenu(el, menuFinder) { - const menu = findItem(getNode(el), `.o_dropdown > button`, menuFinder); + const menu = findItem(el, `.o_dropdown > button`, menuFinder); await click(menu); } @@ -62,7 +24,7 @@ odoo.define('web.test_utils_control_panel', function (require) { * @returns {Promise} */ async function toggleMenuItem(el, itemFinder) { - const item = findItem(getNode(el), `.o_menu_item > a`, itemFinder); + const item = findItem(el, `.o_menu_item > a`, itemFinder); await click(item); } @@ -73,7 +35,7 @@ odoo.define('web.test_utils_control_panel', function (require) { * @returns {Promise} */ async function toggleMenuItemOption(el, itemFinder, optionFinder) { - const item = findItem(getNode(el), `.o_menu_item > a`, itemFinder); + const item = findItem(el, `.o_menu_item > a`, itemFinder); const option = findItem(item.parentNode, '.o_item_option > a', optionFinder); await click(option); } @@ -84,7 +46,7 @@ odoo.define('web.test_utils_control_panel', function (require) { * @returns {boolean} */ function isItemSelected(el, itemFinder) { - const item = findItem(getNode(el), `.o_menu_item > a`, itemFinder); + const item = findItem(el, `.o_menu_item > a`, itemFinder); return item.classList.contains('selected'); } @@ -95,7 +57,7 @@ odoo.define('web.test_utils_control_panel', function (require) { * @returns {boolean} */ function isOptionSelected(el, itemFinder, optionFinder) { - const item = findItem(getNode(el), `.o_menu_item > a`, itemFinder); + const item = findItem(el, `.o_menu_item > a`, itemFinder); const option = findItem(item.parentNode, '.o_item_option > a', optionFinder); return option.classList.contains('selected'); } @@ -217,7 +179,7 @@ odoo.define('web.test_utils_control_panel', function (require) { * @returns {Promise} */ async function deleteFavorite(el, favoriteFinder) { - const favorite = findItem(getNode(el), `.o_favorite_menu .o_menu_item`, favoriteFinder); + const favorite = findItem(el, `.o_favorite_menu .o_menu_item`, favoriteFinder); await click(favorite.querySelector('i.fa-trash-o')); } @@ -245,7 +207,7 @@ odoo.define('web.test_utils_control_panel', function (require) { * @returns {Promise} */ async function removeFacet(el, facetFinder = 0) { - const facet = findItem(getNode(el), `.o_searchview .o_searchview_facet`, facetFinder); + const facet = findItem(el, `.o_searchview .o_searchview_facet`, facetFinder); await click(facet.querySelector('.o_facet_remove')); } @@ -275,7 +237,7 @@ odoo.define('web.test_utils_control_panel', function (require) { * @returns {Promise} */ async function toggleActionMenu(el, menuFinder = "Action") { - const dropdown = findItem(getNode(el), `.o_cp_action_menus button`, menuFinder); + const dropdown = findItem(el, `.o_cp_action_menus button`, menuFinder); await click(dropdown); } diff --git a/addons/web/static/tests/helpers/test_utils_dom.js b/addons/web/static/tests/helpers/test_utils_dom.js index 8a7d3553d9e..eedbcbe5155 100644 --- a/addons/web/static/tests/helpers/test_utils_dom.js +++ b/addons/web/static/tests/helpers/test_utils_dom.js @@ -290,6 +290,47 @@ odoo.define('web.test_utils_dom', function (require) { return returnAfterNextAnimationFrame(); } + /** + * Helper method to retrieve a distinct item from a collection of elements defined + * by the given "selector" string. It can either be the index of the item or its + * inner text. + * @param {Element} el + * @param {string} selector + * @param {number | string} [elFinder=0] + * @returns {Element | null} + */ + function findItem(el, selector, elFinder = 0) { + const elements = [...getNode(el).querySelectorAll(selector)]; + if (!elements.length) { + throw new Error(`No element found with selector "${selector}".`); + } + switch (typeof elFinder) { + case "number": { + const match = elements[elFinder]; + if (!match) { + throw new Error( + `No element with selector "${selector}" at index ${elFinder}.` + ); + } + return match; + } + case "string": { + const match = elements.find( + (el) => el.innerText.trim().toLowerCase() === elFinder.toLowerCase() + ); + if (!match) { + throw new Error( + `No element with selector "${selector}" containing "${elFinder}". + `); + } + return match; + } + default: throw new Error( + `Invalid provided element finder: must be a number|string|function.` + ); + } + } + /** * Helper function used to extract an HTML EventTarget element from a given * target. The extracted element will depend on the target type: @@ -497,6 +538,7 @@ odoo.define('web.test_utils_dom', function (require) { clickFirst, clickLast, dragAndDrop, + findItem, getNode, openDatepicker, returnAfterNextAnimationFrame, From ea1741bc743b2066e871b526fc0d7cbf30b08fef Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Mon, 13 Jul 2020 13:01:18 +0000 Subject: [PATCH 2/8] [REF] web: Registry goes ESNext Converted the Registry class to an ES6 class with the new framework standards. --- addons/web/static/src/js/core/registry.js | 266 +++++++++--------- .../web/static/tests/core/registry_tests.js | 1 - 2 files changed, 131 insertions(+), 136 deletions(-) diff --git a/addons/web/static/src/js/core/registry.js b/addons/web/static/src/js/core/registry.js index 6089ccc827e..7b2c69db180 100644 --- a/addons/web/static/src/js/core/registry.js +++ b/addons/web/static/src/js/core/registry.js @@ -1,147 +1,143 @@ -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 { + /** + * @param {Object} [mapping] the initial data in the registry + */ + constructor(mapping) { + this.map = Object.create(mapping || null); + this._scoreMapping = Object.create(null); + this._sortedKeys = null; + this.listeners = []; // listening callbacks on newly added items. } - 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/tests/core/registry_tests.js b/addons/web/static/tests/core/registry_tests.js index fdc437c23ee..9f9f7b467cb 100644 --- a/addons/web/static/tests/core/registry_tests.js +++ b/addons/web/static/tests/core/registry_tests.js @@ -73,4 +73,3 @@ QUnit.module('core', {}, function () { }); }); - From 5698d1fab9cf36714f677ae6942201d5c9354798 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Mon, 13 Jul 2020 13:03:23 +0000 Subject: [PATCH 3/8] [IMP] web: add Registry predicate Before this commit, any value could be added to registries and the class needed to be overridden to assert that added values would answer to a certain predicate. Now, the registry can be instantiated with a given predicate function that all added value must pass to be registered (default: all values are accepted). Existing implentations have been adapted: - FieldRegistry - FieldRegistryOwl. --- addons/web/static/src/js/core/registry.js | 15 +++++++++++++-- .../web/static/src/js/fields/field_registry.js | 18 ++++++------------ .../static/src/js/fields/field_registry_owl.js | 14 ++++---------- addons/web/static/tests/core/registry_tests.js | 15 +++++++++++++++ 4 files changed, 38 insertions(+), 24 deletions(-) diff --git a/addons/web/static/src/js/core/registry.js b/addons/web/static/src/js/core/registry.js index 7b2c69db180..1ab16236e59 100644 --- a/addons/web/static/src/js/core/registry.js +++ b/addons/web/static/src/js/core/registry.js @@ -20,13 +20,21 @@ odoo.define("web.Registry", function (require) { */ class Registry { /** - * @param {Object} [mapping] the initial data in the registry + * @function predicate + * @param {any} value + * @returns {boolean} */ - constructor(mapping) { + /** + * @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; } //-------------------------------------------------------------------------- @@ -42,6 +50,9 @@ odoo.define("web.Registry", function (require) { * @returns {Registry} can be used to chain add calls. */ add(key, value, score) { + if (!this.predicate(value)) { + throw new Error(`Value of key "${key}" does not pass the addition predicate.`); + } this._scoreMapping[key] = score === undefined ? key : score; this._sortedKeys = null; this.map[key] = value; 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/tests/core/registry_tests.js b/addons/web/static/tests/core/registry_tests.js index 9f9f7b467cb..eb3389a4383 100644 --- a/addons/web/static/tests/core/registry_tests.js +++ b/addons/web/static/tests/core/registry_tests.js @@ -70,6 +70,21 @@ QUnit.module('core', {}, function () { assert.deepEqual(registry.values(), ['bar', 'foo', 'qux']); }); + QUnit.test("predicate prevents invalid values", function (assert) { + assert.expect(5); + + const predicate = value => typeof value === "number"; + const registry = new Registry(null, predicate); + registry.onAdd((key) => assert.step(key)); + + assert.ok(registry.add("age", 23)); + assert.throws( + () => registry.add("name", "Fred"), + new Error(`Value of key "name" does not pass the addition predicate.`) + ); + assert.deepEqual(registry.entries(), { age: 23 }); + assert.verifySteps(["age"]); + }); }); }); From d6ffbfd479fcd235e6e8fdad2cbd84286ca359b6 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Mon, 13 Jul 2020 13:14:32 +0000 Subject: [PATCH 4/8] [IMP] web: Model: extensions registry Refactoring of the Model class of `model.js` to support multiple extensions. The purpose is to build a model having the appropriate modules for a given situation. For instance, we don't need the control panel model if we don't have a control panel to start with. Some logic of the control panel model (which was until now the "main" search model) has been given to another model extension named "Search Model Extension" which will synchronize the informations of all models providing search data (domain, context, etc.). The model instantiated in the actions/views is now an instance of the "Action Model" (which directly extends the Model) and is meant to replace the view model in the long term. It might however need some improvements to hold such a role (partial interface rendering, etc.). Part of task 2228968 Co-authored-by: Julien Mougenot Co-authored-by: Mathieu Duckerts-Antoine --- ...el.js => control_panel_model_extension.js} | 631 ++++++++---------- addons/web/static/src/js/core/utils.js | 25 + addons/web/static/src/js/model.js | 477 +++++++++++-- .../web/static/src/js/views/action_model.js | 190 ++++++ addons/web/views/webclient_templates.xml | 3 +- 5 files changed, 904 insertions(+), 422 deletions(-) rename addons/web/static/src/js/control_panel/{control_panel_model.js => control_panel_model_extension.js} (84%) create mode 100644 addons/web/static/src/js/views/action_model.js 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/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/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/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/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index c0dc0097a38..e048691b37b 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -323,7 +323,7 @@ - + @@ -334,6 +334,7 @@ + From 7ff2e5822f08616dd22c142e0e05ed4355801760 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Mon, 13 Jul 2020 13:17:14 +0000 Subject: [PATCH 5/8] [IMP] web: Search panel Owl Refactoring of the search panel and its inner logic to Owl. The previous search panel widget has been divided in 2 parts: 1. Component The "Search Panel" component's responsibilities are now to render the search panel data given by its model, display some rendering logic (expanded categories, checked groups) and dispatch the events triggered on it. 2. Model Extension The "Search Panel Model Extension" holds all the search logic of the search panel. Its responsibilities are calling the server to update its data and to provide them to its component and other search extensions. Part of task 2228968 Co-authored-by: Julien Mougenot Co-authored-by: Mathieu Duckerts-Antoine --- addons/web/__manifest__.py | 1 + .../web/static/src/js/views/search_panel.js | 926 ++++-------------- .../js/views/search_panel_model_extension.js | 730 ++++++++++++++ addons/web/static/src/scss/search_panel.scss | 196 ++-- addons/web/static/src/xml/kanban.xml | 126 --- addons/web/static/src/xml/search_panel.xml | 132 +++ addons/web/views/webclient_templates.xml | 1 + 7 files changed, 1146 insertions(+), 966 deletions(-) create mode 100644 addons/web/static/src/js/views/search_panel_model_extension.js create mode 100644 addons/web/static/src/xml/search_panel.xml 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/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/kanban.xml b/addons/web/static/src/xml/kanban.xml index f4657d4b3a3..2fd7f8ca211 100644 --- a/addons/web/static/src/xml/kanban.xml +++ b/addons/web/static/src/xml/kanban.xml @@ -209,132 +209,6 @@ - - -
- - -
-
- - -
- - - -
- -
-
    -
  • -
    -
    -
  • - - - -
-
-
- - - -
  • -
    - - -
    -
      - - - -
    -
  • -
    -
    - - -
    - - - -
    - -
    -
      - - - - - - - -
    -
    -
    - -
  • - - -
    -
    - - -
    -
    -
      - - - - -
    -
  • -
      - - - - - -
    -
    - -
  • - - -
    -
    - - -
    -
    -
  • -
    -
    diff --git a/addons/web/static/src/xml/search_panel.xml b/addons/web/static/src/xml/search_panel.xml new file mode 100644 index 00000000000..4b6dc4d7a60 --- /dev/null +++ b/addons/web/static/src/xml/search_panel.xml @@ -0,0 +1,132 @@ + + + + +
    +
    +
    + + +
    +
    + +
    +
      + + + + +
    • + + + +
      +
      + + + +
      +
      +
        + + + + + +
      +
    • +
        + + + + + + +
      +
      + + + +
    +
    +
    +
    + + + + +
  • +
    + + +
    +
      + + + +
    +
  • +
    +
    + + +
  • + +
    + + +
    +
  • +
    + +
    diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index e048691b37b..42492ede85a 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -333,6 +333,7 @@ + From 3162141e4d71e9c55aff5241c5578571b68c332d Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Mon, 13 Jul 2020 13:18:20 +0000 Subject: [PATCH 6/8] [IMP] lunch: Conversion of model to extension Conversion of the lunch kanban model to a proper search extension. Now that the view search model supports model extensions, it has become more convenient to provide additional domain informations by appending new extensions. This is why the lunch "location" logic (locationId and userId) has been shifted to a new model extension ("Lunch Model Extension") which provides an additional domain part to the other search extensions. Part of task 2228968 Co-authored-by: Julien Mougenot Co-authored-by: Mathieu Duckerts-Antoine --- .../static/src/js/lunch_controller_common.js | 51 ++------- .../lunch/static/src/js/lunch_kanban_view.js | 12 +-- addons/lunch/static/src/js/lunch_list_view.js | 9 +- .../static/src/js/lunch_model_extension.js | 100 ++++++++++++++++++ addons/lunch/views/lunch_templates.xml | 2 +- 5 files changed, 116 insertions(+), 58 deletions(-) create mode 100644 addons/lunch/static/src/js/lunch_model_extension.js 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 @@ + From bb3bc7c9e4992dc07b10e87f87662bdd72f24d88 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Mon, 13 Jul 2020 13:23:36 +0000 Subject: [PATCH 7/8] [IMP] *: adapt modules to Owl search panel Adaptation of the rest of the environment to the changes regarding the refactored search panel and model. This commit includes the following changes: - Since the control panel model is now part of the search model, the view widget is responsible of its instantiation with the appropriate API; creating an ActionModel holding the required model extensions. - The controller is now importing/exporting the state of the entire search model as well as the current state of the search panel (given throught its props). Changes have been made to adapt to this behaviour. - The multiple components linked to the control panel model have been adapted to reflect the changes brought to it (they will now listen to the "searchModel" instead of the "controlPanelModel"). - The `test_utils_create` methods have also been adapted to properly instantiate a searchModel Part of task 2228968 Co-authored-by: Julien Mougenot Co-authored-by: Mathieu Duckerts-Antoine --- .../base_import/static/src/js/import_menu.js | 8 +- .../board/static/src/js/add_to_board_menu.js | 5 +- .../src/js/add_to_google_spreadsheet_menu.js | 2 +- .../static/src/widgets/discuss/discuss.js | 2 +- .../static/src/js/chrome/abstract_action.js | 61 +++-- .../js/chrome/action_manager_act_window.js | 3 +- .../src/js/control_panel/comparison_menu.js | 6 +- .../src/js/control_panel/control_panel.js | 16 +- .../js/control_panel/custom_favorite_item.js | 6 +- .../js/control_panel/custom_filter_item.js | 4 +- .../js/control_panel/custom_group_by_item.js | 4 +- .../src/js/control_panel/favorite_menu.js | 8 +- .../src/js/control_panel/filter_menu.js | 6 +- .../src/js/control_panel/groupby_menu.js | 6 +- .../static/src/js/control_panel/search_bar.js | 19 +- .../src/js/views/abstract_controller.js | 166 ++++++------- .../web/static/src/js/views/abstract_view.js | 233 +++++++++--------- .../src/js/views/kanban/kanban_controller.js | 2 +- addons/web/static/src/xml/base.xml | 8 +- addons/web/views/webclient_templates.xml | 2 +- 20 files changed, 288 insertions(+), 279 deletions(-) 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/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/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/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/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/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/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/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" /> -
    -