diff --git a/.gitignore b/.gitignore index ac767604569..acb779112d7 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,12 @@ setup/win32/static/less/ setup/win32/static/wkhtmltopdf/ setup/win32/static/postgresql*.exe +# js tooling +node_modules +package-lock.json +package.json +.husky + # various virtualenv /bin/ /build/ diff --git a/addons/account/__manifest__.py b/addons/account/__manifest__.py index e64b6f69656..f6aab5d6fdc 100644 --- a/addons/account/__manifest__.py +++ b/addons/account/__manifest__.py @@ -115,8 +115,8 @@ You could use this simplified accounting in case you work with an (external) acc 'account/static/tests/tours/**/*', ], 'web.qunit_suite_tests': [ - ('after', 'web/static/tests/views/kanban_tests.js', 'account/static/tests/account_payment_field_tests.js'), - ('after', 'web/static/tests/views/kanban_tests.js', 'account/static/tests/section_and_note_tests.js'), + ('after', 'web/static/tests/legacy/views/kanban_tests.js', 'account/static/tests/account_payment_field_tests.js'), + ('after', 'web/static/tests/legacy/views/kanban_tests.js', 'account/static/tests/section_and_note_tests.js'), ], 'web.assets_qweb': [ 'account/static/src/xml/**/*', diff --git a/addons/account/static/src/scss/account_dashboard.scss b/addons/account/static/src/scss/account_dashboard.scss index b93e2d35c55..5050be09648 100644 --- a/addons/account/static/src/scss/account_dashboard.scss +++ b/addons/account/static/src/scss/account_dashboard.scss @@ -47,7 +47,7 @@ } .fa-check { - color: theme-color('success'); + @extend .text-success; } } diff --git a/addons/auth_totp/static/tests/totp_flow.js b/addons/auth_totp/static/tests/totp_flow.js index eea245041a9..6555b7c2d34 100644 --- a/addons/auth_totp/static/tests/totp_flow.js +++ b/addons/auth_totp/static/tests/totp_flow.js @@ -219,7 +219,7 @@ tour.register('totp_admin_disables', { } }, { content: "Open Actions menu", - trigger: 'button.o_dropdown_toggler_btn:contains("Action")' + trigger: 'button.o_dropdown_toggler:contains("Action")' }, { content: "Select totp remover", trigger: 'a.dropdown-item:contains(Disable TOTP on users)' diff --git a/addons/base_automation/__manifest__.py b/addons/base_automation/__manifest__.py index 4122299bea1..60252ab3b70 100644 --- a/addons/base_automation/__manifest__.py +++ b/addons/base_automation/__manifest__.py @@ -22,6 +22,9 @@ trigger an automatic reminder email. 'views/base_automation_view.xml', ], 'assets': { + 'web.assets_qweb': [ + 'base_automation/static/src/xml/*.xml', + ], 'web.assets_backend': [ 'base_automation/static/src/js/**/*', ], diff --git a/addons/base_automation/static/src/js/base_automation_error_dialog.js b/addons/base_automation/static/src/js/base_automation_error_dialog.js index 3fa4cf0cc06..4004b0d1673 100644 --- a/addons/base_automation/static/src/js/base_automation_error_dialog.js +++ b/addons/base_automation/static/src/js/base_automation_error_dialog.js @@ -1,78 +1,60 @@ -odoo.define('base_automation.BaseAutomatioErrorDialog', function (require) { - "use strict"; +/** @odoo-module */ - const CrashManager = require('web.CrashManager'); - const ErrorDialog = CrashManager.ErrorDialog; - const ErrorDialogRegistry = require('web.ErrorDialogRegistry'); - const session = require('web.session'); +import { ErrorDialog } from "@web/core/errors/error_dialogs"; +import session from "web.session"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/service_hook"; - const BaseAutomationErrorDialog = ErrorDialog.extend({ - xmlDependencies: (ErrorDialog.prototype.xmlDependencies || []).concat( - ['/base_automation/static/src/xml/base_automation_error_dialog.xml'] - ), - template: 'CrashManager.BaseAutomationError', - events: { - 'click .o_disable_action_button': '_onDisableAction', - 'click .o_edit_action_button': '_onEditAction', - }, - /** - * Assign the `base_automation` object based on the error data, - * which is then used by the `CrashManager.BaseAutomationError` template - * and the events defined above. - * @override - * @param {Object} error - * @param {string} error.data.context.base_automation.id the ID of the failing automated action - * @param {string} error.data.context.base_automation.name the name of the failing automated action - */ - init: function (parent, options, error) { - this._super.apply(this, arguments); - this.base_automation = error.data.context.base_automation; - this.is_admin = session.is_admin; - }, +export class BaseAutomationErrorDialog extends ErrorDialog { + setup() { + super.setup(...arguments); + const { id, name } = this.props.data.context.base_automation; + this.actionId = id; + this.actionName = name; + this.isUserAdmin = useService("user").isAdmin; + this.actionService = useService("action"); + this.orm = useService("orm"); + } - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- - /** - * This method is called when the user clicks on the 'Disable action' button - * displayed when a crash occurs in the evaluation of an automated action. - * Then, we write `active` to `False` on the automated action to disable it. - * - * @private - * @param {MouseEvent} ev - */ - _onDisableAction: function (ev) { - ev.preventDefault(); - this._rpc({ - model: 'base.automation', - method: 'write', - args: [[this.base_automation.id], { - active: false, - }], - }).then(this.destroy.bind(this)); - }, - /** - * This method is called when the user clicks on the 'Edit action' button - * displayed when a crash occurs in the evaluation of an automated action. - * Then, we redirect the user to the automated action form. - * - * @private - * @param {MouseEvent} ev - */ - _onEditAction: function (ev) { - ev.preventDefault(); - this.do_action({ - name: 'Automated Actions', - res_model: 'base.automation', - res_id: this.base_automation.id, - views: [[false, 'form']], - type: 'ir.actions.act_window', - view_mode: 'form', - }); - }, - }); + /** + * This method is called when the user clicks on the 'Disable action' button + * displayed when a crash occurs in the evaluation of an automated action. + * Then, we write `active` to `False` on the automated action to disable it. + * + * @private + * @param {MouseEvent} ev + */ + async disableAction(ev) { + await this.orm.write("base.automation", [this.actionId], { + active: false, + }); + this.close(); + } + /** + * This method is called when the user clicks on the 'Edit action' button + * displayed when a crash occurs in the evaluation of an automated action. + * Then, we redirect the user to the automated action form. + * + * @private + * @param {MouseEvent} ev + */ + editAction(ev) { + this.actionService.doAction({ + name: "Automated Actions", + res_model: "base.automation", + res_id: this.actionId, + views: [[false, "form"]], + type: "ir.actions.act_window", + view_mode: "form", + }); + this.close(); + } +} - ErrorDialogRegistry.add('base_automation', BaseAutomationErrorDialog); +BaseAutomationErrorDialog.bodyTemplate = "base_automation.ErrorDialogBody"; -}); +registry.category("error_dialogs").add("base_automation", BaseAutomationErrorDialog); diff --git a/addons/base_automation/static/src/xml/base_automation_error_dialog.xml b/addons/base_automation/static/src/xml/base_automation_error_dialog.xml index e45c59117ec..e694dd3311c 100644 --- a/addons/base_automation/static/src/xml/base_automation_error_dialog.xml +++ b/addons/base_automation/static/src/xml/base_automation_error_dialog.xml @@ -1,36 +1,34 @@ - - - -

