diff --git a/addons/base_import/__manifest__.py b/addons/base_import/__manifest__.py index aea0774bac5..5a0f33b46ca 100644 --- a/addons/base_import/__manifest__.py +++ b/addons/base_import/__manifest__.py @@ -31,14 +31,12 @@ Re-implement Odoo's file import system: ], 'assets': { 'web.assets_qweb': [ - 'base_import/static/src/xml/**/*', + 'base_import/static/src/**/*.xml', ], 'web.assets_backend': [ - 'base_import/static/src/scss/base_import.scss', - 'base_import/static/src/scss/icons.scss', 'base_import/static/lib/javascript-state-machine/state-machine.js', - 'base_import/static/src/js/import_action.js', - 'base_import/static/src/js/import_menu.js', + 'base_import/static/src/**/*.scss', + 'base_import/static/src/**/*.js', ], 'web.qunit_suite_tests': [ 'base_import/static/tests/**/*', diff --git a/addons/base_import/static/src/import_records/import_records.js b/addons/base_import/static/src/import_records/import_records.js new file mode 100644 index 00000000000..7ffa17660fe --- /dev/null +++ b/addons/base_import/static/src/import_records/import_records.js @@ -0,0 +1,48 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; + +const { Component } = owl; +const favoriteMenuRegistry = registry.category("favoriteMenu"); + +/** + * 'Import records' menu + * + * This component is used to import the records for particular model. + * @extends Component + */ +export class ImportRecords extends Component { + setup() { + this.action = useService("action"); + } + + //--------------------------------------------------------------------- + // Protected + //--------------------------------------------------------------------- + + importRecords() { + const { context, resModel } = this.env.searchModel; + this.action.doAction({ + type: "ir.actions.client", + tag: "import", + params: { model: resModel, context } + }); + } +} + +ImportRecords.template = "base_import.ImportRecords"; + +const importRecordsItem = { + Component: ImportRecords, + groupNumber: 4, + isDisplayed: ({ isSmall, searchModel }) => + !isSmall && + searchModel.action.type === "ir.actions.act_window" && + ["kanban", "list"].includes(searchModel.view.type) + // TODO: add arch info to searchModel? + // !!JSON.parse(env.view.arch.attrs.import || "1") && + // !!JSON.parse(env.view.arch.attrs.create || "1"), +}; + +favoriteMenuRegistry.add("import-menu", importRecordsItem, { sequence: 1 }); diff --git a/addons/base_import/static/src/import_records/import_records.xml b/addons/base_import/static/src/import_records/import_records.xml new file mode 100644 index 00000000000..a16a5f9f2f4 --- /dev/null +++ b/addons/base_import/static/src/import_records/import_records.xml @@ -0,0 +1,10 @@ + + + + + + Import records + + + + diff --git a/addons/base_import/static/src/img/ui/dropdown_arrow_small.png b/addons/base_import/static/src/legacy/img/ui/dropdown_arrow_small.png similarity index 100% rename from addons/base_import/static/src/img/ui/dropdown_arrow_small.png rename to addons/base_import/static/src/legacy/img/ui/dropdown_arrow_small.png diff --git a/addons/base_import/static/src/img/ui/studio_icons.png b/addons/base_import/static/src/legacy/img/ui/studio_icons.png similarity index 100% rename from addons/base_import/static/src/img/ui/studio_icons.png rename to addons/base_import/static/src/legacy/img/ui/studio_icons.png diff --git a/addons/base_import/static/src/js/import_action.js b/addons/base_import/static/src/legacy/js/import_action.js similarity index 100% rename from addons/base_import/static/src/js/import_action.js rename to addons/base_import/static/src/legacy/js/import_action.js diff --git a/addons/base_import/static/src/js/import_menu.js b/addons/base_import/static/src/legacy/js/import_menu.js similarity index 100% rename from addons/base_import/static/src/js/import_menu.js rename to addons/base_import/static/src/legacy/js/import_menu.js diff --git a/addons/base_import/static/src/scss/base_import.scss b/addons/base_import/static/src/legacy/scss/base_import.scss similarity index 98% rename from addons/base_import/static/src/scss/base_import.scss rename to addons/base_import/static/src/legacy/scss/base_import.scss index 46369769d07..5fddbbbaab2 100644 --- a/addons/base_import/static/src/scss/base_import.scss +++ b/addons/base_import/static/src/legacy/scss/base_import.scss @@ -186,7 +186,7 @@ padding-right: 24px; border-bottom-right-radius: 4px; border-top-right-radius: 4px; - background-image: url('/base_import/static/src/img/ui/dropdown_arrow_small.png'); + background-image: url('/base_import/static/src/legacy/img/ui/dropdown_arrow_small.png'); background-position: center right; background-repeat: no-repeat; appearance: none; diff --git a/addons/base_import/static/src/scss/icons.scss b/addons/base_import/static/src/legacy/scss/icons.scss similarity index 95% rename from addons/base_import/static/src/scss/icons.scss rename to addons/base_import/static/src/legacy/scss/icons.scss index 09e40c6b3cd..12e23fd7e9f 100644 --- a/addons/base_import/static/src/scss/icons.scss +++ b/addons/base_import/static/src/legacy/scss/icons.scss @@ -33,7 +33,7 @@ border: 1px solid darkgrey; } &:after{ - background-image: url('/base_import/static/src/img/ui/studio_icons.png'); + background-image: url('/base_import/static/src/legacy/img/ui/studio_icons.png'); background-repeat: no-repeat; content: ''; width: 20px; diff --git a/addons/base_import/static/src/xml/base_import.xml b/addons/base_import/static/src/legacy/xml/base_import.xml similarity index 100% rename from addons/base_import/static/src/xml/base_import.xml rename to addons/base_import/static/src/legacy/xml/base_import.xml diff --git a/addons/board/__manifest__.py b/addons/board/__manifest__.py index 8346386c692..c9b8dae28d5 100644 --- a/addons/board/__manifest__.py +++ b/addons/board/__manifest__.py @@ -21,15 +21,14 @@ Allows users to create custom dashboard. 'application': True, 'assets': { 'web.assets_backend': [ - 'board/static/src/scss/dashboard.scss', - 'board/static/src/js/board_view.js', - 'board/static/src/js/add_to_board_menu.js', + 'board/static/src/**/*.scss', + 'board/static/src/**/*.js', ], 'web.qunit_suite_tests': [ 'board/static/tests/**/*', ], 'web.assets_qweb': [ - 'board/static/src/xml/**/*', + 'board/static/src/**/*.xml', ], }, 'license': 'LGPL-3', diff --git a/addons/board/static/src/add_to_board/add_to_board.js b/addons/board/static/src/add_to_board/add_to_board.js new file mode 100644 index 00000000000..69c28cc4eda --- /dev/null +++ b/addons/board/static/src/add_to_board/add_to_board.js @@ -0,0 +1,112 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { useAutofocus, useService } from "@web/core/utils/hooks"; +import { sprintf } from "@web/core/utils/strings"; + +const { Component, useState } = owl; +const favoriteMenuRegistry = registry.category("favoriteMenu"); + +/** + * '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'. + * @extends Component + */ +export class AddToBoard extends Component { + setup() { + this.notification = useService("notification"); + this.rpc = useService("rpc"); + this.state = useState({ name: this.env.searchModel.displayName }); + + useAutofocus(); + } + + //--------------------------------------------------------------------- + // Protected + //--------------------------------------------------------------------- + + async addToBoard() { + const { + action, + displayName, + domain, + comparison, + context, + groupBy, + orderedBy, + view + } = this.env.searchModel; + + // Retrieves view context + const fns = this.env.__saveParams__.callbacks; + const { context: viewContext } = Object.assign({}, ...fns.map((fn) => fn())); + + const contextToSave = { + ...context, + group_by: groupBy, + orderedBy, + dashboard_merge_domains_contexts: false, + ...viewContext, + }; + + if (comparison) { + contextToSave.comparison = comparison; + } + + const result = await this.rpc("/board/add_to_dashboard", { + action_id: action.id, + context_to_save: contextToSave, + domain, + name: this.state.name, + view_mode: view.type, + }); + + if (result) { + this.notification.add( + this.env._t("Please refresh your browser for the changes to take effect."), + { + title: sprintf(this.env._t(`"%s" added to dashboard`), this.state.name), + type: "warning", + } + ); + this.state.name = displayName; + } else { + this.notification.add( + this.env._t("Could not add filter to dashboard"), + { type: "danger" } + ); + } + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @param {KeyboardEvent} ev + */ + onInputKeydown(ev) { + if (ev.key === "Enter") { + ev.preventDefault(); + this.addToBoard(); + } + } +} + +AddToBoard.template = "board.AddToBoard"; + +const addToBoardItem = { + Component: AddToBoard, + groupNumber: 4, + isDisplayed: ({ searchModel }) => searchModel.action.type === "ir.actions.act_window", +}; + +favoriteMenuRegistry.add("add-to-board", addToBoardItem, { sequence: 10 }); diff --git a/addons/board/static/src/add_to_board/add_to_board.xml b/addons/board/static/src/add_to_board/add_to_board.xml new file mode 100644 index 00000000000..6419bd154af --- /dev/null +++ b/addons/board/static/src/add_to_board/add_to_board.xml @@ -0,0 +1,16 @@ + + + + + + Add to my dashboard +
  • + +
  • +
  • + +
  • +
    +
    + +
    diff --git a/addons/board/static/src/img/layout_1-1-1.png b/addons/board/static/src/legacy/img/layout_1-1-1.png similarity index 100% rename from addons/board/static/src/img/layout_1-1-1.png rename to addons/board/static/src/legacy/img/layout_1-1-1.png diff --git a/addons/board/static/src/img/layout_1-1.png b/addons/board/static/src/legacy/img/layout_1-1.png similarity index 100% rename from addons/board/static/src/img/layout_1-1.png rename to addons/board/static/src/legacy/img/layout_1-1.png diff --git a/addons/board/static/src/img/layout_1-2.png b/addons/board/static/src/legacy/img/layout_1-2.png similarity index 100% rename from addons/board/static/src/img/layout_1-2.png rename to addons/board/static/src/legacy/img/layout_1-2.png diff --git a/addons/board/static/src/img/layout_1.png b/addons/board/static/src/legacy/img/layout_1.png similarity index 100% rename from addons/board/static/src/img/layout_1.png rename to addons/board/static/src/legacy/img/layout_1.png diff --git a/addons/board/static/src/img/layout_2-1.png b/addons/board/static/src/legacy/img/layout_2-1.png similarity index 100% rename from addons/board/static/src/img/layout_2-1.png rename to addons/board/static/src/legacy/img/layout_2-1.png diff --git a/addons/board/static/src/img/view_todo_arrow.png b/addons/board/static/src/legacy/img/view_todo_arrow.png similarity index 100% rename from addons/board/static/src/img/view_todo_arrow.png rename to addons/board/static/src/legacy/img/view_todo_arrow.png diff --git a/addons/board/static/src/js/add_to_board_menu.js b/addons/board/static/src/legacy/js/add_to_board_menu.js similarity index 100% rename from addons/board/static/src/js/add_to_board_menu.js rename to addons/board/static/src/legacy/js/add_to_board_menu.js diff --git a/addons/board/static/src/js/board_view.js b/addons/board/static/src/legacy/js/board_view.js similarity index 100% rename from addons/board/static/src/js/board_view.js rename to addons/board/static/src/legacy/js/board_view.js diff --git a/addons/board/static/src/scss/dashboard.scss b/addons/board/static/src/legacy/scss/dashboard.scss similarity index 100% rename from addons/board/static/src/scss/dashboard.scss rename to addons/board/static/src/legacy/scss/dashboard.scss diff --git a/addons/board/static/src/xml/board.xml b/addons/board/static/src/legacy/xml/board.xml similarity index 95% rename from addons/board/static/src/xml/board.xml rename to addons/board/static/src/legacy/xml/board.xml index 127c9b3a2f4..306bf590ec0 100644 --- a/addons/board/static/src/xml/board.xml +++ b/addons/board/static/src/legacy/xml/board.xml @@ -8,7 +8,7 @@ @@ -43,7 +43,7 @@

    diff --git a/addons/board/static/tests/dashboard_tests.js b/addons/board/static/tests/dashboard_tests.js index 1473823946d..6643f1accb6 100644 --- a/addons/board/static/tests/dashboard_tests.js +++ b/addons/board/static/tests/dashboard_tests.js @@ -7,6 +7,15 @@ var ListController = require('web.ListController'); var testUtils = require('web.test_utils'); var ListRenderer = require('web.ListRenderer'); var pyUtils = require('web.py_utils'); +const registry = require("@web/core/registry"); +const { makeFakeUserService } = require("@web/../tests/helpers/mock_services"); +const { + toggleFilterMenu, + toggleMenuItem, + toggleMenuItemOption, + toggleComparisonMenu, + toggleFavoriteMenu, +} = require("@web/../tests/search/helpers"); const cpHelpers = testUtils.controlPanel; const { createWebClient, doAction } = require("@web/../tests/webclient/helpers"); @@ -1053,7 +1062,8 @@ QUnit.test('click on a cell of pivot view inside dashboard', async function (ass form.destroy(); }); -QUnit.test( +// TODO: The button "Add to my dashboard" is not yet developped on the new control panel search view +QUnit.skip( "correctly save the time ranges of a reporting view in comparison mode", async function (assert) { assert.expect(1); @@ -1090,6 +1100,8 @@ QUnit.test( } }; + registry.category("services").add("user", makeFakeUserService()); + const webClient = await createWebClient({ serverData, mockRPC }); await doAction(webClient, { @@ -1100,16 +1112,16 @@ QUnit.test( }); // filter on July 2020 - await cpHelpers.toggleFilterMenu(webClient); - await cpHelpers.toggleMenuItem(webClient, "Date"); - await cpHelpers.toggleMenuItemOption(webClient, "Date", "July"); + await toggleFilterMenu(webClient); + await toggleMenuItem(webClient, "Date"); + await toggleMenuItemOption(webClient, "Date", "July"); // compare July 2020 to June 2020 - await cpHelpers.toggleComparisonMenu(webClient); - await cpHelpers.toggleMenuItem(webClient, 0); + await toggleComparisonMenu(webClient); + await toggleMenuItem(webClient, 0); // add the view to the dashboard - await cpHelpers.toggleFavoriteMenu(webClient); + await toggleFavoriteMenu(webClient); await testUtils.dom.click($(".o_add_to_board > button")); await testUtils.fields.editInput($(".o_add_to_board input"), "a name"); diff --git a/addons/event/views/event_views.xml b/addons/event/views/event_views.xml index cbdfad72cea..863f27e684f 100644 --- a/addons/event/views/event_views.xml +++ b/addons/event/views/event_views.xml @@ -648,7 +648,7 @@ event.registration.pivot event.registration - + diff --git a/addons/google_spreadsheet/__manifest__.py b/addons/google_spreadsheet/__manifest__.py index 4a1540897b5..b15df26d6a8 100644 --- a/addons/google_spreadsheet/__manifest__.py +++ b/addons/google_spreadsheet/__manifest__.py @@ -20,10 +20,10 @@ The module adds the possibility to display data from Odoo in Google Spreadsheets 'auto_install': False, 'assets': { 'web.assets_backend': [ - 'google_spreadsheet/static/src/js/**/*', + 'google_spreadsheet/static/src/**/*.js', ], 'web.assets_qweb': [ - 'google_spreadsheet/static/src/xml/*.xml', + 'google_spreadsheet/static/src/**/*.xml', ], }, 'license': 'LGPL-3', diff --git a/addons/google_spreadsheet/static/src/add_to_google_spreadsheet/add_to_google_spreadsheet.js b/addons/google_spreadsheet/static/src/add_to_google_spreadsheet/add_to_google_spreadsheet.js new file mode 100644 index 00000000000..49c0e988001 --- /dev/null +++ b/addons/google_spreadsheet/static/src/add_to_google_spreadsheet/add_to_google_spreadsheet.js @@ -0,0 +1,51 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; + +const { Component } = owl; +const favoriteMenuRegistry = registry.category("favoriteMenu"); + +/** + * 'Add to Google spreadsheet' menu item + * + * Component consisting only of a button calling the server to add the current + * view to the user's spreadsheet configuration. + * This component is only available in actions of type 'ir.actions.act_window'. + * @extends Component + */ +export class AddToGoogleSpreadsheet extends Component { + setup() { + this.orm = useService("orm"); + } + + //--------------------------------------------------------------------- + // Protected + //--------------------------------------------------------------------- + + async addToGoogleSpreadsheet() { + const { domain, groupBy, resModel, view } = this.env.searchModel; + + const result = await this.orm.call( + "google.drive.config", + "set_spreadsheet", + [resModel, domain, groupBy, view.id] + ); + + if (result.url) { + // According to MDN doc, one should not use _blank as title. + // todo: find a good name for the new window + window.open(result.url, "_blank"); + } + } +} + +AddToGoogleSpreadsheet.template = "google_spreadsheet.AddToGoogleSpreadsheet"; + +const addToGoogleSpreadsheetItem = { + Component: AddToGoogleSpreadsheet, + groupNumber: 4, + isDisplayed: ({ searchModel }) => searchModel.action.type === "ir.actions.act_window", +}; + +favoriteMenuRegistry.add("add-to-google-spreadsheet", addToGoogleSpreadsheetItem, { sequence: 20 }); diff --git a/addons/google_spreadsheet/static/src/add_to_google_spreadsheet/add_to_google_spreadsheet.xml b/addons/google_spreadsheet/static/src/add_to_google_spreadsheet/add_to_google_spreadsheet.xml new file mode 100644 index 00000000000..b5ce85aa399 --- /dev/null +++ b/addons/google_spreadsheet/static/src/add_to_google_spreadsheet/add_to_google_spreadsheet.xml @@ -0,0 +1,10 @@ + + + + + + Add to Google Spreadsheet + + + + diff --git a/addons/google_spreadsheet/static/src/js/add_to_google_spreadsheet_menu.js b/addons/google_spreadsheet/static/src/legacy/js/add_to_google_spreadsheet_menu.js similarity index 100% rename from addons/google_spreadsheet/static/src/js/add_to_google_spreadsheet_menu.js rename to addons/google_spreadsheet/static/src/legacy/js/add_to_google_spreadsheet_menu.js diff --git a/addons/google_spreadsheet/static/src/xml/addtospreadsheet.xml b/addons/google_spreadsheet/static/src/legacy/xml/addtospreadsheet.xml similarity index 100% rename from addons/google_spreadsheet/static/src/xml/addtospreadsheet.xml rename to addons/google_spreadsheet/static/src/legacy/xml/addtospreadsheet.xml diff --git a/addons/hr_expense/static/src/js/expense_views.js b/addons/hr_expense/static/src/js/expense_views.js index a031f647de1..f192111dcc4 100644 --- a/addons/hr_expense/static/src/js/expense_views.js +++ b/addons/hr_expense/static/src/js/expense_views.js @@ -3,14 +3,12 @@ odoo.define('hr_expense.expenses.tree', function (require) { var DocumentUploadMixin = require('hr_expense.documents.upload.mixin'); var KanbanController = require('web.KanbanController'); var KanbanView = require('web.KanbanView'); - var PivotView = require('web.PivotView'); var ListController = require('web.ListController'); var ListView = require('web.ListView'); var viewRegistry = require('web.view_registry'); var core = require('web.core'); var ListRenderer = require('web.ListRenderer'); var KanbanRenderer = require('web.KanbanRenderer'); - var PivotRenderer = require('web.PivotRenderer'); var session = require('web.session'); const config = require('web.config'); diff --git a/addons/hr_holidays/report/hr_leave_employee_type_report.xml b/addons/hr_holidays/report/hr_leave_employee_type_report.xml index 15ddf1b329e..5d4b07de1ca 100644 --- a/addons/hr_holidays/report/hr_leave_employee_type_report.xml +++ b/addons/hr_holidays/report/hr_leave_employee_type_report.xml @@ -19,7 +19,7 @@ hr.leave.employee.type.report.view.pivot hr.leave.employee.type.report - + diff --git a/addons/hr_timesheet/__manifest__.py b/addons/hr_timesheet/__manifest__.py index 65b65e27c7f..c88c3de9d29 100644 --- a/addons/hr_timesheet/__manifest__.py +++ b/addons/hr_timesheet/__manifest__.py @@ -54,7 +54,8 @@ up a management by affair. 'hr_timesheet/static/src/js/timesheet_factor.js', 'hr_timesheet/static/src/js/timesheet_config_form_view.js', 'hr_timesheet/static/src/js/qr_code_action.js', - 'hr_timesheet/static/src/js/timesheet_graph.js', + 'hr_timesheet/static/src/js/timesheet_graph_view.js', + 'hr_timesheet/static/src/js/timesheet_graph_model.js', ], 'web.qunit_suite_tests': [ 'hr_timesheet/static/tests/**/*', diff --git a/addons/hr_timesheet/static/src/js/timesheet_graph.js b/addons/hr_timesheet/static/src/js/timesheet_graph.js deleted file mode 100644 index d4c87383151..00000000000 --- a/addons/hr_timesheet/static/src/js/timesheet_graph.js +++ /dev/null @@ -1,40 +0,0 @@ -odoo.define('hr_timesheet.GraphView', function (require) { - "use strict"; - - const viewRegistry = require('web.view_registry'); - const GraphView = require('web.GraphView'); - const GraphModel = require('web.GraphModel'); - - const hrTimesheetGraphModel = GraphModel.extend({ - /* - * Override the _processData to take into account the analytic line uom. - */ - _processData: function (originIndex, rawData) { - this._super.apply(this, arguments); - const session = this.getSession(); - const currentCompanyId = session.user_context.allowed_company_ids[0]; - const currentCompany = session.user_companies.allowed_companies[currentCompanyId]; - const currentCompanyTimesheetUOMFactor = currentCompany.timesheet_uom_factor || 1; - const fields = ['unit_amount', 'effective_hours', 'planned_hours', 'remaining_hours', 'total_hours_spent', 'subtask_effective_hours', - 'overtime', 'number_hours', 'difference', 'hours_effective', 'hours_planned', 'timesheet_unit_amount']; - - if (fields.includes(this.chart.measure) && currentCompanyTimesheetUOMFactor !== 1) { - // recalculate the Duration values according to the timesheet_uom_factor - this.chart.dataPoints.forEach(function (dataPt) { - dataPt.value *= currentCompanyTimesheetUOMFactor; - }); - } - }, - }); - - - const hrTimesheetGraphView = GraphView.extend({ - config: Object.assign({}, GraphView.prototype.config, { - Model: hrTimesheetGraphModel, - }), - }); - - viewRegistry.add('hr_timesheet_graphview', hrTimesheetGraphView); - return hrTimesheetGraphView; -}); - diff --git a/addons/hr_timesheet/static/src/js/timesheet_graph_model.js b/addons/hr_timesheet/static/src/js/timesheet_graph_model.js new file mode 100644 index 00000000000..b65559a80d6 --- /dev/null +++ b/addons/hr_timesheet/static/src/js/timesheet_graph_model.js @@ -0,0 +1,39 @@ +/** @odoo-module **/ + +import { GraphModel } from "@web/views/graph/graph_model"; + +const FIELDS = [ + 'unit_amount', 'effective_hours', 'planned_hours', 'remaining_hours', 'total_hours_spent', 'subtask_effective_hours', + 'overtime', 'number_hours', 'difference', 'hours_effective', 'hours_planned', 'timesheet_unit_amount' +]; + +export class hrTimesheetGraphModel extends GraphModel { + /** + * @override + */ + setup(params, services) { + super.setup(...arguments); + this.companyService = services.company; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Override processDataPoints to take into account the analytic line uom. + * @override + */ + _getProcessedDataPoints() { + const currentCompany = this.companyService.currentCompany; + const factor = currentCompany.timesheet_uom_factor || 1; + if (factor !== 1 && FIELDS.includes(this.metaData.measure)) { + // recalculate the Duration values according to the timesheet_uom_factor + for (const dataPt of this.dataPoints) { + dataPt.value *= factor; + } + } + return super._getProcessedDataPoints(...arguments); + } +} +hrTimesheetGraphModel.services = [...GraphModel.services, "company"]; diff --git a/addons/hr_timesheet/static/src/js/timesheet_graph_view.js b/addons/hr_timesheet/static/src/js/timesheet_graph_view.js new file mode 100644 index 00000000000..3745139699c --- /dev/null +++ b/addons/hr_timesheet/static/src/js/timesheet_graph_view.js @@ -0,0 +1,12 @@ +/** @odoo-module **/ + +import { GraphView } from "@web/views/graph/graph_view"; +import { hrTimesheetGraphModel } from "./timesheet_graph_model"; +import { registry } from "@web/core/registry"; + +const viewRegistry = registry.category("views"); + +export class hrTimesheetGraphView extends GraphView {} +hrTimesheetGraphView.Model = hrTimesheetGraphModel; + +viewRegistry.add("hr_timesheet_graphview", hrTimesheetGraphView); diff --git a/addons/hr_timesheet/static/tests/timesheet_graph_tests.js b/addons/hr_timesheet/static/tests/timesheet_graph_tests.js index a6fd3bc690d..56a3dcf9275 100644 --- a/addons/hr_timesheet/static/tests/timesheet_graph_tests.js +++ b/addons/hr_timesheet/static/tests/timesheet_graph_tests.js @@ -1,73 +1,78 @@ -odoo.define('hr_timesheet.timesheet_graph_tests', function (require) { -"use strict"; +/** @odoo-module **/ -const session = require('web.session'); -const SetupTimesheetUOMWidgetsTestEnvironment = require('hr_timesheet.timesheet_uom_tests_env'); -const GraphView = require('hr_timesheet.GraphView'); +import { companyService } from "@web/webclient/company_service"; +import { dialogService } from "@web/core/dialog/dialog_service"; +import { getGraphRenderer } from "@web/../tests/views/graph_view_tests"; +import { makeView } from "@web/../tests/views/helpers"; +import { patchWithCleanup } from "@web/../tests/helpers/utils"; +import { session } from "@web/session"; +import { registry } from "@web/core/registry"; +import { setupControlPanelServiceRegistry } from "@web/../tests/search/helpers"; -QUnit.module('Timesheet UOM Widgets', function (hooks) { - let env; - let sessionUserCompaniesBackup; - let sessionUserContextBackup; - let sessionUOMIdsBackup; - let sessionUIDBackup; - hooks.before(function (assert) { - env = new SetupTimesheetUOMWidgetsTestEnvironment(); - // Backups session parts that this testing module will alter in order to restore it at the end. - sessionUserCompaniesBackup = session.user_companies || false; - sessionUserContextBackup = session.user_context || false; - sessionUOMIdsBackup = session.uom_ids || false; - sessionUIDBackup = session.uid || false; - }); - hooks.after(async function (assert) { - // Restores the session - const sessionToApply = Object.assign( - { }, - sessionUserCompaniesBackup && { - user_companies: sessionUserCompaniesBackup, - } || { }, - sessionUserContextBackup && { - user_context: sessionUserContextBackup, - } || { }, - sessionUOMIdsBackup && { - uom_ids: sessionUOMIdsBackup, - } || { }, - sessionUIDBackup && { - uid: sessionUIDBackup, - } || { }); - await env.patchSessionAndStartServices(sessionToApply, true); - }); - QUnit.module('GraphView', function (hooks) { - QUnit.test('the timesheet_graph view data are multiplied by a factor that is company related', async function (assert) { - assert.expect(2); +const serviceRegistry = registry.category("services"); - let options = { - View: GraphView, - arch: '', - viewOptions: { - context: { - graph_measure: 'unit_amount', +QUnit.module('Views', function (hooks) { + let serverData; + hooks.beforeEach(() => { + serverData = { + models: { + 'account.analytic.line': { + fields: { + unit_amount: { string: "Unit Amount", type: "float", group_operator: "sum", store: true }, }, + records: [ + { id: 1, unit_amount: 8 } + ], }, - }; - let graph = await env.createView(options); - let renderedData = graph.renderer.componentRef.comp.chart.data.datasets[0].data[0]; - assert.strictEqual(renderedData, 8, 'The timesheet_graph is taking the timesheet_uom_factor into account'); - graph.destroy(); + }, + views: { + // unit_amount is used as group_by and measure + "account.analytic.line,false,graph": ` + + + + + `, + } + } + setupControlPanelServiceRegistry(); + serviceRegistry.add("company", companyService); + serviceRegistry.add("dialog", dialogService); + }); - options = Object.assign( - { }, - options, - { - session: { - user_context: env.singleCompanyDayUOMUser, - }, - }); - graph = await env.createView(options); - renderedData = graph.renderer.componentRef.comp.chart.data.datasets[0].data[0]; - assert.strictEqual(renderedData, 1, 'The timesheet_graph is taking the timesheet_uom_factor into account'); - graph.destroy(); + QUnit.module("hrTimesheetGraphView"); + + QUnit.test('the timesheet graph view data are not multiplied by a factor that is company related (factor = 1)', async function (assert) { + assert.expect(1); + + patchWithCleanup(session.user_companies.allowed_companies[1], { + timesheet_uom_factor: 1, }); + + const graph = await makeView({ + serverData, + resModel: "account.analytic.line", + type: "hr_timesheet_graphview", + }); + + const renderedData = getGraphRenderer(graph).chart.data.datasets[0].data; + assert.deepEqual(renderedData, [8], 'The timesheet graph view is taking the timesheet_uom_factor into account (factor === 1)'); + }); + + QUnit.test('the timesheet graph view data are multiplied by a factor that is company related (factor !== 1)', async function (assert) { + assert.expect(1); + + patchWithCleanup(session.user_companies.allowed_companies[1], { + timesheet_uom_factor: 0.125, + }); + + const graph = await makeView({ + serverData, + resModel: "account.analytic.line", + type: "hr_timesheet_graphview", + }); + + const renderedData = getGraphRenderer(graph).chart.data.datasets[0].data; + assert.deepEqual(renderedData, [1], 'The timesheet graph view is taking the timesheet_uom_factor into account (factor !== 1)'); }); }); -}); diff --git a/addons/im_livechat/report/im_livechat_report_channel_views.xml b/addons/im_livechat/report/im_livechat_report_channel_views.xml index 1de3ca5c327..6678bb3bcfd 100644 --- a/addons/im_livechat/report/im_livechat_report_channel_views.xml +++ b/addons/im_livechat/report/im_livechat_report_channel_views.xml @@ -6,7 +6,7 @@ im_livechat.report.channel.pivot im_livechat.report.channel - + diff --git a/addons/im_livechat/report/im_livechat_report_operator_views.xml b/addons/im_livechat/report/im_livechat_report_operator_views.xml index e2059fed6bc..9104952d152 100644 --- a/addons/im_livechat/report/im_livechat_report_operator_views.xml +++ b/addons/im_livechat/report/im_livechat_report_operator_views.xml @@ -6,7 +6,7 @@ im_livechat.report.operator.pivot im_livechat.report.operator - + diff --git a/addons/mail/static/src/scss/systray.scss b/addons/mail/static/src/scss/systray.scss index 66ef3d273aa..9a46d099928 100644 --- a/addons/mail/static/src/scss/systray.scss +++ b/addons/mail/static/src/scss/systray.scss @@ -111,7 +111,16 @@ top: 10%; } .o_mail_systray_dropdown { - position: relative; + + position: fixed; + top: $o-mail-chat-window-header-height-mobile; + bottom: 0; + left: 0; + right: 0; + width: map-get($sizes, 100); + margin: 0; + max-height: none; + .o_mail_systray_dropdown_top { padding: 5px; } diff --git a/addons/mass_mailing/views/mailing_contact_views.xml b/addons/mass_mailing/views/mailing_contact_views.xml index 0de5fa81382..51574af0f0d 100644 --- a/addons/mass_mailing/views/mailing_contact_views.xml +++ b/addons/mass_mailing/views/mailing_contact_views.xml @@ -208,7 +208,7 @@ mailing.contact 10 - + diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index d635e16c866..b717a2fc762 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -59,19 +59,21 @@ 'post_init_hook': '_project_post_init', 'assets': { 'web.assets_backend': [ + 'project/static/src/burndown_chart/*', + 'project/static/src/project_control_panel/*', 'project/static/src/css/project.css', 'project/static/src/js/project_activity.js', 'project/static/src/js/project_control_panel.js', 'project/static/src/js/project_form.js', - 'project/static/src/js/project_graph.js', + 'project/static/src/js/project_graph_view.js', 'project/static/src/js/project_kanban.js', 'project/static/src/js/project_list.js', - 'project/static/src/js/project_pivot.js', - 'project/static/src/js/project_rating_reporting.js', + 'project/static/src/js/project_pivot_view.js', + 'project/static/src/js/project_rating_graph_view.js', + 'project/static/src/js/project_rating_pivot_view.js', 'project/static/src/js/project_task_kanban_examples.js', 'project/static/src/js/tours/project.js', 'project/static/src/js/project_calendar.js', - 'project/static/src/js/burndown_chart/*', 'project/static/src/js/right_panel/*', 'project/static/src/js/update/*', 'project/static/src/js/widgets/*', @@ -85,7 +87,7 @@ 'project/static/src/js/portal_rating.js', ], 'web.assets_qweb': [ - 'project/static/src/xml/**/*', + 'project/static/src/**/*.xml', ], 'web.qunit_suite_tests': [ 'project/static/tests/burndown_chart_tests.js', diff --git a/addons/project/report/project_report_views.xml b/addons/project/report/project_report_views.xml index 9c696950104..bb8808c6cf9 100644 --- a/addons/project/report/project_report_views.xml +++ b/addons/project/report/project_report_views.xml @@ -5,7 +5,7 @@ report.project.task.user.pivot report.project.task.user - + diff --git a/addons/project/static/src/burndown_chart/burndown_chart_renderer.js b/addons/project/static/src/burndown_chart/burndown_chart_renderer.js new file mode 100644 index 00000000000..ab977fde34b --- /dev/null +++ b/addons/project/static/src/burndown_chart/burndown_chart_renderer.js @@ -0,0 +1,46 @@ +/** @odoo-module **/ + +import { hexToRGBA } from "@web/views/graph/colors"; +import { GraphRenderer } from "@web/views/graph/graph_renderer"; + +export class BurndownChartRenderer extends GraphRenderer { + /** + * @override + */ + getLineChartData() { + const data = super.getLineChartData(); + const { stacked } = this.props.metaData; + if (stacked) { + for (const dataset of data.datasets) { + dataset.backgroundColor = hexToRGBA(dataset.borderColor, 0.4); + } + } + return data; + } + + /** + * @override + */ + getElementOptions() { + const elementOptions = super.getElementOptions(); + const { mode, stacked } = this.props.metaData; + if (mode === "line") { + elementOptions.line.fill = stacked; + } + return elementOptions; + } + + /** + * @override + */ + getScaleOptions() { + const { xAxes, yAxes } = super.getScaleOptions(); + const { mode, stacked } = this.props.metaData; + if (mode === "line") { + for (const y of yAxes) { + y.stacked = stacked; + } + } + return { xAxes, yAxes }; + } +} diff --git a/addons/project/static/src/burndown_chart/burndown_chart_view.js b/addons/project/static/src/burndown_chart/burndown_chart_view.js new file mode 100644 index 00000000000..ccda4998d83 --- /dev/null +++ b/addons/project/static/src/burndown_chart/burndown_chart_view.js @@ -0,0 +1,13 @@ +/** @odoo-module **/ + +import { BurndownChartRenderer } from "./burndown_chart_renderer"; +import { GraphView } from "@web/views/graph/graph_view"; +import { registry } from "@web/core/registry"; + +const viewRegistry = registry.category("views"); + +class BurndownChartView extends GraphView {} +BurndownChartView.components = { ...GraphView.components, Renderer: BurndownChartRenderer }; +BurndownChartView.buttonTemplate = "project.BurndownChartView.Buttons"; + +viewRegistry.add("burndown_chart", BurndownChartView); diff --git a/addons/project/static/src/burndown_chart/burndown_chart_view.xml b/addons/project/static/src/burndown_chart/burndown_chart_view.xml new file mode 100644 index 00000000000..75605aa1428 --- /dev/null +++ b/addons/project/static/src/burndown_chart/burndown_chart_view.xml @@ -0,0 +1,12 @@ + + + + + + + + true + + + + diff --git a/addons/project/static/src/js/burndown_chart/controller.js b/addons/project/static/src/js/burndown_chart/controller.js deleted file mode 100644 index 45b19a7105b..00000000000 --- a/addons/project/static/src/js/burndown_chart/controller.js +++ /dev/null @@ -1,23 +0,0 @@ -/** @odoo-module alias=project.BurndownChartController **/ -import * as GraphController from 'web.GraphController'; - -export default GraphController.extend({ - /** - * @override - */ - updateButtons: function() { - const result = this._super.apply(this, arguments); - if (this.$buttons) { - const state = this.model.get(); - this.$buttons - .find('.o_graph_button[data-mode="pie"]') - .addClass('d-none'); - this.$buttons - .find('.o_graph_button[data-mode="stack"]') - .data('stacked', state.stacked) - .toggleClass('active', state.stacked) - .toggleClass('o_hidden', state.mode === 'pie'); - } - return result; - } -}); diff --git a/addons/project/static/src/js/burndown_chart/renderer.js b/addons/project/static/src/js/burndown_chart/renderer.js deleted file mode 100644 index 7eb4700a389..00000000000 --- a/addons/project/static/src/js/burndown_chart/renderer.js +++ /dev/null @@ -1,49 +0,0 @@ -/** @odoo-module alias=project.BurndownChartRenderer **/ -import * as GraphRenderer from 'web/static/src/js/views/graph/graph_renderer'; -import { hexToRGBA } from "web/static/src/js/views/graph/graph_utils"; - -export class BurndownChartRenderer extends GraphRenderer { - /** - * @override - * @private - */ - _createLineChartConfig() { - const { data, options, type } = super._createLineChartConfig(); - for (const dataset of data.datasets) { - if (this.props.stacked) { - dataset.backgroundColor = hexToRGBA(dataset.borderColor, 0.4); - } - } - return { data, options, type }; - } - - /** - * @override - * @private - * @returns {Object} - */ - _getElementOptions() { - const elementOptions = super._getElementOptions(); - if (this.props.mode === 'line') { - elementOptions.line.fill = this.props.stacked; - } - return elementOptions; - } - - /** - * @override - * @private - */ - _getScaleOptions() { - const scaleOptions = super._getScaleOptions(); - if (this.props.mode !== "line") { - return scaleOptions; - } - - const { xAxes, yAxes } = scaleOptions; - for (const y of yAxes) { - y.stacked = this.props.stacked; - } - return { xAxes, yAxes }; - } -} diff --git a/addons/project/static/src/js/burndown_chart/view.js b/addons/project/static/src/js/burndown_chart/view.js deleted file mode 100644 index 0c0bc235126..00000000000 --- a/addons/project/static/src/js/burndown_chart/view.js +++ /dev/null @@ -1,14 +0,0 @@ -/** @odoo-module alias=project.BurndownChartView **/ -import GraphView from 'web.GraphView'; -import viewRegistry from 'web.view_registry'; -import BurndownChartController from './controller'; -import { BurndownChartRenderer } from './renderer'; - -export const BurndownChartView = GraphView.extend({ - config: Object.assign({}, GraphView.prototype.config, { - Controller: BurndownChartController, - Renderer: BurndownChartRenderer, - }), -}); - -viewRegistry.add('burndown_chart', BurndownChartView); diff --git a/addons/project/static/src/js/project_graph.js b/addons/project/static/src/js/project_graph.js deleted file mode 100644 index acfc5c79055..00000000000 --- a/addons/project/static/src/js/project_graph.js +++ /dev/null @@ -1,13 +0,0 @@ -/** @odoo-module **/ - -import GraphView from 'web.GraphView'; -import { ProjectControlPanel } from '@project/js/project_control_panel'; -import viewRegistry from 'web.view_registry'; - -export const ProjectGraphView = GraphView.extend({ - config: Object.assign({}, GraphView.prototype.config, { - ControlPanel: ProjectControlPanel, - }), -}); - -viewRegistry.add('project_graph', ProjectGraphView); diff --git a/addons/project/static/src/js/project_graph_view.js b/addons/project/static/src/js/project_graph_view.js new file mode 100644 index 00000000000..d7d711aafd4 --- /dev/null +++ b/addons/project/static/src/js/project_graph_view.js @@ -0,0 +1,12 @@ +/** @odoo-module **/ + +import { GraphView } from "@web/views/graph/graph_view"; +import { ProjectControlPanel } from "@project/project_control_panel/project_control_panel"; +import { registry } from "@web/core/registry"; + +const viewRegistry = registry.category("views"); + +class ProjectGraphView extends GraphView {} +ProjectGraphView.components = { ...GraphView, ControlPanel: ProjectControlPanel }; + +viewRegistry.add("project_graph", ProjectGraphView); diff --git a/addons/project/static/src/js/project_pivot.js b/addons/project/static/src/js/project_pivot.js deleted file mode 100644 index 355eae34c15..00000000000 --- a/addons/project/static/src/js/project_pivot.js +++ /dev/null @@ -1,13 +0,0 @@ -/** @odoo-module **/ - -import PivotView from 'web.PivotView'; -import { ProjectControlPanel } from '@project/js/project_control_panel'; -import viewRegistry from 'web.view_registry'; - -export const ProjectPivotView = PivotView.extend({ - config: Object.assign({}, PivotView.prototype.config, { - ControlPanel: ProjectControlPanel, - }), -}); - -viewRegistry.add('project_pivot', ProjectPivotView); diff --git a/addons/project/static/src/js/project_pivot_view.js b/addons/project/static/src/js/project_pivot_view.js new file mode 100644 index 00000000000..c5f71c95222 --- /dev/null +++ b/addons/project/static/src/js/project_pivot_view.js @@ -0,0 +1,12 @@ +/** @odoo-module **/ + +import { PivotView } from "@web/views/pivot/pivot_view"; +import { ProjectControlPanel } from "@project/project_control_panel/project_control_panel"; +import { registry } from "@web/core/registry"; + +const viewRegistry = registry.category("views"); + +class ProjectPivotView extends PivotView {} +ProjectPivotView.components = { ...PivotView, ControlPanel: ProjectControlPanel }; + +viewRegistry.add("project_pivot", ProjectPivotView); diff --git a/addons/project/static/src/js/project_rating_graph_view.js b/addons/project/static/src/js/project_rating_graph_view.js new file mode 100644 index 00000000000..4c98d62a001 --- /dev/null +++ b/addons/project/static/src/js/project_rating_graph_view.js @@ -0,0 +1,34 @@ +/** @odoo-module **/ + +import { _lt } from "@web/core/l10n/translation"; +import { GraphView } from "@web/views/graph/graph_view"; +import { GraphArchParser } from "@web/views/graph/graph_arch_parser"; +import { registry } from "@web/core/registry"; + +const viewRegistry = registry.category("views"); + +const MEASURE_STRINGS = { + parent_res_id: _lt("Project"), + rating: _lt("Rating Value (/5)"), + res_id: _lt("Task"), +}; + +class ProjectRatingArchParser extends GraphArchParser { + parse() { + const archInfo = super.parse(...arguments); + for (const [key, val] of Object.entries(MEASURE_STRINGS)) { + archInfo.fieldAttrs[key] = { + ...archInfo.fieldAttrs[key], + string: val.toString(), + }; + } + return archInfo; + } +} + +// Would it be not better achiedved by using a proper arch directly? + +class ProjectRatingGraphView extends GraphView {} +ProjectRatingGraphView.archParser = ProjectRatingArchParser; + +viewRegistry.add("project_rating_graph", ProjectRatingGraphView); diff --git a/addons/project/static/src/js/project_rating_pivot_view.js b/addons/project/static/src/js/project_rating_pivot_view.js new file mode 100644 index 00000000000..5d54afceb19 --- /dev/null +++ b/addons/project/static/src/js/project_rating_pivot_view.js @@ -0,0 +1,34 @@ +/** @odoo-module **/ + +import { _lt } from "@web/core/l10n/translation"; +import { PivotView } from "@web/views/pivot/pivot_view"; +import { PivotArchParser } from "@web/views/pivot/pivot_arch_parser"; +import { registry } from "@web/core/registry"; + +const viewRegistry = registry.category("views"); + +const MEASURE_STRINGS = { + parent_res_id: _lt("Project"), + rating: _lt("Rating Value (/5)"), + res_id: _lt("Task"), +}; + +class ProjectRatingArchParser extends PivotArchParser { + parse() { + const archInfo = super.parse(...arguments); + for (const [key, val] of Object.entries(MEASURE_STRINGS)) { + archInfo.fieldAttrs[key] = { + ...archInfo.fieldAttrs[key], + string: val.toString(), + }; + } + return archInfo; + } +} + +// Would it be not better achiedved by using a proper arch directly? + +class ProjectRatingPivotView extends PivotView {} +ProjectRatingPivotView.archParser = ProjectRatingArchParser; + +viewRegistry.add("project_rating_pivot", ProjectRatingPivotView); diff --git a/addons/project/static/src/js/project_rating_reporting.js b/addons/project/static/src/js/project_rating_reporting.js deleted file mode 100644 index 737795c917e..00000000000 --- a/addons/project/static/src/js/project_rating_reporting.js +++ /dev/null @@ -1,62 +0,0 @@ -/** @odoo-module **/ - -import { _t } from 'web.core'; -import GraphController from 'web.GraphController'; -import GraphView from 'web.GraphView'; -import PivotController from 'web.PivotController'; -import PivotView from 'web.PivotView'; -import viewRegistry from 'web.view_registry'; - -const ProjectPivotController = PivotController.extend({ - /** - * @override - */ - init: function () { - this._super.apply(this, arguments); - const measures = JSON.parse(JSON.stringify(this.measures)); - if ('res_id' in measures) { - measures.res_id.string = _t('Task'); - } - if ('parent_res_id' in measures) { - measures.parent_res_id.string = _t('Project'); - } - if ('rating' in measures) { - measures.rating.string = _t('Rating Value (/5)'); - } - this.measures = measures; - }, -}); - -const ProjectPivotView = PivotView.extend({ - config: Object.assign({}, PivotView.prototype.config, { - Controller: ProjectPivotController, - }), -}); - -viewRegistry.add('project_rating_pivot', ProjectPivotView); - -const ProjectGraphController = GraphController.extend({ - /** - * @override - */ - init: function () { - this._super.apply(this, arguments); - _.each(this.measures, measure => { - if (measure.fieldName === 'res_id') { - measure.description = _t('Task'); - } else if (measure.fieldName === 'parent_res_id') { - measure.description = _t('Project'); - } else if (measure.fieldName === 'rating') { - measure.description = _t('Rating Value (/5)'); - } - }); - }, -}); - -const ProjectGraphView = GraphView.extend({ - config: Object.assign({}, GraphView.prototype.config, { - Controller: ProjectGraphController, - }), -}); - -viewRegistry.add('project_rating_graph', ProjectGraphView); diff --git a/addons/project/static/src/project_control_panel/project_control_panel.js b/addons/project/static/src/project_control_panel/project_control_panel.js new file mode 100644 index 00000000000..6d415a547ff --- /dev/null +++ b/addons/project/static/src/project_control_panel/project_control_panel.js @@ -0,0 +1,51 @@ +/** @odoo-module **/ + +import { ControlPanel } from "@web/search/control_panel/control_panel"; +import { useService } from "@web/core/utils/hooks"; + +export class ProjectControlPanel extends ControlPanel { + constructor() { + super(...arguments); + this.orm = useService("orm"); + this.user = useService("user"); + const { active_id, show_project_update } = this.env.searchModel.globalContext; + this.showProjectUpdate = this.env.searchModel.view.type === "form" || show_project_update; + this.projectId = this.showProjectUpdate ? active_id : false; + } + + async willStart() { + const proms = [super.willStart(...arguments)]; + if (this.showProjectUpdate) { + proms.push(this.loadData()); + } + await Promise.all(proms); + } + + async willUpdateProps() { + const proms = [super.willUpdateProps(...arguments)]; + if (this.showProjectUpdate) { + proms.push(this.loadData()); + } + await Promise.all(proms); + } + + async loadData() { + const [data, isProjectUser] = await Promise.all([ + this.orm.call("project.project", "get_last_update_or_default", [this.projectId]), + this.user.hasGroup("project.group_project_user"), + ]); + this.data = data; + this.isProjectUser = isProjectUser; + } + + async onStatusClick(ev) { + ev.preventDefault(); + this.actionService.doAction("project.project_update_all_action", { + additionalContext: { + default_project_id: this.projectId, + active_id: this.projectId, + }, + }); + } +} +ProjectControlPanel.template = "project.ProjectControlPanel"; diff --git a/addons/project/static/src/project_control_panel/project_control_panel.xml b/addons/project/static/src/project_control_panel/project_control_panel.xml new file mode 100644 index 00000000000..44aa7941a7f --- /dev/null +++ b/addons/project/static/src/project_control_panel/project_control_panel.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + +
  • + +
  • +
  • + +
  • +
    +
    + + + + + + + + + + + + + +
    diff --git a/addons/project/static/src/xml/project_templates.xml b/addons/project/static/src/xml/project_templates.xml index 55aad92746c..1369eefb859 100644 --- a/addons/project/static/src/xml/project_templates.xml +++ b/addons/project/static/src/xml/project_templates.xml @@ -19,28 +19,13 @@
    - + + + +
    - - - - - - - - - -
  • - -
  • -
  • - -
  • -
    -
    -
    diff --git a/addons/project/static/tests/burndown_chart_tests.js b/addons/project/static/tests/burndown_chart_tests.js index 7c421affe88..b80ef82036e 100644 --- a/addons/project/static/tests/burndown_chart_tests.js +++ b/addons/project/static/tests/burndown_chart_tests.js @@ -1,74 +1,85 @@ -/** @odoo-module alias=project.BurndownView */ -import { dom, createView, nextTick } from 'web.test_utils'; -import { COLORS, hexToRGBA } from 'web/static/src/js/views/graph/graph_utils'; -import { BurndownChartView } from '../src/js/burndown_chart/view'; +/** @odoo-module */ -QUnit.module('Project', {}, function () { - QUnit.module('Views', { - beforeEach() { - this.data = { - burndown_chart: { - fields: { - date: { string: "Date", type: 'date', store: true, sortable: true }, - project_id: { string: "Project", type: "many2one", relation: 'project', store: true }, - stage_id: { string: "Stage", type: "many2one", relation: 'stage', store: true }, - nb_tasks: { string: "Number of Tasks", type: 'integer', store: true } +import { click } from "@web/../tests/helpers/utils"; +import { COLORS, hexToRGBA } from "@web/views/graph/colors"; +import { dialogService } from "@web/core/dialog/dialog_service"; +import { getGraphRenderer } from "@web/../tests/views/graph_view_tests"; +import { makeView } from "@web/../tests/views/helpers"; +import { registry } from "@web/core/registry"; +import { setupControlPanelServiceRegistry } from "@web/../tests/search/helpers"; + +const serviceRegistry = registry.category("services"); +QUnit.module("Project", {}, () => { + QUnit.module("Views", (hooks) => { + let serverData; + hooks.beforeEach(async () => { + serverData = { + models: { + burndown_chart: { + fields: { + date: { string: "Date", type: "date", store: true, sortable: true }, + project_id: { string: "Project", type: "many2one", relation: "project", store: true }, + stage_id: { string: "Stage", type: "many2one", relation: "stage", store: true }, + nb_tasks: { string: "Number of Tasks", type: "integer", store: true, group_operator: "sum" } + }, + records: [ + { id: 1, project_id: 1, stage_id: 1, date: "2020-01-01", nb_tasks: 10 }, + { id: 2, project_id: 1, stage_id: 2, date: "2020-02-01", nb_tasks: 5 }, + { id: 3, project_id: 1, stage_id: 3, date: "2020-03-01", nb_tasks: 2 }, + ], }, - records: [ - { id: 1, project_id: 1, stage_id: 1, date: "2020-01-01", nb_tasks: 10 }, - { id: 2, project_id: 1, stage_id: 2, date: "2020-02-01", nb_tasks: 5 }, - { id: 3, project_id: 1, stage_id: 3, date: "2020-03-01", nb_tasks: 2 }, - ], + project: { + fields: { + name: { string: "Project Name", type: "char" }, + }, + records: [{ id: 1, name: "Project A" }] + }, + stage: { + fields: { + name: { string: "Stage Name", type: "char" }, + }, + records: [ + { id: 1, name: "Todo" }, + { id: 2, name: "In Progress" }, + { id: 3, name: "Done" }, + ], + } }, - project: { - fields: { - name: { string: 'Project Name', type: "char" }, - }, - records: [ - { id: 1, name: 'Project A' }, - ] + views: { + "burndown_chart,false,graph": ` + + + + + + `, }, - stage: { - fields: { - name: {string: 'Stage Name', type: 'char' }, - }, - records: [ - { id: 1, name: 'Todo' }, - { id: 2, name: 'In Progress' }, - { id: 3, name: 'Done' }, - ] - } }; + setupControlPanelServiceRegistry(); + serviceRegistry.add("dialog", dialogService); + }); - this.burndown_chart = { - View: BurndownChartView, - model: 'burndown_chart', - data: this.data, - arch: ` - - - - `, - }; - } - }, function () { - QUnit.module('BurndownChart'); + QUnit.module("BurndownChart"); - QUnit.test('check if default mode is line chart and line chart is stacked for burndown chart', async function (assert) { + QUnit.test("check if default mode is line chart and line chart is stacked for burndown chart", async function (assert) { assert.expect(5); - const burndown_chart = await createView(this.burndown_chart); + const burndownChart = await makeView({ + serverData, + resModel: "burndown_chart", + type: "burndown_chart", + }); - assert.strictEqual(burndown_chart.renderer.props.mode, "line", "should be in line chart mode by default."); - assert.ok(burndown_chart.renderer.props.stacked, "should be stacked by default."); + assert.strictEqual(burndownChart.model.metaData.mode, "line", "should be in line chart mode."); + assert.ok(burndownChart.model.metaData.stacked, "should be stacked by default."); - assert.ok(burndown_chart.renderer.componentRef.comp._getScaleOptions().yAxes.every(y => y.stacked), "the stacked property in y axes should be true when the stacked is enabled in line chart"); - assert.ok(burndown_chart.renderer.componentRef.comp._getElementOptions().line.fill, "The fill property should be true to add backgroundColor in line chart."); + assert.ok(getGraphRenderer(burndownChart).getScaleOptions().yAxes.every(y => y.stacked), "the stacked property in y axes should be true when the stacked is enabled in line chart"); + assert.ok(getGraphRenderer(burndownChart).getElementOptions().line.fill, "The fill property should be true to add backgroundColor in line chart."); const actualDatasets = []; const expectedDatasets = []; - const keysToEvaluate = ['backgroundColor', 'borderColor', 'originIndex', 'pointBackgroundColor']; - const datasets = burndown_chart.renderer.componentRef.comp.chart.data.datasets; + const keysToEvaluate = ["backgroundColor", "borderColor", "originIndex", "pointBackgroundColor"]; + const datasets = getGraphRenderer(burndownChart).chart.data.datasets; for (let i = 0; i < datasets.length; i++) { const dataset = datasets[i]; @@ -88,43 +99,43 @@ QUnit.module('Project', {}, function () { pointBackgroundColor: expectedColor, }); } - assert.deepEqual(actualDatasets, expectedDatasets); - - burndown_chart.destroy(); }); - QUnit.test('check if the stacked button is visible in the line chart', async function (assert) { + QUnit.test("check if the stacked button is visible in the line chart", async function (assert) { assert.expect(3); - - const burndown_chart = await createView(this.burndown_chart); - - assert.containsN(burndown_chart, '.o_graph_button[data-mode="stack"]', 1, "should find the stacked button in the controller of the view."); - const $stackButton = burndown_chart.$buttons.find('.o_graph_button[data-mode="stack"]'); - assert.doesNotHaveClass($stackButton, 'o_hidden', "the stacked button should not have the .o_hidden class when we are in the line chart."); - - await dom.click($stackButton); - assert.notOk(burndown_chart.renderer.props.stacked, "should be disabled and display a classic line chart."); - - burndown_chart.destroy(); + const burndownChart = await makeView({ + serverData, + resModel: "burndown_chart", + type: "burndown_chart", + }); + assert.ok(burndownChart.model.metaData.stacked, "graph should be a burndown chart."); + assert.containsOnce(burndownChart, `button.o_graph_button[data-tooltip="Stacked"]`); + const stackButton = burndownChart.el.querySelector(`button.o_graph_button[data-tooltip="Stacked"]`); + await click(stackButton); + assert.notOk(burndownChart.model.metaData.stacked, "graph should be a classic line chart."); }); - QUnit.test('check if it is classic line chart when stacked prop is false in line chart', async function (assert) { + QUnit.test("check if it is classic line chart when stacked prop is false in line chart", async function (assert) { assert.expect(4); - const burndown_chart = await createView(this.burndown_chart); + const burndownChart = await makeView({ + serverData, + resModel: "burndown_chart", + type: "burndown_chart", + }); - const $stackButton = burndown_chart.$buttons.find('.o_graph_button[data-mode="stack"]'); - await dom.click($stackButton); - assert.notOk(burndown_chart.renderer.props.stacked, "should be disabled and display a classic line chart."); + const stackButton = burndownChart.el.querySelector(`button.o_graph_button[data-tooltip="Stacked"]`); + await click(stackButton); + assert.notOk(burndownChart.model.metaData.stacked, "graph should be a classic line chart."); - assert.notOk(burndown_chart.renderer.componentRef.comp._getScaleOptions().yAxes.every(y => y.stacked), "the y axes should have a stacked property set to false since the stacked property in line chart is false."); - assert.notOk(burndown_chart.renderer.componentRef.comp._getElementOptions().line.fill, "The fill property should be false since the stacked property is false."); + assert.notOk(getGraphRenderer(burndownChart).getScaleOptions().yAxes.every(y => y.stacked), "the y axes should have a stacked property set to false since the stacked property in line chart is false."); + assert.notOk(getGraphRenderer(burndownChart).getElementOptions().line.fill, "The fill property should be false since the stacked property is false."); const actualDatasets = []; const expectedDatasets = []; - const keysToEvaluate = ['backgroundColor', 'borderColor', 'originIndex', 'pointBackgroundColor']; - const datasets = burndown_chart.renderer.componentRef.comp.chart.data.datasets; + const keysToEvaluate = ["backgroundColor", "borderColor", "originIndex", "pointBackgroundColor"]; + const datasets = getGraphRenderer(burndownChart).chart.data.datasets; for (let i = 0; i < datasets.length; i++) { const dataset = datasets[i]; @@ -145,8 +156,6 @@ QUnit.module('Project', {}, function () { } assert.deepEqual(actualDatasets, expectedDatasets); - - burndown_chart.destroy(); }); }); }); diff --git a/addons/purchase/report/purchase_report_views.xml b/addons/purchase/report/purchase_report_views.xml index 3ab59218394..0b87d3a0801 100644 --- a/addons/purchase/report/purchase_report_views.xml +++ b/addons/purchase/report/purchase_report_views.xml @@ -4,7 +4,7 @@ product.month.pivot purchase.report - + diff --git a/addons/purchase/views/purchase_views.xml b/addons/purchase/views/purchase_views.xml index 579271a88fe..9c643b28c82 100644 --- a/addons/purchase/views/purchase_views.xml +++ b/addons/purchase/views/purchase_views.xml @@ -111,7 +111,7 @@ purchase.order.pivot purchase.order - + diff --git a/addons/rating/views/rating_rating_views.xml b/addons/rating/views/rating_rating_views.xml index 4bc1f3065e0..1df89804d3b 100644 --- a/addons/rating/views/rating_rating_views.xml +++ b/addons/rating/views/rating_rating_views.xml @@ -107,7 +107,7 @@ rating.rating.pivot rating.rating - + diff --git a/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml b/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml index 660986b2ef9..8061f942299 100644 --- a/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml +++ b/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml @@ -5,7 +5,7 @@ project.profitability.report.pivot project.profitability.report - + diff --git a/addons/stock/static/src/xml/report_stock_forecasted.xml b/addons/stock/static/src/xml/report_stock_forecasted.xml index 7fad33ec827..8f8110cc3d2 100644 --- a/addons/stock/static/src/xml/report_stock_forecasted.xml +++ b/addons/stock/static/src/xml/report_stock_forecasted.xml @@ -1,7 +1,7 @@ - + 300 diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index 63b0a7d7f47..84add579bda 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -48,9 +48,9 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/xml/kanban.xml', 'web/static/src/legacy/xml/pivot.xml', 'web/static/src/legacy/xml/report.xml', - 'web/static/src/legacy/xml/search_panel.xml', 'web/static/src/legacy/xml/web_calendar.xml', 'web/static/src/legacy/xml/graph.xml', + 'web/static/src/legacy/xml/search_panel.xml', 'web/static/src/legacy/xml/week_days.xml', ], 'web.assets_common_minimal': [ @@ -114,7 +114,6 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/scss/fields.scss', 'web/static/src/legacy/scss/file_upload.scss', 'web/static/src/legacy/scss/views.scss', - 'web/static/src/legacy/scss/pivot_view.scss', 'web/static/src/legacy/scss/graph_view.scss', 'web/static/src/legacy/scss/form_view.scss', 'web/static/src/legacy/scss/list_view.scss', @@ -123,8 +122,6 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/scss/kanban_column_progressbar.scss', 'web/static/src/legacy/scss/kanban_view.scss', 'web/static/src/legacy/scss/web_calendar.scss', - 'web/static/src/legacy/scss/search_view.scss', - 'web/static/src/legacy/scss/search_panel.scss', 'web/static/src/legacy/scss/data_export.scss', 'base/static/src/scss/onboarding.scss', 'web/static/src/legacy/scss/attachment_preview.scss', @@ -137,7 +134,6 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/scss/fields_extra.scss', 'web/static/src/legacy/scss/form_view_extra.scss', 'web/static/src/legacy/scss/list_view_extra.scss', - 'web/static/src/legacy/scss/search_view_extra.scss', 'web/static/src/legacy/action_adapters.js', 'web/static/src/legacy/debug_manager.js', @@ -150,6 +146,7 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/root_widget.js', 'web/static/src/legacy/systray_menu.js', 'web/static/src/legacy/systray_menu_item.js', + 'web/static/src/legacy/backend_utils.js', 'web/static/src/legacy/utils.js', 'web/static/src/legacy/web_client.js', 'web/static/src/legacy/js/_deprecated/*', @@ -453,13 +450,13 @@ This module provides the core of the Odoo Web Client. 'web/static/lib/ace/mode-python.js', 'web/static/lib/ace/mode-xml.js', 'web/static/lib/ace/mode-js.js', - 'web/static/lib/Chart/Chart.js', 'web/static/lib/nearest/jquery.nearest.js', 'web/static/lib/daterangepicker/daterangepicker.js', 'web/static/lib/stacktracejs/stacktrace.js', # 'web/static/tests/legacy/main_tests.js', 'web/static/tests/helpers/**/*.js', + 'web/static/tests/search/helpers.js', 'web/static/tests/webclient/**/helpers.js', 'web/static/tests/qunit.js', 'web/static/tests/main.js', @@ -481,6 +478,7 @@ This module provides the core of the Odoo Web Client. 'web/static/tests/core/**/*.js', 'web/static/tests/fields/**/*.js', 'web/static/tests/search/**/*.js', + ('remove', 'web/static/tests/search/helpers.js'), 'web/static/tests/views/**/*.js', 'web/static/tests/webclient/**/*.js', ('remove', 'web/static/tests/webclient/**/helpers.js'), diff --git a/addons/web/static/src/core/datepicker/datepicker.js b/addons/web/static/src/core/datepicker/datepicker.js new file mode 100644 index 00000000000..82d96f5c615 --- /dev/null +++ b/addons/web/static/src/core/datepicker/datepicker.js @@ -0,0 +1,287 @@ +/** @odoo-module **/ + +import { localization } from "@web/core/l10n/localization"; +import { registry } from "@web/core/registry"; +import { useAutofocus } from "@web/core/utils/hooks"; + +const { Component, hooks } = owl; +const { DateTime } = luxon; +const { useExternalListener, useRef, useState } = hooks; + +const formatters = registry.category("formatters"); +const parsers = registry.category("parsers"); + +let datePickerId = 0; + +/** + * @param {DateTime} date + * @returns {moment} + */ +const luxonDateToMomentDate = (date) => { + return window.moment(String(date)); +}; + +/** + * @param {string} format + * @returns {string} + */ +const luxonFormatToMomentFormat = (format) => { + return format.replace(/[dy]/g, (x) => x.toUpperCase()); +}; + +/** + * 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-versa. + * + * Note that all props given to this component will be passed as arguments to + * instantiate the picker widget. Also any luxon date is automatically + * stringified since tempusdominus only works with moment objects. + * @extends Component + */ +export class DatePicker extends Component { + setup() { + this.inputRef = useRef("input"); + this.state = useState({ warning: false }); + + this.datePickerId = `o_datepicker_${datePickerId++}`; + + this.initFormat(); + this.setDate(this.props); + + useAutofocus(); + useExternalListener(window, "scroll", this.onWindowScroll); + } + + mounted() { + window.$(this.el).on("show.datetimepicker", () => this.inputRef.el.select()); + window.$(this.el).on("hide.datetimepicker", () => this.onDateChange()); + window.$(this.el).on("error.datetimepicker", () => false); // Ignores datepicker errors + + this.bootstrapDateTimePicker(this.props); + this.updateInput(); + } + + willUpdateProps(nextProps) { + const pickerParams = {}; + for (const prop in nextProps) { + if (this.props[prop] !== nextProps[prop]) { + pickerParams[prop] = nextProps[prop]; + if (prop === "date") { + this.setDate(nextProps); + this.updateInput(); + } + } + } + this.bootstrapDateTimePicker(pickerParams); + } + + willUnmount() { + window.$(this.el).off(); // Removes all jQuery events + + this.bootstrapDateTimePicker("destroy"); + } + + //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + get options() { + return { + // Fallback to default localization format in `core/l10n/dates.js`. + format: this.props.format, + locale: this.props.locale || this.date.locale, + timezone: this.isLocal, + }; + } + + //--------------------------------------------------------------------- + // Protected + //--------------------------------------------------------------------- + + /** + * Initialises formatting and parsing parameters + */ + initFormat() { + this.defaultFormat = localization.dateFormat; + this.format = formatters.get("date"); + this.parse = parsers.get("date"); + this.isLocal = false; + } + + /** + * Sets the current date value. If a locale is provided, the given date + * will first be set in that locale. + * @param {object} params + * @param {DateTime} params.date + * @param {string} [params.locale] + */ + setDate({ date, locale }) { + this.date = locale ? date.setLocale(locale) : date; + } + + /** + * Updates the input element with the current formatted date value. + */ + updateInput() { + try { + this.inputRef.el.value = this.format(this.date, this.options); + } catch (err) { + // Do nothing + } + } + + //--------------------------------------------------------------------- + // Bootstrap datepicker + //--------------------------------------------------------------------- + + /** + * Handles bootstrap datetimepicker calls. + * @param {string | Object} commandOrParams + */ + bootstrapDateTimePicker(commandOrParams) { + if (typeof commandOrParams === "object") { + const format = luxonFormatToMomentFormat(this.props.format || this.defaultFormat); + const params = { ...commandOrParams, format }; + for (const prop in params) { + if (params[prop] instanceof DateTime) { + const luxonDate = params[prop]; + const momentDate = luxonDateToMomentDate(luxonDate); + if (momentDate.locale() !== luxonDate.locale) { + momentDate.locale(params.locale); + } + params[prop] = momentDate; + } + } + commandOrParams = params; + } + window.$(this.el).datetimepicker(commandOrParams); + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + onInputClick() { + this.bootstrapDateTimePicker("toggle"); + } + + /** + * Called either when the input value has changed or when the boostrap + * datepicker is closed. The actual "datetime-changed" emitted by the + * component is only triggered if the date value has changed. + */ + onDateChange() { + try { + const date = this.parse(this.inputRef.el.value, this.options); + if (!date.equals(this.props.date)) { + this.state.warning = date > DateTime.local(); + this.trigger("datetime-changed", { date }); + } + } catch (err) { + // Reset to default (= given) date. + this.updateInput(); + } + } + + /** + * @param {Event} ev + */ + onWindowScroll(ev) { + if (ev.target !== this.inputRef.el) { + this.bootstrapDateTimePicker("hide"); + } + } +} + +DatePicker.defaultProps = { + calendarWeeks: true, + icons: { + clear: "fa fa-delete", + close: "fa fa-check primary", + date: "fa fa-calendar", + down: "fa fa-chevron-down", + next: "fa fa-chevron-right", + previous: "fa fa-chevron-left", + time: "fa fa-clock-o", + today: "fa fa-calendar-check-o", + up: "fa fa-chevron-up", + }, + maxDate: DateTime.fromObject({ year: 9999, month: 12, day: 31 }), + minDate: DateTime.fromObject({ year: 1000 }), + useCurrent: false, + widgetParent: "body", +}; +DatePicker.props = { + // Components props + date: DateTime, + warn_future: { type: Boolean, optional: true }, + // Bootstrap datepicker options + buttons: { + type: Object, + shape: { + showClear: Boolean, + showClose: Boolean, + showToday: Boolean, + }, + optional: true, + }, + calendarWeeks: Boolean, + format: { type: String, optional: true }, + icons: { + type: Object, + shape: { + clear: String, + close: String, + date: String, + down: String, + next: String, + previous: String, + time: String, + today: String, + up: String, + }, + }, + keyBinds: { validate: (kb) => typeof kb === "object" || kb === null, optional: true }, + locale: { type: String, optional: true }, + maxDate: DateTime, + minDate: DateTime, + readonly: { type: Boolean, optional: true }, + useCurrent: Boolean, + widgetParent: String, +}; +DatePicker.template = "web.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 + */ +export class DateTimePicker extends DatePicker { + /** + * @override + */ + initFormat() { + this.defaultFormat = localization.dateTimeFormat; + this.format = formatters.get("datetime"); + this.parse = parsers.get("datetime"); + this.isLocal = true; + } +} + +DateTimePicker.defaultProps = { + ...DatePicker.defaultProps, + buttons: { + showClear: false, + showClose: true, + showToday: false, + }, +}; diff --git a/addons/web/static/src/core/datepicker/datepicker.xml b/addons/web/static/src/core/datepicker/datepicker.xml new file mode 100644 index 00000000000..b72e3a216b0 --- /dev/null +++ b/addons/web/static/src/core/datepicker/datepicker.xml @@ -0,0 +1,25 @@ + + + + + +
    + + + This date is on the future. Make sure it is what you expected. + + +
    +
    + +
    diff --git a/addons/web/static/src/core/debug/debug_context.js b/addons/web/static/src/core/debug/debug_context.js index d8f6987bcf4..a7593ec6131 100644 --- a/addons/web/static/src/core/debug/debug_context.js +++ b/addons/web/static/src/core/debug/debug_context.js @@ -1,4 +1,5 @@ /** @odoo-module **/ + import { registry } from "../registry"; import { memoize } from "../utils/functions"; import { useEffect } from "@web/core/utils/hooks"; @@ -78,9 +79,12 @@ export function useEnvDebugContext() { } export function useDebugCategory(category, context = {}) { - const debugContext = useEnvDebugContext(); - useEffect( - () => debugContext.activateCategory(category, context), - () => [] - ); + const env = useEnv(); + if (env.debug) { + const debugContext = useEnvDebugContext(); + useEffect( + () => debugContext.activateCategory(category, context), + () => [] + ); + } } diff --git a/addons/web/static/src/core/debug/debug_menu_items.js b/addons/web/static/src/core/debug/debug_menu_items.js index 6ef2c887d35..b01286079c9 100644 --- a/addons/web/static/src/core/debug/debug_menu_items.js +++ b/addons/web/static/src/core/debug/debug_menu_items.js @@ -1,8 +1,8 @@ /** @odoo-module **/ -import { browser } from "../browser/browser"; -import { routeToUrl } from "../browser/router_service"; -import { registry } from "../registry"; +import { browser } from "@web/core/browser/browser"; +import { routeToUrl } from "@web/core/browser/router_service"; +import { registry } from "@web/core/registry"; function activateAssetsDebugging({ env }) { return { diff --git a/addons/web/static/src/core/debug/debug_utils.js b/addons/web/static/src/core/debug/debug_utils.js new file mode 100644 index 00000000000..bd91c81db55 --- /dev/null +++ b/addons/web/static/src/core/debug/debug_utils.js @@ -0,0 +1,14 @@ +/** @odoo-module **/ + +export function editModelDebug(env, title, model, id) { + return env.services.action.doAction({ + res_model: model, + res_id: id, + name: title, + type: "ir.actions.act_window", + views: [[false, "form"]], + view_mode: "form", + target: "new", + flags: { action_buttons: true, headless: true }, + }); +} diff --git a/addons/web/static/src/core/domain.js b/addons/web/static/src/core/domain.js index fa92462d090..af1e0f5c3a4 100644 --- a/addons/web/static/src/core/domain.js +++ b/addons/web/static/src/core/domain.js @@ -203,16 +203,16 @@ function matchCondition(record, condition) { return fieldValue > value; case ">=": return fieldValue >= value; - case "in": - if (Array.isArray(fieldValue)) { - return fieldValue.some((fv) => value.includes(fv)); - } - return value.includes(fieldValue); - case "not in": - if (Array.isArray(fieldValue)) { - return !fieldValue.some((fv) => value.includes(fv)); - } - return !value.includes(fieldValue); + case "in": { + const val = Array.isArray(value) ? value : [value]; + const fieldVal = Array.isArray(fieldValue) ? fieldValue : [fieldValue]; + return fieldVal.some((fv) => val.includes(fv)); + } + case "not in": { + const val = Array.isArray(value) ? value : [value]; + const fieldVal = Array.isArray(fieldValue) ? fieldValue : [fieldValue]; + return !fieldVal.some((fv) => val.includes(fv)); + } case "like": if (fieldValue === false) { return false; diff --git a/addons/web/static/src/core/dropdown/dropdown.js b/addons/web/static/src/core/dropdown/dropdown.js index 6b15cda3461..fdaaec9f9bf 100644 --- a/addons/web/static/src/core/dropdown/dropdown.js +++ b/addons/web/static/src/core/dropdown/dropdown.js @@ -48,12 +48,6 @@ export class Dropdown extends Component { // Set up UI active element related behavior --------------------------- this.ui = useService("ui"); - useBus(this.ui.bus, "active-element-changed", (activeElement) => { - if (activeElement !== this.myActiveEl && this.state.open) { - // Close when UI active element changes to something different - this.close(); - } - }); useEffect( () => { Promise.resolve().then(() => { diff --git a/addons/web/static/src/core/dropdown/dropdown.scss b/addons/web/static/src/core/dropdown/dropdown.scss index 7cbacae0411..4093ec49ebd 100644 --- a/addons/web/static/src/core/dropdown/dropdown.scss +++ b/addons/web/static/src/core/dropdown/dropdown.scss @@ -68,21 +68,14 @@ &_menu { @extend .dropdown-menu; - &.o_dropdown_menu_right { - // FIXME: does commenting this break anything? - // @extend .dropdown-menu-right; - } - & > li.o_dropdown { &.focus > .o_dropdown_toggler { background-color: $dropdown-link-hover-bg; } & > .o_dropdown_toggler { - // FIXME: without this there is no selection feedback - // @extend .dropdown-item; &:after { - @include o-position-absolute($right: 0); - transform: translate(-.6em, .3em); + @include o-position-absolute($right: 0, $top: 0); + transform: translate(-.6em, .6em); font: .9em/1em FontAwesome; content: "\f0da"; } @@ -145,10 +138,6 @@ button.o_dropdown_toggler { box-shadow: none !important; } - &.o_dropdown_active { - @extend %--state-active; - } - @include hover-focus-active() { @extend %--state-active; } diff --git a/addons/web/static/src/core/dropdown/dropdown.xml b/addons/web/static/src/core/dropdown/dropdown.xml index 525c5b66317..48c33d17f38 100644 --- a/addons/web/static/src/core/dropdown/dropdown.xml +++ b/addons/web/static/src/core/dropdown/dropdown.xml @@ -17,7 +17,7 @@ t-att-data-hotkey="props.hotkey" t-ref="togglerRef" > - + diff --git a/addons/web/static/src/core/l10n/dates.js b/addons/web/static/src/core/l10n/dates.js index e2cfa3c6cc5..4dbda3c7566 100644 --- a/addons/web/static/src/core/l10n/dates.js +++ b/addons/web/static/src/core/l10n/dates.js @@ -1,18 +1,14 @@ /** @odoo-module **/ -import { memoize } from "../utils/functions"; -import { sprintf } from "../utils/strings"; -import { localization } from "./localization"; -import { _t } from "./translation"; +import { localization } from "@web/core/l10n/localization"; +import { _t } from "@web/core/l10n/translation"; +import { memoize } from "@web/core/utils/functions"; +import { sprintf } from "@web/core/utils/strings"; const { DateTime } = luxon; -/** - * Change the method toJSON to return the formated value to send server side. - */ -DateTime.prototype.toJSON = function () { - return this.toFormat("yyyy-MM-dd HH:mm:ss"); -}; +const SERVER_DATE_FORMAT = "yyyy-MM-dd"; +const SERVER_TIME_FORMAT = "HH:mm:ss"; // ----------------------------------------------------------------------------- // Helpers @@ -146,7 +142,24 @@ export const strftimeToLuxonFormat = memoize(function strftimeToLuxonFormat(valu // ----------------------------------------------------------------------------- /** - * Format a DateTime object + * Formats a DateTime object to a date string + * + * `options.timezone` is defaulted to false on dates since we assume they + * shouldn't be affected by timezones like datetimes. + * + * @see formatDateTime + * @returns {string} + */ +export function formatDate(value, options = {}) { + return formatDateTime(value, { + timezone: false, // Timezone should never alter a 'date' value. + ...options, + format: options.format || localization.dateFormat, + }); +} + +/** + * Formats a DateTime object to a datetime string * * @param {DateTime | false} value * @param {Object} options @@ -178,28 +191,21 @@ export function formatDateTime(value, options = {}) { // ----------------------------------------------------------------------------- /** - * Parses a string value to an UTC Luxon DateTime object. + * Parses a string value to a Luxon DateTime object. * - * @param {string} value value to parse. - * - Value can take the form of a smart date: - * e.g. "+3w" for three weeks from now. - * (`options.format` and `options.timezone` are ignored in this case) + * `options.timezone` is defaulted to false on dates since we assume they + * shouldn't be affected by timezones like datetimes. * - * - If value cannot be parsed within the provided format, - * ISO8601 and SQL formats are then tried. - * - * @param {object} options - * @param {string} [options.format] - * Provided format used to parse the input value. - * Default=the session localization format - * @returns {DateTime|false} Luxon DateTime object (in the UTC timezone) + * @see parseDateTime (Note: since we're only interested by the date itself, the + * returned value will always be set at the start of the day) + * @returns {DateTime | false} Luxon DateTime object */ export function parseDate(value, options = {}) { - return parseDateTime(value, { dateOnly: true, format: options.format }); + return parseDateTime(value, { timezone: false, ...options }).startOf("day"); } /** - * Parses a string value to an UTC Luxon DateTime object. + * Parses a string value to a Luxon DateTime object. * * @param {string} value value to parse. * - Value can take the form of a smart date: @@ -210,11 +216,6 @@ export function parseDate(value, options = {}) { * ISO8601 and SQL formats are then tried. * * @param {object} options - * @param {boolean} [options.dateOnly] - * The output date will represent the start of day (time is truncated). - * - * Default=false. - * * @param {string} [options.format] * Provided format used to parse the input value. * @@ -222,19 +223,18 @@ export function parseDate(value, options = {}) { * * @param {boolean} [options.timezone] * - True = input value is considered being in localtime. - * - False = input value is considered being in utc time. + * - False = input value is considered being in utc time, and the returned + * value will have the UTC zone. * * NB: ISO strings containing timezone information * will have priority over this option. * * Default=false. * - * @returns {DateTime|false} Luxon DateTime object (in the UTC timezone) + * @returns {DateTime | false} Luxon DateTime object */ export function parseDateTime(value, options = {}) { if (!value) { - // BOI: is {DateTime|false} really what we want ? - // I think we should return a DateTime.invalid when falsy. return false; } @@ -242,6 +242,7 @@ export function parseDateTime(value, options = {}) { const parseOpts = { setZone: true, zone: options.timezone ? "local" : "utc", + locale: options.locale, }; let result = constrain(parseSmartDateInput(value)); @@ -279,6 +280,23 @@ export function parseDateTime(value, options = {}) { throw new Error(sprintf(_t("'%s' is not a correct date or datetime"), value)); } - result = result.toUTC(); - return options.dateOnly ? result.startOf("day") : result; + return options.timezone ? result : result.toUTC(); } + +/** + * Returns a serialized string representing the given date. + * @param {DateTime} value + * @returns {string} + */ +export const serializeDate = (value) => { + return formatDate(value, { format: SERVER_DATE_FORMAT }); +}; + +/** + * Returns a serialized string representing the given datetime. + * @param {DateTime} value + * @returns {string} + */ +export const serializeDateTime = (value) => { + return formatDateTime(value, { format: `${SERVER_DATE_FORMAT} ${SERVER_TIME_FORMAT}` }); +}; diff --git a/addons/web/static/src/core/orm_service.js b/addons/web/static/src/core/orm_service.js index b3c2f053fbc..8c3024c487d 100644 --- a/addons/web/static/src/core/orm_service.js +++ b/addons/web/static/src/core/orm_service.js @@ -87,7 +87,7 @@ function validateArray(name, array) { } } -class ORM { +export class ORM { constructor(rpc, user) { this.rpc = rpc; this.user = user; @@ -129,9 +129,9 @@ class ORM { } readGroup(model, domain, fields, groupby, options = {}, ctx = {}) { + validateArray("domain", domain); validatePrimitiveList("fields", "string", fields); validatePrimitiveList("groupby", "string", groupby); - validateArray("domain", domain); const kwargs = { domain, groupby, @@ -139,7 +139,7 @@ class ORM { context: ctx, }; assignOptions(kwargs, options, ["lazy", "offset", "orderby", "limit"]); - return this.call(model, "web_read_group", [], kwargs); + return this.call(model, "read_group", [], kwargs); } search(model, domain, options = {}, ctx = {}) { @@ -169,6 +169,20 @@ class ORM { return this.call(model, "unlink", [ids], { context: ctx }); } + webReadGroup(model, domain, fields, groupby, options = {}, ctx = {}) { + validateArray("domain", domain); + validatePrimitiveList("fields", "string", fields); + validatePrimitiveList("groupby", "string", groupby); + const kwargs = { + domain, + groupby, + fields, + context: ctx, + }; + assignOptions(kwargs, options, ["lazy", "offset", "orderby", "limit"]); + return this.call(model, "web_read_group", [], kwargs); + } + webSearchRead(model, domain, fields, options = {}, ctx = {}) { validateArray("domain", domain); validatePrimitiveList("fields", "string", fields); diff --git a/addons/web/static/src/core/tooltip/tooltip.js b/addons/web/static/src/core/tooltip/tooltip.js new file mode 100644 index 00000000000..f35de09b408 --- /dev/null +++ b/addons/web/static/src/core/tooltip/tooltip.js @@ -0,0 +1,11 @@ +/** @odoo-module **/ + +const { Component } = owl; + +export class Tooltip extends Component {} +Tooltip.template = "web.Tooltip"; +Tooltip.props = { + tooltip: String, + Component: { optional: true }, + props: { optional: true }, +}; diff --git a/addons/web/static/src/core/tooltip/tooltip.scss b/addons/web/static/src/core/tooltip/tooltip.scss new file mode 100644 index 00000000000..98933b8ad56 --- /dev/null +++ b/addons/web/static/src/core/tooltip/tooltip.scss @@ -0,0 +1,25 @@ +.o-tooltip { + font-size: smaller; + + .o-tooltip--string { + background-color: $o-tooltip-title-background-color; + font-weight: bold; + padding: 5px 8px; + } + + .o-tooltip--help { + white-space: pre-line; + padding: 8px; + margin-bottom: 0px; + } + + .o-tooltip--technical { + padding: 8px; + margin: 0 0 0 15px; + list-style-type: circle; + + .o-tooltip--technical--title { + font-weight: bold; + } + } +} diff --git a/addons/web/static/src/core/tooltip/tooltip.xml b/addons/web/static/src/core/tooltip/tooltip.xml new file mode 100644 index 00000000000..d9c74413ede --- /dev/null +++ b/addons/web/static/src/core/tooltip/tooltip.xml @@ -0,0 +1,11 @@ + + + + +
    + + +
    +
    + +
    diff --git a/addons/web/static/src/core/tooltip/tooltip_hook.js b/addons/web/static/src/core/tooltip/tooltip_hook.js new file mode 100644 index 00000000000..96c6d0065cb --- /dev/null +++ b/addons/web/static/src/core/tooltip/tooltip_hook.js @@ -0,0 +1,102 @@ +/** @odoo-module **/ + +import { browser } from "@web/core/browser/browser"; +import { useListener } from "@web/core/utils/hooks"; +import { usePopover } from "@web/core/popover/popover_hook"; +import { Tooltip } from "./tooltip"; +import { registry } from "@web/core/registry"; + +/** + * The useTooltip hook allows to display custom tooltips on every elements with + * a "data-tooltip" attribute inside the Component using the hook. This attribute + * can be set on elements for which we prefer a custom tooltip instead of the + * native one displaying the value of the "title" attribute. + * + * Usage (with a parent somewhere using the useTooltip hook): + * + * + * The ideal position of the tooltip can be specified thanks to the attribute + * "data-tooltip-position": + * + */ +const OPEN_DELAY = 400; +const CLOSE_DELAY = 200; +export function useTooltip() { + const popover = usePopover(); + let openTooltipTimeout; + let closeTooltip; + let target = null; + + /** + * Closes the currently opened tooltip if any, or prevent it from opening. + */ + function cleanup() { + browser.clearTimeout(openTooltipTimeout); + if (closeTooltip) { + closeTooltip(); + } + } + + /** + * Opens the tooltip after a delay, if the event's target has a data-tooltip + * attribute. + * + * @param {MouseEvent} ev a "mouseenter" event + */ + function onMouseEnter(ev) { + const dataset = ev.target.dataset; + const tooltip = dataset.tooltip; + + let Component, props; + if ("tooltipComponent" in dataset) { + ({ Component, props } = JSON.parse(dataset["tooltipComponent"])); + Component = registry.category("tooltips").get(Component, null); + } + if (tooltip || Component) { + cleanup(); + target = ev.target; + openTooltipTimeout = browser.setTimeout(() => { + const position = dataset.tooltipPosition; + closeTooltip = popover.add( + target, + Tooltip, + { tooltip, Component, props }, + { position } + ); + }, OPEN_DELAY); + } + } + + // Track mouse position to be able to detect that we are no longer hovering + // the target, thus that we should close the tooltip + let positionX; + let positionY; + useListener("mousemove", (ev) => { + positionX = ev.x; + positionY = ev.y; + }); + + // Regularly check that the target is still in the DOM and we're still + // hovering it, because if not, we have to close the tooltip + browser.setInterval(() => { + if (!target) { + return; + } + if (!document.body.contains(target)) { + return cleanup(); // target is no longer in the DOM + } + const targetRect = target.getBoundingClientRect(); + if ( + positionX < targetRect.left || + positionX > targetRect.right || + positionY < targetRect.top || + positionY > targetRect.bottom + ) { + return cleanup(); // mouse is no longer hovering the target + } + }, CLOSE_DELAY); + + // Listen (using event delegation) to "mouseenter" events on all nodes with + // the "data-tooltip" attribute, to open the tooltip. + useListener("mouseenter", "[data-tooltip]", onMouseEnter, { capture: true }); +} diff --git a/addons/web/static/src/core/utils/hooks.js b/addons/web/static/src/core/utils/hooks.js index fabcf18aeeb..c870d5e8659 100644 --- a/addons/web/static/src/core/utils/hooks.js +++ b/addons/web/static/src/core/utils/hooks.js @@ -130,6 +130,84 @@ export function useEffect(effect, computeDependencies = () => [NaN]) { onWillUnmount(() => cleanup()); } +// ----------------------------------------------------------------------------- +// useListener +// ----------------------------------------------------------------------------- + +/** + * The useListener hook offers an alternative to Owl's classical event + * registration mechanism (with attribute 't-on-eventName' in xml). + * + * It is especially useful for abstract components, meant to be extended by + * specific ones. If those abstract components need to define event handlers, + * but don't have any template (because the template completely depends on + * specific cases), then using the 't-on' mechanism isn't adequate, as the + * handlers would be lost by the template override. In this case, using this + * hook instead is more convenient. + * + * Usage: like all Owl hooks, this function has to be called in the + * constructor of an Owl component: + * + * useListener('click', () => { console.log('clicked'); }); + * + * It also allows to do event delegation, by specifying a native query selector + * as second argument. In this case, the handler is only called if the event + * is triggered on an element matching the given selector. + * + * useListener('click', 'button', () => { console.log('clicked'); }); + * + * Note: components that alter the event's target (e.g. Portal) are not + * expected to behave as expected with event delegation. + * + * @param {string} eventName the name of the event + * @param {string} [querySelector] a JS native selector for event delegation + * @param {function} handler the event handler (will be bound to the component) + * @param {Object} [options] to be passed to addEventListener as options. + * Useful for listening in the capture phase + */ +export function useListener(eventName, querySelector, handler, options = {}) { + if (typeof arguments[1] !== "string") { + querySelector = null; + handler = arguments[1]; + options = arguments[2] || {}; + } + if (typeof handler !== "function") { + throw new Error("The handler must be a function"); + } + + const comp = useComponent(); + let boundHandler; + if (querySelector) { + boundHandler = function (ev) { + let el = ev.target; + let target; + while (el && !target) { + if (el.matches(querySelector)) { + target = el; + } else if (el === comp.el) { + el = null; + } else { + el = el.parentElement; + } + } + if (el) { + handler.call(comp, ev); + } + }; + } else { + boundHandler = handler.bind(comp); + } + useEffect( + () => { + comp.el.addEventListener(eventName, boundHandler, options); + return () => { + comp.el.removeEventListener(eventName, boundHandler, options); + }; + }, + () => [] + ); +} + // ----------------------------------------------------------------------------- // useService // ----------------------------------------------------------------------------- diff --git a/addons/web/static/src/core/utils/numbers.js b/addons/web/static/src/core/utils/numbers.js new file mode 100644 index 00000000000..01947258343 --- /dev/null +++ b/addons/web/static/src/core/utils/numbers.js @@ -0,0 +1,22 @@ +/** @odoo-module **/ + +/** + * @param {Number} value + * @param {Number} comparisonValue + * @returns {Number} + */ +export function computeVariation(value, comparisonValue) { + if (isNaN(value) || isNaN(comparisonValue)) { + return NaN; + } + if (comparisonValue === 0) { + if (value === 0) { + return 0; + } else if (value > 0) { + return 1; + } else { + return -1; + } + } + return (value - comparisonValue) / Math.abs(comparisonValue); +} diff --git a/addons/web/static/src/core/utils/objects.js b/addons/web/static/src/core/utils/objects.js index 7962bdf01e3..4e7638b0b53 100644 --- a/addons/web/static/src/core/utils/objects.js +++ b/addons/web/static/src/core/utils/objects.js @@ -10,3 +10,14 @@ export function shallowEqual(obj1, obj2) { obj1Keys.every((key) => obj1[key] === obj2[key]) ); } + +/** + * Deep copies an object. As it relies on JSON this function as some limitations + * - no support for circular objects + * - no support for specific classes, that will at best be lost and at worst crash (Map, Set etc...) + * @param {Object} An object that is fully JSON stringifiable + * @return {Object} + */ +export function deepCopy(obj) { + return JSON.parse(JSON.stringify(obj)); +} diff --git a/addons/web/static/src/core/utils/xml.js b/addons/web/static/src/core/utils/xml.js new file mode 100644 index 00000000000..7d618bc566f --- /dev/null +++ b/addons/web/static/src/core/utils/xml.js @@ -0,0 +1,35 @@ +/** @odoo-module **/ + +export class XMLParser { + /** + * to override. Should return the parsed content of the arch. + * It can call the visitArch function if desired + */ + parse() {} + + visitXML(xml, callback) { + function visit(xml) { + if (xml) { + let didVisitChildren = false; + const visitChildren = () => { + for (let child of xml.children) { + visit(child); + } + didVisitChildren = true; + }; + const shouldVisitChildren = callback(xml, visitChildren); + if (shouldVisitChildren !== false && !didVisitChildren) { + visitChildren(); + } + } + } + const xmlDoc = typeof xml === "string" ? this.parseXML(xml) : xml; + visit(xmlDoc); + } + + parseXML(arch) { + const parser = new DOMParser(); + const xml = parser.parseFromString(arch, "text/xml"); + return xml.documentElement; + } +} diff --git a/addons/web/static/src/fields/fields.js b/addons/web/static/src/fields/fields.js new file mode 100644 index 00000000000..a21b13eadb0 --- /dev/null +++ b/addons/web/static/src/fields/fields.js @@ -0,0 +1,115 @@ +/** @odoo-module **/ +import { registry } from "@web/core/registry"; +import legacyFieldRegistry from "web.field_registry"; +import owlFieldRegistry from "web.field_registry_owl"; +import { ComponentAdapter } from "web.OwlCompatibility"; +import { useEffect } from "@web/core/utils/hooks"; + +const { Component, tags, hooks } = owl; + +const fieldRegistry = registry.category("fields"); + +function getFieldFromRegistry(registry, { fieldType, viewType, fieldName, fieldsDescription }) { + if (viewType && fieldType) { + const specificType = `${viewType}.${fieldType}`; + if (registry.contains(specificType)) { + return registry.get(specificType); + } + } + if (!registry.contains(fieldType)) { + const field = fieldsDescription[fieldName]; + fieldType = field && field.type; + } + return registry.get(fieldType, null); +} + +class Field extends Component { + static getTangibleField({ record, type, name }) { + const FieldClass = getFieldFromRegistry(fieldRegistry, { + fieldName: name, + fieldType: type, + viewType: record.viewtype, + fieldsDescription: record.fields, + }); + return { FieldClass }; + } + + setup() { + const { record, type, name } = this.props; + this.fieldRef = hooks.useRef("fieldRef"); + this.FieldComponent = Field.getTangibleField({ record, type, name }).FieldClass; + } +} + +Field.template = tags.xml/* xml */ ` + +`; + +class FieldSupportsLegacy extends Field { + static getTangibleField({ record, type, fieldName }) { + let { FieldClass } = super.getTangibleField(...arguments); + if (!FieldClass) { + FieldClass = getFieldFromRegistry(owlFieldRegistry, { + fieldName, + fieldType: type, + viewType: record.viewType, + fieldsDescription: record.fields, + }); + if (FieldClass) { + return { FieldClass, isOwlLegacy: true }; + } else { + FieldClass = getFieldFromRegistry(legacyFieldRegistry, { + fieldName, + fieldType: type, + viewType: record.viewType, + fieldsDescription: record.fields, + }); + return { FieldClass, isLegacy: true }; + } + } + return {}; + } + + setup() { + super.setup(); + this.renderId = 0; + useEffect(() => { + this.renderId++; + }); + if (!this.FieldComponent) { + this.env = owl.Component.env; + const { record, type, name } = this.props; + const { FieldClass, isOwlLegacy, isLegacy } = FieldSupportsLegacy.getTangibleField({ + record, + type, + name, + }); + this.FieldComponent = FieldClass; + this.isOwlLegacy = isOwlLegacy; + this.isLegacy = isLegacy; + } + } + + get legacyProps() { + const legacyRecord = Object.assign({}, this.props.model._legacyRecord_); + legacyRecord.data = Object.assign({}, legacyRecord.data, this.props.record.data); + return { + fieldName: this.props.name, + record: legacyRecord, + }; + } + + get widgetArgs() { + const { fieldName, record } = this.legacyProps; + return [fieldName, record]; + } +} +FieldSupportsLegacy.template = tags.xml/* xml */ ` + + + + +`; +FieldSupportsLegacy.components = { ComponentAdapter }; + +export { FieldSupportsLegacy as Field }; diff --git a/addons/web/static/src/fields/formatters.js b/addons/web/static/src/fields/formatters.js index cec5c4ef1a4..e7f98f9b611 100644 --- a/addons/web/static/src/fields/formatters.js +++ b/addons/web/static/src/fields/formatters.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import { formatDateTime } from "@web/core/l10n/dates"; +import { formatDate, formatDateTime } from "@web/core/l10n/dates"; import { localization as l10n } from "@web/core/l10n/localization"; import { _lt } from "@web/core/l10n/translation"; import { registry } from "@web/core/registry"; @@ -36,7 +36,9 @@ function insertThousandsSep(number, thousandsSep = ",", grouping = [3, 0]) { * @private * @param {number} number to format * @param {Object} [options] Options to format - * @param {number} [options.decimals=0] maximum number of decimals to use + * @param {number} [options.decimals=0] number of decimals to use + * if minDigits > 1 is used and effective on the number then decimals + * will be shrunk to zero, to avoid displaying irrelevant figures ( 0.01 compared to 1000 ) * @param {number} [options.minDigits=1] * the minimum number of digits to preserve when switching to another * level of thousands (e.g. with a value of '2', 4321 will still be @@ -44,11 +46,11 @@ function insertThousandsSep(number, thousandsSep = ",", grouping = [3, 0]) { * @returns {string} */ function humanNumber(number, options = { decimals: 0, minDigits: 1 }) { - number = Math.round(number); const decimals = options.decimals || 0; const minDigits = options.minDigits || 1; const d2 = Math.pow(10, decimals); const numberMagnitude = +number.toExponential().split("e+")[1]; + number = Math.round(number * d2) / d2; // the case numberMagnitude >= 21 corresponds to a number // better expressed in the scientific format. if (numberMagnitude >= 21) { @@ -72,7 +74,10 @@ function humanNumber(number, options = { decimals: 0, minDigits: 1 }) { } } const { decimalPoint, grouping, thousandsSep } = l10n; - const [integerPart, decimalPart] = String(number).split("."); + + // determine if we should keep the decimals (we don't want to display 1,020.02k for 1020020) + const decimalsToKeep = number >= 1000 ? 0 : decimals; + const [integerPart, decimalPart] = number.toFixed(decimalsToKeep).split("."); const int = insertThousandsSep(sign * Number(integerPart), thousandsSep, grouping); if (!decimalPart) { return int + symbol; @@ -282,7 +287,13 @@ export function formatMonetary(value, options = {}) { const currency = session.currencies[currencyId]; const digits = (currency && currency.digits) || options.digits; - const formatted = options.humanReadable ? humanNumber(value) : formatFloat(value, { digits }); + let formatted; + if (options.humanReadable) { + formatted = humanNumber(value, { decimals: digits ? digits[1] : 2 }); + } else { + formatted = formatFloat(value, { digits }); + } + if (!currency || options.noSymbol) { return formatted; } @@ -312,6 +323,7 @@ export function formatPercentage(value, options = {}) { registry .category("formatters") + .add("date", formatDate) .add("datetime", formatDateTime) .add("float", formatFloat) .add("float_factor", formatFloatFactor) diff --git a/addons/web/static/src/fields/parsers.js b/addons/web/static/src/fields/parsers.js index 9b391bbbf58..68d61aa716c 100644 --- a/addons/web/static/src/fields/parsers.js +++ b/addons/web/static/src/fields/parsers.js @@ -152,4 +152,5 @@ registry .add("float", parseFloat) .add("float_time", parseFloatTime) .add("integer", parseInteger) + .add("monetary", parseMonetary) .add("percentage", parsePercentage); diff --git a/addons/web/static/src/legacy/action_adapters.js b/addons/web/static/src/legacy/action_adapters.js index a414c6482ac..2a6fb3dff59 100644 --- a/addons/web/static/src/legacy/action_adapters.js +++ b/addons/web/static/src/legacy/action_adapters.js @@ -8,7 +8,8 @@ import { useDebugCategory } from "../core/debug/debug_context"; import { Dialog } from "../core/dialog/dialog"; import { useEffect, useService } from "@web/core/utils/hooks"; import { ViewNotFoundError } from "../webclient/actions/action_service"; -import { cleanDomFromBootstrap, wrapSuccessOrFail, mapDoActionOptionAPI } from "./utils"; +import { cleanDomFromBootstrap, wrapSuccessOrFail } from "./utils"; +import { mapDoActionOptionAPI } from "./backend_utils"; const { Component, tags, hooks } = owl; @@ -335,15 +336,16 @@ export class ViewAdapter extends ActionAdapter { async _trigger_up(ev) { const payload = ev.data; if (ev.name === "switch_view") { - const state = ev.target.exportState(); try { - await this.actionService.switchView(payload.view_type, { - resId: payload.res_id, - resIds: state.resIds, - searchModel: state.searchModel, - searchPanel: state.searchPanel, - mode: payload.mode, - }); + const props = {}; + if (payload.mode) { + props.mode = payload.mode; + } + // if (payload.res_id) { + // if make 'open a record, come back, and create a new record' crash + props.resId = payload.res_id; + // } + await this.actionService.switchView(payload.view_type, props); } catch (e) { if (e instanceof ViewNotFoundError) { return; diff --git a/addons/web/static/src/legacy/backend_utils.js b/addons/web/static/src/legacy/backend_utils.js new file mode 100644 index 00000000000..d1c014158cf --- /dev/null +++ b/addons/web/static/src/legacy/backend_utils.js @@ -0,0 +1,265 @@ +/** @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; + } + + 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); +} + +/** + * @param {string} state + * @returns {string} + */ +export function searchModelStateToLegacy(state) { + if (!state) { + return; + } + + const parsedState = JSON.parse(state); + const query = []; + for (const queryElem of parsedState.query) { + const searchItemId = queryElem.searchItemId; + const searchItem = parsedState.searchItems[searchItemId]; + const groupId = searchItem.groupId; + const legacyQueryElem = { filterId: searchItemId, groupId }; + switch (searchItem.type) { + case "dateFilter": + legacyQueryElem.optionId = queryElem.generatorId; + break; + case "dateGroupBy": + legacyQueryElem.optionId = queryElem.intervalId; + break; + case "comparison": + legacyQueryElem.type = "comparison"; + legacyQueryElem.dateFilterId = searchItem.dateFilterId; + break; + case "field": + legacyQueryElem.value = queryElem.autocompleteValue.value; + legacyQueryElem.label = queryElem.autocompleteValue.label; + legacyQueryElem.operator = queryElem.autocompleteValue.operator; + break; + } + query.push(legacyQueryElem); + } + + const filters = {}; + for (const item of Object.values(parsedState.searchItems)) { + const filter = Object.assign({}, item); + switch (item.type) { + case "dateGroupBy": + filter.type = "groupBy"; + filter.hasOptions = true; + filter.defaultOptionId = item.defaultIntervalId; + delete filter.defaultIntervalId; + break; + case "dateFilter": + filter.type = "filter"; + filter.isDateFilter = true; + filter.hasOptions = true; + filter.defaultOptionId = item.defaultGeneratorId; + delete filter.defaultGeneratorId; + break; + case "favorite": + filter.orderedBy = item.orderBy; + delete filter.orderBy; + break; + } + filters[item.id] = filter; + } + + const { nextGroupId, nextGroupNumber, nextId, sections, searchPanelInfo } = parsedState; + const legacyState = {}; + legacyState.ControlPanelModelExtension = { + query, + filters, + nextGroupId, + nextGroupNumber, + nextId, + }; + legacyState.SearchPanelModelExtension = { sections, searchPanelInfo }; + + for (const [key, extension] of Object.entries(ActionModel.registry.entries())) { + if (!["ControlPanel", "SearchPanel"].includes(key) && parsedState[key] !== undefined) { + legacyState[extension.name] = parsedState[key]; + } + } + + return JSON.stringify(legacyState); +} + +export function exportGlobalState(legacyControllerState) { + const { resIds, searchModel, searchPanel } = legacyControllerState; + const globalState = {}; + if (searchPanel) { + globalState.searchPanel = searchPanel; + } + if (resIds) { + globalState.resIds = resIds; + } + const newSearchModel = searchModelStateFromLegacy(searchModel); + if (newSearchModel) { + globalState.searchModel = newSearchModel; + } + return globalState; +} + +export function exportLocalState(legacyControllerState) { + const state = Object.assign({}, legacyControllerState); + delete state.searchModel; + delete state.searchPanel; + delete state.resIds; + return state; +} + +export function mapDoActionOptionAPI(legacyOptions) { + legacyOptions = legacyOptions || {}; + // use camelCase instead of snake_case for some keys + Object.assign(legacyOptions, { + additionalContext: legacyOptions.additional_context, + clearBreadcrumbs: legacyOptions.clear_breadcrumbs, + viewType: legacyOptions.view_type, + onClose: legacyOptions.on_close, + props: { + resId: legacyOptions.res_id, + }, + }); + if (legacyOptions.controllerState) { + legacyOptions.props.globalState = exportGlobalState(legacyOptions.controllerState); + } + delete legacyOptions.additional_context; + delete legacyOptions.clear_breadcrumbs; + delete legacyOptions.view_type; + delete legacyOptions.res_id; + delete legacyOptions.on_close; + return legacyOptions; +} + +export function makeLegacyActionManagerService(legacyEnv) { + // add a service to redirect 'do-action' events triggered on the bus in the + // legacy env to the action-manager service in the wowl env + return { + dependencies: ["action"], + start(env) { + function do_action(action, options) { + const legacyOptions = mapDoActionOptionAPI(options); + return env.services.action.doAction(action, legacyOptions); + } + legacyEnv.bus.on("do-action", null, (payload) => { + const { action, options } = payload; + do_action(action, options); + }); + return { do_action }; + }, + }; +} + +export function breadcrumbsToLegacy(breadcrumbs) { + if (!breadcrumbs) { + return; + } + return breadcrumbs.slice().map((bc) => { + return { title: bc.name, controllerID: bc.jsId }; + }); +} diff --git a/addons/web/static/src/legacy/debug_manager.js b/addons/web/static/src/legacy/debug_manager.js index 090892acad7..1264ca824e6 100644 --- a/addons/web/static/src/legacy/debug_manager.js +++ b/addons/web/static/src/legacy/debug_manager.js @@ -1,212 +1,15 @@ /** @odoo-module **/ -import { Dialog } from "../core/dialog/dialog"; -import { formatDateTime, parseDateTime } from "../core/l10n/dates"; -import { _lt } from "../core/l10n/translation"; -import { registry } from "../core/registry"; +import { _lt } from "@web/core/l10n/translation"; +import { Dialog } from "@web/core/dialog/dialog"; +import { formatDateTime, parseDateTime } from "@web/core/l10n/dates"; import { formatMany2one } from "@web/fields/formatters"; +import { registry } from "@web/core/registry"; -const { hooks, tags } = owl; +const { hooks } = owl; const { useState } = hooks; -function editModelDebug(env, title, model, id) { - return env.services.action.doAction({ - res_model: model, - res_id: id, - name: title, - type: "ir.actions.act_window", - views: [[false, "form"]], - view_mode: "form", - target: "new", - flags: { action_buttons: true, headless: true }, - }); -} - -// Action items - -function actionSeparator() { - return { - type: "separator", - sequence: 100, - }; -} - -function accessSeparator({ accessRights, action }) { - const { canSeeModelAccess, canSeeRecordRules } = accessRights; - if (!action.res_model || (!canSeeModelAccess && !canSeeRecordRules)) { - return null; - } - return { - type: "separator", - sequence: 200, - }; -} - -function editAction({ action, env }) { - if (!action.id) { - return null; - } - const description = env._t("Edit Action"); - return { - type: "item", - description, - callback: () => { - editModelDebug(env, description, action.type, action.id); - }, - sequence: 110, - }; -} - -function viewFields({ action, env }) { - if (!action.res_model) { - return null; - } - const description = env._t("View Fields"); - return { - type: "item", - description, - callback: async () => { - const modelId = ( - await env.services.orm.search("ir.model", [["model", "=", action.res_model]], { - limit: 1, - }) - )[0]; - env.services.action.doAction({ - res_model: "ir.model.fields", - name: description, - views: [ - [false, "list"], - [false, "form"], - ], - domain: [["model_id", "=", modelId]], - type: "ir.actions.act_window", - context: { - default_model_id: modelId, - }, - }); - }, - sequence: 120, - }; -} - -function manageFilters({ action, env }) { - if (!action.res_model) { - return null; - } - const description = env._t("Manage Filters"); - return { - type: "item", - description, - callback: () => { - // manage_filters - env.services.action.doAction({ - res_model: "ir.filters", - name: description, - views: [ - [false, "list"], - [false, "form"], - ], - type: "ir.actions.act_window", - context: { - search_default_my_filters: true, - search_default_model_id: action.res_model, - }, - }); - }, - sequence: 130, - }; -} - -function technicalTranslation({ action, env }) { - if (!action.res_model) { - return null; - } - return { - type: "item", - description: env._t("Technical Translation"), - callback: async () => { - const result = await env.services.orm.call( - "ir.translation", - "get_technical_translations", - [action.res_model] - ); - env.services.action.doAction(result); - }, - sequence: 140, - }; -} - -function viewAccessRights({ accessRights, action, env }) { - if (!action.res_model || !accessRights.canSeeModelAccess) { - return null; - } - const description = env._t("View Access Rights"); - return { - type: "item", - description, - callback: async () => { - const modelId = ( - await env.services.orm.search("ir.model", [["model", "=", action.res_model]], { - limit: 1, - }) - )[0]; - env.services.action.doAction({ - res_model: "ir.model.access", - name: description, - views: [ - [false, "list"], - [false, "form"], - ], - domain: [["model_id", "=", modelId]], - type: "ir.actions.act_window", - context: { - default_model_id: modelId, - }, - }); - }, - sequence: 210, - }; -} - -function viewRecordRules({ accessRights, action, env }) { - if (!action.res_model || !accessRights.canSeeRecordRules) { - return null; - } - const description = env._t("Model Record Rules"); - return { - type: "item", - description: env._t("View Record Rules"), - callback: async () => { - const modelId = ( - await env.services.orm.search("ir.model", [["model", "=", action.res_model]], { - limit: 1, - }) - )[0]; - env.services.action.doAction({ - res_model: "ir.rule", - name: description, - views: [ - [false, "list"], - [false, "form"], - ], - domain: [["model_id", "=", modelId]], - type: "ir.actions.act_window", - context: { - default_model_id: modelId, - }, - }); - }, - sequence: 220, - }; -} - -class FieldViewGetDialog extends Dialog {} -FieldViewGetDialog.props = Object.assign({}, Dialog.props, { - arch: { type: String }, - close: Function, -}); -FieldViewGetDialog.bodyTemplate = tags.xml`
    `;
    -FieldViewGetDialog.title = _lt("Fields View Get");
    +const debugRegistry = registry.category("debug");
     
     class GetMetadataDialog extends Dialog {
         setup() {
    @@ -243,7 +46,6 @@ class GetMetadataDialog extends Dialog {
     }
     GetMetadataDialog.bodyTemplate = "web.DebugMenu.getMetadataBody";
     GetMetadataDialog.title = _lt("View Metadata");
    -
     class SetDefaultDialog extends Dialog {
         setup() {
             super.setup();
    @@ -389,66 +191,7 @@ SetDefaultDialog.bodyTemplate = "web.DebugMenu.setDefaultBody";
     SetDefaultDialog.footerTemplate = "web.DebugMenu.SetDefaultFooter";
     SetDefaultDialog.title = _lt("Set Default");
     
    -function viewSeparator() {
    -    return {
    -        type: "separator",
    -        sequence: 300,
    -    };
    -}
    -
    -function fieldsViewGet({ component, env }) {
    -    return {
    -        type: "item",
    -        description: env._t("Fields View Get"),
    -        callback: () => {
    -            const props = {
    -                arch: component.props.viewInfo.arch,
    -            };
    -            env.services.dialog.add(FieldViewGetDialog, props);
    -        },
    -        sequence: 340,
    -    };
    -}
    -
    -export function editView({ accessRights, action, component, env }) {
    -    if (!accessRights.canEditView) {
    -        return null;
    -    }
    -    let { view_id: viewId, type: viewType } = component.props.viewInfo;
    -    viewType = viewType === "tree" ? "list" : viewType;
    -    const displayName = action.views.find((v) => v.type === viewType).name.toString();
    -    const description = env._t("Edit View: ") + displayName;
    -    return {
    -        type: "item",
    -        description,
    -        callback: () => {
    -            editModelDebug(env, description, "ir.ui.view", viewId);
    -        },
    -        sequence: 350,
    -    };
    -}
    -
    -export function editSearchView({ accessRights, component, env }) {
    -    if (!accessRights.canEditView) {
    -        return null;
    -    }
    -    const description = env._t("Edit SearchView");
    -    return {
    -        type: "item",
    -        description,
    -        callback: () => {
    -            editModelDebug(
    -                env,
    -                description,
    -                "ir.ui.view",
    -                component.props.viewParams.controlPanelFieldsView.view_id
    -            );
    -        },
    -        sequence: 360,
    -    };
    -}
    -
    -// Form view itemss
    +// Form view items
     
     function setDefaults({ action, component, env }) {
         return {
    @@ -515,26 +258,6 @@ function manageAttachments({ action, component, env }) {
         };
     }
     
    -const debugRegistry = registry.category("debug");
    -
    -debugRegistry
    -    .category("action")
    -    .add("actionSeparator", actionSeparator)
    -    .add("editAction", editAction)
    -    .add("viewFields", viewFields)
    -    .add("manageFilters", manageFilters)
    -    .add("technicalTranslation", technicalTranslation)
    -    .add("accessSeparator", accessSeparator)
    -    .add("viewAccessRights", viewAccessRights)
    -    .add("viewRecordRules", viewRecordRules);
    -
    -debugRegistry
    -    .category("view")
    -    .add("viewSeparator", viewSeparator)
    -    .add("fieldsViewGet", fieldsViewGet)
    -    .add("editView", editView)
    -    .add("editSearchView", editSearchView);
    -
     debugRegistry
         .category("form")
         .add("setDefaults", setDefaults)
    diff --git a/addons/web/static/src/legacy/js/components/datepicker.js b/addons/web/static/src/legacy/js/components/datepicker.js
    index 94ce7ece4fe..2c27a55d5f4 100644
    --- a/addons/web/static/src/legacy/js/components/datepicker.js
    +++ b/addons/web/static/src/legacy/js/components/datepicker.js
    @@ -221,7 +221,7 @@ odoo.define('web.DatePickerOwl', function (require) {
             useCurrent: Boolean,
             widgetParent: String,
         };
    -    DatePicker.template = 'web.DatePicker';
    +    DatePicker.template = "web.Legacy.DatePicker";
     
         /**
          * Date/time picker
    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
    index 08e54983eee..b97b6f2fe65 100644
    --- 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
    @@ -13,10 +13,6 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
         const FAVORITE_SHARED_GROUP = 2;
         const DISABLE_FAVORITE = "search_disable_custom_filters";
     
    -    let filterId = 1;
    -    let groupId = 1;
    -    let groupNumber = 0;
    -
         /**
          * Control panel model
          *
    @@ -215,6 +211,9 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
              */
             prepareState() {
                 Object.assign(this.state, {
    +                nextGroupId: 1,
    +                nextGroupNumber: 1,
    +                nextId: 1,
                     filters: {},
                     query: [],
                 });
    @@ -318,13 +317,13 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
                 const preFavorite = await this._saveQuery(preFilter);
                 this.clearQuery();
                 const filter = Object.assign(preFavorite, {
    -                groupId,
    -                id: filterId,
    +                groupId: this.state.nextGroupId,
    +                id: this.state.nextId,
                 });
    -            this.state.filters[filterId] = filter;
    -            this.state.query.push({ groupId, filterId });
    -            groupId++;
    -            filterId++;
    +            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++;
             }
     
             /**
    @@ -340,18 +339,18 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
                 const newFilterIdS = [];
                 prefilters.forEach(preFilter => {
                     const filter = Object.assign(preFilter, {
    -                    groupId,
    -                    groupNumber,
    -                    id: filterId,
    +                    groupId: this.state.nextGroupId,
    +                    groupNumber: this.state.nextGroupNumber,
    +                    id: this.state.nextId,
                         type: 'filter',
                     });
    -                this.state.filters[filterId] = filter;
    -                this.state.query.push({ groupId, filterId });
    -                newFilterIdS.push(filterId);
    -                filterId++;
    +                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++;
                 });
    -            groupId++;
    -            groupNumber++;
    +            this.state.nextGroupId++;
    +            this.state.nextGroupNumber++;
                 return newFilterIdS;
             }
     
    @@ -366,22 +365,22 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
                     description: field.string || field.name,
                     fieldName: field.name,
                     fieldType: field.type,
    -                groupId: groupBy ? groupBy.groupId : groupId++,
    -                groupNumber,
    -                id: filterId,
    +                groupId: groupBy ? groupBy.groupId : this.state.nextGroupId++,
    +                groupNumber: this.state.nextGroupNumber,
    +                id: this.state.nextId,
                     type: 'groupBy',
                     custom: true,
                 };
    -            this.state.filters[filterId] = filter;
    +            this.state.filters[this.state.nextId] = filter;
                 if (['date', 'datetime'].includes(field.type)) {
                     filter.hasOptions = true;
                     filter.defaultOptionId = DEFAULT_INTERVAL;
    -                this.toggleFilterWithOptions(filterId);
    +                this.toggleFilterWithOptions(this.state.nextId);
                 } else {
    -                this.toggleFilter(filterId);
    +                this.toggleFilter(this.state.nextId);
                 }
    -            groupNumber++;
    -            filterId++;
    +            this.state.nextGroupNumber++;
    +            this.state.nextId++;
             }
     
             /**
    @@ -732,14 +731,14 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
              */
             _createGroupOfFilters(pregroup) {
                 pregroup.forEach(preFilter => {
    -                const filter = Object.assign(preFilter, { groupId, id: filterId });
    -                this.state.filters[filterId] = filter;
    +                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);
                     }
    -                filterId++;
    +                this.state.nextId++;
                 });
    -            groupId++;
    +            this.state.nextGroupId++;
             }
     
             /**
    @@ -784,7 +783,7 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
                         }
                         currentTag = preFilter.tag;
                         currentGroup = [];
    -                    groupNumber++;
    +                    this.state.nextGroupNumber++;
                     }
                     if (preFilter.tag !== 'separator') {
                         const filter = {
    @@ -811,7 +810,7 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
                             }
                         }
                         if (filter.type === 'filter' || filter.type === 'groupBy') {
    -                        filter.groupNumber = groupNumber;
    +                        filter.groupNumber = this.state.nextGroupNumber;
                         }
                         this._extractAttributes(filter, preFilter.attrs);
                         currentGroup.push(filter);
    diff --git a/addons/web/static/src/legacy/js/model.js b/addons/web/static/src/legacy/js/model.js
    index a367706ea4c..46aff673cbb 100644
    --- a/addons/web/static/src/legacy/js/model.js
    +++ b/addons/web/static/src/legacy/js/model.js
    @@ -151,7 +151,7 @@ odoo.define("web.Model", function (require) {
          * 3. Registration of the extension into the main model
          * @see Registry()
          * ```
    -     *  ActionModel.registry.add("SearchPanel", ControlPanelModelExtension, 10);
    +     *  ActionModel.registry.add("ControlPanel", ControlPanelModelExtension, 10);
          * ```
          *
          *-------------------------------------------------------------------------
    diff --git a/addons/web/static/src/legacy/js/views/graph/graph_model.js b/addons/web/static/src/legacy/js/views/graph/graph_model.js
    index b1bcddb47c2..1e02c729b40 100644
    --- a/addons/web/static/src/legacy/js/views/graph/graph_model.js
    +++ b/addons/web/static/src/legacy/js/views/graph/graph_model.js
    @@ -75,6 +75,10 @@ return AbstractModel.extend({
                 orderBy: params.orderBy
             });
     
    +        if (this.chart.measure === "__count") {
    +            this.chart.measure = "__count__";
    +        }
    +
             this._computeDerivedParams();
     
             return this._loadGraph();
    @@ -117,6 +121,10 @@ return AbstractModel.extend({
                 this.chart.timeRanges = params.timeRanges;
             }
     
    +        if (this.chart.measure === "__count") {
    +            this.chart.measure = "__count__";
    +        }
    +
             this._computeDerivedParams();
     
             if ('mode' in params) {
    diff --git a/addons/web/static/src/legacy/js/views/graph/graph_renderer.js b/addons/web/static/src/legacy/js/views/graph/graph_renderer.js
    index d14f52accf9..ba35e0d976b 100644
    --- a/addons/web/static/src/legacy/js/views/graph/graph_renderer.js
    +++ b/addons/web/static/src/legacy/js/views/graph/graph_renderer.js
    @@ -956,7 +956,7 @@ odoo.define("web/static/src/js/views/graph/graph_renderer", function (require) {
             }
         }
     
    -    GraphRenderer.template = "web.GraphRenderer";
    +    GraphRenderer.template = "web.Legacy.GraphRenderer";
         GraphRenderer.props = {
             arch: {
                 type: Object,
    diff --git a/addons/web/static/src/legacy/js/views/pivot/pivot_renderer.js b/addons/web/static/src/legacy/js/views/pivot/pivot_renderer.js
    index 09e2e44085c..d10a6b62350 100644
    --- a/addons/web/static/src/legacy/js/views/pivot/pivot_renderer.js
    +++ b/addons/web/static/src/legacy/js/views/pivot/pivot_renderer.js
    @@ -49,7 +49,7 @@
             }
         }
     
    -    PivotCustomGroupByItem.template = "web.PivotCustomGroupByItem";
    +    PivotCustomGroupByItem.template = "web.legacy.PivotCustomGroupByItem";
         PivotCustomGroupByItem.props = { fields: Array };
     
         export class PivotGroupByMenu extends DropdownMenu {
    @@ -98,7 +98,7 @@
             }
         }
     
    -    PivotGroupByMenu.template = "web.PivotGroupByMenu";
    +    PivotGroupByMenu.template = "web.legacy.PivotGroupByMenu";
         PivotGroupByMenu.components = { PivotCustomGroupByItem };
         PivotGroupByMenu.props = {
             ...DropdownMenu.props,
    @@ -303,7 +303,7 @@
             }
         }
     
    -    PivotRenderer.template = 'web.PivotRenderer';
    +    PivotRenderer.template = 'web.legacy.PivotRenderer';
         PivotRenderer.components = { PivotGroupByMenu };
     
         export default PivotRenderer;
    diff --git a/addons/web/static/src/legacy/js/views/search_panel.js b/addons/web/static/src/legacy/js/views/search_panel.js
    index e5936260e86..c8e5f8c7f1c 100644
    --- a/addons/web/static/src/legacy/js/views/search_panel.js
    +++ b/addons/web/static/src/legacy/js/views/search_panel.js
    @@ -46,6 +46,14 @@
                 this._updateActiveValues();
             }
     
    +        //---------------------------------------------------------------------
    +        // Getters
    +        //---------------------------------------------------------------------
    +
    +        get sections() {
    +            return this.model.get("sections", s => !s.empty);
    +        }
    +
             //---------------------------------------------------------------------
             // Public
             //---------------------------------------------------------------------
    @@ -204,6 +212,6 @@
             importedState: { type: String, optional: 1 },
             searchModel: Model,
         };
    -    SearchPanel.template = "web.SearchPanel";
    +    SearchPanel.template = "web.Legacy.SearchPanel";
     
         export default SearchPanel;
    diff --git a/addons/web/static/src/legacy/legacy_client_actions.js b/addons/web/static/src/legacy/legacy_client_actions.js
    index 38d33c805a3..e33a50980ae 100644
    --- a/addons/web/static/src/legacy/legacy_client_actions.js
    +++ b/addons/web/static/src/legacy/legacy_client_actions.js
    @@ -6,7 +6,7 @@ import Widget from "web.Widget";
     import { setScrollPosition } from "../core/utils/scrolling";
     import { useSetupAction } from "../webclient/actions/action_hook";
     import { ClientActionAdapter } from "./action_adapters";
    -import { breadcrumbsToLegacy } from "./utils";
    +import { breadcrumbsToLegacy } from "./backend_utils";
     
     const { Component, hooks, tags } = owl;
     const actionRegistry = registry.category("actions");
    @@ -42,7 +42,7 @@ function registerClientAction(name, action) {
                         this.props.state && this.props.state.__on_reverse_breadcrumb__;
                     useSetupAction({
                         beforeLeave: () => this.controllerRef.comp.__widget.canBeRemoved(),
    -                    export: () => this.controllerRef.comp.exportState(),
    +                    exportLocalState: () => this.controllerRef.comp.exportState(),
                     });
                     this.onScrollTo = (ev) => {
                         setScrollPosition(this, { left: ev.detail.left, top: ev.detail.top });
    diff --git a/addons/web/static/src/legacy/legacy_service_provider.js b/addons/web/static/src/legacy/legacy_service_provider.js
    index ce4e291f1c3..b8efe0125b1 100644
    --- a/addons/web/static/src/legacy/legacy_service_provider.js
    +++ b/addons/web/static/src/legacy/legacy_service_provider.js
    @@ -5,7 +5,7 @@ import Context from "web.Context";
     
     import { browser } from "../core/browser/browser";
     import { registry } from "../core/registry";
    -import { mapDoActionOptionAPI } from "./utils";
    +import { mapDoActionOptionAPI } from "./backend_utils";
     
     export const legacyServiceProvider = {
         dependencies: ["effect", "action"],
    diff --git a/addons/web/static/src/legacy/legacy_setup.js b/addons/web/static/src/legacy/legacy_setup.js
    index b29673ba33f..5eee8f2774b 100644
    --- a/addons/web/static/src/legacy/legacy_setup.js
    +++ b/addons/web/static/src/legacy/legacy_setup.js
    @@ -3,13 +3,13 @@
     import { registry } from "../core/registry";
     import {
         makeLegacyNotificationService,
    -    makeLegacyActionManagerService,
         makeLegacyRpcService,
         makeLegacySessionService,
         makeLegacyDialogMappingService,
         makeLegacyCrashManagerService,
         makeLegacyCommandService,
     } from "./utils";
    +import { makeLegacyActionManagerService } from "./backend_utils";
     import * as AbstractService from "web.AbstractService";
     import * as legacyEnv from "web.env";
     import * as session from "web.session";
    diff --git a/addons/web/static/src/legacy/legacy_views.js b/addons/web/static/src/legacy/legacy_views.js
    index 01a752db645..681295e9198 100644
    --- a/addons/web/static/src/legacy/legacy_views.js
    +++ b/addons/web/static/src/legacy/legacy_views.js
    @@ -6,7 +6,12 @@ import { useSetupAction } from "../webclient/actions/action_hook";
     import legacyViewRegistry from "web.view_registry";
     import { ViewAdapter } from "./action_adapters";
     import Widget from "web.Widget";
    -import { breadcrumbsToLegacy } from "./utils";
    +import {
    +    breadcrumbsToLegacy,
    +    exportGlobalState,
    +    exportLocalState,
    +    searchModelStateToLegacy,
    +} from "./backend_utils";
     import { setScrollPosition } from "../core/utils/scrolling";
     import { registry } from "@web/core/registry";
     
    @@ -34,6 +39,17 @@ function registerView(name, LegacyView) {
                 this.Widget = Widget; // fool the ComponentAdapter with a simple Widget
                 this.View = LegacyView;
                 this.viewInfo = {};
    +
    +            let resIds;
    +            let searchModel;
    +            let searchPanel;
    +            const { globalState } = this.props;
    +            if (globalState) {
    +                resIds = globalState.resIds;
    +                searchModel = searchModelStateToLegacy(globalState.searchModel);
    +                searchPanel = globalState.searchPanel;
    +            }
    +
                 this.viewParams = Object.assign({}, this.props.actionFlags, {
                     action: this.props.action,
                     // legacy views automatically add the last part of the breadcrumbs
    @@ -45,13 +61,9 @@ function registerView(name, LegacyView) {
                             "resId" in this.props
                                 ? this.props.resId
                                 : this.props.state && this.props.state.currentId,
    -                    resIds: this.props.resIds || (this.props.state && this.props.state.resIds),
    -                    searchModel:
    -                        this.props.searchModel ||
    -                        (this.props.state && this.props.state.searchModel),
    -                    searchPanel:
    -                        this.props.searchPanel ||
    -                        (this.props.state && this.props.state.searchPanel),
    +                    resIds: this.props.resIds || resIds,
    +                    searchModel,
    +                    searchPanel,
                     },
                 });
     
    @@ -71,7 +83,8 @@ function registerView(name, LegacyView) {
                     this.props.state && this.props.state.__on_reverse_breadcrumb__;
                 useSetupAction({
                     beforeLeave: () => this.controllerRef.comp.__widget.canBeRemoved(),
    -                export: () => this.controllerRef.comp.exportState(),
    +                exportGlobalState: () => exportGlobalState(this.controllerRef.comp.exportState()),
    +                exportLocalState: () => exportLocalState(this.controllerRef.comp.exportState()),
                 });
                 this.onScrollTo = (ev) => {
                     setScrollPosition(this, { left: ev.detail.left, top: ev.detail.top });
    @@ -85,8 +98,7 @@ function registerView(name, LegacyView) {
                     context: this.props.context,
                 };
                 const options = {
    -                actionId: this.props.actionId,
    -                context: this.props.context,
    +                actionId: this.props.action.id,
                     loadActionMenus: this.props.loadActionMenus,
                     loadIrFilters: this.props.loadIrFilters,
                 };
    diff --git a/addons/web/static/src/legacy/utils.js b/addons/web/static/src/legacy/utils.js
    index 5310281cdf7..0ab1e395dda 100644
    --- a/addons/web/static/src/legacy/utils.js
    +++ b/addons/web/static/src/legacy/utils.js
    @@ -10,53 +10,6 @@ import {
     } from "../core/network/rpc_service";
     import { ErrorDialog } from "../core/errors/error_dialogs";
     
    -export function mapDoActionOptionAPI(legacyOptions) {
    -    legacyOptions = legacyOptions || {};
    -    // use camelCase instead of snake_case for some keys
    -    Object.assign(legacyOptions, {
    -        additionalContext: legacyOptions.additional_context,
    -        clearBreadcrumbs: legacyOptions.clear_breadcrumbs,
    -        viewType: legacyOptions.view_type,
    -        onClose: legacyOptions.on_close,
    -        props: {
    -            resId: legacyOptions.res_id,
    -        },
    -    });
    -    if (legacyOptions.controllerState) {
    -        if (legacyOptions.controllerState.searchModel) {
    -            legacyOptions.props.searchModel = legacyOptions.controllerState.searchModel;
    -        }
    -        if (legacyOptions.controllerState.searchPanel) {
    -            legacyOptions.props.searchPanel = legacyOptions.controllerState.searchPanel;
    -        }
    -    }
    -    delete legacyOptions.additional_context;
    -    delete legacyOptions.clear_breadcrumbs;
    -    delete legacyOptions.view_type;
    -    delete legacyOptions.res_id;
    -    delete legacyOptions.on_close;
    -    return legacyOptions;
    -}
    -
    -export function makeLegacyActionManagerService(legacyEnv) {
    -    // add a service to redirect 'do-action' events triggered on the bus in the
    -    // legacy env to the action-manager service in the wowl env
    -    return {
    -        dependencies: ["action"],
    -        start(env) {
    -            function do_action(action, options) {
    -                const legacyOptions = mapDoActionOptionAPI(options);
    -                return env.services.action.doAction(action, legacyOptions);
    -            }
    -            legacyEnv.bus.on("do-action", null, (payload) => {
    -                const { action, options } = payload;
    -                do_action(action, options);
    -            });
    -            return { do_action };
    -        },
    -    };
    -}
    -
     export function makeLegacyRpcService(legacyEnv) {
         return {
             start(env) {
    @@ -218,15 +171,6 @@ export function mapLegacyEnvToWowlEnv(legacyEnv, wowlEnv) {
         });
     }
     
    -export function breadcrumbsToLegacy(breadcrumbs) {
    -    if (!breadcrumbs) {
    -        return;
    -    }
    -    return breadcrumbs.slice().map((bc) => {
    -        return { title: bc.name, controllerID: bc.jsId };
    -    });
    -}
    -
     const reBSTooltip = /^bs-.*$/;
     
     export function cleanDomFromBootstrap() {
    diff --git a/addons/web/static/src/legacy/xml/base.xml b/addons/web/static/src/legacy/xml/base.xml
    index ed071fc1583..87f31711859 100644
    --- a/addons/web/static/src/legacy/xml/base.xml
    +++ b/addons/web/static/src/legacy/xml/base.xml
    @@ -125,7 +125,7 @@
         
     
     
    -
    +
         
    - - -
    -
    - -
    -
    - - +
  • @@ -96,7 +96,7 @@ />
      - +
    @@ -104,7 +104,7 @@ - +
  • diff --git a/addons/web/static/src/search/comparison_menu/comparison_menu.js b/addons/web/static/src/search/comparison_menu/comparison_menu.js new file mode 100644 index 00000000000..450e833932c --- /dev/null +++ b/addons/web/static/src/search/comparison_menu/comparison_menu.js @@ -0,0 +1,33 @@ +/** @odoo-module **/ + +import { FACET_ICONS } from "../utils/misc"; +import { useBus } from "@web/core/utils/hooks"; + +const { Component } = owl; + +export class ComparisonMenu extends Component { + setup() { + this.icon = FACET_ICONS.comparison; + + useBus(this.env.searchModel, "update", this.render); + } + + /** + * @returns {Object[]} + */ + get items() { + return this.env.searchModel.getSearchItems( + (searchItem) => searchItem.type === "comparison" + ); + } + + /** + * @param {CustomEvent} + */ + onComparisonSelected(ev) { + const { itemId } = ev.detail.payload; + this.env.searchModel.toggleSearchItem(itemId); + } +} + +ComparisonMenu.template = "web.ComparisonMenu"; diff --git a/addons/web/static/src/search/comparison_menu/comparison_menu.xml b/addons/web/static/src/search/comparison_menu/comparison_menu.xml new file mode 100644 index 00000000000..afe62a61554 --- /dev/null +++ b/addons/web/static/src/search/comparison_menu/comparison_menu.xml @@ -0,0 +1,25 @@ + + + + + + + + Comparison + + + + + + + + + + diff --git a/addons/web/static/src/search/control_panel/control_panel.js b/addons/web/static/src/search/control_panel/control_panel.js index 7a15102ed59..dac80643c39 100644 --- a/addons/web/static/src/search/control_panel/control_panel.js +++ b/addons/web/static/src/search/control_panel/control_panel.js @@ -1,9 +1,21 @@ /** @odoo-module **/ +import { ComparisonMenu } from "../comparison_menu/comparison_menu"; +import { FavoriteMenu } from "../favorite_menu/favorite_menu"; +import { FilterMenu } from "../filter_menu/filter_menu"; +import { GroupByMenu } from "../group_by_menu/group_by_menu"; +import { SearchBar } from "../search_bar/search_bar"; import { useService } from "@web/core/utils/hooks"; const { Component } = owl; +const MAPPING = { + filter: FilterMenu, + groupBy: GroupByMenu, + comparison: ComparisonMenu, + favorite: FavoriteMenu, +}; + export class ControlPanel extends Component { setup() { this.actionService = useService("action"); @@ -20,13 +32,31 @@ export class ControlPanel extends Component { "bottom-left": true, "bottom-right": true, }, - this.props.display + this.props.display || this.env.searchModel.display.controlPanel ); display.top = display["top-left"] || display["top-right"]; display.bottom = display["bottom-left"] || display["bottom-right"]; return display; } + /** + * @returns {Component[]} + */ + get searchMenus() { + const searchMenus = []; + for (const key of this.env.searchModel.searchMenuTypes) { + // look in display instead? + if ( + key === "comparison" && + this.env.searchModel.getSearchItems((i) => i.type === "comparison").length === 0 + ) { + continue; + } + searchMenus.push({ Component: MAPPING[key], key }); + } + return searchMenus; + } + /** * Called when an element of the breadcrumbs is clicked. * @@ -46,8 +76,27 @@ export class ControlPanel extends Component { } } +ControlPanel.components = { ComparisonMenu, FavoriteMenu, FilterMenu, GroupByMenu, SearchBar }; ControlPanel.template = "web.ControlPanel"; +// ControlPanel.props = { +// breadcrumbs: { type: Array, element: { jsId: String, name: String }, optional: true }, +// display: { type: Object, optional: true }, +// displayName: { type: String, optional: true }, +// viewSwitcherEntries: { +// type: Array, +// element: { +// type: Object, +// shape: { +// active: { type: Boolean, optional: true }, +// icon: String, +// multiRecord: { type: Boolean, optional: true }, +// name: [Object, String], +// type: String, +// }, +// }, +// optional: true, +// }, +// }; ControlPanel.defaultProps = { breadcrumbs: [], - display: {}, }; diff --git a/addons/web/static/src/search/control_panel/control_panel.xml b/addons/web/static/src/search/control_panel/control_panel.xml index fb3a87ea024..0cc3a796d30 100644 --- a/addons/web/static/src/search/control_panel/control_panel.xml +++ b/addons/web/static/src/search/control_panel/control_panel.xml @@ -10,31 +10,48 @@
  • - -
    - -
    -
    - -
    -
    - - -
    - - - + +
    +
    + + + + + + +
    +
    +
    + + +
    + +
    +
    + + + + +
    + + + +
    diff --git a/addons/web/static/src/search/favorite_menu/custom_favorite_item.js b/addons/web/static/src/search/favorite_menu/custom_favorite_item.js new file mode 100644 index 00000000000..f66b017f422 --- /dev/null +++ b/addons/web/static/src/search/favorite_menu/custom_favorite_item.js @@ -0,0 +1,100 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { useAutofocus, useService } from "@web/core/utils/hooks"; + +const { Component, hooks } = owl; +const { useRef, useState } = hooks; + +const favoriteMenuRegistry = registry.category("favoriteMenu"); + +export class CustomFavoriteItem extends Component { + setup() { + this.notificationService = useService("notification"); + this.descriptionRef = useRef("description"); + useAutofocus(); + this.state = useState({ + description: this.env.searchModel.displayName, + isDefault: false, + isShared: false, + }); + } + + /** + * @param {Event} ev + */ + saveFavorite(ev) { + if (!this.state.description.length) { + this.notificationService.add( + this.env._t("A name for your favorite filter is required."), + { type: "danger" } + ); + ev.stopPropagation(); + return this.descriptionRef.el.focus(); + } + const favorites = this.env.searchModel.getSearchItems( + (s) => s.type === "favorite" && s.description === this.state.description + ); + if (favorites.length) { + this.notificationService.add(this.env._t("A filter with same name already exists."), { + type: "danger", + }); + ev.stopPropagation(); + return this.descriptionRef.el.focus(); + } + const { description, isDefault, isShared } = this.state; + this.env.searchModel.createNewFavorite({ description, isDefault, isShared }); + + Object.assign(this.state, { + description: this.env.searchModel.displayName, + isDefault: false, + isShared: false, + }); + } + + /** + * @param {Event} ev + */ + onDefaultCheckboxChange(ev) { + const { checked } = ev.target; + this.state.isDefault = checked; + if (checked) { + this.state.isShared = false; + } + } + + /** + * @param {Event} ev + */ + onShareCheckboxChange(ev) { + const { checked } = ev.target; + this.state.isShared = checked; + if (checked) { + this.state.isDefault = false; + } + } + + /** + * @param {KeyboardEvent} 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; + } + } +} + +CustomFavoriteItem.template = "web.CustomFavoriteItem"; +favoriteMenuRegistry.add( + "custom-favorite-item", + { Component: CustomFavoriteItem, groupNumber: 3 }, + { sequence: 0 } +); diff --git a/addons/web/static/src/search/favorite_menu/custom_favorite_item.xml b/addons/web/static/src/search/favorite_menu/custom_favorite_item.xml new file mode 100644 index 00000000000..82b7aa92cee --- /dev/null +++ b/addons/web/static/src/search/favorite_menu/custom_favorite_item.xml @@ -0,0 +1,32 @@ + + + + + + + Save current search + +
  • + + + Use by default + + + Share with all users + +
  • +
  • + +
  • +
    +
    + +
    diff --git a/addons/web/static/src/search/favorite_menu/favorite_menu.js b/addons/web/static/src/search/favorite_menu/favorite_menu.js new file mode 100644 index 00000000000..38e36f9a515 --- /dev/null +++ b/addons/web/static/src/search/favorite_menu/favorite_menu.js @@ -0,0 +1,68 @@ +/** @odoo-module **/ + +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import { FACET_ICONS } from "../utils/misc"; +import { registry } from "@web/core/registry"; +import { useBus } from "@web/core/utils/hooks"; +import { useService } from "@web/core/utils/hooks"; + +const favoriteMenuRegistry = registry.category("favoriteMenu"); + +const { Component } = owl; + +export class FavoriteMenu extends Component { + setup() { + this.icon = FACET_ICONS.favorite; + this.dialogService = useService("dialog"); + + useBus(this.env.searchModel, "update", this.render); + } + + /** + * @returns {Array} + */ + get items() { + const favorites = this.env.searchModel.getSearchItems( + (searchItem) => searchItem.type === "favorite" + ); + const registryMenus = []; + for (const item of favoriteMenuRegistry.getAll()) { + if ("isDisplayed" in item ? item.isDisplayed(this.env) : true) { + registryMenus.push({ + Component: item.Component, + groupNumber: item.groupNumber, + key: item.Component.name, + }); + } + } + return [...favorites, ...registryMenus]; + } + + /** + * @param {CustomEvent} ev + */ + onFavoriteSelected(ev) { + const { itemId } = ev.detail.payload; + this.env.searchModel.toggleSearchItem(itemId); + } + + /** + * @param {number} itemId + */ + openConfirmationDialog(itemId) { + const { userId } = this.items.find((item) => item.id === itemId); + const dialogProps = { + title: this.env._t("Warning"), + body: userId + ? this.env._t("Are you sure that you want to remove this filter?") + : this.env._t( + "This filter is global and will be removed for everybody if you continue." + ), + confirm: () => this.env.searchModel.deleteFavorite(itemId), + cancel: () => {}, + }; + this.dialogService.add(ConfirmationDialog, dialogProps); + } +} + +FavoriteMenu.template = "web.FavoriteMenu"; diff --git a/addons/web/static/src/search/favorite_menu/favorite_menu.xml b/addons/web/static/src/search/favorite_menu/favorite_menu.xml new file mode 100644 index 00000000000..4631765e594 --- /dev/null +++ b/addons/web/static/src/search/favorite_menu/favorite_menu.xml @@ -0,0 +1,41 @@ + + + + + + + + Favorites + + + + + + + + + + + + + + diff --git a/addons/web/static/src/search/filter_menu/filter_menu.js b/addons/web/static/src/search/filter_menu/filter_menu.js new file mode 100644 index 00000000000..ea450285e57 --- /dev/null +++ b/addons/web/static/src/search/filter_menu/filter_menu.js @@ -0,0 +1,39 @@ +/** @odoo-module **/ + +import { CustomFilterItem } from "./custom_filter_item"; +import { FACET_ICONS } from "../utils/misc"; +import { useBus } from "@web/core/utils/hooks"; + +const { Component } = owl; + +export class FilterMenu extends Component { + setup() { + this.icon = FACET_ICONS.filter; + + useBus(this.env.searchModel, "update", this.render); + } + + /** + * @returns {Object[]} + */ + get items() { + return this.env.searchModel.getSearchItems((searchItem) => + ["filter", "dateFilter"].includes(searchItem.type) + ); + } + + /** + * @param {CustomEvent} ev + */ + onFilterSelected(ev) { + const { itemId, optionId } = ev.detail.payload; + if (optionId) { + this.env.searchModel.toggleDateFilter(itemId, optionId); + } else { + this.env.searchModel.toggleSearchItem(itemId); + } + } +} + +FilterMenu.components = { CustomFilterItem }; +FilterMenu.template = "web.FilterMenu"; diff --git a/addons/web/static/src/search/filter_menu/filter_menu.xml b/addons/web/static/src/search/filter_menu/filter_menu.xml new file mode 100644 index 00000000000..a3fe871b295 --- /dev/null +++ b/addons/web/static/src/search/filter_menu/filter_menu.xml @@ -0,0 +1,57 @@ + + + + + + + + Filters + + + + +
  • + +
  • + + + +
    +
    + +
    diff --git a/addons/web/static/src/search/group_by_menu/group_by_menu.js b/addons/web/static/src/search/group_by_menu/group_by_menu.js new file mode 100644 index 00000000000..d9426bbbf32 --- /dev/null +++ b/addons/web/static/src/search/group_by_menu/group_by_menu.js @@ -0,0 +1,72 @@ +/** @odoo-module **/ + +import { CustomGroupByItem } from "./custom_group_by_item"; +import { FACET_ICONS, GROUPABLE_TYPES } from "../utils/misc"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { sortBy } from "@web/core/utils/arrays"; +import { useBus } from "@web/core/utils/hooks"; + +const { Component } = owl; + +export class GroupByMenu extends Component { + setup() { + this.icon = FACET_ICONS.groupBy; + this.dropdownProps = Object.keys(this.props) + .filter((key) => key in Dropdown.props) + .reduce((obj, key) => ({ ...obj, [key]: this.props[key] }), {}); + const fields = []; + for (const [fieldName, field] of Object.entries(this.env.searchModel.searchViewFields)) { + if (this.validateField(fieldName, field)) { + fields.push(Object.assign({ name: fieldName }, field)); + } + } + this.fields = sortBy(fields, "string"); + + useBus(this.env.searchModel, "update", this.render); + } + + /** + * @returns {Object[]} + */ + get items() { + return this.env.searchModel.getSearchItems((searchItem) => + ["groupBy", "dateGroupBy"].includes(searchItem.type) + ); + } + + /** + * @param {string} fieldName + * @param {Object} field + * @returns {boolean} + */ + validateField(fieldName, field) { + const { sortable, type } = field; + return fieldName !== "id" && sortable && GROUPABLE_TYPES.includes(type); + } + + /** + * @param {CustomEvent} ev + */ + onGroupBySelected(ev) { + const { itemId, optionId } = ev.detail.payload; + if (optionId) { + this.env.searchModel.toggleDateGroupBy(itemId, optionId); + } else { + this.env.searchModel.toggleSearchItem(itemId); + } + } + + /** + * @param {CustomEvent} ev + */ + onAddCustomGroup(ev) { + const { fieldName } = ev.detail; + this.env.searchModel.createNewGroupBy(fieldName); + } +} + +GroupByMenu.components = { CustomGroupByItem }; +GroupByMenu.template = "web.GroupByMenu"; +GroupByMenu.defaultProps = { + showActiveItems: true, +}; diff --git a/addons/web/static/src/search/group_by_menu/group_by_menu.xml b/addons/web/static/src/search/group_by_menu/group_by_menu.xml new file mode 100644 index 00000000000..d990950a320 --- /dev/null +++ b/addons/web/static/src/search/group_by_menu/group_by_menu.xml @@ -0,0 +1,58 @@ + + + + + + + + Group By + + + + +