summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/utils
diff options
context:
space:
mode:
Diffstat (limited to 'addons/mail/static/src/utils')
-rw-r--r--addons/mail/static/src/utils/deferred/deferred.js21
-rw-r--r--addons/mail/static/src/utils/test_utils.js767
-rw-r--r--addons/mail/static/src/utils/throttle/throttle.js382
-rw-r--r--addons/mail/static/src/utils/throttle/throttle_tests.js407
-rw-r--r--addons/mail/static/src/utils/timer/timer.js165
-rw-r--r--addons/mail/static/src/utils/timer/timer_tests.js427
-rw-r--r--addons/mail/static/src/utils/utils.js193
7 files changed, 2362 insertions, 0 deletions
diff --git a/addons/mail/static/src/utils/deferred/deferred.js b/addons/mail/static/src/utils/deferred/deferred.js
new file mode 100644
index 00000000..f96696fb
--- /dev/null
+++ b/addons/mail/static/src/utils/deferred/deferred.js
@@ -0,0 +1,21 @@
+odoo.define('mail/static/src/utils/deferred/deferred.js', function (require) {
+'use strict';
+
+/**
+ * @returns {Deferred}
+ */
+function makeDeferred() {
+ let resolve;
+ let reject;
+ const prom = new Promise(function (res, rej) {
+ resolve = res.bind(this);
+ reject = rej.bind(this);
+ });
+ prom.resolve = (...args) => resolve(...args);
+ prom.reject = (...args) => reject(...args);
+ return prom;
+}
+
+return { makeDeferred };
+
+});
diff --git a/addons/mail/static/src/utils/test_utils.js b/addons/mail/static/src/utils/test_utils.js
new file mode 100644
index 00000000..be15afe7
--- /dev/null
+++ b/addons/mail/static/src/utils/test_utils.js
@@ -0,0 +1,767 @@
+odoo.define('mail/static/src/utils/test_utils.js', function (require) {
+'use strict';
+
+const BusService = require('bus.BusService');
+
+const {
+ addMessagingToEnv,
+ addTimeControlToEnv,
+} = require('mail/static/src/env/test_env.js');
+const ModelManager = require('mail/static/src/model/model_manager.js');
+const ChatWindowService = require('mail/static/src/services/chat_window_service/chat_window_service.js');
+const DialogService = require('mail/static/src/services/dialog_service/dialog_service.js');
+const { nextTick } = require('mail/static/src/utils/utils.js');
+const DiscussWidget = require('mail/static/src/widgets/discuss/discuss.js');
+const MessagingMenuWidget = require('mail/static/src/widgets/messaging_menu/messaging_menu.js');
+const MockModels = require('mail/static/tests/helpers/mock_models.js');
+
+const AbstractStorageService = require('web.AbstractStorageService');
+const NotificationService = require('web.NotificationService');
+const RamStorage = require('web.RamStorage');
+const {
+ createActionManager,
+ createView,
+ makeTestPromise,
+ mock: {
+ addMockEnvironment,
+ patch: legacyPatch,
+ unpatch: legacyUnpatch,
+ },
+} = require('web.test_utils');
+const Widget = require('web.Widget');
+
+const { Component } = owl;
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * Create a fake object 'dataTransfer', linked to some files,
+ * which is passed to drag and drop events.
+ *
+ * @param {Object[]} files
+ * @returns {Object}
+ */
+function _createFakeDataTransfer(files) {
+ return {
+ dropEffect: 'all',
+ effectAllowed: 'all',
+ files,
+ items: [],
+ types: ['Files'],
+ };
+}
+
+/**
+ * @private
+ * @param {Object} callbacks
+ * @param {function[]} callbacks.init
+ * @param {function[]} callbacks.mount
+ * @param {function[]} callbacks.destroy
+ * @param {function[]} callbacks.return
+ * @returns {Object} update callbacks
+ */
+function _useChatWindow(callbacks) {
+ const {
+ mount: prevMount,
+ destroy: prevDestroy,
+ } = callbacks;
+ return Object.assign({}, callbacks, {
+ mount: prevMount.concat(async () => {
+ // trigger mounting of chat window manager
+ await Component.env.services['chat_window']._onWebClientReady();
+ }),
+ destroy: prevDestroy.concat(() => {
+ Component.env.services['chat_window'].destroy();
+ }),
+ });
+}
+
+/**
+ * @private
+ * @param {Object} callbacks
+ * @param {function[]} callbacks.init
+ * @param {function[]} callbacks.mount
+ * @param {function[]} callbacks.destroy
+ * @param {function[]} callbacks.return
+ * @returns {Object} update callbacks
+ */
+function _useDialog(callbacks) {
+ const {
+ mount: prevMount,
+ destroy: prevDestroy,
+ } = callbacks;
+ return Object.assign({}, callbacks, {
+ mount: prevMount.concat(async () => {
+ // trigger mounting of dialog manager
+ await Component.env.services['dialog']._onWebClientReady();
+ }),
+ destroy: prevDestroy.concat(() => {
+ Component.env.services['dialog'].destroy();
+ }),
+ });
+}
+
+/**
+ * @private
+ * @param {Object} callbacks
+ * @param {function[]} callbacks.init
+ * @param {function[]} callbacks.mount
+ * @param {function[]} callbacks.destroy
+ * @param {function[]} callbacks.return
+ * @return {Object} update callbacks
+ */
+function _useDiscuss(callbacks) {
+ const {
+ init: prevInit,
+ mount: prevMount,
+ return: prevReturn,
+ } = callbacks;
+ let discussWidget;
+ const state = {
+ autoOpenDiscuss: false,
+ discussData: {},
+ };
+ return Object.assign({}, callbacks, {
+ init: prevInit.concat(params => {
+ const {
+ autoOpenDiscuss = state.autoOpenDiscuss,
+ discuss: discussData = state.discussData
+ } = params;
+ Object.assign(state, { autoOpenDiscuss, discussData });
+ delete params.autoOpenDiscuss;
+ delete params.discuss;
+ }),
+ mount: prevMount.concat(async params => {
+ const { selector, widget } = params;
+ DiscussWidget.prototype._pushStateActionManager = () => {};
+ discussWidget = new DiscussWidget(widget, state.discussData);
+ await discussWidget.appendTo($(selector));
+ if (state.autoOpenDiscuss) {
+ await discussWidget.on_attach_callback();
+ }
+ }),
+ return: prevReturn.concat(result => {
+ Object.assign(result, { discussWidget });
+ }),
+ });
+}
+
+/**
+ * @private
+ * @param {Object} callbacks
+ * @param {function[]} callbacks.init
+ * @param {function[]} callbacks.mount
+ * @param {function[]} callbacks.destroy
+ * @param {function[]} callbacks.return
+ * @returns {Object} update callbacks
+ */
+function _useMessagingMenu(callbacks) {
+ const {
+ mount: prevMount,
+ return: prevReturn,
+ } = callbacks;
+ let messagingMenuWidget;
+ return Object.assign({}, callbacks, {
+ mount: prevMount.concat(async ({ selector, widget }) => {
+ messagingMenuWidget = new MessagingMenuWidget(widget, {});
+ await messagingMenuWidget.appendTo($(selector));
+ await messagingMenuWidget.on_attach_callback();
+ }),
+ return: prevReturn.concat(result => {
+ Object.assign(result, { messagingMenuWidget });
+ }),
+ });
+}
+
+//------------------------------------------------------------------------------
+// Public: rendering timers
+//------------------------------------------------------------------------------
+
+/**
+ * Returns a promise resolved at the next animation frame.
+ *
+ * @returns {Promise}
+ */
+function nextAnimationFrame() {
+ const requestAnimationFrame = owl.Component.scheduler.requestAnimationFrame;
+ return new Promise(function (resolve) {
+ setTimeout(() => requestAnimationFrame(() => resolve()));
+ });
+}
+
+/**
+ * Returns a promise resolved the next time OWL stops rendering.
+ *
+ * @param {function} func function which, when called, is
+ * expected to trigger OWL render(s).
+ * @param {number} [timeoutDelay=5000] in ms
+ * @returns {Promise}
+ */
+const afterNextRender = (function () {
+ const stop = owl.Component.scheduler.stop;
+ const stopPromises = [];
+
+ owl.Component.scheduler.stop = function () {
+ const wasRunning = this.isRunning;
+ stop.call(this);
+ if (wasRunning) {
+ while (stopPromises.length) {
+ stopPromises.pop().resolve();
+ }
+ }
+ };
+
+ async function afterNextRender(func, timeoutDelay = 5000) {
+ // Define the potential errors outside of the promise to get a proper
+ // trace if they happen.
+ const startError = new Error("Timeout: the render didn't start.");
+ const stopError = new Error("Timeout: the render didn't stop.");
+ // Set up the timeout to reject if no render happens.
+ let timeoutNoRender;
+ const timeoutProm = new Promise((resolve, reject) => {
+ timeoutNoRender = setTimeout(() => {
+ let error = startError;
+ if (owl.Component.scheduler.isRunning) {
+ error = stopError;
+ }
+ console.error(error);
+ reject(error);
+ }, timeoutDelay);
+ });
+ // Set up the promise to resolve if a render happens.
+ const prom = makeTestPromise();
+ stopPromises.push(prom);
+ // Start the function expected to trigger a render after the promise
+ // has been registered to not miss any potential render.
+ const funcRes = func();
+ // Make them race (first to resolve/reject wins).
+ await Promise.race([prom, timeoutProm]);
+ clearTimeout(timeoutNoRender);
+ // Wait the end of the function to ensure all potential effects are
+ // taken into account during the following verification step.
+ await funcRes;
+ // Wait one more frame to make sure no new render has been queued.
+ await nextAnimationFrame();
+ if (owl.Component.scheduler.isRunning) {
+ await afterNextRender(() => {}, timeoutDelay);
+ }
+ }
+
+ return afterNextRender;
+})();
+
+
+//------------------------------------------------------------------------------
+// Public: test lifecycle
+//------------------------------------------------------------------------------
+
+function beforeEach(self) {
+ const data = MockModels.generateData();
+
+ data.partnerRootId = 2;
+ data['res.partner'].records.push({
+ active: false,
+ display_name: "OdooBot",
+ id: data.partnerRootId,
+ });
+
+ data.currentPartnerId = 3;
+ data['res.partner'].records.push({
+ display_name: "Your Company, Mitchell Admin",
+ id: data.currentPartnerId,
+ name: "Mitchell Admin",
+ });
+ data.currentUserId = 2;
+ data['res.users'].records.push({
+ display_name: "Your Company, Mitchell Admin",
+ id: data.currentUserId,
+ name: "Mitchell Admin",
+ partner_id: data.currentPartnerId,
+ });
+
+ data.publicPartnerId = 4;
+ data['res.partner'].records.push({
+ active: false,
+ display_name: "Public user",
+ id: data.publicPartnerId,
+ });
+ data.publicUserId = 3;
+ data['res.users'].records.push({
+ active: false,
+ display_name: "Public user",
+ id: data.publicUserId,
+ name: "Public user",
+ partner_id: data.publicPartnerId,
+ });
+
+ const originals = {
+ '_.debounce': _.debounce,
+ '_.throttle': _.throttle,
+ };
+
+ (function patch() {
+ // patch _.debounce and _.throttle to be fast and synchronous
+ _.debounce = _.identity;
+ _.throttle = _.identity;
+ })();
+
+ function unpatch() {
+ _.debounce = originals['_.debounce'];
+ _.throttle = originals['_.throttle'];
+ }
+
+ Object.assign(self, {
+ components: [],
+ data,
+ unpatch,
+ widget: undefined
+ });
+}
+
+function afterEach(self) {
+ if (self.env) {
+ self.env.bus.off('hide_home_menu', null);
+ self.env.bus.off('show_home_menu', null);
+ self.env.bus.off('will_hide_home_menu', null);
+ self.env.bus.off('will_show_home_menu', null);
+ }
+ // The components must be destroyed before the widget, because the
+ // widget might destroy the models before destroying the components,
+ // and the components might still rely on messaging (or other) record(s).
+ while (self.components.length > 0) {
+ const component = self.components.pop();
+ component.destroy();
+ }
+ if (self.widget) {
+ self.widget.destroy();
+ self.widget = undefined;
+ }
+ self.env = undefined;
+ self.unpatch();
+}
+
+/**
+ * Creates and returns a new root Component with the given props and mounts it
+ * on target.
+ * Assumes that self.env is set to the correct value.
+ * Components created this way are automatically registered for clean up after
+ * the test, which will happen when `afterEach` is called.
+ *
+ * @param {Object} self the current QUnit instance
+ * @param {Class} Component the component class to create
+ * @param {Object} param2
+ * @param {Object} [param2.props={}] forwarded to component constructor
+ * @param {DOM.Element} param2.target mount target for the component
+ * @returns {owl.Component} the new component instance
+ */
+async function createRootComponent(self, Component, { props = {}, target }) {
+ Component.env = self.env;
+ const component = new Component(null, props);
+ delete Component.env;
+ self.components.push(component);
+ await afterNextRender(() => component.mount(target));
+ return component;
+}
+
+/**
+ * Main function used to make a mocked environment with mocked messaging env.
+ *
+ * @param {Object} [param0={}]
+ * @param {string} [param0.arch] makes only sense when `param0.hasView` is set:
+ * the arch to use in createView.
+ * @param {Object} [param0.archs]
+ * @param {boolean} [param0.autoOpenDiscuss=false] makes only sense when
+ * `param0.hasDiscuss` is set: determine whether mounted discuss should be
+ * open initially.
+ * @param {boolean} [param0.debug=false]
+ * @param {Object} [param0.data] makes only sense when `param0.hasView` is set:
+ * the data to use in createView.
+ * @param {Object} [param0.discuss={}] makes only sense when `param0.hasDiscuss`
+ * is set: provide data that is passed to discuss widget (= client action) as
+ * 2nd positional argument.
+ * @param {Object} [param0.env={}]
+ * @param {function} [param0.mockFetch]
+ * @param {function} [param0.mockRPC]
+ * @param {boolean} [param0.hasActionManager=false] if set, use
+ * createActionManager.
+ * @param {boolean} [param0.hasChatWindow=false] if set, mount chat window
+ * service.
+ * @param {boolean} [param0.hasDiscuss=false] if set, mount discuss app.
+ * @param {boolean} [param0.hasMessagingMenu=false] if set, mount messaging
+ * menu.
+ * @param {boolean} [param0.hasTimeControl=false] if set, all flow of time
+ * with `env.browser.setTimeout` are fully controlled by test itself.
+ * @see addTimeControlToEnv that adds `advanceTime` function in
+ * `env.testUtils`.
+ * @param {boolean} [param0.hasView=false] if set, use createView to create a
+ * view instead of a generic widget.
+ * @param {Deferred|Promise} [param0.messagingBeforeCreationDeferred=Promise.resolve()]
+ * Deferred that let tests block messaging creation and simulate resolution.
+ * Useful for testing working components when messaging is not yet created.
+ * @param {string} [param0.model] makes only sense when `param0.hasView` is set:
+ * the model to use in createView.
+ * @param {integer} [param0.res_id] makes only sense when `param0.hasView` is set:
+ * the res_id to use in createView.
+ * @param {Object} [param0.services]
+ * @param {Object} [param0.session]
+ * @param {Object} [param0.View] makes only sense when `param0.hasView` is set:
+ * the View class to use in createView.
+ * @param {Object} [param0.viewOptions] makes only sense when `param0.hasView`
+ * is set: the view options to use in createView.
+ * @param {Object} [param0.waitUntilEvent]
+ * @param {String} [param0.waitUntilEvent.eventName]
+ * @param {String} [param0.waitUntilEvent.message]
+ * @param {function} [param0.waitUntilEvent.predicate]
+ * @param {integer} [param0.waitUntilEvent.timeoutDelay]
+ * @param {string} [param0.waitUntilMessagingCondition='initialized'] Determines
+ * the condition of messaging when this function is resolved.
+ * Supported values: ['none', 'created', 'initialized'].
+ * - 'none': the function resolves regardless of whether messaging is created.
+ * - 'created': the function resolves when messaging is created, but
+ * regardless of whether messaging is initialized.
+ * - 'initialized' (default): the function resolves when messaging is
+ * initialized.
+ * To guarantee messaging is not created, test should pass a pending deferred
+ * as param of `messagingBeforeCreationDeferred`. To make sure messaging is
+ * not initialized, test should mock RPC `mail/init_messaging` and block its
+ * resolution.
+ * @param {...Object} [param0.kwargs]
+ * @throws {Error} in case some provided parameters are wrong, such as
+ * `waitUntilMessagingCondition`.
+ * @returns {Object}
+ */
+async function start(param0 = {}) {
+ let callbacks = {
+ init: [],
+ mount: [],
+ destroy: [],
+ return: [],
+ };
+ const {
+ env: providedEnv,
+ hasActionManager = false,
+ hasChatWindow = false,
+ hasDialog = false,
+ hasDiscuss = false,
+ hasMessagingMenu = false,
+ hasTimeControl = false,
+ hasView = false,
+ messagingBeforeCreationDeferred = Promise.resolve(),
+ waitUntilEvent,
+ waitUntilMessagingCondition = 'initialized',
+ } = param0;
+ if (!['none', 'created', 'initialized'].includes(waitUntilMessagingCondition)) {
+ throw Error(`Unknown parameter value ${waitUntilMessagingCondition} for 'waitUntilMessaging'.`);
+ }
+ delete param0.env;
+ delete param0.hasActionManager;
+ delete param0.hasChatWindow;
+ delete param0.hasDiscuss;
+ delete param0.hasMessagingMenu;
+ delete param0.hasTimeControl;
+ delete param0.hasView;
+ if (hasChatWindow) {
+ callbacks = _useChatWindow(callbacks);
+ }
+ if (hasDialog) {
+ callbacks = _useDialog(callbacks);
+ }
+ if (hasDiscuss) {
+ callbacks = _useDiscuss(callbacks);
+ }
+ if (hasMessagingMenu) {
+ callbacks = _useMessagingMenu(callbacks);
+ }
+ const {
+ init: initCallbacks,
+ mount: mountCallbacks,
+ destroy: destroyCallbacks,
+ return: returnCallbacks,
+ } = callbacks;
+ const { debug = false } = param0;
+ initCallbacks.forEach(callback => callback(param0));
+
+ let env = Object.assign(providedEnv || {});
+ env.session = Object.assign(
+ {
+ is_bound: Promise.resolve(),
+ url: s => s,
+ },
+ env.session
+ );
+ env = addMessagingToEnv(env);
+ if (hasTimeControl) {
+ env = addTimeControlToEnv(env);
+ }
+
+ const services = Object.assign({}, {
+ bus_service: BusService.extend({
+ _beep() {}, // Do nothing
+ _poll() {}, // Do nothing
+ _registerWindowUnload() {}, // Do nothing
+ isOdooFocused() {
+ return true;
+ },
+ updateOption() {},
+ }),
+ chat_window: ChatWindowService.extend({
+ _getParentNode() {
+ return document.querySelector(debug ? 'body' : '#qunit-fixture');
+ },
+ _listenHomeMenu: () => {},
+ }),
+ dialog: DialogService.extend({
+ _getParentNode() {
+ return document.querySelector(debug ? 'body' : '#qunit-fixture');
+ },
+ _listenHomeMenu: () => {},
+ }),
+ local_storage: AbstractStorageService.extend({ storage: new RamStorage() }),
+ notification: NotificationService.extend(),
+ }, param0.services);
+
+ const kwargs = Object.assign({}, param0, {
+ archs: Object.assign({}, {
+ 'mail.message,false,search': '<search/>'
+ }, param0.archs),
+ debug: param0.debug || false,
+ services: Object.assign({}, services, param0.services),
+ }, { env });
+ let widget;
+ let mockServer; // only in basic mode
+ let testEnv;
+ const selector = debug ? 'body' : '#qunit-fixture';
+ if (hasView) {
+ widget = await createView(kwargs);
+ legacyPatch(widget, {
+ destroy() {
+ destroyCallbacks.forEach(callback => callback({ widget }));
+ this._super(...arguments);
+ legacyUnpatch(widget);
+ if (testEnv) {
+ testEnv.destroyMessaging();
+ }
+ }
+ });
+ } else if (hasActionManager) {
+ widget = await createActionManager(kwargs);
+ legacyPatch(widget, {
+ destroy() {
+ destroyCallbacks.forEach(callback => callback({ widget }));
+ this._super(...arguments);
+ legacyUnpatch(widget);
+ if (testEnv) {
+ testEnv.destroyMessaging();
+ }
+ }
+ });
+ } else {
+ const Parent = Widget.extend({ do_push_state() {} });
+ const parent = new Parent();
+ mockServer = await addMockEnvironment(parent, kwargs);
+ widget = new Widget(parent);
+ await widget.appendTo($(selector));
+ Object.assign(widget, {
+ destroy() {
+ delete widget.destroy;
+ destroyCallbacks.forEach(callback => callback({ widget }));
+ parent.destroy();
+ if (testEnv) {
+ testEnv.destroyMessaging();
+ }
+ },
+ });
+ }
+
+ testEnv = Component.env;
+
+ /**
+ * Components cannot use web.bus, because they cannot use
+ * EventDispatcherMixin, and webclient cannot easily access env.
+ * Communication between webclient and components by core.bus
+ * (usable by webclient) and messagingBus (usable by components), which
+ * the messaging service acts as mediator since it can easily use both
+ * kinds of buses.
+ */
+ testEnv.bus.on(
+ 'hide_home_menu',
+ null,
+ () => testEnv.messagingBus.trigger('hide_home_menu')
+ );
+ testEnv.bus.on(
+ 'show_home_menu',
+ null,
+ () => testEnv.messagingBus.trigger('show_home_menu')
+ );
+ testEnv.bus.on(
+ 'will_hide_home_menu',
+ null,
+ () => testEnv.messagingBus.trigger('will_hide_home_menu')
+ );
+ testEnv.bus.on(
+ 'will_show_home_menu',
+ null,
+ () => testEnv.messagingBus.trigger('will_show_home_menu')
+ );
+
+ /**
+ * Returns a promise resolved after the expected event is received.
+ *
+ * @param {Object} param0
+ * @param {string} param0.eventName event to wait
+ * @param {function} param0.func function which, when called, is expected to
+ * trigger the event
+ * @param {string} [param0.message] assertion message
+ * @param {function} [param0.predicate] predicate called with event data.
+ * If not provided, only the event name has to match.
+ * @param {number} [param0.timeoutDelay=5000] how long to wait at most in ms
+ * @returns {Promise}
+ */
+ const afterEvent = (async ({ eventName, func, message, predicate, timeoutDelay = 5000 }) => {
+ // Set up the timeout to reject if the event is not triggered.
+ let timeoutNoEvent;
+ const timeoutProm = new Promise((resolve, reject) => {
+ timeoutNoEvent = setTimeout(() => {
+ let error = message
+ ? new Error(message)
+ : new Error(`Timeout: the event ${eventName} was not triggered.`);
+ console.error(error);
+ reject(error);
+ }, timeoutDelay);
+ });
+ // Set up the promise to resolve if the event is triggered.
+ const eventProm = new Promise(resolve => {
+ testEnv.messagingBus.on(eventName, null, data => {
+ if (!predicate || predicate(data)) {
+ resolve();
+ }
+ });
+ });
+ // Start the function expected to trigger the event after the
+ // promise has been registered to not miss any potential event.
+ const funcRes = func();
+ // Make them race (first to resolve/reject wins).
+ await Promise.race([eventProm, timeoutProm]);
+ clearTimeout(timeoutNoEvent);
+ // If the event is triggered before the end of the async function,
+ // ensure the function finishes its job before returning.
+ await funcRes;
+ });
+
+ const result = {
+ afterEvent,
+ env: testEnv,
+ mockServer,
+ widget,
+ };
+
+ const start = async () => {
+ messagingBeforeCreationDeferred.then(async () => {
+ /**
+ * Some models require session data, like locale text direction
+ * (depends on fully loaded translation).
+ */
+ await env.session.is_bound;
+
+ testEnv.modelManager = new ModelManager(testEnv);
+ testEnv.modelManager.start();
+ /**
+ * Create the messaging singleton record.
+ */
+ testEnv.messaging = testEnv.models['mail.messaging'].create();
+ testEnv.messaging.start().then(() =>
+ testEnv.messagingInitializedDeferred.resolve()
+ );
+ testEnv.messagingCreatedPromise.resolve();
+ });
+ if (waitUntilMessagingCondition === 'created') {
+ await testEnv.messagingCreatedPromise;
+ }
+ if (waitUntilMessagingCondition === 'initialized') {
+ await testEnv.messagingInitializedDeferred;
+ }
+
+ if (mountCallbacks.length > 0) {
+ await afterNextRender(async () => {
+ await Promise.all(mountCallbacks.map(callback => callback({ selector, widget })));
+ });
+ }
+ returnCallbacks.forEach(callback => callback(result));
+ };
+ if (waitUntilEvent) {
+ await afterEvent(Object.assign({ func: start }, waitUntilEvent));
+ } else {
+ await start();
+ }
+ return result;
+}
+
+//------------------------------------------------------------------------------
+// Public: file utilities
+//------------------------------------------------------------------------------
+
+/**
+ * Drag some files over a DOM element
+ *
+ * @param {DOM.Element} el
+ * @param {Object[]} file must have been create beforehand
+ * @see testUtils.file.createFile
+ */
+function dragenterFiles(el, files) {
+ const ev = new Event('dragenter', { bubbles: true });
+ Object.defineProperty(ev, 'dataTransfer', {
+ value: _createFakeDataTransfer(files),
+ });
+ el.dispatchEvent(ev);
+}
+
+/**
+ * Drop some files on a DOM element
+ *
+ * @param {DOM.Element} el
+ * @param {Object[]} files must have been created beforehand
+ * @see testUtils.file.createFile
+ */
+function dropFiles(el, files) {
+ const ev = new Event('drop', { bubbles: true });
+ Object.defineProperty(ev, 'dataTransfer', {
+ value: _createFakeDataTransfer(files),
+ });
+ el.dispatchEvent(ev);
+}
+
+/**
+ * Paste some files on a DOM element
+ *
+ * @param {DOM.Element} el
+ * @param {Object[]} files must have been created beforehand
+ * @see testUtils.file.createFile
+ */
+function pasteFiles(el, files) {
+ const ev = new Event('paste', { bubbles: true });
+ Object.defineProperty(ev, 'clipboardData', {
+ value: _createFakeDataTransfer(files),
+ });
+ el.dispatchEvent(ev);
+}
+
+//------------------------------------------------------------------------------
+// Export
+//------------------------------------------------------------------------------
+
+return {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ dragenterFiles,
+ dropFiles,
+ nextAnimationFrame,
+ nextTick,
+ pasteFiles,
+ start,
+};
+
+});
diff --git a/addons/mail/static/src/utils/throttle/throttle.js b/addons/mail/static/src/utils/throttle/throttle.js
new file mode 100644
index 00000000..6b9ff008
--- /dev/null
+++ b/addons/mail/static/src/utils/throttle/throttle.js
@@ -0,0 +1,382 @@
+odoo.define('mail/static/src/utils/throttle/throttle.js', function (require) {
+'use strict';
+
+const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+
+/**
+ * This module define an utility function that enables throttling calls on a
+ * provided function. Such throttled calls can be canceled, flushed and/or
+ * cleared:
+ *
+ * - cancel: Canceling a throttle function call means that if a function call is
+ * pending invocation, cancel removes this pending call invocation. It however
+ * preserves the internal timer of the cooling down phase of this throttle
+ * function, meaning that any following throttle function call will be pending
+ * and has to wait for the remaining time of the cooling down phase before
+ * being invoked.
+ *
+ * - flush: Flushing a throttle function call means that if a function call is
+ * pending invocation, flush immediately terminates the cooling down phase and
+ * the pending function call is immediately invoked. Flush also works without
+ * any pending function call: it just terminates the cooling down phase, so
+ * that a following function call is guaranteed to be immediately called.
+ *
+ * - clear: Clearing a throttle function combines canceling and flushing
+ * together.
+ */
+
+//------------------------------------------------------------------------------
+// Errors
+//------------------------------------------------------------------------------
+
+/**
+ * List of internal and external Throttle errors.
+ * Internal errors are prefixed with `_`.
+ */
+
+ /**
+ * Error when throttle function has been canceled with `.cancel()`. Used to
+ * let the caller know of throttle function that the call has been canceled,
+ * which means the inner function will not be called. Usually caller should
+ * just accept it and kindly treat this error as a polite warning.
+ */
+class ThrottleCanceledError extends Error {
+ /**
+ * @override
+ */
+ constructor(throttleId, ...args) {
+ super(...args);
+ this.name = 'ThrottleCanceledError';
+ this.throttleId = throttleId;
+ }
+}
+/**
+ * Error when throttle function has been reinvoked again. Used to let know
+ * caller of throttle function that the call has been canceled and replaced with
+ * another one, which means the (potentially) following inner function will be
+ * in the context of another call. Same as for `ThrottleCanceledError`, usually
+ * caller should just accept it and kindly treat this error as a polite
+ * warning.
+ */
+class ThrottleReinvokedError extends Error {
+ /**
+ * @override
+ */
+ constructor(throttleId, ...args) {
+ super(...args);
+ this.name = 'ThrottleReinvokedError';
+ this.throttleId = throttleId;
+ }
+}
+/**
+ * Error when throttle function has been flushed with `.flush()`. Used
+ * internally to immediately invoke pending inner functions, since a flush means
+ * the termination of cooling down phase.
+ *
+ * @private
+ */
+class _ThrottleFlushedError extends Error {
+ /**
+ * @override
+ */
+ constructor(throttleId, ...args) {
+ super(...args);
+ this.name = '_ThrottleFlushedError';
+ this.throttleId = throttleId;
+ }
+}
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * This class models the behaviour of the cancelable, flushable and clearable
+ * throttle version of a provided function. See definitions at the top of this
+ * file.
+ */
+class Throttle {
+
+ /**
+ * @param {Object} env the OWL env
+ * @param {function} func provided function for making throttled version.
+ * @param {integer} duration duration of the 'cool down' phase, i.e.
+ * the minimum duration between the most recent function call that has
+ * been made and the following function call (of course, assuming no flush
+ * in-between).
+ */
+ constructor(env, func, duration) {
+ /**
+ * Reference to the OWL envirionment. Useful to fine-tune control of
+ * time flow in tests.
+ * @see mail/static/src/utils/test_utils.js:start.hasTimeControl
+ */
+ this.env = env;
+ /**
+ * Unique id of this throttle function. Useful for the ThrottleError
+ * management, in order to determine whether these errors come from
+ * this throttle or from another one (e.g. inner function makes use of
+ * another throttle).
+ */
+ this.id = _.uniqueId('throttle_');
+ /**
+ * Deferred of current cooling down phase in progress. Defined only when
+ * there is a cooling down phase in progress. Resolved when cooling down
+ * phase terminates from timeout, and rejected if flushed.
+ *
+ * @see _ThrottleFlushedError for rejection of this deferred.
+ */
+ this._coolingDownDeferred = undefined;
+ /**
+ * Duration, in milliseconds, of the cool down phase.
+ */
+ this._duration = duration;
+ /**
+ * Inner function to be invoked and throttled.
+ */
+ this._function = func;
+ /**
+ * Determines whether the throttle function is currently in cool down
+ * phase. Cool down phase happens just after inner function has been
+ * invoked, and during this time any following function call are pending
+ * and will be invoked only after the end of the cool down phase (except
+ * if canceled).
+ */
+ this._isCoolingDown = false;
+ /**
+ * Deferred of a currently pending invocation to inner function. Defined
+ * only during a cooling down phase and just after when throttle
+ * function has been called during this cooling down phase. It is kept
+ * until cooling down phase ends (either from timeout or flushed
+ * throttle) or until throttle is canceled (i.e. removes pending invoke
+ * while keeping cooling down phase live on).
+ */
+ this._pendingInvokeDeferred = undefined;
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Cancel any buffered function call while keeping the cooldown phase
+ * running.
+ */
+ cancel() {
+ if (!this._isCoolingDown) {
+ return;
+ }
+ if (!this._pendingInvokeDeferred) {
+ return;
+ }
+ this._pendingInvokeDeferred.reject(new ThrottleCanceledError(this.id));
+ }
+
+ /**
+ * Clear any buffered function call and immediately terminates any cooling
+ * down phase in progress.
+ */
+ clear() {
+ this.cancel();
+ this.flush();
+ }
+
+ /**
+ * Called when there is a call to the function. This function is throttled,
+ * so the time it is called depends on whether the "cooldown stage" occurs
+ * or not:
+ *
+ * - no cooldown stage: function is called immediately, and it starts
+ * the cooldown stage when successful.
+ * - in cooldown stage: function is called when the cooldown stage has
+ * ended from timeout.
+ *
+ * Note that after the cooldown stage, only the last attempted function
+ * call will be considered.
+ *
+ * @param {...any} args
+ * @throws {ThrottleReinvokedError|ThrottleCanceledError}
+ * @returns {any} result of called function, if it's called.
+ */
+ async do(...args) {
+ if (!this._isCoolingDown) {
+ return this._invokeFunction(...args);
+ }
+ if (this._pendingInvokeDeferred) {
+ this._pendingInvokeDeferred.reject(new ThrottleReinvokedError(this.id));
+ }
+ try {
+ this._pendingInvokeDeferred = makeDeferred();
+ await Promise.race([this._coolingDownDeferred, this._pendingInvokeDeferred]);
+ } catch (error) {
+ if (
+ !(error instanceof _ThrottleFlushedError) ||
+ error.throttleId !== this.id
+ ) {
+ throw error;
+ }
+ } finally {
+ this._pendingInvokeDeferred = undefined;
+ }
+ return this._invokeFunction(...args);
+ }
+
+ /**
+ * Flush the internal throttle timer, so that the following function call
+ * is immediate. For instance, if there is a cooldown stage, it is aborted.
+ */
+ flush() {
+ if (!this._isCoolingDown) {
+ return;
+ }
+ const coolingDownDeferred = this._coolingDownDeferred;
+ this._coolingDownDeferred = undefined;
+ this._isCoolingDown = false;
+ coolingDownDeferred.reject(new _ThrottleFlushedError(this.id));
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Invoke the inner function of this throttle and starts cooling down phase
+ * immediately after.
+ *
+ * @private
+ * @param {...any} args
+ */
+ _invokeFunction(...args) {
+ const res = this._function(...args);
+ this._startCoolingDown();
+ return res;
+ }
+
+ /**
+ * Called just when the inner function is being called. Starts the cooling
+ * down phase, which turn any call to this throttle function as pending
+ * inner function calls. This will be called after the end of cooling down
+ * phase (except if canceled).
+ */
+ async _startCoolingDown() {
+ if (this._coolingDownDeferred) {
+ throw new Error("Cannot start cooling down if there's already a cooling down in progress.");
+ }
+ // Keep local reference of cooling down deferred, because the one stored
+ // on `this` could be overwritten by another call to this throttle.
+ const coolingDownDeferred = makeDeferred();
+ this._coolingDownDeferred = coolingDownDeferred;
+ this._isCoolingDown = true;
+ const cooldownTimeoutId = this.env.browser.setTimeout(
+ () => coolingDownDeferred.resolve(),
+ this._duration
+ );
+ let unexpectedError;
+ try {
+ await coolingDownDeferred;
+ } catch (error) {
+ if (
+ !(error instanceof _ThrottleFlushedError) ||
+ error.throttleId !== this.id
+ ) {
+ // This branching should never happen.
+ // Still defined in case of programming error.
+ unexpectedError = error;
+ }
+ } finally {
+ this.env.browser.clearTimeout(cooldownTimeoutId);
+ this._coolingDownDeferred = undefined;
+ this._isCoolingDown = false;
+ }
+ if (unexpectedError) {
+ throw unexpectedError;
+ }
+ }
+
+}
+
+//------------------------------------------------------------------------------
+// Public
+//------------------------------------------------------------------------------
+
+/**
+ * A function that creates a cancelable, flushable and clearable throttle
+ * version of a provided function. See definitions at the top of this file.
+ *
+ * This throttle mechanism allows calling a function at most once during a
+ * certain period:
+ *
+ * - When a function call is made, it enters a 'cooldown' phase, in which any
+ * attempt to call the function is buffered until the cooldown phase ends.
+ * - At most 1 function call can be buffered during the cooldown phase, and the
+ * latest one in this phase will be considered at its end.
+ * - When a cooldown phase ends, any buffered function call will be performed
+ * and another cooldown phase will follow up.
+ *
+ * @param {Object} env the OWL env
+ * @param {function} func the function to throttle.
+ * @param {integer} duration duration, in milliseconds, of the cooling down
+ * phase of the throttling.
+ * @param {Object} [param2={}]
+ * @param {boolean} [param2.silentCancelationErrors=true] if unset, caller
+ * of throttle function will observe some errors that come from current
+ * throttle call that has been canceled, such as when throttle function has
+ * been explicitly canceled with `.cancel()` or when another new throttle call
+ * has been registered.
+ * @see ThrottleCanceledError for when a call has been canceled from explicit
+ * call.
+ * @see ThrottleReinvokedError for when a call has been canceled from another
+ * new throttle call has been registered.
+ * @returns {function} the cancelable, flushable and clearable throttle version
+ * of the provided function.
+ */
+function throttle(
+ env,
+ func,
+ duration,
+ { silentCancelationErrors = true } = {}
+) {
+ const throttleObj = new Throttle(env, func, duration);
+ const callable = async (...args) => {
+ try {
+ // await is important, otherwise errors are not intercepted.
+ return await throttleObj.do(...args);
+ } catch (error) {
+ const isSelfReinvokedError = (
+ error instanceof ThrottleReinvokedError &&
+ error.throttleId === throttleObj.id
+ );
+ const isSelfCanceledError = (
+ error instanceof ThrottleCanceledError &&
+ error.throttleId === throttleObj.id
+ );
+
+ if (silentCancelationErrors && (isSelfReinvokedError || isSelfCanceledError)) {
+ // Silently ignore cancelation errors.
+ // Promise is indefinitely pending for async functions.
+ return new Promise(() => {});
+ } else {
+ throw error;
+ }
+ }
+ };
+ Object.assign(callable, {
+ cancel: () => throttleObj.cancel(),
+ clear: () => throttleObj.clear(),
+ flush: () => throttleObj.flush(),
+ });
+ return callable;
+}
+
+/**
+ * Make external throttle errors accessible from throttle function.
+ */
+Object.assign(throttle, {
+ ThrottleReinvokedError,
+ ThrottleCanceledError,
+});
+
+
+return throttle;
+
+});
diff --git a/addons/mail/static/src/utils/throttle/throttle_tests.js b/addons/mail/static/src/utils/throttle/throttle_tests.js
new file mode 100644
index 00000000..d3e6ad66
--- /dev/null
+++ b/addons/mail/static/src/utils/throttle/throttle_tests.js
@@ -0,0 +1,407 @@
+odoo.define('mail/static/src/utils/throttle/throttle_tests.js', function (require) {
+'use strict';
+
+const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js');
+const throttle = require('mail/static/src/utils/throttle/throttle.js');
+const { nextTick } = require('mail/static/src/utils/utils.js');
+
+const { ThrottleReinvokedError, ThrottleCanceledError } = throttle;
+
+QUnit.module('mail', {}, function () {
+QUnit.module('utils', {}, function () {
+QUnit.module('throttle', {}, function () {
+QUnit.module('throttle_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+ this.throttles = [];
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ // Important: tests should cleanly intercept cancelation errors that
+ // may result from this teardown.
+ for (const t of this.throttles) {
+ t.clear();
+ }
+ afterEach(this);
+ },
+});
+
+QUnit.test('single call', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasInvokedFunc = false;
+ const throttledFunc = throttle(
+ this.env,
+ () => {
+ hasInvokedFunc = true;
+ return 'func_result';
+ },
+ 0
+ );
+ this.throttles.push(throttledFunc);
+
+ assert.notOk(
+ hasInvokedFunc,
+ "func should not have been invoked on immediate throttle initialization"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasInvokedFunc,
+ "func should not have been invoked from throttle initialization after 0ms"
+ );
+
+ throttledFunc().then(res => {
+ assert.step('throttle_observed_invoke');
+ assert.strictEqual(
+ res,
+ 'func_result',
+ "throttle call return should forward result of inner func"
+ );
+ });
+ await nextTick();
+ assert.ok(
+ hasInvokedFunc,
+ "func should have been immediately invoked on first throttle call"
+ );
+ assert.verifySteps(
+ ['throttle_observed_invoke'],
+ "throttle should have observed invoked on first throttle call"
+ );
+});
+
+QUnit.test('2nd (throttled) call', async function (assert) {
+ assert.expect(8);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let funcCalledAmount = 0;
+ const throttledFunc = throttle(
+ this.env,
+ () => {
+ funcCalledAmount++;
+ return `func_result_${funcCalledAmount}`;
+ },
+ 1000
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(result => {
+ assert.step('throttle_observed_invoke_1');
+ assert.strictEqual(
+ result,
+ 'func_result_1',
+ "throttle call return should forward result of inner func 1"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc().then(res => {
+ assert.step('throttle_observed_invoke_2');
+ assert.strictEqual(
+ res,
+ 'func_result_2',
+ "throttle call return should forward result of inner func 2"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.verifySteps(
+ ['throttle_observed_invoke_2'],
+ "inner function of throttle should not have been invoked after 1s of 2nd call (throttled with 1s internal clock)"
+ );
+});
+
+QUnit.test('throttled call reinvocation', async function (assert) {
+ assert.expect(11);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let funcCalledAmount = 0;
+ const throttledFunc = throttle(
+ this.env,
+ () => {
+ funcCalledAmount++;
+ return `func_result_${funcCalledAmount}`;
+ },
+ 1000,
+ { silentCancelationErrors: false }
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(result => {
+ assert.step('throttle_observed_invoke_1');
+ assert.strictEqual(
+ result,
+ 'func_result_1',
+ "throttle call return should forward result of inner func 1"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc()
+ .then(() => {
+ throw new Error("2nd throttle call should not be resolved (should have been canceled by reinvocation)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof ThrottleReinvokedError,
+ "Should generate a Throttle reinvoked error (from another throttle function call)"
+ );
+ assert.step('throttle_reinvoked_1');
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc()
+ .then(result => {
+ assert.step('throttle_observed_invoke_2');
+ assert.strictEqual(
+ result,
+ 'func_result_2',
+ "throttle call return should forward result of inner func 2"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_reinvoked_1'],
+ "2nd throttle call should have been canceled from 3rd throttle call (reinvoked before cooling down phase has ended)"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.verifySteps(
+ ['throttle_observed_invoke_2'],
+ "inner function of throttle should have been invoked after 1s of 1st call (throttled with 1s internal clock, 3rd throttle call re-use timer of 2nd throttle call)"
+ );
+});
+
+QUnit.test('flush throttled call', async function (assert) {
+ assert.expect(9);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ const throttledFunc = throttle(
+ this.env,
+ () => {},
+ 1000,
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_1'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_2'));
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(10);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 10ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc.flush();
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_2'],
+ "inner function of throttle should have been invoked from 2nd call after flush"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_3'));
+ await nextTick();
+ await this.env.testUtils.advanceTime(999);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.verifySteps(
+ ['throttle_observed_invoke_3'],
+ "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)"
+ );
+});
+
+QUnit.test('cancel throttled call', async function (assert) {
+ assert.expect(10);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ const throttledFunc = throttle(
+ this.env,
+ () => {},
+ 1000,
+ { silentCancelationErrors: false }
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_1'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc()
+ .then(() => {
+ throw new Error("2nd throttle call should not be resolved (should have been canceled)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof ThrottleCanceledError,
+ "Should generate a Throttle canceled error (from `.cancel()`)"
+ );
+ assert.step('throttle_canceled');
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(500);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc.cancel();
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_canceled'],
+ "2nd throttle function call should have been canceled"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_3'));
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "3rd throttle function call should not have invoked inner function yet (cancel reuses inner clock of throttle)"
+ );
+
+ await this.env.testUtils.advanceTime(500);
+ assert.verifySteps(
+ ['throttle_observed_invoke_3'],
+ "3rd throttle function call should have invoke inner function after 500ms (cancel reuses inner clock of throttle which was at 500ms in, throttle set at 1ms)"
+ );
+});
+
+QUnit.test('clear throttled call', async function (assert) {
+ assert.expect(9);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ const throttledFunc = throttle(
+ this.env,
+ () => {},
+ 1000,
+ { silentCancelationErrors: false }
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_1'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc()
+ .then(() => {
+ throw new Error("2nd throttle call should not be resolved (should have been canceled from clear)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof ThrottleCanceledError,
+ "Should generate a Throttle canceled error (from `.clear()`)"
+ );
+ assert.step('throttle_canceled');
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(500);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc.clear();
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_canceled'],
+ "2nd throttle function call should have been canceled (from `.clear()`)"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_3'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_3'],
+ "3rd throttle function call should have invoke inner function immediately (`.clear()` flushes throttle)"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/utils/timer/timer.js b/addons/mail/static/src/utils/timer/timer.js
new file mode 100644
index 00000000..56d7f58e
--- /dev/null
+++ b/addons/mail/static/src/utils/timer/timer.js
@@ -0,0 +1,165 @@
+odoo.define('mail/static/src/utils/timer/timer.js', function (require) {
+'use strict';
+
+const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+
+//------------------------------------------------------------------------------
+// Errors
+//------------------------------------------------------------------------------
+
+/**
+ * List of Timer errors.
+ */
+
+ /**
+ * Error when timer has been cleared with `.clear()` or `.reset()`. Used to
+ * let know caller of timer that the countdown has been aborted, which
+ * means the inner function will not be called. Usually caller should just
+ * accept it and kindly treated this error as a polite warning.
+ */
+ class TimerClearedError extends Error {
+ /**
+ * @override
+ */
+ constructor(timerId, ...args) {
+ super(...args);
+ this.name = 'TimerClearedError';
+ this.timerId = timerId;
+ }
+}
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * This class creates a timer which, when times out, calls a function.
+ * Note that the timer is not started on initialization (@see start method).
+ */
+class Timer {
+
+ /**
+ * @param {Object} env the OWL env
+ * @param {function} onTimeout
+ * @param {integer} duration
+ * @param {Object} [param3={}]
+ * @param {boolean} [param3.silentCancelationErrors=true] if unset, caller
+ * of timer will observe some errors that come from current timer calls
+ * that has been cleared with `.clear()` or `.reset()`.
+ * @see TimerClearedError for when timer has been aborted from `.clear()`
+ * or `.reset()`.
+ */
+ constructor(env, onTimeout, duration, { silentCancelationErrors = true } = {}) {
+ this.env = env;
+ /**
+ * Determine whether the timer has a pending timeout.
+ */
+ this.isRunning = false;
+ /**
+ * Duration, in milliseconds, until timer times out and calls the
+ * timeout function.
+ */
+ this._duration = duration;
+ /**
+ * Determine whether the caller of timer `.start()` and `.reset()`
+ * should observe cancelation errors from `.clear()` or `.reset()`.
+ */
+ this._hasSilentCancelationErrors = silentCancelationErrors;
+ /**
+ * The function that is called when the timer times out.
+ */
+ this._onTimeout = onTimeout;
+ /**
+ * Deferred of a currently pending invocation to inner function on
+ * timeout.
+ */
+ this._timeoutDeferred = undefined;
+ /**
+ * Internal reference of `setTimeout()` that is used to invoke function
+ * when timer times out. Useful to clear it when timer is cleared/reset.
+ */
+ this._timeoutId = undefined;
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Clear the timer, which basically sets the state of timer as if it was
+ * just instantiated, without being started. This function makes sense only
+ * when this timer is running.
+ */
+ clear() {
+ this.env.browser.clearTimeout(this._timeoutId);
+ this.isRunning = false;
+ if (!this._timeoutDeferred) {
+ return;
+ }
+ this._timeoutDeferred.reject(new TimerClearedError(this.id));
+ }
+
+ /**
+ * Reset the timer, i.e. the pending timeout is refreshed with initial
+ * duration. This function makes sense only when this timer is running.
+ */
+ async reset() {
+ this.clear();
+ await this.start();
+ }
+
+ /**
+ * Starts the timer, i.e. after a certain duration, it times out and calls
+ * a function back. This function makes sense only when this timer is not
+ * yet running.
+ *
+ * @throws {Error} in case the timer is already running.
+ */
+ async start() {
+ if (this.isRunning) {
+ throw new Error("Cannot start a timer that is currently running.");
+ }
+ this.isRunning = true;
+ const timeoutDeferred = makeDeferred();
+ this._timeoutDeferred = timeoutDeferred;
+ const timeoutId = this.env.browser.setTimeout(
+ () => {
+ this.isRunning = false;
+ timeoutDeferred.resolve(this._onTimeout());
+ },
+ this._duration
+ );
+ this._timeoutId = timeoutId;
+ let result;
+ try {
+ result = await timeoutDeferred;
+ } catch (error) {
+ if (
+ !this._hasSilentCancelationErrors ||
+ !(error instanceof TimerClearedError) ||
+ error.timerId !== this.id
+ ) {
+ // This branching should never happens.
+ // Still defined in case of programming error.
+ throw error;
+ }
+ } finally {
+ this.env.browser.clearTimeout(timeoutId);
+ this._timeoutDeferred = undefined;
+ this.isRunning = false;
+ }
+ return result;
+ }
+
+}
+
+/**
+ * Make external timer errors accessible from timer class.
+ */
+Object.assign(Timer, {
+ TimerClearedError,
+});
+
+return Timer;
+
+});
diff --git a/addons/mail/static/src/utils/timer/timer_tests.js b/addons/mail/static/src/utils/timer/timer_tests.js
new file mode 100644
index 00000000..e2d33e91
--- /dev/null
+++ b/addons/mail/static/src/utils/timer/timer_tests.js
@@ -0,0 +1,427 @@
+odoo.define('mail/static/src/utils/timer/timer_tests.js', function (require) {
+'use strict';
+
+const { afterEach, beforeEach, nextTick, start } = require('mail/static/src/utils/test_utils.js');
+const Timer = require('mail/static/src/utils/timer/timer.js');
+
+const { TimerClearedError } = Timer;
+
+QUnit.module('mail', {}, function () {
+QUnit.module('utils', {}, function () {
+QUnit.module('timer', {}, function () {
+QUnit.module('timer_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+ this.timers = [];
+
+ this.start = async (params) => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ // Important: tests should cleanly intercept cancelation errors that
+ // may result from this teardown.
+ for (const timer of this.timers) {
+ timer.clear();
+ }
+ afterEach(this);
+ },
+});
+
+QUnit.test('timer does not timeout on initialization', async function (assert) {
+ assert.expect(3);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0
+ )
+ );
+
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out on immediate initialization"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out from initialization after 0ms"
+ );
+
+ await this.env.testUtils.advanceTime(1000 * 1000);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out from initialization after 1000s"
+ );
+});
+
+QUnit.test('timer start (duration: 0ms)', async function (assert) {
+ assert.expect(2);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.ok(
+ hasTimedOut,
+ "timer should have timed out on start after 0ms"
+ );
+});
+
+QUnit.test('timer start observe termination (duration: 0ms)', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => {
+ hasTimedOut = true;
+ return 'timeout_result';
+ },
+ 0
+ )
+ );
+
+ this.timers[0].start()
+ .then(result => {
+ assert.strictEqual(
+ result,
+ 'timeout_result',
+ "value returned by start should be value returned by function on timeout"
+ );
+ assert.step('timeout');
+ });
+ await nextTick();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+ assert.verifySteps(
+ [],
+ "timer.start() should not have yet observed timeout"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.ok(
+ hasTimedOut,
+ "timer should have timed out on start after 0ms"
+ );
+ assert.verifySteps(
+ ['timeout'],
+ "timer.start() should have observed timeout after 0ms"
+ );
+});
+
+QUnit.test('timer start (duration: 1000s)', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 1000 * 1000
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out on start after 0ms"
+ );
+
+ await this.env.testUtils.advanceTime(1000);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out on start after 1000ms"
+ );
+
+ await this.env.testUtils.advanceTime(998 * 1000 + 999);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out on start after 9999ms"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.ok(
+ hasTimedOut,
+ "timer should have timed out on start after 10s"
+ );
+});
+
+QUnit.test('[no cancelation intercept] timer start then immediate clear (duration: 0ms)', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ this.timers[0].clear();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start and clear"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 0ms of clear"
+ );
+
+ await this.env.testUtils.advanceTime(1000);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 1s of clear"
+ );
+});
+
+QUnit.test('[no cancelation intercept] timer start then clear before timeout (duration: 1000ms)', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 1000
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after 999ms of start"
+ );
+
+ this.timers[0].clear();
+ await this.env.testUtils.advanceTime(1);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 1ms of clear that happens 999ms after start (globally 1s await)"
+ );
+
+ await this.env.testUtils.advanceTime(1000);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 1001ms after clear (timer fully cleared)"
+ );
+});
+
+QUnit.test('[no cancelation intercept] timer start then reset before timeout (duration: 1000ms)', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 1000
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 999ms of start"
+ );
+
+ this.timers[0].reset();
+ await this.env.testUtils.advanceTime(1);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 1ms of reset which happens 999ms after start"
+ );
+
+ await this.env.testUtils.advanceTime(998);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 999ms of reset"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.ok(
+ hasTimedOut,
+ "timer should not have timed out after 1s of reset"
+ );
+});
+
+QUnit.test('[with cancelation intercept] timer start then immediate clear (duration: 0ms)', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0,
+ { silentCancelationErrors: false }
+ )
+ );
+
+ this.timers[0].start()
+ .then(() => {
+ throw new Error("timer.start() should not be resolved (should have been canceled by clear)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof TimerClearedError,
+ "Should generate a Timer cleared error (from `.clear()`)"
+ );
+ assert.step('timer_cleared');
+ });
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+ await nextTick();
+ assert.verifySteps([], "should not have observed cleared timer (timer not yet cleared)");
+
+ this.timers[0].clear();
+ await nextTick();
+ assert.verifySteps(
+ ['timer_cleared'],
+ "timer.start() should have observed it has been cleared"
+ );
+});
+
+QUnit.test('[with cancelation intercept] timer start then immediate reset (duration: 0ms)', async function (assert) {
+ assert.expect(9);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0,
+ { silentCancelationErrors: false }
+ )
+ );
+
+ this.timers[0].start()
+ .then(() => {
+ throw new Error("timer.start() should not observe a timeout");
+ })
+ .catch(error => {
+ assert.ok(error instanceof TimerClearedError, "Should generate a Timer cleared error (from `.reset()`)");
+ assert.step('timer_cleared');
+ });
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+ await nextTick();
+ assert.verifySteps([], "should not have observed cleared timer (timer not yet cleared)");
+
+ this.timers[0].reset()
+ .then(() => assert.step('timer_reset_timeout'));
+ await nextTick();
+ assert.verifySteps(
+ ['timer_cleared'],
+ "timer.start() should have observed it has been cleared"
+ );
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after reset"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.ok(
+ hasTimedOut,
+ "timer should have timed out after reset timeout"
+ );
+ assert.verifySteps(
+ ['timer_reset_timeout'],
+ "timer.reset() should have observed it has timed out"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/utils/utils.js b/addons/mail/static/src/utils/utils.js
new file mode 100644
index 00000000..2cfaa531
--- /dev/null
+++ b/addons/mail/static/src/utils/utils.js
@@ -0,0 +1,193 @@
+odoo.define('mail/static/src/utils/utils.js', function (require) {
+'use strict';
+
+const { delay } = require('web.concurrency');
+const {
+ patch: webUtilsPatch,
+ unaccent,
+ unpatch: webUtilsUnpatch,
+} = require('web.utils');
+
+//------------------------------------------------------------------------------
+// Public
+//------------------------------------------------------------------------------
+
+const classPatchMap = new WeakMap();
+const eventHandledWeakMap = new WeakMap();
+
+/**
+ * Returns the given string after cleaning it. The goal of the clean is to give
+ * more convenient results when comparing it to potential search results, on
+ * which the clean should also be called before comparing them.
+ *
+ * @param {string} searchTerm
+ * @returns {string}
+ */
+function cleanSearchTerm(searchTerm) {
+ return unaccent(searchTerm.toLowerCase());
+}
+
+/**
+ * Executes the provided functions in order, but with a potential delay between
+ * them if they take too much time. This is done in order to avoid blocking the
+ * main thread for too long.
+ *
+ * @param {function[]} functions
+ * @param {integer} [maxTimeFrame=100] time (in ms) until a delay is introduced
+ */
+async function executeGracefully(functions, maxTimeFrame = 100) {
+ let startDate = new Date();
+ for (const func of functions) {
+ if (new Date() - startDate > maxTimeFrame) {
+ await new Promise(resolve => setTimeout(resolve));
+ startDate = new Date();
+ }
+ await func();
+ }
+}
+
+/**
+ * Returns whether the given event has been handled with the given markName.
+ *
+ * @param {Event} ev
+ * @param {string} markName
+ * @returns {boolean}
+ */
+function isEventHandled(ev, markName) {
+ if (!eventHandledWeakMap.get(ev)) {
+ return false;
+ }
+ return eventHandledWeakMap.get(ev).includes(markName);
+}
+
+/**
+ * Marks the given event as handled by the given markName. Useful to allow
+ * handlers in the propagation chain to make a decision based on what has
+ * already been done.
+ *
+ * @param {Event} ev
+ * @param {string} markName
+ */
+function markEventHandled(ev, markName) {
+ if (!eventHandledWeakMap.get(ev)) {
+ eventHandledWeakMap.set(ev, []);
+ }
+ eventHandledWeakMap.get(ev).push(markName);
+}
+
+/**
+ * Wait a task tick, so that anything in micro-task queue that can be processed
+ * is processed.
+ */
+async function nextTick() {
+ await delay(0);
+}
+
+/**
+ * Inspired by web.utils:patch utility function
+ *
+ * @param {Class} Class
+ * @param {string} patchName
+ * @param {Object} patch
+ * @returns {function} unpatch function
+ */
+function patchClassMethods(Class, patchName, patch) {
+ let metadata = classPatchMap.get(Class);
+ if (!metadata) {
+ metadata = {
+ origMethods: {},
+ patches: {},
+ current: []
+ };
+ classPatchMap.set(Class, metadata);
+ }
+ if (metadata.patches[patchName]) {
+ throw new Error(`Patch [${patchName}] already exists`);
+ }
+ metadata.patches[patchName] = patch;
+ applyPatch(Class, patch);
+ metadata.current.push(patchName);
+
+ function applyPatch(Class, patch) {
+ Object.keys(patch).forEach(function (methodName) {
+ const method = patch[methodName];
+ if (typeof method === "function") {
+ const original = Class[methodName];
+ if (!(methodName in metadata.origMethods)) {
+ metadata.origMethods[methodName] = original;
+ }
+ Class[methodName] = function (...args) {
+ const previousSuper = this._super;
+ this._super = original;
+ const res = method.call(this, ...args);
+ this._super = previousSuper;
+ return res;
+ };
+ }
+ });
+ }
+
+ return () => unpatchClassMethods.bind(Class, patchName);
+}
+
+/**
+ * @param {Class} Class
+ * @param {string} patchName
+ * @param {Object} patch
+ * @returns {function} unpatch function
+ */
+function patchInstanceMethods(Class, patchName, patch) {
+ return webUtilsPatch(Class, patchName, patch);
+}
+
+/**
+ * Inspired by web.utils:unpatch utility function
+ *
+ * @param {Class} Class
+ * @param {string} patchName
+ */
+function unpatchClassMethods(Class, patchName) {
+ let metadata = classPatchMap.get(Class);
+ if (!metadata) {
+ return;
+ }
+ classPatchMap.delete(Class);
+
+ // reset to original
+ for (let k in metadata.origMethods) {
+ Class[k] = metadata.origMethods[k];
+ }
+
+ // apply other patches
+ for (let name of metadata.current) {
+ if (name !== patchName) {
+ patchClassMethods(Class, name, metadata.patches[name]);
+ }
+ }
+}
+
+/**
+ * @param {Class} Class
+ * @param {string} patchName
+ */
+function unpatchInstanceMethods(Class, patchName) {
+ return webUtilsUnpatch(Class, patchName);
+}
+
+//------------------------------------------------------------------------------
+// Export
+//------------------------------------------------------------------------------
+
+return {
+ cleanSearchTerm,
+ executeGracefully,
+ isEventHandled,
+ markEventHandled,
+ nextTick,
+ patchClassMethods,
+ patchInstanceMethods,
+ unpatchClassMethods,
+ unpatchInstanceMethods,
+};
+
+});