- The error occurred during the execution of the automated action - "" - (ID: ). -
-

-

- You can ask an administrator to disable or correct this automated action. -

-

- You can disable this automated action or edit it to solve the issue.
- Disabling this automated action will enable you to continue your workflow - but any data created after this could potentially be corrupted, - as you are effectively disabling a customization that may set - important and/or required fields. -

-
-
- - - - - +
diff --git a/addons/base_automation/static/tests/base_automation_error_dialog.js b/addons/base_automation/static/tests/base_automation_error_dialog.js index 280bc79712b..df04c028466 100644 --- a/addons/base_automation/static/tests/base_automation_error_dialog.js +++ b/addons/base_automation/static/tests/base_automation_error_dialog.js @@ -1,75 +1,130 @@ -odoo.define('base_automation.BaseAutomatioErrorDialogTests', function (require) { -'use strict'; +/** @odoo-modules */ - const CrashManager = require('web.CrashManager').CrashManager; - const session = require('web.session'); - const makeTestEnvironment = require("web.test_env"); +import { registry } from "@web/core/registry"; +import { errorService } from "@web/core/errors/error_service"; +import { dialogService } from "@web/core/dialog/dialog_service"; +import { notificationService } from "@web/core/notifications/notification_service"; +import { uiService } from "@web/core/ui/ui_service"; +import { hotkeyService } from "@web/core/hotkeys/hotkey_service"; +import { registerCleanup } from "@web/../tests/helpers/cleanup"; +import { makeTestEnv } from "@web/../tests/helpers/mock_env"; +import { makeFakeRPCService, makeFakeLocalizationService } from "@web/../tests/helpers/mock_services"; +import { RPCError } from "@web/core/network/rpc_service"; - QUnit.module('base_automation', {}, function () { +import { BaseAutomationErrorDialog } from "../src/js/base_automation_error_dialog"; +import { patchWithCleanup } from "@web/../tests/helpers/utils"; +import { DialogContainer } from "@web/core/dialog/dialog_container"; +import { getFixture } from "@web/../tests/helpers/utils"; +import { nextTick } from "@web/../tests/helpers/utils"; - QUnit.module('Error Dialog'); +const serviceRegistry = registry.category("services"); - QUnit.test('Error due to an automated action', async function (assert) { - assert.expect(4); +QUnit.module("base_automation", {}, function () { - let baseAutomationName = 'Test base automation error dialog'; - let error = { - type: 'Odoo Client Error', - message: 'Message', - data: { - debug: 'Traceback', - context: { - exception_class: 'base_automation', - base_automation: { - id: 1, - name: baseAutomationName, - }, - }, - }, + let unhandledRejectionCb; + QUnit.module("Error Dialog", { + async beforeEach() { + serviceRegistry.add("dialog", dialogService); + serviceRegistry.add("ui", uiService); + serviceRegistry.add("error", errorService); + serviceRegistry.add("hotkey", hotkeyService); + serviceRegistry.add("localization", makeFakeLocalizationService()); + serviceRegistry.add("action", { start: () => {} }); + serviceRegistry.add("orm", { start: () => {} }); + serviceRegistry.add("user", { start: () => ({ isAdmin: true }) }); + registry.category("error_dialogs").add("base_automation", BaseAutomationErrorDialog); + // Both of these are unused but required for the error service to call error handlers + serviceRegistry.add("notification", notificationService); + serviceRegistry.add("rpc", makeFakeRPCService()); + const windowAddEventListener = window.addEventListener; + window.addEventListener = (type, cb) => { + if (type === "unhandledrejection") { + unhandledRejectionCb = cb; + } }; - // Force the user session to be admin, to display the disable and edit action buttons, - // then reset back to the origin value after the test. - let isAdmin = session.is_admin; - session.is_admin = true; + registerCleanup(() => { + window.addEventListener = windowAddEventListener; + }); + }, + }); - let crashManager = new CrashManager(); - crashManager.env = makeTestEnvironment(); - let dialog = crashManager.show_error(error); + QUnit.test("Error due to an automated action", async function (assert) { + assert.expect(4); - await dialog._opened; - - assert.containsOnce(document.body, '.modal .o_clipboard_button'); - assert.containsOnce(document.body, '.modal .o_disable_action_button'); - assert.containsOnce(document.body, '.modal .o_edit_action_button'); - assert.ok(dialog.$el.text().indexOf(baseAutomationName) !== -1); - - session.is_admin = isAdmin; - crashManager.destroy(); + const error = new RPCError(); + const errorContext = { + exception_class: "base_automation", + base_automation: { + id: 1, + name: "Test base automation error dialog", + }, + }; + Object.assign(error, { + subType: "Odoo Client Error", + message: "Message", + data: { + debug: "Traceback", + context: errorContext, + }, + exceptionName: errorContext.exception_class, }); - QUnit.test('Error not due to an automated action', async function (assert) { - assert.expect(3); - - let error = { - type: 'Odoo Client Error', - message: 'Message', - data: { - debug: 'Traceback', - }, - }; - let crashManager = new CrashManager(); - crashManager.env = makeTestEnvironment(); - let dialog = crashManager.show_error(error); - - await dialog._opened; - - assert.containsOnce(document.body, '.modal .o_clipboard_button'); - assert.containsNone(document.body, '.modal .o_disable_action_button'); - assert.containsNone(document.body, '.modal .o_edit_action_button'); - - crashManager.destroy(); + patchWithCleanup(BaseAutomationErrorDialog.prototype, { + setup() { + assert.equal(this.props.data.context, errorContext, "Received the correct error context"); + this._super(); + }, }); + const env = await makeTestEnv(); + const { Component: Container, props } = registry.category("main_components").get("DialogContainer"); + const dialogContainer = await owl.mount(Container, { target: getFixture(), env, props }); + + const errorEvent = new PromiseRejectionEvent("error", { reason: { + message: error, + legacy: true, + event: $.Event(), + }, promise: null }); + await unhandledRejectionCb(errorEvent); + await nextTick(); + assert.containsOnce(dialogContainer, '.modal .fa-clipboard'); + assert.containsOnce(dialogContainer, '.modal .o_disable_action_button'); + assert.containsOnce(dialogContainer, '.modal .o_edit_action_button'); + }); + + QUnit.test("Error not due to an automated action", async function (assert) { + assert.expect(3); + + const error = new RPCError(); + Object.assign(error, { + subType: "Odoo Client Error", + message: "Message", + data: { + debug: "Traceback", + }, + }); + + patchWithCleanup(DialogContainer.prototype, { + mounted() { + this._super(); + this.el.classList.add("o_dialog_container"); + } + }); + + const env = await makeTestEnv(); + const { Component: Container, props } = registry.category("main_components").get("DialogContainer"); + const dialogContainer = await owl.mount(Container, { target: getFixture(), env, props }); + + const errorEvent = new PromiseRejectionEvent("error", { reason: { + message: error, + legacy: true, + event: $.Event(), + }, promise: null }); + await unhandledRejectionCb(errorEvent); + await nextTick(); + assert.containsOnce(dialogContainer, '.modal .fa-clipboard'); + assert.containsNone(dialogContainer, '.modal .o_disable_action_button'); + assert.containsNone(dialogContainer, '.modal .o_edit_action_button'); }); }); diff --git a/addons/base_import/static/src/js/import_action.js b/addons/base_import/static/src/js/import_action.js index 7cdbc968781..f5374be0571 100644 --- a/addons/base_import/static/src/js/import_action.js +++ b/addons/base_import/static/src/js/import_action.js @@ -6,8 +6,6 @@ var config = require('web.config'); var core = require('web.core'); var session = require('web.session'); var time = require('web.time'); -var AbstractWebClient = require('web.AbstractWebClient'); -var Loading = require('web.Loading'); var QWeb = core.qweb; var _t = core._t; @@ -800,9 +798,6 @@ var DataImport = AbstractAction.extend({ {tracking_disable: tracking_disable} ); var self = this; - this.trigger_up('with_client', {callback: function () { - this.loading.ignore_events = true; - }}); $.blockUI({message: QWeb.render('Throbber')}); $(document.body).addClass('o_ui_blocked'); var opts = this.import_options(); @@ -851,9 +846,6 @@ var DataImport = AbstractAction.extend({ }).finally(function () { $(document.body).removeClass('o_ui_blocked'); $.unblockUI(); - self.trigger_up('with_client', {callback: function () { - delete this.loading.ignore_events; - }}); }); }, /** * @@ -877,6 +869,8 @@ var DataImport = AbstractAction.extend({ method: 'execute_import', args: args.concat([opts]), kwargs: kwargs + }, { + shadow: true, }).then(function (results) { _.each(results.messages, offset_by(opts.skip)); if (!kwargs.dryrun && !results.ids) { @@ -1172,18 +1166,6 @@ StateMachine.create({ ], }); -Loading.include({ - on_rpc_event: function () { - if (this.ignore_events) { - return - } - this._super.apply(this, arguments); - } -}); -AbstractWebClient.prototype.custom_events['with_client'] = function (ev) { - ev.data.callback.call(this); -}; - function offset_by(by) { return function offset_message(msg) { if (msg.rows) { diff --git a/addons/board/__manifest__.py b/addons/board/__manifest__.py index b504003488d..a9cf9adb486 100644 --- a/addons/board/__manifest__.py +++ b/addons/board/__manifest__.py @@ -22,7 +22,6 @@ Allows users to create custom dashboard. 'assets': { 'web.assets_backend': [ 'board/static/src/scss/dashboard.scss', - 'board/static/src/js/action_manager_board_action.js', 'board/static/src/js/board_view.js', 'board/static/src/js/add_to_board_menu.js', ], diff --git a/addons/board/static/src/js/action_manager_board_action.js b/addons/board/static/src/js/action_manager_board_action.js deleted file mode 100644 index 05414572311..00000000000 --- a/addons/board/static/src/js/action_manager_board_action.js +++ /dev/null @@ -1,33 +0,0 @@ -odoo.define('board.ActionManager', function (require) { -"use strict"; - -/** - * The purpose of this file is to patch the ActionManager to properly generate - * the flags for the 'ir.actions.act_window' of model 'board.board'. - */ - -var ActionManager = require('web.ActionManager'); - -ActionManager.include({ - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @override - * @private - */ - _executeWindowAction: function (action) { - if (action.res_model === 'board.board' && action.view_mode === 'form') { - action.target = 'inline'; - _.extend(action.flags, { - hasActionMenus: false, - hasSearchView: false, - headless: true, - }); - } - return this._super.apply(this, arguments); - }, -}); - -}); diff --git a/addons/board/static/src/js/board_view.js b/addons/board/static/src/js/board_view.js index 87b5034f04d..d0cb35da927 100644 --- a/addons/board/static/src/js/board_view.js +++ b/addons/board/static/src/js/board_view.js @@ -446,6 +446,19 @@ var BoardView = FormView.extend({ this._super.apply(this, arguments); this.controllerParams.customViewID = viewInfo.custom_view_id; }, + /** + * @override + */ + _extractParamsFromAction(action) { + action.target = "inline"; + action.flags = action.flags || {}; + Object.assign(action.flags, { + hasActionMenus: false, + hasSearchView: false, + headless: true, + }); + return this._super.apply(this, arguments); + }, }); return BoardView; diff --git a/addons/board/static/tests/dashboard_tests.js b/addons/board/static/tests/dashboard_tests.js index 8ba4be76009..905a0ff87f1 100644 --- a/addons/board/static/tests/dashboard_tests.js +++ b/addons/board/static/tests/dashboard_tests.js @@ -9,11 +9,12 @@ var ListRenderer = require('web.ListRenderer'); var pyUtils = require('web.py_utils'); const cpHelpers = testUtils.controlPanel; -var createActionManager = testUtils.createActionManager; +const { createWebClient, doAction } = require("@web/../tests/webclient/helpers"); var createView = testUtils.createView; const patchDate = testUtils.mock.patchDate; +let serverData; QUnit.module('Dashboard', { beforeEach: function () { this.data = { @@ -48,6 +49,8 @@ QUnit.module('Dashboard', { }], }, }; + + serverData = { models: this.data }; }, }); @@ -82,7 +85,7 @@ QUnit.test('dashboard basic rendering', async function (assert) { "with a dashboard, the renderer should have the proper css class"); assert.containsOnce(form, '.o_dashboard .o_view_nocontent', "should have a no content helper"); - assert.strictEqual(form.$('.o_control_panel .breadcrumb-item').text(), "My Dashboard", + assert.strictEqual(form.getTitle(), "My Dashboard", "should have the correct title"); form.destroy(); }); @@ -696,143 +699,156 @@ QUnit.test('dashboard intercepts custom events triggered by sub controllers', as board.destroy(); }); -QUnit.test('save actions to dashboard', async function (assert) { +QUnit.test("save actions to dashboard", async function (assert) { assert.expect(6); testUtils.patch(ListController, { getOwnedQueryParams: function () { var result = this._super.apply(this, arguments); result.context = { - 'fire': 'on the bayou', + fire: "on the bayou", }; return result; - } - }); - - this.data['partner'].fields.foo.sortable = true; - - var actionManager = await createActionManager({ - data: this.data, - archs: { - 'partner,false,list': '', - 'partner,false,search': '', }, - mockRPC: function (route, args) { - if (route === '/board/add_to_dashboard') { - assert.deepEqual(args.context_to_save.group_by, ['foo'], - 'The group_by should have been saved'); - assert.deepEqual(args.context_to_save.orderedBy, - [{ - name: 'foo', + }); + + serverData.models.partner.fields.foo.sortable = true; + + serverData.views = { + "partner,false,list": '', + "partner,false,search": "", + }; + + const mockRPC = (route, args) => { + if (route === "/board/add_to_dashboard") { + assert.deepEqual( + args.context_to_save.group_by, + ["foo"], + "The group_by should have been saved" + ); + assert.deepEqual( + args.context_to_save.orderedBy, + [ + { + name: "foo", asc: true, - }], - 'The orderedBy should have been saved'); - assert.strictEqual(args.context_to_save.fire, 'on the bayou', - 'The context of a controller should be passed and flattened'); - assert.strictEqual(args.action_id, 1, - "should save the correct action"); - assert.strictEqual(args.view_mode, 'list', - "should save the correct view type"); - return Promise.resolve(true); - } - return this._super.apply(this, arguments); + }, + ], + "The orderedBy should have been saved" + ); + assert.strictEqual( + args.context_to_save.fire, + "on the bayou", + "The context of a controller should be passed and flattened" + ); + assert.strictEqual(args.action_id, 1, "should save the correct action"); + assert.strictEqual(args.view_mode, "list", "should save the correct view type"); + return Promise.resolve(true); } - }); + }; - await actionManager.doAction({ + const webClient = await createWebClient({ serverData, mockRPC }); + + await doAction(webClient, { id: 1, - res_model: 'partner', - type: 'ir.actions.act_window', - views: [[false, 'list']], + res_model: "partner", + type: "ir.actions.act_window", + views: [[false, "list"]], }); - assert.containsOnce(actionManager, '.o_list_view', - "should display the list view"); + assert.containsOnce(webClient, ".o_list_view", "should display the list view"); // Sort the list - await testUtils.dom.click($('.o_column_sortable')); + await testUtils.dom.click($(".o_column_sortable")); // Group It - await cpHelpers.toggleGroupByMenu(actionManager); - await cpHelpers.toggleAddCustomGroup(actionManager); - await cpHelpers.applyGroup(actionManager); + await cpHelpers.toggleGroupByMenu(webClient); + await cpHelpers.toggleAddCustomGroup(webClient); + await cpHelpers.applyGroup(webClient); // add this action to dashboard - await cpHelpers.toggleFavoriteMenu(actionManager); + await cpHelpers.toggleFavoriteMenu(webClient); - await testUtils.dom.click($('.o_add_to_board > button')); - await testUtils.fields.editInput($('.o_add_to_board input'), 'a name'); - await testUtils.dom.click($('.o_add_to_board div button')); + await testUtils.dom.click($(".o_add_to_board > button")); + await testUtils.fields.editInput($(".o_add_to_board input"), "a name"); + await testUtils.dom.click($(".o_add_to_board div button")); testUtils.unpatch(ListController); - - actionManager.destroy(); }); -QUnit.test('save two searches to dashboard', async function (assert) { +QUnit.test("save two searches to dashboard", async function (assert) { // the second search saved should not be influenced by the first assert.expect(2); - var actionManager = await createActionManager({ - data: this.data, - archs: { - 'partner,false,list': '', - 'partner,false,search': '', - }, - mockRPC: function (route, args) { - if (route === '/board/add_to_dashboard') { - if (filter_count === 0) { - assert.deepEqual(args.domain, [["display_name", "ilike", "a"]], - "the correct domain should be sent"); - } - if (filter_count === 1) { - assert.deepEqual(args.domain, [["display_name", "ilike", "b"]], - "the correct domain should be sent"); - } + serverData.views = { + "partner,false,list": '', + "partner,false,search": "", + }; - filter_count += 1; - return Promise.resolve(true); + const mockRPC = (route, args) => { + if (route === "/board/add_to_dashboard") { + if (filter_count === 0) { + assert.deepEqual( + args.domain, + [["display_name", "ilike", "a"]], + "the correct domain should be sent" + ); + } + if (filter_count === 1) { + assert.deepEqual( + args.domain, + [["display_name", "ilike", "b"]], + "the correct domain should be sent" + ); } - return this._super.apply(this, arguments); - }, - }); - await actionManager.doAction({ + filter_count += 1; + return Promise.resolve(true); + } + }; + + const webClient = await createWebClient({ serverData, mockRPC }); + + await doAction(webClient, { id: 1, - res_model: 'partner', - type: 'ir.actions.act_window', - views: [[false, 'list']], + res_model: "partner", + type: "ir.actions.act_window", + views: [[false, "list"]], }); var filter_count = 0; // Add a first filter - await cpHelpers.toggleFilterMenu(actionManager); - await cpHelpers.toggleAddCustomFilter(actionManager); - await testUtils.fields.editInput(actionManager.el.querySelector('.o_generator_menu_value .o_input'), 'a'); - await cpHelpers.applyFilter(actionManager); + await cpHelpers.toggleFilterMenu(webClient); + await cpHelpers.toggleAddCustomFilter(webClient); + await testUtils.fields.editInput( + webClient.el.querySelector(".o_generator_menu_value .o_input"), + "a" + ); + await cpHelpers.applyFilter(webClient); // Add it to dashboard - await cpHelpers.toggleFavoriteMenu(actionManager); - await testUtils.dom.click($('.o_add_to_board > button')); - await testUtils.dom.click($('.o_add_to_board div button')); + await cpHelpers.toggleFavoriteMenu(webClient); + await testUtils.dom.click($(".o_add_to_board > button")); + await testUtils.dom.click($(".o_add_to_board div button")); // Remove it - await testUtils.dom.click(actionManager.el.querySelector('.o_facet_remove')); + await testUtils.dom.click(webClient.el.querySelector(".o_facet_remove")); // Add the second filter - await cpHelpers.toggleFilterMenu(actionManager); - await cpHelpers.toggleAddCustomFilter(actionManager); - await testUtils.fields.editInput(actionManager.el.querySelector('.o_generator_menu_value .o_input'), "b"); - await cpHelpers.applyFilter(actionManager); + await cpHelpers.toggleFilterMenu(webClient); + await cpHelpers.toggleAddCustomFilter(webClient); + await testUtils.fields.editInput( + webClient.el.querySelector(".o_generator_menu_value .o_input"), + "b" + ); + await cpHelpers.applyFilter(webClient); // Add it to dashboard - await cpHelpers.toggleFavoriteMenu(actionManager); - await testUtils.dom.click(actionManager.el.querySelector('.o_add_to_board > button')); - await testUtils.dom.click(actionManager.el.querySelector('.o_add_to_board div button')); - - actionManager.destroy(); + await cpHelpers.toggleFavoriteMenu(webClient); + await testUtils.dom.click(webClient.el.querySelector(".o_add_to_board > button")); + await testUtils.dom.click(webClient.el.querySelector(".o_add_to_board div button")); }); -QUnit.test('save a action domain to dashboard', async function (assert) { +QUnit.test("save a action domain to dashboard", async function (assert) { // View domains are to be added to the dashboard domain assert.expect(1); @@ -840,47 +856,43 @@ QUnit.test('save a action domain to dashboard', async function (assert) { var filter_domain = ["display_name", "ilike", "b"]; // The filter domain already contains the view domain, but is always added by dashboard.., - var expected_domain = ['&', view_domain, '&', view_domain, filter_domain]; + var expected_domain = ["&", view_domain, "&", view_domain, filter_domain]; - var actionManager = await createActionManager({ - data: this.data, - archs: { - 'partner,false,list': '', - 'partner,false,search': '', - }, - mockRPC: function (route, args) { - if (route === '/board/add_to_dashboard') { - assert.deepEqual(args.domain, expected_domain, - "the correct domain should be sent"); - return Promise.resolve(true); - } - return this._super.apply(this, arguments); - }, - }); + serverData.views = { + "partner,false,list": '', + "partner,false,search": "", + }; - await actionManager.doAction({ + const mockRPC = (route, args) => { + if (route === "/board/add_to_dashboard") { + assert.deepEqual(args.domain, expected_domain, "the correct domain should be sent"); + return Promise.resolve(true); + } + }; + + const webClient = await createWebClient({ serverData, mockRPC }); + + await doAction(webClient, { id: 1, - res_model: 'partner', - type: 'ir.actions.act_window', - views: [[false, 'list']], + res_model: "partner", + type: "ir.actions.act_window", + views: [[false, "list"]], domain: [view_domain], }); // Add a filter - await cpHelpers.toggleFilterMenu(actionManager); - await cpHelpers.toggleAddCustomFilter(actionManager); + await cpHelpers.toggleFilterMenu(webClient); + await cpHelpers.toggleAddCustomFilter(webClient); await testUtils.fields.editInput( - actionManager.el.querySelector('.o_generator_menu_value .o_input'), + webClient.el.querySelector(".o_generator_menu_value .o_input"), "b" ); - await cpHelpers.applyFilter(actionManager); + await cpHelpers.applyFilter(webClient); // Add it to dashboard - await cpHelpers.toggleFavoriteMenu(actionManager); - await testUtils.dom.click(actionManager.el.querySelector('.o_add_to_board > button')); + await cpHelpers.toggleFavoriteMenu(webClient); + await testUtils.dom.click(webClient.el.querySelector(".o_add_to_board > button")); // add - await testUtils.dom.click(actionManager.el.querySelector('.o_add_to_board div button')); - - actionManager.destroy(); + await testUtils.dom.click(webClient.el.querySelector(".o_add_to_board div button")); }); QUnit.test("Views should be loaded in the user's language", async function (assert) { @@ -1041,62 +1053,71 @@ QUnit.test('click on a cell of pivot view inside dashboard', async function (ass form.destroy(); }); -QUnit.test('correctly save the time ranges of a reporting view in comparison mode', async function (assert) { - assert.expect(1); +QUnit.test( + "correctly save the time ranges of a reporting view in comparison mode", + async function (assert) { + assert.expect(1); - const unpatchDate = patchDate(2020, 6, 1, 11, 0, 0); + const unpatchDate = patchDate(2020, 6, 1, 11, 0, 0); - this.data.partner.fields.date = { string: 'Date', type: 'date', sortable: true }; + serverData.models.partner.fields.date = { + string: "Date", + type: "date", + sortable: true, + }; - const actionManager = await createActionManager({ - data: this.data, - archs: { - 'partner,false,pivot': '', - 'partner,false,search': '', - }, - mockRPC: function (route, args) { - if (route === '/board/add_to_dashboard') { + serverData.views = { + "partner,false,pivot": '', + "partner,false,search": '', + }; + + const mockRPC = (route, args) => { + if (route === "/board/add_to_dashboard") { assert.deepEqual(args.context_to_save.comparison, { comparisonId: "previous_period", fieldName: "date", fieldDescription: "Date", rangeDescription: "July 2020", - range: ["&",["date", ">=", "2020-07-01"], ["date", "<=", "2020-07-31"]], - comparisonRange: ["&", ["date", ">=", "2020-06-01"], ["date", "<=", "2020-06-30"]], + range: ["&", ["date", ">=", "2020-07-01"], ["date", "<=", "2020-07-31"]], + comparisonRange: [ + "&", + ["date", ">=", "2020-06-01"], + ["date", "<=", "2020-06-30"], + ], comparisonRangeDescription: "June 2020", }); return Promise.resolve(true); } - return this._super.apply(this, arguments); - }, - }); + }; - await actionManager.doAction({ - id: 1, - res_model: 'partner', - type: 'ir.actions.act_window', - views: [[false, 'pivot']], - }); + const webClient = await createWebClient({ serverData, mockRPC }); - // filter on July 2020 - await cpHelpers.toggleFilterMenu(actionManager); - await cpHelpers.toggleMenuItem(actionManager, 'Date'); - await cpHelpers.toggleMenuItemOption(actionManager, 'Date', 'July'); + await doAction(webClient, { + id: 1, + res_model: "partner", + type: "ir.actions.act_window", + views: [[false, "pivot"]], + }); - // compare July 2020 to June 2020 - await cpHelpers.toggleComparisonMenu(actionManager); - await cpHelpers.toggleMenuItem(actionManager, 0); + // filter on July 2020 + await cpHelpers.toggleFilterMenu(webClient); + await cpHelpers.toggleMenuItem(webClient, "Date"); + await cpHelpers.toggleMenuItemOption(webClient, "Date", "July"); - // add the view to the dashboard - await cpHelpers.toggleFavoriteMenu(actionManager); + // compare July 2020 to June 2020 + await cpHelpers.toggleComparisonMenu(webClient); + await cpHelpers.toggleMenuItem(webClient, 0); - await testUtils.dom.click($('.o_add_to_board > button')); - await testUtils.fields.editInput($('.o_add_to_board input'), 'a name'); - await testUtils.dom.click($('.o_add_to_board div button')); + // add the view to the dashboard + await cpHelpers.toggleFavoriteMenu(webClient); - unpatchDate(); - actionManager.destroy(); -}); + await testUtils.dom.click($(".o_add_to_board > button")); + await testUtils.fields.editInput($(".o_add_to_board input"), "a name"); + await testUtils.dom.click($(".o_add_to_board div button")); + + unpatchDate(); + } +); QUnit.test('correctly display the time range descriptions of a reporting view in comparison mode', async function (assert) { assert.expect(1); diff --git a/addons/bus/__manifest__.py b/addons/bus/__manifest__.py index 9dd82600664..880e2d8dda5 100644 --- a/addons/bus/__manifest__.py +++ b/addons/bus/__manifest__.py @@ -19,10 +19,7 @@ 'bus/static/src/js/services/bus_service.js', ], 'web.qunit_suite_tests': [ - 'bus/static/tests/bus_tests.js', - ], - 'web.assets_tests': [ - 'bus/static/tests/bus_tests_tour.js', + 'bus/static/tests/*.js', ], } } diff --git a/addons/bus/static/src/js/services/assets_watchdog_service.js b/addons/bus/static/src/js/services/assets_watchdog_service.js new file mode 100644 index 00000000000..5567ed03cb3 --- /dev/null +++ b/addons/bus/static/src/js/services/assets_watchdog_service.js @@ -0,0 +1,93 @@ +/** @odoo-module **/ + +import { browser } from "@web/core/browser/browser"; +import { registry } from "@web/core/registry"; + +export const assetsWatchdogService = { + dependencies: ["notification"], + + start(env, { notification }) { + const assets = {}; + let isNotificationDisplayed = false; + let bundleNotifTimerID = null; + + env.bus.on("WEB_CLIENT_READY", null, async () => { + const legacyEnv = owl.Component.env; + + document.querySelectorAll("*[data-asset-bundle]").forEach((el) => { + assets[el.getAttribute("data-asset-bundle")] = el.getAttribute( + "data-asset-version" + ); + }); + + legacyEnv.services.bus_service.onNotification(this, onNotification); + legacyEnv.services.bus_service.addChannel("bundle_changed"); + }); + + /** + * Displays one notification on user's screen when assets have changed + */ + function displayBundleChangedNotification() { + if (!isNotificationDisplayed) { + // Wrap the notification inside a delay. + // The server may be overwhelmed with recomputing assets + // We wait until things settle down + browser.clearTimeout(bundleNotifTimerID); + bundleNotifTimerID = browser.setTimeout(() => { + notification.add( + env._t("The page appears to be out of date."), + { + title: env._t("Refresh"), + type: "warning", + sticky: true, + buttons: [ + { + name: env._t("Refresh"), + primary: true, + onClick: () => { + browser.location.reload(); + }, + }, + ], + onClose: () => { + isNotificationDisplayed = false; + }, + } + ); + isNotificationDisplayed = true; + }, getBundleNotificationDelay()); + } + } + + /** + * Computes a random delay to avoid hammering the server + * when bundles change with all the users reloading + * at the same time + * + * @return {number} delay in milliseconds + */ + function getBundleNotificationDelay() { + return 10000 + Math.floor(Math.random() * 50) * 1000; + } + + /** + * Reacts to bus's notification + * + * @param {Array} notifications: list of received notifications + */ + function onNotification(notifications) { + for (const notif of notifications) { + if (notif[0][1] === "bundle_changed") { + const bundleXmlId = notif[1][0]; + const bundleVersion = notif[1][1]; + if (bundleXmlId in assets && bundleVersion !== assets[bundleXmlId]) { + displayBundleChangedNotification(); + break; + } + } + } + } + }, +}; + +registry.category("services").add("assetsWatchdog", assetsWatchdogService); diff --git a/addons/bus/static/src/js/web_client_bus.js b/addons/bus/static/src/js/web_client_bus.js deleted file mode 100644 index 759ec2dc945..00000000000 --- a/addons/bus/static/src/js/web_client_bus.js +++ /dev/null @@ -1,109 +0,0 @@ -odoo.define('bus.WebClient', function (require) { - "use strict"; - - const core = require('web.core'); - const WebClient = require('web.WebClient'); - - const _t = core._t; - - WebClient.include({ - - //---------------------------------------------------------------------- - // Public - //---------------------------------------------------------------------- - - /** - * Detects the presence of assets in DOM's HEAD - * - * @override - */ - async start() { - this._assetsChangedNotificationId = null; - this._assets = {}; - await this._super(...arguments); - }, - /** - * Assigns handler to bus notification - * - * @override - */ - show_application() { - const shown = this._super(...arguments); - document.querySelectorAll('*[data-asset-bundle]').forEach(el => { - this._assets[el.getAttribute('data-asset-bundle')] = el.getAttribute('data-asset-version'); - }); - this.call('bus_service', 'onNotification', this, this._onNotification); - this.call('bus_service', 'addChannel', 'bundle_changed'); - return shown; - }, - - //---------------------------------------------------------------------- - // Private - //---------------------------------------------------------------------- - - /** - * Displays one notification on user's screen when assets have changed - * - * @private - */ - _displayBundleChangedNotification() { - if (!this._assetsChangedNotificationId) { - // Wrap the notification inside a delay. - // The server may be overwhelmed with recomputing assets - // We wait until things settle down - clearTimeout(this._bundleNotifTimerID); - this._bundleNotifTimerID = setTimeout(() => { - this._assetsChangedNotificationId = this.call('notification', 'notify', { - title: _t('Refresh'), - message: _t('The page appears to be out of date.'), - sticky: true, - onClose: () => { - this._assetsChangedNotificationId = null; - }, - buttons: [{ - text: _t('Refresh'), - primary: true, - click: () => { - window.location.reload(true); - } - }], - }); - }, this._getBundleNotificationDelay()); - } - }, - /** - * Computes a random delay to avoid hammering the server - * when bundles change with all the users reloading - * at the same time - * - * @private - * @return {number} delay in milliseconds - */ - _getBundleNotificationDelay() { - return 10000 + Math.floor(Math.random()*50) * 1000; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Reacts to bus's notification - * - * @private - * @param {Array} notifications: list of received notifications - */ - _onNotification(notifications) { - for (const notif of notifications) { - if (notif[0][1] === 'bundle_changed') { - const bundleXmlId = notif[1][0]; - const bundleVersion = notif[1][1]; - if (bundleXmlId in this._assets && bundleVersion !== this._assets[bundleXmlId]) { - this._displayBundleChangedNotification(); - break; - } - } - } - } - }); -}); diff --git a/addons/bus/static/tests/assets_watchdog_tests.js b/addons/bus/static/tests/assets_watchdog_tests.js new file mode 100644 index 00000000000..e21834939aa --- /dev/null +++ b/addons/bus/static/tests/assets_watchdog_tests.js @@ -0,0 +1,76 @@ +/** @odoo-module */ + +import * as legacyRegistry from "web.Registry"; +import * as BusService from "bus.BusService"; +import * as RamStorage from "web.RamStorage"; +import * as AbstractStorageService from "web.AbstractStorageService"; + +import { createWebClient } from "@web/../tests/webclient/helpers"; +import { assetsWatchdogService } from "@bus/js/services/assets_watchdog_service"; +import { click, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils"; +import { browser } from "@web/core/browser/browser"; +import { registry } from "@web/core/registry"; + +const LocalStorageService = AbstractStorageService.extend({ + storage: new RamStorage(), +}); +const mainComponentRegistry = registry.category("main_components"); +const serviceRegistry = registry.category("services"); + +QUnit.module("Bus Assets WatchDog", (hooks) => { + let legacyServicesRegistry; + hooks.beforeEach((assert) => { + legacyServicesRegistry = new legacyRegistry(); + legacyServicesRegistry.add("bus_service", BusService); + legacyServicesRegistry.add("local_storage", LocalStorageService); + + serviceRegistry.add("assetsWatchdog", assetsWatchdogService); + + patchWithCleanup(browser, { + setTimeout: (fn) => fn(), + clearTimeout: () => {}, + location: { + reload: () => assert.step("reloadPage"), + }, + }); + }); + + QUnit.test("can listen on bus and displays notifications in DOM", async (assert) => { + assert.expect(5); + + let pollNumber = 0; + const mockRPC = (route, args) => { + if (route === "/longpolling/poll") { + if (pollNumber > 0) { + return new Promise(() => {}); // let it hang to avoid further calls + } + pollNumber++; + assert.deepEqual(args.channels, ["bundle_changed"]); + return Promise.resolve([ + { + id: "prout", + channel: ["db_name", "bundle_changed"], + message: ["web.assets_backend", "newHash"], + }, + ]); + } + }; + + const webClient = await createWebClient({ + legacyParams: { serviceRegistry: legacyServicesRegistry }, + mockRPC, + }); + + await nextTick(); + + assert.containsOnce(webClient.el, ".o_notification_body"); + assert.strictEqual( + webClient.el.querySelector(".o_notification_body .o_notification_content").textContent, + "The page appears to be out of date." + ); + + // reload by clicking on the reload button + await click(webClient.el, ".o_notification_buttons .btn-primary"); + assert.verifySteps(["reloadPage"]); + }); +}); diff --git a/addons/bus/static/tests/bus_tests_tour.js b/addons/bus/static/tests/bus_tests_tour.js deleted file mode 100644 index 2248e0a1eb3..00000000000 --- a/addons/bus/static/tests/bus_tests_tour.js +++ /dev/null @@ -1,25 +0,0 @@ -odoo.define("bus.tour", function (require) { - "use strict"; - - const tour = require("web_tour.tour"); - - tour.register("bundle_changed_notification", { - test: true, - url: '/web', - }, [{ - trigger: '.o_web_client', - run() { - const webClient = odoo.__DEBUG__.services['web.web_client']; - const _delayFn = webClient._getBundleNotificationDelay; - webClient._getBundleNotificationDelay = () => 0; - this.call('bus_service', 'trigger', - 'notification', - [[['db_name', 'bundle_changed'], ['web.assets_backend', 'hash']]] - ); - webClient._getBundleNotificationDelay = _delayFn; - } - }, { - trigger: '.o_notification_title:contains(Refresh)', - }] - ); -}); diff --git a/addons/bus/tests/test_assetsbundle.py b/addons/bus/tests/test_assetsbundle.py index 2bbb89e8be0..4abc687d34e 100644 --- a/addons/bus/tests/test_assetsbundle.py +++ b/addons/bus/tests/test_assetsbundle.py @@ -36,7 +36,8 @@ class BusWebTests(odoo.tests.HttpCase): self.patch(type(self.env['bus.bus']), 'sendone', patched_sendone) - self.start_tour('/web', "bundle_changed_notification", login='admin', timeout=180) + self.authenticate('admin', 'admin') + self.url_open('/web') # One sendone for each asset bundle and for each CSS / JS self.assertEqual( diff --git a/addons/calendar/__manifest__.py b/addons/calendar/__manifest__.py index a34889b121b..f44f1abeca5 100644 --- a/addons/calendar/__manifest__.py +++ b/addons/calendar/__manifest__.py @@ -47,6 +47,7 @@ If you need to manage your meetings, you should install the CRM module. 'calendar/static/src/js/calendar_view.js', 'calendar/static/src/js/mail_activity.js', 'calendar/static/src/js/systray_activity_menu.js', + 'calendar/static/src/js/services/calendar_notification_service.js', ], 'web.qunit_suite_tests': [ 'calendar/static/tests/**/*', diff --git a/addons/calendar/static/src/js/base_calendar.js b/addons/calendar/static/src/js/base_calendar.js index a4de081c604..29220944a06 100644 --- a/addons/calendar/static/src/js/base_calendar.js +++ b/addons/calendar/static/src/js/base_calendar.js @@ -3,116 +3,10 @@ odoo.define('base_calendar.base_calendar', function (require) { var BasicModel = require('web.BasicModel'); var fieldRegistry = require('web.field_registry'); -var Notification = require('web.Notification'); var relationalFields = require('web.relational_fields'); -var session = require('web.session'); -var WebClient = require('web.WebClient'); const FieldMany2ManyTagsAvatar = relationalFields.FieldMany2ManyTagsAvatar; -var CalendarNotification = Notification.extend({ - template: "CalendarNotification", - xmlDependencies: (Notification.prototype.xmlDependencies || []) - .concat(['/calendar/static/src/xml/notification_calendar.xml']), - - init: function(parent, params) { - this._super(parent, params); - this.eid = params.eventID; - this.sticky = true; - - this.events = _.extend(this.events || {}, { - 'click .link2event': function() { - var self = this; - - this._rpc({ - route: '/web/action/load', - params: { - action_id: 'calendar.action_calendar_event_notify', - }, - }) - .then(function(r) { - r.res_id = self.eid; - return self.do_action(r); - }); - }, - - 'click .link2recall': function() { - this.close(); - }, - - 'click .link2showed': function() { - this._rpc({route: '/calendar/notify_ack'}) - .then(this.close.bind(this, false), this.close.bind(this, false)); - }, - }); - }, -}); - -WebClient.include({ - display_calendar_notif: function(notifications) { - var self = this; - var last_notif_timer = 0; - - // Clear previously set timeouts and destroy currently displayed calendar notifications - clearTimeout(this.get_next_calendar_notif_timeout); - _.each(this.calendar_notif_timeouts, clearTimeout); - this.calendar_notif_timeouts = {}; - - // For each notification, set a timeout to display it - _.each(notifications, function(notif) { - var key = notif.event_id + ',' + notif.alarm_id; - if (key in self.calendar_notif) { - return; - } - self.calendar_notif_timeouts[key] = setTimeout(function () { - var notificationID = self.call('notification', 'notify', { - Notification: CalendarNotification, - title: notif.title, - message: notif.message, - eventID: notif.event_id, - onClose: function () { - delete self.calendar_notif[key]; - }, - }); - self.calendar_notif[key] = notificationID; - }, notif.timer * 1000); - last_notif_timer = Math.max(last_notif_timer, notif.timer); - }); - - // Set a timeout to get the next notifications when the last one has been displayed - if (last_notif_timer > 0) { - this.get_next_calendar_notif_timeout = setTimeout(this.get_next_calendar_notif.bind(this), last_notif_timer * 1000); - } - }, - get_next_calendar_notif: function() { - session.rpc("/calendar/notify", {}, {shadow: true}) - .then(this.display_calendar_notif.bind(this)) - .guardedCatch(function(reason) { // - var err = reason.message; - var ev = reason.event; - if(err.code === -32098) { - // Prevent the CrashManager to display an error - // in case of an xhr error not due to a server error - ev.preventDefault(); - } - }); - }, - show_application: function() { - // An event is triggered on the bus each time a calendar event with alarm - // in which the current user is involved is created, edited or deleted - this.calendar_notif_timeouts = {}; - this.calendar_notif = {}; - this.call('bus_service', 'onNotification', this, function (notifications) { - _.each(notifications, (function (notification) { - if (notification[0][1] === 'calendar.alarm') { - this.display_calendar_notif(notification[1]); - } - }).bind(this)); - }); - return this._super.apply(this, arguments).then(this.get_next_calendar_notif.bind(this)); - }, -}); - BasicModel.include({ /** * @private diff --git a/addons/calendar/static/src/js/services/calendar_notification_service.js b/addons/calendar/static/src/js/services/calendar_notification_service.js new file mode 100644 index 00000000000..78c1a20d480 --- /dev/null +++ b/addons/calendar/static/src/js/services/calendar_notification_service.js @@ -0,0 +1,108 @@ +/** @odoo-module **/ + +import { browser } from "@web/core/browser/browser"; +import { ConnectionLostError } from "@web/core/network/rpc_service"; +import { registry } from "@web/core/registry"; + +export const calendarNotificationService = { + dependencies: ["action", "notification", "rpc"], + + start(env, { action, notification, rpc }) { + let calendarNotifTimeouts = {}; + let nextCalendarNotifTimeout = null; + const displayedNotifications = new Set(); + + env.bus.on("WEB_CLIENT_READY", null, async () => { + const legacyEnv = owl.Component.env; + legacyEnv.services.bus_service.onNotification(this, (notifications) => { + for (const notif of notifications) { + if (notif[0][1] === "calendar.alarm") { + displayCalendarNotification(notif[1]); + } + } + }); + legacyEnv.services.bus_service.startPolling(); + }); + + /** + * Displays the Calendar notification on user's screen + */ + function displayCalendarNotification(notifications) { + let lastNotifTimer = 0; + + // Clear previously set timeouts and destroy currently displayed calendar notifications + browser.clearTimeout(nextCalendarNotifTimeout); + Object.values(calendarNotifTimeouts).forEach((notif) => browser.clearTimeout(notif)); + calendarNotifTimeouts = {}; + + // For each notification, set a timeout to display it + notifications.forEach(function (notif) { + const key = notif.event_id + "," + notif.alarm_id; + if (displayedNotifications.has(key)) { + return; + } + calendarNotifTimeouts[key] = browser.setTimeout(function () { + const notificationRemove = notification.add(notif.message, { + title: notif.title, + type: "warning", + sticky: true, + onClose: () => { + displayedNotifications.delete(key); + }, + buttons: [ + { + name: env._t("OK"), + primary: true, + onClick: async () => { + await rpc("/calendar/notify_ack"); + notificationRemove(); + }, + }, + { + name: env._t("Details"), + onClick: async () => { + await action.doAction( + "calendar.action_calendar_event_notify", + { + resId: notif.event_id, + } + ); + notificationRemove(); + }, + }, + { + name: env._t("Snooze"), + onClick: () => { + notificationRemove(); + }, + }, + ], + }); + displayedNotifications.add(key); + }, notif.timer * 1000); + lastNotifTimer = Math.max(lastNotifTimer, notif.timer); + }); + + // Set a timeout to get the next notifications when the last one has been displayed + if (lastNotifTimer > 0) { + nextCalendarNotifTimeout = browser.setTimeout( + getNextCalendarNotif, + lastNotifTimer * 1000 + ); + } + } + + async function getNextCalendarNotif() { + try { + const result = await rpc("/calendar/notify", {}, { silent: true }); + displayCalendarNotification(result); + } catch (error) { + if (!(error instanceof ConnectionLostError)) { + throw error; + } + } + } + }, +}; + +registry.category("services").add("calendarNotification", calendarNotificationService); diff --git a/addons/calendar/static/src/scss/calendar.scss b/addons/calendar/static/src/scss/calendar.scss index aafb36e2626..314a417b4f6 100644 --- a/addons/calendar/static/src/scss/calendar.scss +++ b/addons/calendar/static/src/scss/calendar.scss @@ -53,10 +53,10 @@ } .o_accepted { - color: theme-color('success'); + @extend .text-success; } .o_declined { - color: theme-color('danger'); + @extend .text-danger; } } .o_meeting_filter { diff --git a/addons/calendar/static/src/xml/notification_calendar.xml b/addons/calendar/static/src/xml/notification_calendar.xml deleted file mode 100644 index 3c51293f36c..00000000000 --- a/addons/calendar/static/src/xml/notification_calendar.xml +++ /dev/null @@ -1,19 +0,0 @@ - - diff --git a/addons/calendar/static/tests/calendar_notification_tests.js b/addons/calendar/static/tests/calendar_notification_tests.js new file mode 100644 index 00000000000..81eb2a5cce0 --- /dev/null +++ b/addons/calendar/static/tests/calendar_notification_tests.js @@ -0,0 +1,219 @@ +/** @odoo-module */ + +import * as legacyRegistry from "web.Registry"; +import * as BusService from "bus.BusService"; +import * as RamStorage from "web.RamStorage"; +import * as AbstractStorageService from "web.AbstractStorageService"; + +import { createWebClient } from "@web/../tests/webclient/helpers"; +import { calendarNotificationService } from "@calendar/js/services/calendar_notification_service"; +import { click, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils"; +import { browser } from "@web/core/browser/browser"; +import { registry } from "@web/core/registry"; + +const LocalStorageService = AbstractStorageService.extend({ + storage: new RamStorage(), +}); +const serviceRegistry = registry.category("services"); + +QUnit.module("Calendar Notification", (hooks) => { + let legacyServicesRegistry; + hooks.beforeEach(() => { + legacyServicesRegistry = new legacyRegistry(); + legacyServicesRegistry.add("bus_service", BusService); + legacyServicesRegistry.add("local_storage", LocalStorageService); + + serviceRegistry.add("calendarNotification", calendarNotificationService); + + patchWithCleanup(browser, { + setTimeout: (fn) => fn(), + clearTimeout: () => {}, + }); + }); + + QUnit.test( + "can listen on bus and display notifications in DOM and click OK", + async (assert) => { + assert.expect(5); + + let pollNumber = 0; + const mockRPC = (route, args) => { + if (route === "/longpolling/poll") { + if (pollNumber > 0) { + return new Promise(() => {}); // let it hang to avoid further calls + } + pollNumber++; + return Promise.resolve([ + { + id: "prout", + channel: ["db_name", "calendar.alarm", 7], + message: [ + { + alarm_id: 1, + event_id: 2, + title: "Meeting", + message: "Very old meeting message", + timer: 20 * 60, + notify_at: "1978-04-14 12:45:00", + }, + ], + }, + ]); + } + if (route === "/calendar/notify") { + return Promise.resolve([]); + } + if (route === "/calendar/notify_ack") { + assert.step("notifyAck"); + return Promise.resolve(true); + } + }; + + const webClient = await createWebClient({ + legacyParams: { serviceRegistry: legacyServicesRegistry }, + mockRPC, + }); + + await nextTick(); + + assert.containsOnce(webClient.el, ".o_notification_body"); + assert.strictEqual( + webClient.el.querySelector(".o_notification_body .o_notification_content") + .textContent, + "Very old meeting message" + ); + + await click(webClient.el.querySelector(".o_notification_buttons .btn")); + assert.verifySteps(["notifyAck"]); + assert.containsNone(webClient.el, ".o_notification"); + } + ); + + QUnit.test( + "can listen on bus and display notifications in DOM and click Detail", + async (assert) => { + assert.expect(5); + + let pollNumber = 0; + const mockRPC = (route, args) => { + if (route === "/longpolling/poll") { + if (pollNumber > 0) { + return new Promise(() => {}); // let it hang to avoid further calls + } + pollNumber++; + return Promise.resolve([ + { + id: "prout", + channel: ["db_name", "calendar.alarm", 7], + message: [ + { + alarm_id: 1, + event_id: 2, + title: "Meeting", + message: "Very old meeting message", + timer: 20 * 60, + notify_at: "1978-04-14 12:45:00", + }, + ], + }, + ]); + } + if (route === "/calendar/notify") { + return Promise.resolve([]); + } + }; + + const fakeActionService = { + name: "action", + start() { + return { + doAction(actionId) { + assert.step(actionId); + return Promise.resolve(true); + }, + loadState(state, options) { + return Promise.resolve(true); + }, + }; + }, + }; + serviceRegistry.add("action", fakeActionService, { force: true }); + + const webClient = await createWebClient({ + legacyParams: { serviceRegistry: legacyServicesRegistry }, + mockRPC, + }); + + await nextTick(); + + assert.containsOnce(webClient.el, ".o_notification_body"); + assert.strictEqual( + webClient.el.querySelector(".o_notification_body .o_notification_content") + .textContent, + "Very old meeting message" + ); + + await click(webClient.el.querySelectorAll(".o_notification_buttons .btn")[1]); + assert.verifySteps(["calendar.action_calendar_event_notify"]); + assert.containsNone(webClient.el, ".o_notification"); + } + ); + + QUnit.test( + "can listen on bus and display notifications in DOM and click Snooze", + async (assert) => { + assert.expect(4); + + let pollNumber = 0; + const mockRPC = (route, args) => { + if (route === "/longpolling/poll") { + if (pollNumber > 0) { + return new Promise(() => {}); // let it hang to avoid further calls + } + pollNumber++; + return Promise.resolve([ + { + id: "prout", + channel: ["db_name", "calendar.alarm", 7], + message: [ + { + alarm_id: 1, + event_id: 2, + title: "Meeting", + message: "Very old meeting message", + timer: 20 * 60, + notify_at: "1978-04-14 12:45:00", + }, + ], + }, + ]); + } + if (route === "/calendar/notify") { + return Promise.resolve([]); + } + if (route === "/calendar/notify_ack") { + assert.step("notifyAck"); + return Promise.resolve(true); + } + }; + + const webClient = await createWebClient({ + legacyParams: { serviceRegistry: legacyServicesRegistry }, + mockRPC, + }); + + await nextTick(); + + assert.containsOnce(webClient.el, ".o_notification_body"); + assert.strictEqual( + webClient.el.querySelector(".o_notification_body .o_notification_content") + .textContent, + "Very old meeting message" + ); + + await click(webClient.el.querySelectorAll(".o_notification_buttons .btn")[2]); + assert.verifySteps([], "should only close the notification withtout calling a rpc"); + assert.containsNone(webClient.el, ".o_notification"); + } + ); +}); diff --git a/addons/google_drive/static/src/xml/gdrive.xml b/addons/google_drive/static/src/xml/gdrive.xml index d12b3459516..db1e3a11c8a 100644 --- a/addons/google_drive/static/src/xml/gdrive.xml +++ b/addons/google_drive/static/src/xml/gdrive.xml @@ -1,7 +1,7 @@
  • -
      +