diff --git a/addons/board/static/src/add_to_board/legacy_add_to_board.js b/addons/board/static/src/add_to_board/legacy_add_to_board.js deleted file mode 100644 index cda6329dbaf..00000000000 --- a/addons/board/static/src/add_to_board/legacy_add_to_board.js +++ /dev/null @@ -1,158 +0,0 @@ -/** @odoo-module alias=board.AddToBoardMenu **/ - - import Context from "web.Context"; - import Domain from "web.Domain"; - import { Dropdown } from "@web/core/dropdown/dropdown"; - import FavoriteMenu from "web.FavoriteMenu"; - import { sprintf } from "web.utils"; - import { useAutofocus } from "@web/core/utils/hooks"; - - const { Component, useState } = owl; - - /** - * 'Add to board' menu - * - * Component consisiting of a toggle button, a text input and an 'Add' button. - * The first button is simply used to toggle the component and will determine - * whether the other elements should be rendered. - * The input will be given the name (or title) of the view that will be added. - * Finally, the last button will send the name as well as some of the action - * properties to the server to add the current view (and its context) to the - * user's dashboard. - * This component is only available in actions of type 'ir.actions.act_window'. - */ - class AddToBoardMenu extends Component { - setup() { - this.interactive = true; - this.state = useState({ - name: this.env.action.name || "", - open: false, - }); - - useAutofocus(); - } - - //--------------------------------------------------------------------- - // Private - //--------------------------------------------------------------------- - - /** - * This is the main function for actually saving the dashboard. This method - * is supposed to call the route /board/add_to_dashboard with proper - * information. - * @private - */ - async addToBoard() { - const searchQuery = this.env.searchModel.get("query"); - const context = new Context(this.env.action.context); - context.add(searchQuery.context); - context.add({ - group_by: searchQuery.groupBy, - orderedBy: searchQuery.orderedBy, - }); - if ( - searchQuery.timeRanges && - Object.prototype.hasOwnProperty.call(searchQuery.timeRanges, "fieldName") - ) { - context.add({ - comparison: searchQuery.timeRanges, - }); - } - let controllerQueryParams; - this.env.searchModel.trigger("get-controller-query-params", (params) => { - controllerQueryParams = params || {}; - }); - controllerQueryParams.context = controllerQueryParams.context || {}; - const queryContext = controllerQueryParams.context; - delete controllerQueryParams.context; - context.add(Object.assign(controllerQueryParams, queryContext)); - - const domainArray = new Domain(this.env.action.domain || []); - const domain = Domain.prototype.normalizeArray( - domainArray.toArray().concat(searchQuery.domain) - ); - - const evalutatedContext = context.eval(); - for (const key in evalutatedContext) { - if ( - Object.prototype.hasOwnProperty.call(evalutatedContext, key) && - /^search_default_/.test(key) - ) { - delete evalutatedContext[key]; - } - } - evalutatedContext.dashboard_merge_domains_contexts = false; - - Object.assign(this.state, { - name: $(".o_input").val() || "", - open: false, - }); - - const result = await this.env.services.rpc({ - route: "/board/add_to_dashboard", - params: { - action_id: this.env.action.id || false, - context_to_save: evalutatedContext, - domain: domain, - view_mode: this.env.view.type, - name: this.state.name, - }, - }); - if (result) { - this.env.services.notification.notify({ - title: sprintf(this.env._t("'%s' added to dashboard"), this.state.name), - message: this.env._t( - "Please refresh your browser for the changes to take effect." - ), - type: "warning", - }); - } else { - this.env.services.notification.notify({ - message: this.env._t("Could not add filter to dashboard"), - type: "danger", - }); - } - } - - //--------------------------------------------------------------------- - // Handlers - //--------------------------------------------------------------------- - - /** - * @private - * @param {KeyboardEvent} ev - */ - onInputKeydown(ev) { - switch (ev.key) { - case "Enter": - ev.preventDefault(); - this.addToBoard(); - break; - case "Escape": - // Gives the focus back to the component. - ev.preventDefault(); - ev.target.blur(); - break; - } - } - - //--------------------------------------------------------------------- - // Static - //--------------------------------------------------------------------- - - /** - * @param {Object} env - * @returns {boolean} - */ - static shouldBeDisplayed(env) { - return env.action.type === "ir.actions.act_window"; - } - } - - AddToBoardMenu.props = {}; - AddToBoardMenu.template = "board.AddToBoard"; - AddToBoardMenu.components = { Dropdown }; - - FavoriteMenu.registry.add("add-to-board-menu", AddToBoardMenu, 10); - - export default AddToBoardMenu; diff --git a/addons/board/static/tests/add_to_dashboard_tests.js b/addons/board/static/tests/add_to_dashboard_tests.js index 410b76a0dc5..7f96567e2ac 100644 --- a/addons/board/static/tests/add_to_dashboard_tests.js +++ b/addons/board/static/tests/add_to_dashboard_tests.js @@ -19,8 +19,6 @@ import { import { createWebClient, doAction } from "@web/../tests/webclient/helpers"; import { browser } from "@web/core/browser/browser"; import { registry } from "@web/core/registry"; -import LegacyAddToBoard from "board.AddToBoardMenu"; -import LegacyFavoriteMenu from "web.FavoriteMenu"; import testUtils from "web.test_utils"; import { makeFakeUserService } from "@web/../tests/helpers/mock_services"; @@ -76,7 +74,6 @@ QUnit.module("Board", (hooks) => { }, }; - LegacyFavoriteMenu.registry.add("add-to-board-menu", LegacyAddToBoard, 10); favoriteMenuRegistry.add("add-to-board", addToBoardItem, { sequence: 10 }); serverData = { models }; target = getFixture(); diff --git a/addons/mail/static/src/js/custom_filter_item.js b/addons/mail/static/src/js/custom_filter_item.js deleted file mode 100644 index 9e82dfc9b09..00000000000 --- a/addons/mail/static/src/js/custom_filter_item.js +++ /dev/null @@ -1,21 +0,0 @@ -/** @odoo-module **/ - -import CustomFilterItem from "web.CustomFilterItem"; -import { patch } from "web.utils"; - -patch(CustomFilterItem.prototype, "mail.CustomFilterItem", { - /** - * With the `mail` module installed, we want to filter out some of the - * available fields in 'Add custom filter' menu (@see CustomFilterItem). - * @override - */ - _validateField(field) { - return ( - this._super(...arguments) && - field.relation !== "mail.message" && - field.name !== "message_ids" - ); - }, -}); - -export default CustomFilterItem; diff --git a/addons/point_of_sale/__manifest__.py b/addons/point_of_sale/__manifest__.py index ed659958c0c..d6a601f0764 100644 --- a/addons/point_of_sale/__manifest__.py +++ b/addons/point_of_sale/__manifest__.py @@ -148,7 +148,6 @@ ('remove', 'web/static/src/legacy/js/public/**/*'), 'web/static/src/legacy/action_adapters.js', 'web/static/src/legacy/backend_utils.js', - 'web/static/src/legacy/js/views/action_model.js', 'web/static/src/legacy/legacy_load_views.js', 'web/static/src/legacy/legacy_service_provider.js', 'web/static/src/legacy/utils.js', diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index ecee52dbfb9..b8a9f8e49f9 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -69,7 +69,6 @@ 'project/static/src/utils/**/*', 'project/static/src/components/**/*', 'project/static/src/views/**/*', - 'project/static/src/js/project_control_panel.js', 'project/static/src/js/project_graph_view.js', 'project/static/src/js/project_pivot_view.js', 'project/static/src/js/project_rating_graph_view.js', @@ -174,7 +173,6 @@ 'web/static/src/legacy/js/fields/field_utils.js', 'web/static/src/legacy/js/views/abstract_model.js', 'web/static/src/legacy/js/views/basic/basic_model.js', - 'web/static/src/legacy/js/views/action_model.js', 'web/static/src/legacy/js/views/view_utils.js', 'web/static/src/legacy/js/services/data_manager.js', 'web/static/src/legacy/js/services/session.js', @@ -186,7 +184,6 @@ 'web/static/src/legacy/js/widgets/model_field_selector.js', 'web/static/src/legacy/js/widgets/model_field_selector_popover.js', 'web/static/src/legacy/js/env.js', - 'web/static/src/legacy/js/model.js', 'web/static/src/legacy/js/owl_compatibility.js', 'web_editor/static/src/components/**/*', diff --git a/addons/project/static/src/js/project_control_panel.js b/addons/project/static/src/js/project_control_panel.js deleted file mode 100644 index fe94336b977..00000000000 --- a/addons/project/static/src/js/project_control_panel.js +++ /dev/null @@ -1,42 +0,0 @@ -/** @odoo-module **/ - -import ControlPanel from 'web.ControlPanel'; -import session from 'web.session'; - -const { onWillStart, onWillUpdateProps } = owl; - -export class ProjectControlPanel extends ControlPanel { - - setup() { - super.setup(); - this.show_project_update = this.props.view.type === "form" || this.props.action.context.show_project_update; - this.project_id = this.show_project_update ? this.props.action.context.active_id : false; - - onWillStart(() => this._loadWidgetData()); - onWillUpdateProps(() => this._loadWidgetData()); - } - - async _loadWidgetData() { - if (this.show_project_update) { - this.data = await this.rpc({ - model: 'project.project', - method: 'get_last_update_or_default', - args: [this.project_id], - }); - this.is_project_user = await session.user_has_group('project.group_project_user'); - } - } - - async onStatusClick(ev) { - ev.preventDefault(); - await this.trigger('do-action', { - action: "project.project_update_all_action", - options: { - additional_context: { - default_project_id: this.project_id, - active_id: this.project_id - } - } - }); - } -} diff --git a/addons/project/static/src/project_sharing/components/favorite_menu_registry.js b/addons/project/static/src/project_sharing/components/favorite_menu_registry.js deleted file mode 100644 index bdde197a993..00000000000 --- a/addons/project/static/src/project_sharing/components/favorite_menu_registry.js +++ /dev/null @@ -1,35 +0,0 @@ -/** @odoo-module **/ - -import FavoriteMenuLegacy from 'web.FavoriteMenu'; -import CustomFavoriteItemLegacy from 'web.CustomFavoriteItem'; -import { registry } from "@web/core/registry"; - - -/** - * Remove all components contained in the favorite menu registry except the CustomFavoriteItem - * component for only the project sharing feature. - */ -export function prepareFavoriteMenuRegister() { - let customFavoriteItemKey = 'favorite-generator-menu'; - const keys = FavoriteMenuLegacy.registry.keys().filter(key => key !== customFavoriteItemKey); - FavoriteMenuLegacy.registry = Object.assign(FavoriteMenuLegacy.registry, { - map: {}, - _scoreMapping: {}, - _sortedKeys: null, - }); - FavoriteMenuLegacy.registry.add(customFavoriteItemKey, CustomFavoriteItemLegacy, 0); - // notify the listeners, we keep only one key in this registry. - for (const key of keys) { - for (const callback of FavoriteMenuLegacy.registry.listeners) { - callback(key, undefined); - } - } - - customFavoriteItemKey = 'custom-favorite-item'; - const favoriteMenuRegistry = registry.category("favoriteMenu"); - for (const [key] of favoriteMenuRegistry.getEntries()) { - if (key !== customFavoriteItemKey) { - favoriteMenuRegistry.remove(key); - } - } -} diff --git a/addons/project/static/src/project_sharing/main.js b/addons/project/static/src/project_sharing/main.js index 9af1f6eeb8b..61458f6992c 100644 --- a/addons/project/static/src/project_sharing/main.js +++ b/addons/project/static/src/project_sharing/main.js @@ -1,7 +1,5 @@ /** @odoo-module **/ import { startWebClient } from '@web/start'; import { ProjectSharingWebClient } from './project_sharing'; -import { prepareFavoriteMenuRegister } from './components/favorite_menu_registry'; -prepareFavoriteMenuRegister(); startWebClient(ProjectSharingWebClient); diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index a981ba95414..9e6c9697dc5 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -248,11 +248,9 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/js/widgets/model_field_selector.js', 'web/static/src/legacy/js/widgets/model_field_selector_popover.js', 'web/static/src/legacy/js/env.js', - 'web/static/src/legacy/js/model.js', 'web/static/src/legacy/js/owl_compatibility.js', 'web/static/src/legacy/xml/base.xml', - 'web/static/src/legacy/xml/control_panel.xml', 'web/static/src/legacy/xml/fields.xml', 'web/static/src/legacy/xml/week_days.xml', ], diff --git a/addons/web/static/src/legacy/backend_utils.js b/addons/web/static/src/legacy/backend_utils.js index 348deea5e9b..1ed0c56b849 100644 --- a/addons/web/static/src/legacy/backend_utils.js +++ b/addons/web/static/src/legacy/backend_utils.js @@ -1,113 +1,7 @@ /** @odoo-module **/ -import ActionModel from "@web/legacy/js/views/action_model"; - -/** - * @param {string} state - * @returns {string} - */ -function searchModelStateFromLegacy(state) { - /** - * Possible problem if only one of ControlPanelModelExtension or SearchPanelModelExtension is installed. - * We might need to do something in SearchModel. - * @todo (DAM) check when search panel is reworked. - */ - const parsedState = JSON.parse(state); - const newState = {}; - - if (parsedState.ControlPanelModelExtension) { - const { query, filters, nextGroupId, nextGroupNumber, nextId } = - parsedState.ControlPanelModelExtension; - - newState.nextGroupId = nextGroupId; - newState.nextGroupNumber = nextGroupNumber; - newState.nextId = nextId; - newState.query = []; - newState.searchItems = {}; - - for (const queryElem of query) { - const filterId = queryElem.filterId; - const filter = filters[filterId]; - const newQueryElem = { searchItemId: filterId }; - switch (filter.type) { - case "filter": - if (filter.hasOptions) { - newQueryElem.generatorId = queryElem.optionId; - } - break; - case "groupBy": - if (filter.hasOptions) { - newQueryElem.intervalId = queryElem.optionId; - } - break; - case "field": - newQueryElem.autocompleteValue = { - value: queryElem.value, - label: queryElem.label, - operator: queryElem.operator, - }; - break; - } - newState.query.push(newQueryElem); - } - - for (const filter of Object.values(filters)) { - const item = Object.assign({}, filter); - switch (item.type) { - case "groupBy": - if (filter.hasOptions) { - item.type = "dateGroupBy"; - item.defaultIntervalId = item.defaultOptionId; - delete item.hasOptions; - delete item.defaultOptionId; - } - break; - case "filter": - if (filter.hasOptions) { - item.type = "dateFilter"; - item.defaultGeneratorId = item.defaultOptionId; - delete item.hasOptions; - delete item.isDateFilter; - delete item.defaultOptionId; - } - break; - case "favorite": - item.orderBy = item.orderedBy; - delete item.orderedBy; - break; - } - newState.searchItems[filter.id] = item; - } - } - - if (parsedState.SearchPanelModelExtension) { - const { sections, searchPanelInfo } = parsedState.SearchPanelModelExtension; - newState.sections = sections; - //! Can be undefined. See search_model.__legacyParseSearchPanelArchAnyway - newState.searchPanelInfo = searchPanelInfo; - if (newState.searchPanelInfo) { - newState.searchPanelInfo.loaded = true; - } - } - - for (const [key, extension] of Object.entries(ActionModel.registry.entries())) { - if ( - !["ControlPanel", "SearchPanel"].includes(key) && - parsedState[extension.name] !== undefined - ) { - newState[key] = parsedState[extension.name]; - } - } - - if (!Object.keys(newState).length) { - return; - } - - return JSON.stringify(newState); -} - function getGlobalState(legacyControllerState) { - const { resIds, searchModel, searchPanel } = legacyControllerState; + const { resIds, searchPanel } = legacyControllerState; const globalState = {}; if (searchPanel) { globalState.searchPanel = searchPanel; @@ -115,10 +9,6 @@ function getGlobalState(legacyControllerState) { if (resIds) { globalState.resIds = resIds; } - const newSearchModel = searchModelStateFromLegacy(searchModel); - if (newSearchModel) { - globalState.searchModel = newSearchModel; - } return globalState; } diff --git a/addons/web/static/src/legacy/js/chrome/abstract_action.js b/addons/web/static/src/legacy/js/chrome/abstract_action.js index 35114c4f268..8ac1386619a 100644 --- a/addons/web/static/src/legacy/js/chrome/abstract_action.js +++ b/addons/web/static/src/legacy/js/chrome/abstract_action.js @@ -8,12 +8,10 @@ */ import ActionMixin from "web.ActionMixin"; -import ActionModel from "web.ActionModel"; import ControlPanel from "web.ControlPanel"; import Widget from "web.Widget"; import { ComponentWrapper } from "web.OwlCompatibility"; -import { Component } from "@odoo/owl"; const AbstractAction = Widget.extend(ActionMixin, { config: { ControlPanel: ControlPanel, @@ -27,44 +25,6 @@ const AbstractAction = Widget.extend(ActionMixin, { */ hasControlPanel: false, - /** - * If true, this flag indicates that the client action should automatically - * fetch the of a search view (or control panel view). Note that - * to do that, it also needs a specific modelName. - * - * For example, the Discuss application adds the following line in its - * constructor:: - * - * this.searchModelConfig.modelName = 'mail.message'; - * - * @type boolean - */ - loadControlPanel: false, - - /** - * A client action might want to use a search bar in its control panel, or - * it could choose not to use it. - * - * Note that it only makes sense if hasControlPanel is set to true. - * - * @type boolean - */ - withSearchBar: false, - - /** - * This parameter can be set to customize the available sub menus in the - * controlpanel (Filters/Group By/Favorites). This is basically a list of - * the sub menus that we want to use. - * - * Note that it only makes sense if hasControlPanel is set to true. - * - * For example, set ['filter', 'favorite'] to enable the Filters and - * Favorites menus. - * - * @type string[] - */ - searchMenuTypes: [], - /** * @override * @@ -76,67 +36,13 @@ const 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: Component.env, - searchMenuTypes: this.searchMenuTypes, - }; - this.extensions = {}; if (this.hasControlPanel) { - this.extensions.ControlPanel = { - actionId: action.id, - withSearchBar: this.withSearchBar, - }; - - this.viewId = action.search_view_id && action.search_view_id[0]; - this.controlPanelProps = { action, breadcrumbs: options && options.breadcrumbs, - withSearchBar: this.withSearchBar, - searchMenuTypes: this.searchMenuTypes, }; } }, - /** - * The willStart method is actually quite complicated if the client action - * has a controlPanel, because it needs to prepare it. - * - * @override - */ - willStart: async function () { - const superPromise = this._super(...arguments); - if (this.hasControlPanel) { - if (this.loadControlPanel) { - 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.searchModel = new ActionModel(this.extensions, this.searchModelConfig); - if (this.hasControlPanel) { - this.controlPanelProps.searchModel = this.searchModel; - } - return Promise.all([ - superPromise, - this.searchModel.load(), - ]); - }, /** * @override */ @@ -164,27 +70,13 @@ const 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.searchModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams); - } }, /** * @override */ on_detach_callback: function () { ActionMixin.on_detach_callback.call(this); - this.searchModel.off('search', this); - if (this.hasControlPanel) { - this.searchModel.off('get-controller-query-params', this); - } }, - - /** - * @private - * @param {Object} [searchQuery] - */ - _onSearch: function () {}, }); export default AbstractAction; diff --git a/addons/web/static/src/legacy/js/chrome/action_mixin.js b/addons/web/static/src/legacy/js/chrome/action_mixin.js index 1ea607700c3..4b2f32a8079 100644 --- a/addons/web/static/src/legacy/js/chrome/action_mixin.js +++ b/addons/web/static/src/legacy/js/chrome/action_mixin.js @@ -35,16 +35,6 @@ */ contentTemplate: null, - /** - * Events built by and managed by Odoo Framework - * - * It is expected that any Widget Class implementing this mixin - * will also implement the ParentedMixin which actually manages those - */ - custom_events: { - get_controller_query_params: '_onGetOwnedQueryParams', - }, - /** * If an action wants to use a control panel, it will be created and * registered in this _controlPanel key (the widget). The way this control @@ -103,30 +93,6 @@ return Promise.resolve(); }, - /** - * This function is called when the current state of the action - * should be known. For instance, if the action is a view controller, - * this may be useful to reinstantiate a view in the same state. - * - * Typically the state can (and should) be encoded in a query object of - * the form:: - * - * { - * context: {...}, - * groupBy: [...], - * domain = [...], - * orderedBy = [...], - * } - * - * where the context key can contain many information. - * This method is mainly called during the creation of a custom filter. - * - * @returns {Object} - */ - getOwnedQueryParams: function () { - return {}; - }, - /** * Returns a serializable state that will be pushed in the URL by * the action manager, allowing the action to be restarted correctly @@ -210,24 +176,6 @@ _setTitle: function (title) { this._title = title; }, - - //--------------------------------------------------------------------- - // Handlers - //--------------------------------------------------------------------- - - /** - * FIXME: this logic should be rethought - * - * Handles a context request: provides to the caller the state of the - * current controller. - * - * @private - * @param {function} callback used to send the requested state - */ - _onGetOwnedQueryParams: function (callback) { - const state = this.getOwnedQueryParams(); - callback(state || {}); - }, }); export default ActionMixin; diff --git a/addons/web/static/src/legacy/js/components/datepicker.js b/addons/web/static/src/legacy/js/components/datepicker.js deleted file mode 100644 index 2e15fafcecb..00000000000 --- a/addons/web/static/src/legacy/js/components/datepicker.js +++ /dev/null @@ -1,289 +0,0 @@ -/** @odoo-module alias=web.DatePickerOwl **/ - - import config from "web.config"; - import field_utils from "web.field_utils"; - import time from "web.time"; - import { useAutofocus } from "@web/core/utils/hooks"; - import { LegacyComponent } from "@web/legacy/legacy_component"; - - const { - onMounted, - onPatched, - onWillUnmount, - onWillUpdateProps, - useExternalListener, - useState, - } = owl; - - let datePickerId = 0; - - /** - * Date picker - * - * This component exposes the API of the tempusdominus datepicker library. - * As such, its template is a simple input that will open the TD datepicker - * when clicked on. The component will also synchronize any user-input value - * with the library widget and vice-vera. - * - * For further details regarding the implementation of the picker itself, please - * refer to the official tempusdominus documentation (note: all props given - * to this component will be passed as arguments to instantiate the picker widget). - * @extends Component - */ - class DatePicker extends LegacyComponent { - setup() { - this.state = useState({ warning: false }); - - this.datePickerId = `o_datepicker_${datePickerId++}`; - this.typeOfDate = 'date'; - - this.inputRef = useAutofocus(); - useExternalListener(window, 'scroll', this._onWindowScroll); - - onMounted(() => { - $(this.el).on('show.datetimepicker', this._onDateTimePickerShow.bind(this)); - $(this.el).on('hide.datetimepicker', this._onDateTimePickerHide.bind(this)); - $(this.el).on('error.datetimepicker', () => false); - - const pickerOptions = Object.assign({ format: this.defaultFormat }, this.props); - this._datetimepicker(pickerOptions); - this.inputRef.el.value = this._formatDate(this.props.date); - }); - - onWillUnmount(() => { - $(this.el).off('show.datetimepicker hide.datetimepicker error.datetimepicker'); - this._datetimepicker('destroy'); - }); - - let newDateReceived = false; - - onWillUpdateProps((nextProps) => { - for (const prop in nextProps) { - if (prop == "onDateTimeChanged") { - continue; - } - this._datetimepicker(prop, nextProps[prop]); - } - if (nextProps.date) { - newDateReceived = true; - } - }); - - onPatched(() => { - if (newDateReceived) { - newDateReceived = false; - this.inputRef.el.value = this._formatDate(this.props.date); - } - }); - } - - //--------------------------------------------------------------------- - // Private - //--------------------------------------------------------------------- - - /** - * @returns {string} - */ - get defaultFormat() { - return time.getLangDateFormat(); - } - - //--------------------------------------------------------------------- - // Private - //--------------------------------------------------------------------- - - /** - * Handle bootstrap datetimepicker calls. - * @private - * @param {...any} args anything that will be passed to the datetimepicker function. - */ - _datetimepicker(...args) { - this.ignoreBootstrapEvents = true; - $(this.el).datetimepicker(...args); - this.ignoreBootstrapEvents = false; - } - - /** - * @private - * @param {moment} date - * @returns {string} - */ - _formatDate(date) { - try { - return field_utils.format[this.typeOfDate](date, null, { timezone: false }); - } catch { - return false; - } - } - - /** - * @private - * @param {string|false} value - * @returns {moment} - */ - _parseInput(inputValue) { - try { - return field_utils.parse[this.typeOfDate](inputValue, null, { timezone: false }); - } catch { - return false; - } - } - - //--------------------------------------------------------------------- - // Handlers - //--------------------------------------------------------------------- - - /** - * Reacts to the datetimepicker being hidden - * Used to unbind the scroll event from the datetimepicker - * @private - */ - _onDateTimePickerHide() { - if (this.ignoreBootstrapEvents) { - return; - } - const date = this._parseInput(this.inputRef.el.value); - this.state.warning = date.format('YYYY-MM-DD') > moment().format('YYYY-MM-DD'); - this.props.onDateTimeChanged(date); - } - - /** - * Reacts to the datetimepicker being shown - * Could set/verify our widget value - * And subsequently update the datetimepicker - * @private - */ - _onDateTimePickerShow() { - if (this.ignoreBootstrapEvents) { - return; - } - this.inputRef.el.select(); - } - - /** - * @private - */ - _onInputClick() { - this._datetimepicker('toggle'); - } - - /** - * @private - */ - _onInputChange() { - const date = this._parseInput(this.inputRef.el.value); - if (date) { - this.state.warning = date.format('YYYY-MM-DD') > moment().format('YYYY-MM-DD'); - this.props.onDateTimeChanged(date); - } else { - this.inputRef.el.value = this._formatDate(this.props.date); - } - } - - /** - * @private - */ - _onWindowScroll(ev) { - if (!config.device.isIOS && ev.target !== this.inputRef.el) { - this._datetimepicker('hide'); - } - } - } - - DatePicker.defaultProps = { - calendarWeeks: true, - icons: { - clear: 'fa fa-delete', - close: 'fa fa-check primary', - date: 'fa fa-calendar', - down: 'oi oi-chevron-down', - next: 'oi oi-chevron-right', - previous: 'oi oi-chevron-left', - time: 'fa fa-clock-o', - today: 'fa fa-calendar-check-o', - up: 'oi oi-chevron-up', - }, - get locale() {return moment.locale();}, - maxDate: moment({ y: 9999, M: 11, d: 31 }), - minDate: moment({ y: 1000 }), - useCurrent: false, - widgetParent: 'body', - }; - DatePicker.props = { - // Actual date value - date: moment, - // function to call when the date/time changed - onDateTimeChanged: Function, - // Other props - buttons: { - type: Object, - shape: { - showClear: Boolean, - showClose: Boolean, - showToday: Boolean, - }, - optional: 1, - }, - calendarWeeks: { type: Boolean, optional: true }, - format: { type: String, optional: 1 }, - icons: { - type: Object, - shape: { - clear: String, - close: String, - date: String, - down: String, - next: String, - previous: String, - time: String, - today: String, - up: String, - }, - optional: true, - }, - keyBinds: { validate: kb => typeof kb === 'object' || kb === null, optional: 1 }, - locale: { type: String, optional: true }, - maxDate: { type: moment, optional: true }, - minDate: { type: moment, optional: true }, - readonly: { type: Boolean, optional: 1 }, - useCurrent: { type: Boolean, optional: true }, - widgetParent: { type: String, optional: true }, - }; - DatePicker.template = "web.Legacy.DatePicker"; - - /** - * Date/time picker - * - * Similar to the DatePicker component, adding the handling of more specific - * time values: hour-minute-second. - * - * Once again, refer to the tempusdominus documentation for implementation - * details. - * @extends DatePicker - */ - class DateTimePicker extends DatePicker { - setup() { - super.setup(); - this.typeOfDate = 'datetime'; - } - - /** - * @override - */ - get defaultFormat() { - return time.getLangDatetimeFormat(); - } - } - - DateTimePicker.defaultProps = Object.assign(Object.create(DatePicker.defaultProps), { - buttons: { - showClear: false, - showClose: true, - showToday: false, - }, - }); - - export default { - DatePicker, - DateTimePicker, - }; diff --git a/addons/web/static/src/legacy/js/control_panel/comparison_menu.js b/addons/web/static/src/legacy/js/control_panel/comparison_menu.js deleted file mode 100644 index ad6463c4c9e..00000000000 --- a/addons/web/static/src/legacy/js/control_panel/comparison_menu.js +++ /dev/null @@ -1,33 +0,0 @@ -/** @odoo-module alias=web.ComparisonMenu **/ - - import { Dropdown } from "@web/core/dropdown/dropdown"; - import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item"; - import { FACET_ICONS } from "web.searchUtils"; - import { useModel } from "web.Model"; - import { LegacyComponent } from "@web/legacy/legacy_component"; - - class ComparisonMenu extends LegacyComponent { - setup() { - this.icon = FACET_ICONS.comparison; - this.model = useModel('searchModel'); - } - - /** - * @override - */ - get items() { - return this.model.get('filters', f => f.type === 'comparison'); - } - - /** - * @private - * @param {number} itemId - */ - onComparisonSelected(itemId) { - this.model.dispatch("toggleComparison", itemId); - } - } - ComparisonMenu.template = "web.legacy.ComparisonMenu"; - ComparisonMenu.components = { Dropdown, SearchDropdownItem }; - - export default ComparisonMenu; diff --git a/addons/web/static/src/legacy/js/control_panel/control_panel.js b/addons/web/static/src/legacy/js/control_panel/control_panel.js index 291571b0eea..255ad8ecae8 100644 --- a/addons/web/static/src/legacy/js/control_panel/control_panel.js +++ b/addons/web/static/src/legacy/js/control_panel/control_panel.js @@ -1,16 +1,9 @@ /** @odoo-module alias=web.ControlPanel **/ import ActionMenus from "web.ActionMenus"; - import ComparisonMenu from "web.ComparisonMenu"; - import ActionModel from "web.ActionModel"; - import FavoriteMenu from "web.FavoriteMenu"; - import FilterMenu from "web.FilterMenu"; - import GroupByMenu from "web.GroupByMenu"; import Pager from "web.Pager"; - import SearchBar from "web.SearchBar"; - import { useModel } from "web.Model"; import { LegacyComponent } from "@web/legacy/legacy_component"; -import { sprintf } from "@web/core/utils/strings"; + import { sprintf } from "@web/core/utils/strings"; const { onMounted, @@ -53,46 +46,7 @@ import { sprintf } from "@web/core/utils/strings"; * Control panel * * The control panel of the action|view. In its standard form, it is composed of - * several sections/subcomponents. Here is a simplified graph representing the - * action|view and its control panel: - * - * ┌ View Controller | Action ----------------------------------------------------------┐ - * | ┌ Control Panel ──────────────┬──────────────────────────────────────────────────┐ | - * | │ ┌ Breadcrumbs ────────────┐ │ ┌ Search View ─────────────────────────────────┐ │ | - * | │ │ [1] / [2] │ │ │ [3] [ ================ 4 ================= ] │ │ | - * | │ └─────────────────────────┘ │ └──────────────────────────────────────────────┘ │ | - * | ├─────────────────────────────┼──────────────────────────────────────────────────┤ | - * | │ ┌ Buttons ┐ ┌ ActionMenus ┐ │ ┌ Search Menus ─────┐ ┌ Pager ┐┌ View switcher ┐ │ | - * | │ │ [5] │ │ [6] │ │ │ [7] [8] [9] [10] │ │ [11] ││ [12] │ │ | - * | │ └─────────┘ └─────────────┘ │ └───────────────────┘ └───────┘└───────────────┘ │ | - * | └─────────────────────────────┴──────────────────────────────────────────────────┘ | - * | ┌ View Renderer | Action content ────────────────────────────────────────────────┐ | - * | │ │ | - * | │ ... │ | - * | │ │ | - * | │ │ | - * | │ │ | - * | └────────────────────────────────────────────────────────────────────────────────┘ | - * └------------------------------------------------------------------------------------┘ - * - * 1. Breadcrumbs: list of links composed by the `props.breadcrumbs` collection. - * 2. Title: the title of the action|view. Can be empty and will yield 'Unnamed'. - * 3. Search facets: a collection of facet components generated by the `ControlPanelModel` - * and handled by the `SearchBar` component. @see SearchFacet - * 4. SearchBar: @see SearchBar - * 5. Buttons: section in which the action|controller is meant to inject its control - * buttons. The template provides a slot for this purpose. - * 6. Action menus: @see ActionMenus - * 7. Filter menu: @see FilterMenu - * 8. Group by menu: @see GroupByMenu - * 9. Comparison menu: @see ComparisonMenu - * 10. Favorite menu: @see FavoriteMenu - * 11. Pager: @see Pager - * 12. View switcher buttons: list of buttons composed by the `props.views` collection. - * - * Subcomponents (especially in the [Search Menus] section) will call - * the ControlPanelModel to get processed information about the current view|action. - * @see ControlPanelModel for more details. + * several sections/subcomponents. * * Note: an additional temporary (and ugly) mechanic allows to inject a jQuery element * given in `props.cp_content` in a related section: @@ -111,18 +65,11 @@ import { sprintf } from "@web/core/utils/strings"; let subEnvView = this.props.view; useSubEnv({ action: this.props.action, - searchModel: this.props.searchModel, get view() { return subEnvView; }, }); - // Connect to the model - // TODO: move this in enterprise whenever possible - if (this.env.searchModel) { - this.model = useModel('searchModel'); - } - // Reference hooks this.contentRefs = { buttons: useRef('buttons'), @@ -208,35 +155,26 @@ import { sprintf } from "@web/core/utils/strings"; return formattedFields; } } - ControlPanel.modelExtension = "ControlPanel"; - ControlPanel.components = { - SearchBar, ActionMenus, Pager, - ComparisonMenu, FilterMenu, GroupByMenu, FavoriteMenu, }; ControlPanel.defaultProps = { breadcrumbs: [], fields: {}, - searchMenuTypes: [], views: [], withBreadcrumbs: true, - withSearchBar: true, }; ControlPanel.props = { action: Object, breadcrumbs: { type: Array, optional: true }, - searchModel: ActionModel, cp_content: { type: Object, optional: 1 }, fields: { type: Object, optional: true }, pager: { validate: p => typeof p === 'object' || p === null, optional: 1 }, - searchMenuTypes: { type: Array, optional: true }, actionMenus: { validate: s => typeof s === 'object' || s === null, optional: 1 }, title: { type: String, optional: 1 }, view: { type: Object, optional: 1 }, views: { type: Array, optional: true }, withBreadcrumbs: { type: Boolean, optional: true }, - withSearchBar: { type: Boolean, optional: true }, }; ControlPanel.template = 'web.Legacy.ControlPanel'; diff --git a/addons/web/static/src/legacy/js/control_panel/control_panel_model_extension.js b/addons/web/static/src/legacy/js/control_panel/control_panel_model_extension.js deleted file mode 100644 index 89f899b3082..00000000000 --- a/addons/web/static/src/legacy/js/control_panel/control_panel_model_extension.js +++ /dev/null @@ -1,1678 +0,0 @@ -/** @odoo-module alias=web/static/src/js/control_panel/control_panel_model_extension.js **/ - - import { _t } from "@web/core/l10n/translation"; - import { sprintf } from "@web/core/utils/strings"; - import ActionModel from "web.ActionModel"; - import Domain from "web.Domain"; - import pyUtils from "web.py_utils"; - - import { DEFAULT_INTERVAL, DEFAULT_PERIOD, - getComparisonOptions, getIntervalOptions, getPeriodOptions, - constructDateDomain, rankInterval, yearSelected } from "web.searchUtils"; - - const FAVORITE_PRIVATE_GROUP = 1; - const FAVORITE_SHARED_GROUP = 2; - const DISABLE_FAVORITE = "search_disable_custom_filters"; - - const { toRaw } = owl; - /** - * Control panel model - * - * The control panel model state is an object structured in the following way: - * - * { - * filters: Object{}, - * query: Object[], - * } - * - *------------------------------------------------------------------------- - * Filters - *------------------------------------------------------------------------- - * - * The keys are stringified numbers called 'filter ids'. - * The values are objects called 'filters'. - * - * Each filter has the following properties: - * @prop {number} id unique identifier, also the filter's corresponding key - * @prop {number} groupId the id of some group, actually the group itself, - * the (active) 'groups' are reconstructed in _getGroups. - * @prop {string} description the description of the filter - * @prop {string} type 'filter'|'groupBy'|'comparison'|'field'|'favorite' - * - * Other properties can be present according to the corresponding filter type: - * - * • type 'comparison': - * @prop {string} comparisonOptionId option identifier (@see COMPARISON_OPTIONS). - * @prop {string} dateFilterId the id of a date filter (filter of type 'filter' - * with isDateFilter=true) - * - * • type 'filter': - * @prop {number} groupNumber used to separate items in the 'Filters' menu - * @prop {string} [context] context - * @prop {boolean} [invisible] determine if the filter is accessible in the interface - * @prop {boolean} [isDefault] - * @if isDefault = true: - * > @prop {number} [defaultRank=-5] used to determine the order of - * > activation of default filters - * @prop {boolean} [isDateFilter] true if the filter comes from an arch node - * with a valid 'date' attribute. - * @if isDateFilter = true - * > @prop {boolean} [hasOptions=true] - * > @prop {string} defaultOptionId option identifier determined by - * > default_period attribute (@see PERIOD_OPTIONS). - * > Default set to DEFAULT_PERIOD. - * > @prop {string} fieldName determined by the value of 'date' attribute - * > @prop {string} fieldType 'date' or 'datetime', type of the corresponding field - * @else - * > @prop {string} domain - * @prop {string} [name] 'name' attribute set in arch - * - * • type 'groupBy': - * @prop {string} fieldName - * @prop {string} fieldType - * @prop {number} groupNumber used to separate items in the 'Group by' menu - * @prop {boolean} [isDefault] - * @if isDefault = true: - * > @prop {number} defaultRank used to determine the order of activation - * > of default filters - * @prop {boolean} [invisible] determine if the filter is accessible in the interface - * @prop {boolean} [hasOptions] true if field type is 'date' or 'datetime' - * @if hasOptions=true - * > @prop {string} defaultOptionId option identifier (see INTERVAL_OPTIONS) - * default set to DEFAULT_INTERVAL. - * @prop {string} [name] 'name' attribute set in arch - * - * • type 'field': - * @prop {string} fieldName - * @prop {string} fieldType - * @prop {string} [context] - * @prop {string} [domain] - * @prop {string} [filterDomain] - * @prop {boolean} [invisible] determine if the filter is accessible in the interface - * @prop {boolean} [isDefault] - * @prop {string} [operator] - * @if isDefault = true: - * > @prop {number} [defaultRank=-10] used to determine the order of - * > activation of filters - * > @prop {Object} defaultAutocompleteValue of the form { value, label, operator } - * - * • type: 'favorite': - * @prop {Object} [comparison] of the form {comparisonId, fieldName, fieldDescription, - * range, rangeDescription, comparisonRange, comparisonRangeDescription, } - * @prop {Object} context - * @prop {string} domain - * @prop {string[]} groupBys - * @prop {number} groupNumber 1 | 2, 2 if the favorite is shared - * @prop {string[]} orderedBy - * @prop {boolean} [removable=true] indicates that the favorite can be deleted - * @prop {number} serverSideId - * @prop {number} userId - * @prop {boolean} [isDefault] - * - *------------------------------------------------------------------------- - * Query - *------------------------------------------------------------------------- - * - * The query elements are objects called 'query elements'. - * - * Each query element has the following properties: - * @prop {number} filterId the id of some filter - * @prop {number} groupId the id of some group (actually the group itself) - * - * Other properties must be defined according to the corresponding filter type. - * - * • type 'comparison': - * @prop {string} dateFilterId the id of a date filter (filter of type 'filter' - * with hasOptions=true) - * @prop {string} type 'comparison', help when searching if a comparison is active - * - * • type 'filter' with hasOptions=true: - * @prop {string} optionId option identifier (@see PERIOD_OPTIONS) - * - * • type 'groupBy' with hasOptions=true: - * @prop {string} optionId option identifier (@see INTERVAL_OPTIONS) - * - * • type 'field': - * @prop {string} label description put in the facet (can be temporarilly missing) - * @prop {(string|number)} value used as the value of the generated domain - * @prop {string} operator used as the operator of the generated domain - * - * 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 ActionModel.Extension - */ - class ControlPanelModelExtension extends ActionModel.Extension { - /** - * @param {Object} config - * @param {(string|number)} config.actionId - * @param {Object} config.env - * @param {string} config.modelName - * @param {Object} [config.context={}] - * @param {Object[]} [config.archNodes=[]] - * @param {Object[]} [config.dynamicFilters=[]] - * @param {string[]} [config.searchMenuTypes=[]] - * @param {Object} [config.favoriteFilters={}] - * @param {Object} [config.fields={}] - * @param {boolean} [config.withSearchBar=true] - */ - constructor() { - super(...arguments); - - this.actionContext = Object.assign({}, this.config.context); - this.searchMenuTypes = this.config.searchMenuTypes || []; - this.favoriteFilters = this.config.favoriteFilters || []; - this.fields = toRaw(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.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(); - case "irFilterValues": return this.getIrFilterValues({ type: "favorite" }); - } - } - - /** - * @override - */ - async load() { - await Promise.all(this.labelPromises); - } - - /** - * @override - */ - prepareState() { - Object.assign(this.state, { - nextGroupId: 1, - nextGroupNumber: 1, - nextId: 1, - 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. - * @param {Object} - */ - addAutoCompletionValues({ filterId, label, value, operator }) { - const queryElem = this.state.query.find(queryElem => - queryElem.filterId === filterId && - queryElem.value === value && - queryElem.operator === operator - ); - if (!queryElem) { - const { groupId } = this.state.filters[filterId]; - this.state.query.push({ filterId, groupId, label, value, operator }); - } else { - queryElem.label = label; - } - } - - /** - * Remove all the query elements from query. - */ - clearQuery() { - this.state.query = []; - } - - getIrFilterValues(preFilter) { - const groups = this._getGroups(); - - const userContext = this.env.session.user_context; - let controllerQueryParams; - this.config.trigger("get-controller-query-params", params => { - controllerQueryParams = params; - }); - controllerQueryParams = controllerQueryParams || {}; - controllerQueryParams.context = controllerQueryParams.context || {}; - - const queryContext = this._getContext(groups); - const context = pyUtils.eval( - 'contexts', - [userContext, controllerQueryParams.context, queryContext] - ); - for (const key in userContext) { - delete context[key]; - } - - const requireEvaluation = false; - const domain = this._getDomain(groups); - const groupBys = this._getGroupBy(groups); - const timeRanges = this._getTimeRanges(requireEvaluation); - const orderedBy = controllerQueryParams.orderedBy ? - controllerQueryParams.orderedBy : - (this._getOrderedBy(groups) || []); - - const userId = preFilter.isShared ? false : this.env.session.uid; - delete preFilter.isShared; - - Object.assign(preFilter, { - context, - domain, - groupBys, - groupNumber: userId ? FAVORITE_PRIVATE_GROUP : FAVORITE_SHARED_GROUP, - orderedBy, - removable: true, - userId, - }); - if (timeRanges) { - preFilter.comparison = timeRanges; - } - return this._favoriteToIrFilter(preFilter); - } - - /** - * Create a new filter of type 'favorite' and activate it. - * A new group containing only that filter is created. - * The query is emptied before activating the new favorite. - * @param {Object} preFilter - * @returns {Promise} - */ - async createNewFavorite(preFilter) { - const preFavorite = await this._saveQuery(preFilter); - this.clearQuery(); - const filter = Object.assign(preFavorite, { - groupId: this.state.nextGroupId, - id: this.state.nextId, - }); - this.state.filters[this.state.nextId] = filter; - this.state.query.push({ groupId: this.state.nextGroupId, filterId: this.state.nextId }); - this.state.nextGroupId++; - this.state.nextId++; - } - - /** - * Create new filters of type 'filter' and activate them. - * A new group containing only those filters is created. - * @param {Object[]} filters - * @returns {number[]} - */ - createNewFilters(prefilters) { - if (!prefilters.length) { - return []; - } - const newFilterIdS = []; - prefilters.forEach(preFilter => { - const filter = Object.assign(preFilter, { - groupId: this.state.nextGroupId, - groupNumber: this.state.nextGroupNumber, - id: this.state.nextId, - type: 'filter', - }); - this.state.filters[this.state.nextId] = filter; - this.state.query.push({ groupId: this.state.nextGroupId, filterId: this.state.nextId }); - newFilterIdS.push(this.state.nextId); - this.state.nextId++; - }); - this.state.nextGroupId++; - this.state.nextGroupNumber++; - return newFilterIdS; - } - - /** - * Create a new filter of type 'groupBy' and activate it. - * It is added to the unique group of groupbys. - * @param {Object} field - */ - createNewGroupBy(field) { - const groupBy = Object.values(this.state.filters).find(f => f.type === 'groupBy'); - const filter = { - description: field.string || field.name, - fieldName: field.name, - fieldType: field.type, - groupId: groupBy ? groupBy.groupId : this.state.nextGroupId++, - groupNumber: this.state.nextGroupNumber, - id: this.state.nextId, - type: 'groupBy', - custom: true, - }; - this.state.filters[this.state.nextId] = filter; - if (['date', 'datetime'].includes(field.type)) { - filter.hasOptions = true; - filter.defaultOptionId = DEFAULT_INTERVAL; - this.toggleFilterWithOptions(this.state.nextId); - } else { - this.toggleFilter(this.state.nextId); - } - this.state.nextGroupNumber++; - this.state.nextId++; - } - - /** - * Deactivate a group with provided groupId, i.e. delete the query elements - * with given groupId. - * @param {number} groupId - */ - deactivateGroup(groupId) { - this.state.query = this.state.query.filter( - queryElem => queryElem.groupId !== groupId - ); - this._checkComparisonStatus(); - } - - /** - * Delete a filter of type 'favorite' with given filterId server side and - * in control panel model. Of course the filter is also removed - * from the search query. - * @param {number} filterId - */ - async deleteFavorite(filterId) { - const { serverSideId } = this.state.filters[filterId]; - await this.env.dataManager.delete_filter(serverSideId); - const index = this.state.query.findIndex( - queryElem => queryElem.filterId === filterId - ); - delete this.state.filters[filterId]; - if (index >= 0) { - this.state.query.splice(index, 1); - } - } - - /** - * @returns {Object} - */ - getContext() { - const groups = this._getGroups(); - return this._getContext(groups); - } - - /** - * @returns {Array[]} - */ - getDomain() { - const groups = this._getGroups(); - const userContext = this.env.session.user_context; - try { - return Domain.prototype.stringToArray( - this._getDomain(groups), - Object.assign({}, this.actionContext, userContext) - ); - } catch (err) { - throw new Error( - sprintf( - _t("Control panel model extension failed to evaluate domain:\n%(error)s"), - { error: JSON.stringify(err) } - ) - ); - } - } - - /** - * @returns {string[]} - */ - 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; - return this._getTimeRanges(requireEvaluation); - } - - /** - * Used to call dispatch and trigger a 'search'. - */ - search() { - /* ... */ - } - - /** - * Activate/Deactivate a filter of type 'comparison' with provided id. - * At most one filter of type 'comparison' can be activated at every time. - * @param {string} filterId - */ - toggleComparison(filterId) { - const { groupId, dateFilterId } = this.state.filters[filterId]; - const queryElem = this.state.query.find(queryElem => - queryElem.type === 'comparison' && - queryElem.filterId === filterId - ); - // make sure only one comparison can be active - this.state.query = this.state.query.filter(queryElem => queryElem.type !== 'comparison'); - if (!queryElem) { - this.state.query.push({ groupId, filterId, dateFilterId, type: 'comparison', }); - } - } - - /** - * Activate or deactivate the simple filter with given filterId, i.e. - * add or remove a corresponding query element. - * @param {string} filterId - */ - toggleFilter(filterId) { - const index = this.state.query.findIndex( - queryElem => queryElem.filterId === filterId - ); - if (index >= 0) { - this.state.query.splice(index, 1); - } else { - const { groupId, type } = this.state.filters[filterId]; - if (type === 'favorite') { - this.state.query = []; - } - this.state.query.push({ groupId, filterId }); - } - } - - /** - * Used to toggle a query element { filterId, optionId, (groupId) }. - * This can impact the query in various form, e.g. add/remove other query elements - * in case the filter is of type 'filter'. - * @param {string} filterId - * @param {string} [optionId] - */ - toggleFilterWithOptions(filterId, optionId) { - const filter = this.state.filters[filterId]; - optionId = optionId || filter.defaultOptionId; - const option = this.optionGenerators.find(o => o.id === optionId); - - const index = this.state.query.findIndex( - queryElem => queryElem.filterId === filterId && queryElem.optionId === optionId - ); - - if (index >= 0) { - this.state.query.splice(index, 1); - if (filter.type === 'filter' && !yearSelected(this._getSelectedOptionIds(filterId))) { - // This is the case where optionId was the last option - // of type 'year' to be there before being removed above. - // Since other options of type 'month' or 'quarter' do - // not make sense without a year we deactivate all options. - this.state.query = this.state.query.filter( - queryElem => queryElem.filterId !== filterId - ); - } - } else { - this.state.query.push({ groupId: filter.groupId, filterId, optionId }); - if (filter.type === 'filter' && !yearSelected(this._getSelectedOptionIds(filterId))) { - // Here we add 'this_year' as options if no option of type - // year is already selected. - this.state.query.push({ - groupId: filter.groupId, - filterId, - optionId: option.defaultYearId, - }); - } - } - if (filter.type === 'filter') { - this._checkComparisonStatus(); - } - } - - //--------------------------------------------------------------------- - // Private - //--------------------------------------------------------------------- - - /** - * Activate the default favorite (if any) or all default filters. - * @private - */ - _activateDefaultFilters() { - if (this.defaultFavoriteId) { - // Activate default favorite - this.toggleFilter(this.defaultFavoriteId); - } else { - // Activate default filters - Object.values(this.state.filters) - .filter((f) => f.isDefault && f.type !== 'favorite') - .sort((f1, f2) => (f1.defaultRank || 100) - (f2.defaultRank || 100)) - .forEach(f => { - if (f.hasOptions) { - this.toggleFilterWithOptions(f.id); - } else if (f.type === 'field') { - let { operator, label, value } = f.defaultAutocompleteValue; - this.addAutoCompletionValues({ - filterId: f.id, - value, - operator, - label, - }); - } else { - this.toggleFilter(f.id); - } - }); - } - } - - /** - * This function populates the 'filters' object at initialization. - * The filters come from: - * - config.archNodes (types 'comparison', 'filter', 'groupBy', 'field'), - * - config.dynamicFilters (type 'filter'), - * - config.favoriteFilters (type 'favorite'), - * - code itself (type 'timeRange') - * @private - */ - _addFilters() { - this._createGroupOfFavorites(); - this._createGroupOfFiltersFromArch(); - this._createGroupOfDynamicFilters(); - } - - /** - * If a comparison is active, check if it should become inactive. - * The comparison should become inactive if the corresponding date filter has become - * inactive. - * @private - */ - _checkComparisonStatus() { - const activeComparison = this.activeComparison; - if (!activeComparison) { - return; - } - const { dateFilterId } = activeComparison; - const dateFilterIsActive = this.state.query.some( - queryElem => queryElem.filterId === dateFilterId - ); - if (!dateFilterIsActive) { - this.state.query = this.state.query.filter( - queryElem => queryElem.type !== 'comparison' - ); - } - } - - /** - * Returns the active comparison timeRanges object. - * @private - * @param {Object} comparisonFilter - * @returns {Object | null} - */ - _computeTimeRanges(comparisonFilter) { - const { filterId } = this.activeComparison; - if (filterId !== comparisonFilter.id) { - return null; - } - const { dateFilterId, comparisonOptionId } = comparisonFilter; - const { - fieldName, - fieldType, - description: dateFilterDescription, - } = this.state.filters[dateFilterId]; - - const selectedOptionIds = this._getSelectedOptionIds(dateFilterId); - - // compute range and range description - const { domain: range, description: rangeDescription } = constructDateDomain( - this.referenceMoment, fieldName, fieldType, selectedOptionIds, - ); - - // compute comparisonRange and comparisonRange description - const { - domain: comparisonRange, - description: comparisonRangeDescription, - } = constructDateDomain( - this.referenceMoment, fieldName, fieldType, selectedOptionIds, comparisonOptionId - ); - - return { - comparisonId: comparisonOptionId, - fieldName, - fieldDescription: dateFilterDescription, - range, - rangeDescription, - comparisonRange, - comparisonRangeDescription, - }; - } - - /** - * Starting from the array of date filters, create the filters of type - * 'comparison'. - * @private - * @param {Object[]} dateFilters - */ - _createGroupOfComparisons(dateFilters) { - const preFilters = []; - for (const dateFilter of dateFilters) { - for (const comparisonOption of this.comparisonOptions) { - const { id: dateFilterId, description } = dateFilter; - const preFilter = { - type: 'comparison', - comparisonOptionId: comparisonOption.id, - description: `${description}: ${comparisonOption.description}`, - dateFilterId, - }; - preFilters.push(preFilter); - } - } - this._createGroupOfFilters(preFilters); - } - - /** - * Add filters of type 'filter' determined by the key array dynamicFilters. - * @private - */ - _createGroupOfDynamicFilters() { - const dynamicFilters = this.config.dynamicFilters || []; - const pregroup = dynamicFilters.map(filter => { - return { - description: filter.description, - domain: JSON.stringify(filter.domain), - isDefault: true, - type: 'filter', - }; - }); - this._createGroupOfFilters(pregroup); - } - - /** - * 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 (activateFavorite && favorite.isDefault) { - this.defaultFavoriteId = favorite.id; - } - }); - } - - /** - * Using a list (a 'pregroup') of 'prefilters', create new filters in `state.filters` - * for each prefilter. The new filters belong to a same new group. - * @private - * @param {Object[]} pregroup, list of 'prefilters' - * @param {string} type - */ - _createGroupOfFilters(pregroup) { - pregroup.forEach(preFilter => { - const filter = Object.assign(preFilter, { groupId: this.state.nextGroupId, id: this.state.nextId }); - this.state.filters[this.state.nextId] = filter; - if (!this.defaultFavoriteId && filter.isDefault && filter.type === 'field') { - this._prepareDefaultLabel(filter); - } - this.state.nextId++; - }); - this.state.nextGroupId++; - } - - /** - * Parse the arch of a 'search' view and create corresponding filters and groups. - * - * A searchview arch may contain a 'searchpanel' node, but this isn't - * the concern of the ControlPanel (the SearchPanel will handle it). - * Ideally, this code should whitelist the tags to take into account - * instead of blacklisting the others, but with the current (messy) - * structure of a searchview arch, it's way simpler to do it that way. - * @private - */ - _createGroupOfFiltersFromArch() { - const preFilters = this.config.archNodes.reduce( - (preFilters, child) => { - if (child.tag === 'group') { - return [...preFilters, ...child.children.map(c => this._evalArchChild(c))]; - } else { - return [...preFilters, this._evalArchChild(child)]; - } - }, - [] - ); - preFilters.push({ tag: 'separator' }); - - // create groups and filters - let currentTag; - let currentGroup = []; - let pregroupOfGroupBys = []; - - preFilters.forEach(preFilter => { - if ( - preFilter.tag !== currentTag || - ['separator', 'field'].includes(preFilter.tag) - ) { - if (currentGroup.length) { - if (currentTag === 'groupBy') { - pregroupOfGroupBys = [...pregroupOfGroupBys, ...currentGroup]; - } else { - this._createGroupOfFilters(currentGroup); - } - } - currentTag = preFilter.tag; - currentGroup = []; - this.state.nextGroupNumber++; - } - if (preFilter.tag !== 'separator') { - const filter = { - type: preFilter.tag, - // we need to codify here what we want to keep from attrs - // and how, for now I put everything. - // In some sence, some filter are active (totally determined, given) - // 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; - let 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 = this.state.nextGroupNumber; - } - this._extractAttributes(filter, preFilter.attrs); - currentGroup.push(filter); - } - }); - - if (pregroupOfGroupBys.length) { - this._createGroupOfFilters(pregroupOfGroupBys); - } - const dateFilters = Object.values(this.state.filters).filter( - (filter) => filter.isDateFilter - ); - if (dateFilters.length) { - this._createGroupOfComparisons(dateFilters); - } - } - - /** - * 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 null is returned if the filter should not appear - * for some reason. - * @private - * @param {Object} filter - * @param {Object[]} filterQueryElements - * @returns {Object | null} - */ - _enrichFilterCopy(filter, filterQueryElements) { - const isActive = Boolean(filterQueryElements.length); - const f = Object.assign({ isActive }, filter); - - function _enrichOptions(options) { - return options.map(o => { - const { description, id, groupNumber } = o; - const isActive = filterQueryElements.some(a => a.optionId === id); - return { description, id, groupNumber, isActive }; - }); - } - - switch (f.type) { - case 'comparison': { - const { dateFilterId } = filter; - const dateFilterIsActive = this.state.query.some( - queryElem => queryElem.filterId === dateFilterId - ); - if (!dateFilterIsActive) { - return null; - } - break; - } - case 'filter': - if (f.hasOptions) { - f.options = _enrichOptions(this.optionGenerators); - } - break; - case 'groupBy': - if (f.hasOptions) { - f.options = _enrichOptions(this.intervalOptions); - } - break; - case 'field': - f.autoCompleteValues = filterQueryElements.map( - ({ label, value, operator }) => ({ label, value, operator }) - ); - break; - } - return f; - } - - /** - * Process a given arch node and enrich it. - * @private - * @param {Object} child - * @returns {Object} - */ - _evalArchChild(child) { - if (child.attrs.context) { - try { - const context = pyUtils.eval('context', child.attrs.context); - child.attrs.context = context; - if (context.group_by) { - // let us extract basic data since we just evaluated context - // and use a correct tag! - child.attrs.fieldName = context.group_by.split(':')[0]; - child.attrs.defaultInterval = context.group_by.split(':')[1]; - child.tag = 'groupBy'; - } - } catch { } - } - if (child.attrs.name in this.searchDefaults) { - child.attrs.isDefault = true; - let value = this.searchDefaults[child.attrs.name]; - if (child.tag === 'field') { - child.attrs.defaultValue = this.fields[child.attrs.name].type === 'many2one' && Array.isArray(value) ? value[0] : value; - } else if (child.tag === 'groupBy') { - child.attrs.defaultRank = typeof value === 'number' ? value : 100; - } - } - return child; - } - - /** - * Process the attributes set on an arch node and adds various keys to - * the given filter. - * @private - * @param {Object} filter - * @param {Object} attrs - */ - _extractAttributes(filter, attrs) { - if (attrs.isDefault) { - filter.isDefault = attrs.isDefault; - } - filter.description = attrs.string || attrs.help || attrs.name || attrs.domain || 'Ω'; - switch (filter.type) { - case 'filter': - if (attrs.context) { - filter.context = attrs.context; - } - if (attrs.date) { - filter.isDateFilter = true; - filter.hasOptions = true; - filter.fieldName = attrs.date; - filter.fieldType = this.fields[attrs.date].type; - filter.defaultOptionId = attrs.default_period || DEFAULT_PERIOD; - } else { - filter.domain = attrs.domain || '[]'; - } - if (filter.isDefault) { - filter.defaultRank = -5; - } - if (attrs.name) { - filter.name = attrs.name; - } - break; - case 'groupBy': - filter.fieldName = attrs.fieldName; - filter.fieldType = this.fields[attrs.fieldName].type; - if (['date', 'datetime'].includes(filter.fieldType)) { - filter.hasOptions = true; - filter.defaultOptionId = attrs.defaultInterval || DEFAULT_INTERVAL; - } - if (filter.isDefault) { - filter.defaultRank = attrs.defaultRank; - } - if (attrs.name) { - filter.name = attrs.name; - } - break; - case 'field': { - const field = this.fields[attrs.name]; - filter.fieldName = attrs.name; - filter.fieldType = field.type; - if (attrs.domain) { - filter.domain = attrs.domain; - } - if (attrs.filter_domain) { - filter.filterDomain = attrs.filter_domain; - } else if (attrs.operator) { - filter.operator = attrs.operator; - } - if (attrs.context) { - filter.context = attrs.context; - } - if (filter.isDefault) { - let operator = filter.operator; - if (!operator) { - const type = attrs.widget || filter.fieldType; - // Note: many2one as a default filter will have a - // numeric value instead of a string => we want "=" - // instead of "ilike". - if (["char", "html", "many2many", "one2many", "text"].includes(type)) { - operator = "ilike"; - } else { - operator = "="; - } - } - filter.defaultRank = -10; - filter.defaultAutocompleteValue = { - operator, - value: attrs.defaultValue, - }; - } - break; - } - } - if (filter.fieldName && !attrs.string) { - const { string } = this.fields[filter.fieldName]; - filter.description = string; - } - } - - /** - * Returns an object irFilter serving to create an ir_filte in db - * starting from a filter of type 'favorite'. - * @private - * @param {Object} favorite - * @returns {Object} - */ - _favoriteToIrFilter(favorite) { - const irFilter = { - action_id: this.config.actionId, - model_id: this.config.modelName, - }; - - // ir.filter fields - if ('description' in favorite) { - irFilter.name = favorite.description; - } - if ('domain' in favorite) { - irFilter.domain = favorite.domain; - } - if ('isDefault' in favorite) { - irFilter.is_default = favorite.isDefault; - } - if ('orderedBy' in favorite) { - const sort = favorite.orderedBy.map( - ob => ob.name + (ob.asc === false ? " desc" : "") - ); - irFilter.sort = JSON.stringify(sort); - } - if ('serverSideId' in favorite) { - irFilter.id = favorite.serverSideId; - } - if ('userId' in favorite) { - irFilter.user_id = favorite.userId; - } - - // Context - const context = Object.assign({}, favorite.context); - if ('groupBys' in favorite) { - context.group_by = favorite.groupBys; - } - if ('comparison' in favorite) { - context.comparison = favorite.comparison; - } - if (Object.keys(context).length) { - irFilter.context = context; - } - - return irFilter; - } - - /** - * Return the domain resulting from the combination of the auto-completion - * values of a filter of type 'field'. - * @private - * @param {Object} filter - * @param {Object[]} filterQueryElements - * @returns {string} - */ - _getAutoCompletionFilterDomain(filter, filterQueryElements) { - const domains = filterQueryElements.map(({ label, value, operator }) => { - let domain; - if (filter.filterDomain) { - domain = Domain.prototype.stringToArray( - filter.filterDomain, - { - self: label, - raw_value: value, - } - ); - } else { - // Create new domain - domain = [[filter.fieldName, operator, value]]; - } - return Domain.prototype.arrayToString(domain); - }); - return pyUtils.assembleDomains(domains, 'OR'); - } - - /** - * Construct a single context from the contexts of - * filters of type 'filter', 'favorite', and 'field'. - * @private - * @returns {Object} - */ - _getContext(groups) { - const types = ['filter', 'favorite', 'field']; - const contexts = groups.reduce( - (contexts, group) => { - if (types.includes(group.type)) { - contexts.push(...this._getGroupContexts(group)); - } - return contexts; - }, - [] - ); - const evaluationContext = this.env.session.user_context; - try { - return pyUtils.eval('contexts', contexts, evaluationContext); - } catch (err) { - throw new Error( - this.env._t("Failed to evaluate search context") + ":\n" + - JSON.stringify(err) - ); - } - } - - /** - * Compute the string representation or the description of the current domain associated - * with a date filter starting from its corresponding query elements. - * @private - * @param {Object} filter - * @param {Object[]} filterQueryElements - * @param {'domain'|'description'} [key='domain'] - * @returns {string} - */ - _getDateFilterDomain(filter, filterQueryElements, key = 'domain') { - const { fieldName, fieldType } = filter; - const selectedOptionIds = filterQueryElements.map(queryElem => queryElem.optionId); - const dateFilterRange = constructDateDomain( - this.referenceMoment, fieldName, fieldType, selectedOptionIds, - ); - return dateFilterRange[key]; - } - - /** - * Return the string or array representation of a domain created by combining - * appropriately (with an 'AND') the domains coming from the active groups - * of type 'filter', 'favorite', and 'field'. - * @private - * @param {Object[]} groups - * @returns {string} - */ - _getDomain(groups) { - const types = ['filter', 'favorite', 'field']; - const domains = []; - for (const group of groups) { - if (types.includes(group.type)) { - domains.push(this._getGroupDomain(group)); - } - } - return pyUtils.assembleDomains(domains, 'AND'); - } - - /** - * 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; - } - - /** - * @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; - } - - /** - * Return the context of the provided (active) filter. - * @private - * @param {Object} filter - * @param {Object[]} filterQueryElements - * @returns {Object} - */ - _getFilterContext(filter, filterQueryElements) { - let context = filter.context || {}; - // for nodes, a dynamic context (like context="{'field1': self}") - // should set {'field1': [value1, value2]} in the context - if (filter.type === 'field' && filter.context) { - context = pyUtils.eval('context', - filter.context, - { self: filterQueryElements.map(({ value }) => value) }, - ); - } - // the following code aims to remodel this: - // https://github.com/odoo/odoo/blob/12.0/addons/web/static/src/js/views/search/search_inputs.js#L498 - // this is required for the helpdesk tour to pass - // this seems weird to only do that for m2o fields, but a test fails if - // we do it for other fields (my guess being that the test should simply - // be adapted) - if (filter.type === 'field' && filter.isDefault && filter.fieldType === 'many2one') { - context[`default_${filter.fieldName}`] = filter.defaultAutocompleteValue.value; - } - return context; - } - - /** - * Return the domain of the provided filter. - * @private - * @param {Object} filter - * @param {Object[]} filterQueryElements - * @returns {string} domain, string representation of a domain - */ - _getFilterDomain(filter, filterQueryElements) { - if (filter.type === 'filter' && filter.hasOptions) { - const { dateFilterId } = this.activeComparison || {}; - if (this.searchMenuTypes.includes('comparison') && dateFilterId === filter.id) { - return "[]"; - } - return this._getDateFilterDomain(filter, filterQueryElements); - } else if (filter.type === 'field') { - return this._getAutoCompletionFilterDomain(filter, filterQueryElements); - } - return filter.domain; - } - - /** - * Return the groupBys of the provided filter. - * @private - * @param {Object} filter - * @param {Object[]} filterQueryElements - * @returns {string[]} groupBys - */ - _getFilterGroupBys(filter, filterQueryElements) { - if (filter.type === 'groupBy') { - const fieldName = filter.fieldName; - if (filter.hasOptions) { - return filterQueryElements.map( - ({ optionId }) => `${fieldName}:${optionId}` - ); - } else { - return [fieldName]; - } - } else { - return filter.groupBys; - } - } - - /** - * Return the concatenation of groupBys comming from the active filters of - * type 'favorite' and 'groupBy'. - * The result respects the appropriate logic: the groupBys - * coming from an active favorite (if any) come first, then come the - * groupBys comming from the active filters of type 'groupBy' in the order - * defined in this.state.query. If no groupBys are found, one tries to - * find some grouBys in the action context. - * @private - * @param {Object[]} groups - * @returns {string[]} - */ - _getGroupBy(groups) { - const groupBys = groups.reduce( - (groupBys, group) => { - if (['groupBy', 'favorite'].includes(group.type)) { - groupBys.push(...this._getGroupGroupBys(group)); - } - return groupBys; - }, - [] - ); - const groupBy = groupBys.length ? groupBys : (this.actionContext.group_by || []); - return typeof groupBy === 'string' ? [groupBy] : groupBy; - } - - /** - * Return the list of the contexts of the filters active in the given - * group. - * @private - * @param {Object} group - * @returns {Object[]} - */ - _getGroupContexts(group) { - const contexts = group.activities.reduce( - (ctx, qe) => [...ctx, this._getFilterContext(qe.filter, qe.filterQueryElements)], - [] - ); - return contexts; - } - - /** - * Return the string representation of a domain created by combining - * appropriately (with an 'OR') the domains coming from the filters - * active in the given group. - * @private - * @param {Object} group - * @returns {string} string representation of a domain - */ - _getGroupDomain(group) { - const domains = group.activities.map(({ filter, filterQueryElements }) => { - return this._getFilterDomain(filter, filterQueryElements); - }); - return pyUtils.assembleDomains(domains, 'OR'); - } - - /** - * Return the groupBys coming form the filters active in the given group. - * @private - * @param {Object} group - * @returns {string[]} - */ - _getGroupGroupBys(group) { - const groupBys = group.activities.reduce( - (gb, qe) => [...gb, ...this._getFilterGroupBys(qe.filter, qe.filterQueryElements)], - [] - ); - return groupBys; - } - - /** - * Reconstruct the (active) groups from the query elements. - * @private - * @returns {Object[]} - */ - _getGroups() { - const groups = this.state.query.reduce( - (groups, queryElem) => { - const { groupId, filterId } = queryElem; - let group = groups.find(group => group.id === groupId); - const filter = this.state.filters[filterId]; - if (!group) { - const { type } = filter; - group = { - id: groupId, - type, - activities: [] - }; - groups.push(group); - } - group.activities.push(queryElem); - return groups; - }, - [] - ); - groups.forEach(g => this._mergeActivities(g)); - return groups; - } - - /** - * Used to get the key orderedBy of the active favorite. - * @private - * @param {Object[]} groups - * @returns {string[]} orderedBy - */ - _getOrderedBy(groups) { - return groups.reduce( - (orderedBy, group) => { - if (group.type === 'favorite') { - const favoriteOrderedBy = group.activities[0].filter.orderedBy; - if (favoriteOrderedBy) { - // Group order is reversed but inner order is kept - orderedBy = [...favoriteOrderedBy, ...orderedBy]; - } - } - return orderedBy; - }, - [] - ); - } - - /** - * Starting from the id of a date filter, returns the array of option ids currently selected - * for the corresponding filter. - * @private - * @param {string} dateFilterId - * @returns {string[]} - */ - _getSelectedOptionIds(dateFilterId) { - const selectedOptionIds = []; - for (const queryElem of this.state.query) { - if (queryElem.filterId === dateFilterId) { - selectedOptionIds.push(queryElem.optionId); - } - } - 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 - * @param {Object} irFilter - * @returns {Object} - */ - _irFilterToFavorite(irFilter) { - let userId = irFilter.user_id || false; - if (Array.isArray(userId)) { - userId = userId[0]; - } - const groupNumber = userId ? FAVORITE_PRIVATE_GROUP : FAVORITE_SHARED_GROUP; - const context = pyUtils.eval('context', irFilter.context, this.env.session.user_context); - let groupBys = []; - if (context.group_by) { - groupBys = context.group_by; - delete context.group_by; - } - let comparison; - if (context.comparison) { - comparison = context.comparison; - delete context.comparison; - } - let sort; - try { - sort = JSON.parse(irFilter.sort); - } catch (err) { - if (err instanceof SyntaxError) { - sort = []; - } else { - throw err; - } - } - const orderedBy = sort.map(order => { - let fieldName; - let asc; - const sqlNotation = order.split(' '); - if (sqlNotation.length > 1) { - // regex: \fieldName (asc|desc)?\ - fieldName = sqlNotation[0]; - asc = sqlNotation[1] === 'asc'; - } else { - // legacy notation -- regex: \-?fieldName\ - fieldName = order[0] === '-' ? order.slice(1) : order; - asc = order[0] === '-' ? false : true; - } - return { - asc: asc, - name: fieldName, - }; - }); - const favorite = { - context, - description: irFilter.name, - domain: irFilter.domain, - groupBys, - groupNumber, - orderedBy, - removable: true, - serverSideId: irFilter.id, - type: 'favorite', - userId, - }; - if (irFilter.is_default) { - favorite.isDefault = irFilter.is_default; - } - if (comparison) { - favorite.comparison = comparison; - } - return favorite; - } - - /** - * Group the query elements in group.activities by qe -> qe.filterId - * and changes the form of group.activities to make it more suitable for further - * computations. - * @private - * @param {Object} group - */ - _mergeActivities(group) { - const { activities, type } = group; - let res = []; - switch (type) { - case 'filter': - case 'groupBy': { - for (const activity of activities) { - const { filterId } = activity; - let a = res.find(({ filter }) => filter.id === filterId); - if (!a) { - a = { - filter: this.state.filters[filterId], - filterQueryElements: [] - }; - res.push(a); - } - a.filterQueryElements.push(activity); - } - break; - } - case 'favorite': - case 'field': - case 'comparison': { - // all activities in the group have same filterId - const { filterId } = group.activities[0]; - const filter = this.state.filters[filterId]; - res.push({ - filter, - filterQueryElements: group.activities - }); - break; - } - } - if (type === 'groupBy') { - res.forEach(activity => { - activity.filterQueryElements.sort( - (qe1, qe2) => rankInterval(qe1.optionId) - rankInterval(qe2.optionId) - ); - }); - } - group.activities = res; - } - - /** - * Set the key label in defaultAutocompleteValue used by default filters of - * type 'field'. - * @private - * @param {Object} filter - */ - _prepareDefaultLabel(filter) { - const { id, fieldType, fieldName, defaultAutocompleteValue } = filter; - const { selection, context, relation } = this.fields[fieldName]; - if (fieldType === 'selection') { - defaultAutocompleteValue.label = selection.find( - sel => sel[0] === defaultAutocompleteValue.value - )[1]; - } else if (fieldType === 'many2one') { - const updateLabel = label => { - const queryElem = this.state.query.find(({ filterId }) => filterId === id); - if (queryElem) { - queryElem.label = label; - defaultAutocompleteValue.label = label; - } - }; - const promise = this.env.services.rpc({ - args: [defaultAutocompleteValue.value], - context: context, - method: 'name_get', - model: relation, - }) - .then(results => updateLabel(results[0][1])) - .guardedCatch(() => updateLabel(defaultAutocompleteValue.value)); - this.labelPromises.push(promise); - } else { - defaultAutocompleteValue.label = defaultAutocompleteValue.value; - } - } - - /** - * 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. - * If the operation is successful, a new filter of type 'favorite' is - * created and activated. - * @private - * @param {Object} preFilter - * @returns {Promise} - */ - async _saveQuery(preFilter) { - const irFilter = this.getIrFilterValues(preFilter); - const serverSideId = await this.env.dataManager.create_filter(irFilter); - - preFilter.serverSideId = serverSideId; - - return preFilter; - } - - //--------------------------------------------------------------------- - // Static - //--------------------------------------------------------------------- - - /** - * @override - * @returns {{ attrs: Object, children: Object[] }} - */ - static extractArchInfo(archs) { - const { attrs, children } = archs.search; - const controlPanelInfo = { - attrs, - children: [], - }; - for (const child of children) { - if (child.tag !== "searchpanel") { - controlPanelInfo.children.push(child); - } - } - return controlPanelInfo; - } - } - - ActionModel.registry.add("ControlPanel", ControlPanelModelExtension, 10); - - export default ControlPanelModelExtension; diff --git a/addons/web/static/src/legacy/js/control_panel/custom_favorite_item.js b/addons/web/static/src/legacy/js/control_panel/custom_favorite_item.js deleted file mode 100644 index d5f8fc11ca3..00000000000 --- a/addons/web/static/src/legacy/js/control_panel/custom_favorite_item.js +++ /dev/null @@ -1,149 +0,0 @@ -/** @odoo-module alias=web.CustomFavoriteItem **/ - - import { CheckBox } from "@web/core/checkbox/checkbox"; - import { Dropdown } from "@web/core/dropdown/dropdown"; - import FavoriteMenu from "web.FavoriteMenu"; - import { useModel } from "web.Model"; - import { LegacyComponent } from "@web/legacy/legacy_component"; - - const { useState, useRef } = owl; - - let favoriteId = 0; - - /** - * Favorite generator menu - * - * This component is used to add a new favorite linked which will take every - * information out of the current context and save it to a new `ir.filter`. - * - * There are 3 additional inputs to modify the filter: - * - a text input (mandatory): the name of the favorite (must be unique) - * - 'use by default' checkbox: if checked, the favorite will be the default - * filter of the current model (and will bypass - * any existing default filter). Cannot be checked - * along with 'share with all users' checkbox. - * - 'share with all users' checkbox: if checked, the favorite will be available - * with all users instead of the current - * one.Cannot be checked along with 'use - * by default' checkbox. - * Finally, there is a 'Save' button used to apply the current configuration - * and save the context to a new filter. - */ - class CustomFavoriteItem extends LegacyComponent { - setup() { - const favId = favoriteId++; - this.useByDefaultId = `o_favorite_use_by_default_${favId}`; - this.shareAllUsersId = `o_favorite_share_all_users_${favId}`; - this.descriptionRef = useRef("description"); - this.model = useModel("searchModel"); - this.interactive = true; - this.state = useState({ - description: this.env.action.name || "", - isDefault: false, - isShared: false, - }); - } - - //--------------------------------------------------------------------- - // Private - //--------------------------------------------------------------------- - - /** - * @private - */ - saveFavorite() { - if (!this.state.description) { - this.env.services.notification.notify({ - message: this.env._t("A name for your favorite filter is required."), - type: 'danger', - }); - return this.descriptionRef.el.focus(); - } - 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."), - type: 'danger', - }); - return this.descriptionRef.el.focus(); - } - this.model.dispatch('createNewFavorite', { - type: 'favorite', - description: this.state.description, - isDefault: this.state.isDefault, - isShared: this.state.isShared, - }); - // Reset state - Object.assign(this.state, { - description: this.env.action.name || "", - isDefault: false, - isShared: false, - open: false, - }); - } - - //--------------------------------------------------------------------- - // Handlers - //--------------------------------------------------------------------- - - /** - * @private - * @param {boolean} checked - */ - onDefaultCheckboxChange(checked) { - this.state.isDefault = checked; - if (checked) { - this.state.isShared = false; - } - } - - /** - * @private - * @param {boolean} checked - */ - onShareCheckboxChange(checked) { - this.state.isShared = checked; - if (checked) { - this.state.isDefault = false; - } - } - - /** - * @private - * @param {jQueryEvent} ev - */ - onInputKeydown(ev) { - switch (ev.key) { - case 'Enter': - ev.preventDefault(); - this.saveFavorite(); - break; - case 'Escape': - // Gives the focus back to the component. - ev.preventDefault(); - ev.target.blur(); - break; - } - } - - //--------------------------------------------------------------------- - // Static - //--------------------------------------------------------------------- - - /** - * @param {Object} env - * @returns {boolean} - */ - static shouldBeDisplayed(env) { - return true; - } - } - - CustomFavoriteItem.props = {}; - CustomFavoriteItem.template = "web.CustomFavoriteItem"; - CustomFavoriteItem.components = { CheckBox, Dropdown }; - CustomFavoriteItem.groupNumber = 3; // have 'Save Current Search' in its own group - - FavoriteMenu.registry.add('favorite-generator-menu', CustomFavoriteItem, 0); - - export default CustomFavoriteItem; diff --git a/addons/web/static/src/legacy/js/control_panel/custom_filter_item.js b/addons/web/static/src/legacy/js/control_panel/custom_filter_item.js deleted file mode 100644 index 430d16da76b..00000000000 --- a/addons/web/static/src/legacy/js/control_panel/custom_filter_item.js +++ /dev/null @@ -1,281 +0,0 @@ -/** @odoo-module alias=web.CustomFilterItem **/ - - import { Dropdown } from "@web/core/dropdown/dropdown"; - import { DatePicker, DateTimePicker } from "web.DatePickerOwl"; - import Domain from "web.Domain"; - import { FIELD_OPERATORS, FIELD_TYPES } from "web.searchUtils"; - import field_utils from "web.field_utils"; - import { useModel } from "web.Model"; - import { LegacyComponent } from "@web/legacy/legacy_component"; - - const { useState } = owl; - - /** - * Filter generator menu - * - * Component which purpose is to generate new filters from a set of inputs. - * It is made of one or several `condition` objects that will be used to generate - * filters which will be added in the filter menu. Each condition is composed - * of 2, 3 or 4 inputs: - * - * 1. FIELD (select): the field used to form the base of the domain condition; - * - * 2. OPERATOR (select): the symbol determining the operator(s) of the domain - * condition, linking the field to one or several value(s). - * Some operators can have pre-defined values that will replace user inputs. - * @see searchUtils for the list of operators. - * - * 3. [VALUE] (input|select): the value of the domain condition. it will be parsed - * according to the selected field's type. Note that - * it is optional as some operators have defined values. - * The generated condition domain will be as following: - * [ - * [field, operator, (operator_value|input_value)] - * ] - * - * 4. [VALUE] (input): for now, only date-typed fields with the 'between' operator - * allow for a second value. The given input values will then - * be taken as the borders of the date range (between x and y) - * and will be translated as the following domain form: - * [ - * [date_field, '>=', x], - * [date_field, '<=', y], - * ] - */ - class CustomFilterItem extends LegacyComponent { - setup() { - this.model = useModel('searchModel'); - - this.conditions = useState([]); - // Format, filter and sort the fields props - this.fields = Object.values(this.props.fields) - .filter(field => this._validateField(field)) - .concat({ string: 'ID', type: 'id', name: 'id' }) - .sort(({ string: a }, { string: b }) => a > b ? 1 : a < b ? -1 : 0); - - // Give access to constants variables to the template. - this.DECIMAL_POINT = this.env._t.database.parameters.decimal_point; - this.OPERATORS = FIELD_OPERATORS; - this.FIELD_TYPES = FIELD_TYPES; - - // Add first condition - this.addNewCondition(); - } - - //--------------------------------------------------------------------- - // Private - //--------------------------------------------------------------------- - - /** - * Populate the conditions list with a new condition having as properties: - * - the last condition or the first available field - * - the last condition or the first available operator - * - a null or empty array value - * @private - */ - addNewCondition() { - const lastCondition = [...this.conditions].pop(); - const condition = lastCondition - ? Object.assign({}, lastCondition) - : { - field: 0, - operator: 0, - }; - this._setDefaultValue(condition); - this.conditions.push(condition); - } - - /** - * @private - * @param {Object} field - * @returns {boolean} - */ - _validateField(field) { - return !field.deprecated && - field.searchable && - FIELD_TYPES[field.type] && - field.name !== 'id'; - } - - /** - * @private - * @param {Object} condition - */ - _setDefaultValue(condition) { - const fieldType = this.fields[condition.field].type; - const genericType = FIELD_TYPES[fieldType]; - const operator = FIELD_OPERATORS[genericType][condition.operator]; - // Logical value - switch (genericType) { - case 'id': - case 'number': - condition.value = 0; - break; - case 'date': - condition.value = [moment()]; - if (operator.symbol === 'between') { - condition.value.push(moment()); - } - break; - case 'datetime': - condition.value = [moment('00:00:00', 'hh:mm:ss').utcOffset(0, true)]; - if (operator.symbol === 'between') { - condition.value.push(moment('23:59:59', 'hh:mm:ss').utcOffset(0, true)); - } - break; - case 'selection': - if (this.fields[condition.field].selection.length) { - const [firstValue] = this.fields[condition.field].selection[0]; - condition.value = firstValue; - } - else { - condition.value = ""; - } - break; - default: - condition.value = ""; - } - // Displayed value - if (["float", "monetary"].includes(fieldType)) { - condition.displayedValue = `0${this.DECIMAL_POINT}0`; - } else { - condition.displayedValue = String(condition.value); - } - } - - //--------------------------------------------------------------------- - // Handlers - //--------------------------------------------------------------------- - - /** - * Convert all conditions to prefilters. - * @private - */ - onApply() { - const preFilters = this.conditions.map(condition => { - const field = this.fields[condition.field]; - const type = this.FIELD_TYPES[field.type]; - const operator = this.OPERATORS[type][condition.operator]; - const descriptionArray = [field.string, operator.description]; - const domainArray = []; - let domainValue; - // Field type specifics - if ('value' in operator) { - domainValue = [operator.value]; - // No description to push here - } else if (['date', 'datetime'].includes(type)) { - domainValue = condition.value.map( - val => field_utils.parse[type](val, { type }, { timezone: true }) - ); - const dateValue = condition.value.map( - val => field_utils.format[type](val, { type }, { timezone: false }) - ); - descriptionArray.push(`"${dateValue.join(" " + this.env._t("and") + " ")}"`); - } else if (type === "selection") { - domainValue = [condition.value]; - const formattedValue = field_utils.format[type](condition.value, field); - descriptionArray.push(`"${formattedValue}"`); - } else { - domainValue = [condition.value]; - descriptionArray.push(`"${condition.value}"`); - } - // Operator specifics - if (operator.symbol === 'between') { - domainArray.push( - [field.name, '>=', domainValue[0]], - [field.name, '<=', domainValue[1]] - ); - } else { - domainArray.push([field.name, operator.symbol, domainValue[0]]); - } - const preFilter = { - description: descriptionArray.join(" "), - domain: Domain.prototype.arrayToString(domainArray), - type: 'filter', - }; - return preFilter; - }); - - this.model.dispatch('createNewFilters', preFilters); - - // remove conditions - this.conditions.splice(0, this.conditions.length); - - this.addNewCondition(); - } - - /** - * @private - * @param {Object} condition - * @param {number} valueIndex - * @param {Date} date - */ - onDateTimeChanged(condition, valueIndex, date) { - condition.value[valueIndex] = date; - } - - /** - * @private - * @param {Object} condition - * @param {Event} ev - */ - onFieldSelect(condition, ev) { - Object.assign(condition, { - field: ev.target.selectedIndex, - operator: 0, - }); - this._setDefaultValue(condition); - } - - /** - * @private - * @param {Object} condition - * @param {Event} ev - */ - onOperatorSelect(condition, ev) { - condition.operator = ev.target.selectedIndex; - this._setDefaultValue(condition); - } - - /** - * @private - * @param {Object} condition - */ - onRemoveCondition(conditionIndex) { - this.conditions.splice(conditionIndex, 1); - } - - /** - * @private - * @param {Object} condition - * @param {Event} ev - */ - onValueChange(condition, ev) { - if (!ev.target.value) { - return this._setDefaultValue(condition); - } - let { type } = this.fields[condition.field]; - if (type === "id") { - type = "integer"; - } - if (FIELD_TYPES[type] === "number") { - try { - // Write logical value into the 'value' property - condition.value = field_utils.parse[type](ev.target.value); - // Write displayed value in the input and 'displayedValue' property - condition.displayedValue = ev.target.value; - } catch { - // Parsing error: reverts to previous value - ev.target.value = condition.displayedValue; - } - } else { - condition.value = condition.displayedValue = ev.target.value; - } - } - } - - CustomFilterItem.components = { DatePicker, DateTimePicker, Dropdown }; - CustomFilterItem.props = { fields: Object }; - CustomFilterItem.template = "web.Legacy.CustomFilterItem"; - - export default CustomFilterItem; diff --git a/addons/web/static/src/legacy/js/control_panel/favorite_menu.js b/addons/web/static/src/legacy/js/control_panel/favorite_menu.js deleted file mode 100644 index 8f0ae39a53c..00000000000 --- a/addons/web/static/src/legacy/js/control_panel/favorite_menu.js +++ /dev/null @@ -1,88 +0,0 @@ -/** @odoo-module alias=web.FavoriteMenu **/ - - import { Dropdown } from "@web/core/dropdown/dropdown"; - import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item"; - import Dialog from "web.OwlDialog"; - import { FACET_ICONS } from "web.searchUtils"; - import Registry from "web.Registry"; - import { useModel } from "web.Model"; - import { LegacyComponent } from "@web/legacy/legacy_component"; - - const { useState } = owl; - - /** - * 'Favorites' menu - * - * Simple rendering of the filters of type `favorites` given by the control panel - * model. It uses most of the behaviours implemented by the dropdown menu Component, - * with the addition of a submenu registry used to display additional components. - * Only the favorite generator (@see CustomFavoriteItem) is registered in - * the `web` module. - */ - class FavoriteMenu extends LegacyComponent { - setup() { - this.icon = FACET_ICONS.favorite; - this.model = useModel('searchModel'); - this.state = useState({ deletedFavorite: false }); - } - - //--------------------------------------------------------------------- - // Getters - //--------------------------------------------------------------------- - - /** - * @override - */ - get items() { - const favorites = this.model.get('filters', f => f.type === 'favorite'); - const registryMenus = this.constructor.registry.values().reduce( - (menus, Component) => { - if (Component.shouldBeDisplayed(this.env)) { - menus.push({ - key: Component.name, - groupNumber: Component.groupNumber, - Component, - }); - } - return menus; - }, - [] - ); - return [...favorites, ...registryMenus]; - } - - //--------------------------------------------------------------------- - // Handlers - //--------------------------------------------------------------------- - - /** - * @private - * @param {int} id - */ - openConfirmationDialog(id) { - const favorite = this.items.find(fav => fav.id === id); - this.state.deletedFavorite = favorite; - } - - /** - * @private - * @param {number} itemId - */ - onFavoriteSelected(itemId) { - this.model.dispatch('toggleFilter', itemId); - } - - /** - * @private - */ - async _onRemoveFavorite() { - this.model.dispatch('deleteFavorite', this.state.deletedFavorite.id); - this.state.deletedFavorite = false; - } - } - - FavoriteMenu.registry = new Registry(); - FavoriteMenu.components = { Dialog, Dropdown, SearchDropdownItem }; - FavoriteMenu.template = 'web.Legacy.FavoriteMenu'; - - export default FavoriteMenu; diff --git a/addons/web/static/src/legacy/js/control_panel/filter_menu.js b/addons/web/static/src/legacy/js/control_panel/filter_menu.js deleted file mode 100644 index e00d4baa302..00000000000 --- a/addons/web/static/src/legacy/js/control_panel/filter_menu.js +++ /dev/null @@ -1,64 +0,0 @@ -/** @odoo-module alias=web.FilterMenu **/ - - import { Dropdown } from "@web/core/dropdown/dropdown"; - import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item"; - import CustomFilterItem from "web.CustomFilterItem"; - import { FACET_ICONS } from "web.searchUtils"; - import { useModel } from "web.Model"; - import { LegacyComponent } from "@web/legacy/legacy_component"; - - /** - * 'Filters' menu - * - * Simple rendering of the filters of type `filter` given by the control panel - * model. It uses most of the behaviours implemented by the dropdown menu Component, - * with the addition of a filter generator (@see CustomFilterItem). - */ - class FilterMenu extends LegacyComponent { - - setup() { - this.icon = FACET_ICONS.filter; - this.model = useModel('searchModel'); - } - - //--------------------------------------------------------------------- - // Getters - //--------------------------------------------------------------------- - - /** - * @override - */ - get items() { - return this.model.get('filters', f => f.type === 'filter'); - } - - //--------------------------------------------------------------------- - // Handlers - //--------------------------------------------------------------------- - - /** - * @private - * @param {Object} param0 - * @param {number} param0.itemId - * @param {number} [param0.optionId] - */ - onFilterSelected({ itemId, optionId }) { - if (optionId) { - this.model.dispatch('toggleFilterWithOptions', itemId, optionId); - } else { - this.model.dispatch('toggleFilter', itemId); - } - } - } - - FilterMenu.defaultProps = { - class: "", - }; - FilterMenu.props = { - fields: Object, - class: { String, optional: true }, - }; - FilterMenu.template = "web.legacy.FilterMenu"; - FilterMenu.components = { CustomFilterItem, Dropdown, SearchDropdownItem }; - - export default FilterMenu; diff --git a/addons/web/static/src/legacy/js/control_panel/groupby_menu.js b/addons/web/static/src/legacy/js/control_panel/groupby_menu.js deleted file mode 100644 index 8b27f4af044..00000000000 --- a/addons/web/static/src/legacy/js/control_panel/groupby_menu.js +++ /dev/null @@ -1,76 +0,0 @@ -/** @odoo-module alias=web.GroupByMenu **/ - - import { Dropdown } from "@web/core/dropdown/dropdown"; - import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item"; - import { CustomGroupByItem } from "@web/search/custom_group_by_item/custom_group_by_item"; - import { FACET_ICONS, GROUPABLE_TYPES } from "web.searchUtils"; - import { useModel } from "web.Model"; - import { LegacyComponent } from "@web/legacy/legacy_component"; - - class GroupByMenu extends LegacyComponent { - - setup() { - this.icon = FACET_ICONS.groupBy; - - this.fields = Object.values(this.props.fields) - .filter(field => this._validateField(field)) - .sort(({ string: a }, { string: b }) => a > b ? 1 : a < b ? -1 : 0); - - this.model = useModel('searchModel'); - } - - //--------------------------------------------------------------------- - // Getters - //--------------------------------------------------------------------- - - /** - * @override - */ - get items() { - return this.model.get('filters', f => f.type === 'groupBy'); - } - - //--------------------------------------------------------------------- - // Private - //--------------------------------------------------------------------- - - /** - * @see {@link odoo/fields.py} Field._description_sortable - * @see {@link odoo/fields.py} Many2Many.groupable - * @private - * @param {Object} field - * @returns {boolean} - */ - _validateField(field) { - return (field.type === "many2many" ? field.store : field.sortable) && - field.name !== "id" && - GROUPABLE_TYPES.includes(field.type); - } - - //--------------------------------------------------------------------- - // Handlers - //--------------------------------------------------------------------- - - /** - * @param {Object} param0 - * @param {number} param0.itemId - * @param {number} [param0.optionId] - */ - onGroupBySelected({ itemId, optionId }) { - if (optionId) { - this.model.dispatch('toggleFilterWithOptions', itemId, optionId); - } else { - this.model.dispatch('toggleFilter', itemId); - } - } - onAddCustomGroup(fieldName) { - const field = this.props.fields[fieldName]; - this.model.dispatch("createNewGroupBy", field); - } - } - - GroupByMenu.components = { CustomGroupByItem, Dropdown, SearchDropdownItem }; - GroupByMenu.props = { fields: Object }; - GroupByMenu.template = "web.legacy.GroupByMenu"; - - export default GroupByMenu; diff --git a/addons/web/static/src/legacy/js/control_panel/search_bar.js b/addons/web/static/src/legacy/js/control_panel/search_bar.js deleted file mode 100644 index 9e2cde752c8..00000000000 --- a/addons/web/static/src/legacy/js/control_panel/search_bar.js +++ /dev/null @@ -1,491 +0,0 @@ -/** @odoo-module alias=web.SearchBar **/ - - import Domain from "web.Domain"; - import field_utils from "web.field_utils"; - import { useAutofocus } from "@web/core/utils/hooks"; - import { useModel } from "web.Model"; - import { fuzzyTest } from "@web/core/utils/search"; - import { LegacyComponent } from "@web/legacy/legacy_component"; - - const { onMounted, onWillUnmount, toRaw, useExternalListener, useState } = owl; - const CHAR_FIELDS = ['char', 'html', 'many2many', 'many2one', 'one2many', 'text']; - - let sourceId = 0; - - /** - * Search bar - * - * This component has two main roles: - * 1) Display the current search facets - * 2) Create new search filters using an input and an autocompletion values - * generator. - * - * For the first bit, the core logic can be found in the XML template of this - * component, searchfacet components or in the ControlPanelModel itself. - * - * The autocompletion mechanic works with transient subobjects called 'sources'. - * Sources contain the information that will be used to generate new search facets. - * A source is generated either: - * a. From an undetermined user input: the user will give a string and select - * a field from the autocompletion dropdown > this will search the selected - * field records with the given pattern (with an 'ilike' operator); - * b. From a given selection: when given an input by the user, the searchbar - * will pre-fetch 'many2one' field records matching the input value and filter - * 'select' fields with the same value. If the user clicks on one of these - * fetched/filtered values, it will generate a matching search facet targeting - * records having this exact value. - * @extends Component - */ - class SearchBar extends LegacyComponent { - setup() { - this.inputRef = useAutofocus(); - this.model = useModel('searchModel'); - this.state = useState({ - sources: [], - focusedItem: 0, - inputValue: "", - }); - - this.autoCompleteSources = this.model.get('filters', f => f.type === 'field').map( - filter => this._createSource(filter) - ); - this.noResultItem = [null, this.env._t("(no result)")]; - - useExternalListener(window, 'click', this._onWindowClick); - useExternalListener(window, 'keydown', this._onWindowKeydown); - - onMounted(() => { - // 'search' will always patch the search bar, 'focus' will never. - this.model.on('search', this, () => { - this.inputRef.el.focus(); - }); - this.model.on('focus-control-panel', this, () => { - this.inputRef.el.focus(); - }); - }); - - onWillUnmount(() => { - this.model.off('search', this); - this.model.off('focus-control-panel', this); - }); - } - - //--------------------------------------------------------------------- - // Private - //--------------------------------------------------------------------- - - /** - * @private - */ - _closeAutoComplete() { - this.state.sources = []; - this.state.focusedItem = 0; - this.state.inputValue = ""; - this.inputRef.el.value = ""; - this.inputRef.el.focus(); - } - - /** - * @private - * @param {Object} filter - * @returns {Object} - */ - _createSource(filter) { - const field = toRaw(this.props.fields)[filter.fieldName]; - const type = field.type === "reference" ? "char" : field.type; - const source = { - active: true, - description: filter.description, - filterId: filter.id, - filterOperator: filter.operator, - id: sourceId ++, - operator: CHAR_FIELDS.includes(type) ? 'ilike' : '=', - parent: false, - type, - }; - switch (type) { - case 'selection': { - source.active = false; - source.selection = field.selection || []; - break; - } - case 'boolean': { - source.active = false; - source.selection = [ - [true, this.env._t("Yes")], - [false, this.env._t("No")], - ]; - break; - } - case 'many2one': { - source.expand = true; - source.expanded = false; - source.context = field.context; - source.relation = field.relation; - if (filter.domain) { - source.domain = filter.domain; - } - } - } - return source; - } - - /** - * @private - * @param {Object} source - * @param {[any, string]} values - * @param {boolean} [active=true] - */ - _createSubSource(source, [value, label], active = true) { - const subSource = { - active, - filterId: source.filterId, - filterOperator: source.filterOperator, - id: sourceId ++, - label, - operator: '=', - parent: source, - value, - }; - return subSource; - } - - /** - * @private - * @param {Object} source - * @param {boolean} shouldExpand - */ - async _expandSource(source, shouldExpand) { - source.expanded = shouldExpand; - if (shouldExpand) { - let args = source.domain; - if (typeof args === 'string') { - try { - args = Domain.prototype.stringToArray(args); - } catch { - args = []; - } - } - const results = await this.rpc({ - kwargs: { - args, - context: { ...this.model.config.context, ...source.context }, - limit: 8, - name: this.state.inputValue.trim(), - }, - method: 'name_search', - model: source.relation, - }); - const options = results.map(result => this._createSubSource(source, result)); - const parentIndex = this.state.sources.indexOf(source); - if (!options.length) { - options.push(this._createSubSource(source, this.noResultItem, false)); - } - this.state.sources.splice(parentIndex + 1, 0, ...options); - } else { - this.state.sources = this.state.sources.filter(src => src.parent !== source); - } - } - - /** - * @private - * @param {string} query - */ - _filterSources(query) { - return this.autoCompleteSources.reduce( - (sources, source) => { - // Field selection or boolean. - if (source.selection) { - const options = []; - source.selection.forEach(result => { - if (fuzzyTest(query, result[1].toLowerCase())) { - options.push(this._createSubSource(source, result)); - } - }); - if (options.length) { - sources.push(...options); - } - // Any other type. - } else if (this._validateSource(query, source)) { - sources.push(source); - } - // Fold any expanded item. - if (source.expanded) { - source.expanded = false; - } - return sources; - }, - [] - ); - } - - /** - * Focus the search facet at the designated index if any. - * @private - */ - _focusFacet(index) { - const facets = this.el.getElementsByClassName('o_searchview_facet'); - if (facets.length) { - facets[index].focus(); - } - } - - /** - * Try to parse the given rawValue according to the type of the given - * source field type. The returned formatted value is the one that will - * supposedly be sent to the server. - * @private - * @param {string} rawValue - * @param {Object} source - * @returns {string} - */ - _parseWithSource(rawValue, { type }) { - const parser = field_utils.parse[type]; - let parsedValue; - switch (type) { - case 'date': - case 'datetime': { - const parsedDate = parser(rawValue, { type }, { timezone: true }); - const dateFormat = type === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD'; - const momentValue = moment(parsedDate, dateFormat); - if (!momentValue.isValid()) { - throw new Error('Invalid date'); - } - parsedValue = parsedDate.toJSON(); - break; - } - case 'many2one': { - parsedValue = rawValue; - break; - } - default: { - parsedValue = parser(rawValue); - } - } - return parsedValue; - } - - /** - * @private - * @param {Object} source - */ - _selectSource(source) { - // Inactive sources are: - // - Selection sources - // - "no result" items - if (source.active) { - const labelValue = source.label || this.state.inputValue.trim(); - this.model.dispatch('addAutoCompletionValues', { - filterId: source.filterId, - value: "value" in source ? source.value : this._parseWithSource(labelValue, source), - label: labelValue, - operator: source.filterOperator || source.operator, - }); - } - this._closeAutoComplete(); - } - - /** - * @private - * @param {string} query - * @param {Object} source - * @returns {boolean} - */ - _validateSource(query, source) { - try { - this._parseWithSource(query, source); - } catch { - return false; - } - return true; - } - - //--------------------------------------------------------------------- - // Handlers - //--------------------------------------------------------------------- - - /** - * @private - * @param {Object} facet - * @param {number} facetIndex - * @param {KeyboardEvent} ev - */ - _onFacetKeydown(facet, facetIndex, ev) { - switch (ev.key) { - case 'ArrowLeft': - if (facetIndex === 0) { - this.inputRef.el.focus(); - } else { - this._focusFacet(facetIndex - 1); - } - break; - case 'ArrowRight': - const facets = this.el.getElementsByClassName('o_searchview_facet'); - if (facetIndex === facets.length - 1) { - this.inputRef.el.focus(); - } else { - this._focusFacet(facetIndex + 1); - } - break; - case 'Backspace': - this._onFacetRemove(facet); - break; - } - } - - /** - * @private - * @param {Object} facet - */ - _onFacetRemove(facet) { - this.model.dispatch('deactivateGroup', facet.groupId); - } - - /** - * @private - * @param {KeyboardEvent} ev - */ - _onSearchKeydown(ev) { - if (ev.isComposing) { - // This case happens with an IME for example: we let it handle all key events. - return; - } - const currentItem = this.state.sources[this.state.focusedItem] || {}; - switch (ev.key) { - case 'ArrowDown': - ev.preventDefault(); - if (Object.keys(this.state.sources).length) { - let nextIndex = this.state.focusedItem + 1; - if (nextIndex >= this.state.sources.length) { - nextIndex = 0; - } - this.state.focusedItem = nextIndex; - } else { - this.env.bus.trigger('focus-view'); - } - break; - case 'ArrowLeft': - if (currentItem.expanded) { - // Priority 1: fold expanded item. - ev.preventDefault(); - this._expandSource(currentItem, false); - } else if (currentItem.parent) { - // Priority 2: focus parent item. - ev.preventDefault(); - this.state.focusedItem = this.state.sources.indexOf(currentItem.parent); - // Priority 3: Do nothing (navigation inside text). - } else if (ev.target.selectionStart === 0) { - // Priority 4: navigate to rightmost facet. - this._focusFacet(this.model.get("facets").length - 1); - } - break; - case 'ArrowRight': - if (ev.target.selectionStart === this.state.inputValue.length) { - // Priority 1: Do nothing (navigation inside text). - if (currentItem.expand) { - // Priority 2: go to first child or expand item. - ev.preventDefault(); - if (currentItem.expanded) { - this.state.focusedItem ++; - } else { - this._expandSource(currentItem, true); - } - } else if (ev.target.selectionStart === this.state.inputValue.length) { - // Priority 3: navigate to leftmost facet. - this._focusFacet(0); - } - } - break; - case 'ArrowUp': - ev.preventDefault(); - let previousIndex = this.state.focusedItem - 1; - if (previousIndex < 0) { - previousIndex = this.state.sources.length - 1; - } - this.state.focusedItem = previousIndex; - break; - case 'Backspace': - if (!this.state.inputValue.length) { - const facets = this.model.get("facets"); - if (facets.length) { - this._onFacetRemove(facets[facets.length - 1]); - } - } - break; - case 'Enter': - if (!this.state.inputValue.length) { - this.model.dispatch('search'); - break; - } - /* falls through */ - case 'Tab': - if (this.state.inputValue.length) { - ev.preventDefault(); // keep the focus inside the search bar - this._selectSource(currentItem); - } - break; - case 'Escape': - if (this.state.sources.length) { - this._closeAutoComplete(); - } - break; - } - } - - /** - * @private - * @param {InputEvent} ev - */ - _onSearchInput(ev) { - this.state.inputValue = ev.target.value; - const wasVisible = this.state.sources.length; - const query = this.state.inputValue.trim().toLowerCase(); - if (query.length) { - this.state.sources = this._filterSources(query); - } else if (wasVisible) { - this._closeAutoComplete(); - } - } - - /** - * Only handled if the user has moved its cursor at least once after the - * results are loaded and displayed. - * @private - * @param {number} resultIndex - */ - _onSourceMousemove(resultIndex) { - this.state.focusedItem = resultIndex; - } - - /** - * @private - * @param {MouseEvent} ev - */ - _onWindowClick(ev) { - if (this.state.sources.length && !this.el.contains(ev.target)) { - this._closeAutoComplete(); - } - } - - /** - * @private - * @param {KeyboardEvent} ev - */ - _onWindowKeydown(ev) { - if (ev.key === 'Escape' && this.state.sources.length) { - ev.preventDefault(); - ev.stopPropagation(); - this._closeAutoComplete(); - } - } - } - - SearchBar.defaultProps = { - fields: {}, - }; - SearchBar.props = { - fields: { type: Object, optional: true }, - }; - SearchBar.template = 'web.Legacy.SearchBar'; - - export default SearchBar; diff --git a/addons/web/static/src/legacy/js/model.js b/addons/web/static/src/legacy/js/model.js deleted file mode 100644 index a46d0fc29f0..00000000000 --- a/addons/web/static/src/legacy/js/model.js +++ /dev/null @@ -1,478 +0,0 @@ -/** @odoo-module alias=web.Model **/ - - import { groupBy } from "web.utils"; - import Registry from "web.Registry"; - import { useBus } from "@web/core/utils/hooks"; - - const { EventBus, onWillRender, useComponent } = owl; - const isNotNull = (val) => val !== null && val !== undefined; - - /** - * Feature extension of the class Model. - * @see {Model} - */ - class ModelExtension { - /** - * @param {Object} config - * @param {Object} config.env - */ - constructor(config) { - this.config = config; - this.env = this.config.env; - this.shouldLoad = true; - this.state = {}; - } - - //--------------------------------------------------------------------- - // Public - //--------------------------------------------------------------------- - - /** - * Used by the parent model to initiate a load action. The actual - * loading of the extension is determined by the "shouldLoad" property. - * @param {Object} params - */ - async callLoad(params) { - if (this.shouldLoad) { - this.shouldLoad = false; - await this.load(params); - } - } - - /** - * @param {string} method - * @param {...any} args - */ - dispatch(method, ...args) { - if (method in this) { - this[method](...args); - } - } - - /** - * 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 - * @param {Object} params - * @returns {Promise} - */ - async load(/* params */) { - /* ... */ - } - - /** - * Called on initialization if no imported state for the extension is - * found. - * @abstract - */ - prepareState() { - /* ... */ - } - } - /** - * 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("ControlPanel", 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 = new WeakMap(); - 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 and components are notified afterwards. - * @param {string} method - * @param {...any} args - */ - dispatch(method, ...args) { - const isInitialDispatch = !this.dispatching; - this.dispatching = true; - for (const extension of this.extensions.flat()) { - extension.dispatch(method, ...args); - } - if (!isInitialDispatch) { - return; - } - this.dispatching = false; - let rev = this.rev; - // 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. - this._loadExtensions({ isInitialLoad: false }).then(() => { - // Notifies subscribed components - // Purpose: re-render components bound by 'useModel' - 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() { - await this._loadExtensions({ isInitialLoad: true }); - } - - //--------------------------------------------------------------------- - // Private - //--------------------------------------------------------------------- - - /** - * 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} excluded - * @param {string} property - * @param {...any} args - * @returns {any[]} - */ - __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; - } - - /** - * Private handler to loop over all extension layers sequencially and - * wait for a given callback to be completed on all extensions of a - * same layer. - * @private - * @param {Object} params - * @param {boolean} params.isInitialLoad whether this call comes - * from the initial load. - * @returns {Promise} - */ - async _loadExtensions(params) { - for (let layer = 0; layer < this.extensions.length; layer++) { - await Promise.all(this.extensions[layer].map( - (extension) => extension.callLoad(params) - )); - } - } - - /** - * @see Context.__notifyComponents() in owl.js for explanation - * @private - */ - _notifyComponents() { - const rev = ++this.rev; - this.trigger("update", { rev }); - } - } - - Model.Extension = ModelExtension; - - /** - * This is more or less the hook 'useContextWithCB' from owl only slightly - * simplified. - * - * @param {string} modelName - * @returns {model} - */ - function useModel(modelName) { - const component = useComponent(); - const model = component.env[modelName]; - if (!(model instanceof Model)) { - throw new Error(`No Model found when connecting '${component.constructor.name}'`); - } - - model.mapping.set(component, 0); - const __render = component.render.bind(component); - component.render = function () { - if (model.rev > model.mapping.get(component)) { - return; - } - __render(); - }; - - onWillRender(() => { - model.mapping.set(component, model.rev); - }); - - useBus(model, "update", (ev) => { - const { rev } = ev.detail; - if (model.mapping.get(component) < rev) { - model.mapping.set(component, rev); - component.render(); - } - }); - - return model; - } - - export default { - Model, - useModel, - }; diff --git a/addons/web/static/src/legacy/js/views/action_model.js b/addons/web/static/src/legacy/js/views/action_model.js deleted file mode 100644 index cf440be9663..00000000000 --- a/addons/web/static/src/legacy/js/views/action_model.js +++ /dev/null @@ -1,234 +0,0 @@ -/** @odoo-module alias=web.ActionModel **/ - - import Domain from "web.Domain"; - import { FACET_ICONS } from "web.searchUtils"; - import { Model } from "web.Model"; - import { parseArch } from "web.viewUtils"; - import pyUtils from "web.py_utils"; - import Registry from "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 { - - //--------------------------------------------------------------------- - // Public - //--------------------------------------------------------------------- - - /** - * Initiates the asynchronous tasks of the extension and returns a - * promise resolved as soon as all the informations necessary to build - * the search query are ready. - * @returns {Promise} - */ - async callLoad() { - this.loadPromise = super.callLoad(...arguments); - await this.loadPromise; - } - - /** - * Returns a promise resolved when the extension is completely ready. - * @returns {Promise} - */ - async isReady() { - await this.loadPromise; - } - - //--------------------------------------------------------------------- - // 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); - } - - /** - * Returns a promise resolved when all extensions are completely ready. - * @returns {Promise} - */ - async isReady() { - await this._awaitExtensions(); - } - - //--------------------------------------------------------------------- - // Private - //--------------------------------------------------------------------- - - /** - * @private - * @returns {Promise} - */ - async _awaitExtensions() { - await Promise.all(this.extensions.flat().map( - (extension) => extension.isReady() - )); - } - - /** - * @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; - } - - /** - * Overridden to trigger a "search" event as soon as the query data - * are ready. - * @override - */ - async _loadExtensions({ isInitialLoad }) { - await super._loadExtensions(...arguments); - if (!isInitialLoad) { - this.trigger("search", this.get("query")); - await this._awaitExtensions(); - } - } - - //--------------------------------------------------------------------- - // 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 - ); - - export default ActionModel; diff --git a/addons/web/static/src/legacy/xml/base.xml b/addons/web/static/src/legacy/xml/base.xml index 1c763194014..1d0f74a4535 100644 --- a/addons/web/static/src/legacy/xml/base.xml +++ b/addons/web/static/src/legacy/xml/base.xml @@ -31,14 +31,6 @@ role="search" t-ref="searchView" > - @@ -56,16 +48,6 @@ role="search" t-ref="searchViewButtons" > - - - - - -