diff --git a/addons/web/static/src/core/browser/browser.js b/addons/web/static/src/core/browser/browser.js index 6eee2a9ca5d..a3aac8df47f 100644 --- a/addons/web/static/src/core/browser/browser.js +++ b/addons/web/static/src/core/browser/browser.js @@ -25,6 +25,7 @@ export const browser = Object.assign({}, owl.browser, { removeEventListener: window.removeEventListener.bind(window), console: window.console, location: window.location, + history: window.history, navigator: navigator, open: window.open.bind(window), XMLHttpRequest: window.XMLHttpRequest, diff --git a/addons/web/static/src/core/browser/router_service.js b/addons/web/static/src/core/browser/router_service.js index 987047adfb1..9b722e9d839 100644 --- a/addons/web/static/src/core/browser/router_service.js +++ b/addons/web/static/src/core/browser/router_service.js @@ -5,6 +5,11 @@ import { shallowEqual } from "../utils/objects"; import { objectToUrlEncodedString } from "../utils/urls"; import { browser } from "./browser"; +/** + * @typedef {{ [key: string]: string }} Query + * @typedef {{ [key: string]: any }} Route + */ + function parseString(str) { const parts = str.split("&"); const result = {}; @@ -16,139 +21,161 @@ function parseString(str) { return result; } +/** + * For each push request (replaceState or pushState), filterout keys that have been locked before + * overrides locked keys that are explicitly re-locked or unlocked + * registers keys in "hash" in "lockedKeys" according to the "lock" Boolean + * + * @param {Set} lockedKeys A set containing all keys that were locked + * @param {Query} hash An Object representing the pushed url hash + * @param {Query} currentHash The current hash compare against + * @param {Object} [options={}] Whether to lock all hash keys in "hash" to prevent them from being changed afterwards + * @param {Boolean} [options.lock] Whether to lock all hash keys in "hash" to prevent them from being changed afterwards + * @return {Query} The resulting "hash" where previous locking has been applied + */ +function applyLocking(lockedKeys, hash, currentHash, options = {}) { + const newHash = {}; + for (const key in hash) { + if ("lock" in options) { + options.lock ? lockedKeys.add(key) : lockedKeys.delete(key); + } else if (lockedKeys.has(key)) { + // forbid implicit override of key + continue; + } + newHash[key] = hash[key]; + } + for (const key in currentHash) { + if (lockedKeys.has(key) && !(key in newHash)) { + newHash[key] = currentHash[key]; + } + } + return newHash; +} + +function computeNewRoute(hash, replace, currentRoute) { + if (!replace) { + hash = Object.assign({}, currentRoute.hash, hash); + } + hash = sanitizeHash(hash); + if (!shallowEqual(currentRoute.hash, hash)) { + return Object.assign({}, currentRoute, { hash }); + } + return false; +} + function sanitizeHash(hash) { return Object.fromEntries(Object.entries(hash).filter(([, v]) => v !== undefined)); } +/** + * @param {string} hash + * @returns {any} + */ export function parseHash(hash) { - return hash === "#" || hash === "" ? {} : parseString(hash.slice(1)); + return hash && hash !== "#" ? parseString(hash.slice(1)) : {}; } +/** + * @param {string} search + * @returns {any} + */ export function parseSearchQuery(search) { - return search === "" ? {} : parseString(search.slice(1)); + return search ? parseString(search.slice(1)) : {}; } +/** + * @param {{ [key: string]: any }} route + * @returns + */ export function routeToUrl(route) { const search = objectToUrlEncodedString(route.search); const hash = objectToUrlEncodedString(route.hash); return route.pathname + (search ? "?" + search : "") + (hash ? "#" + hash : ""); } -export function redirect(env, url, wait) { - const load = () => browser.location.assign(url); +async function redirect(env, url, wait = false) { if (wait) { - const wait_server = function () { - env.services - .rpc("/web/webclient/version_info", {}) - .then(load) - .catch(() => browser.setTimeout(wait_server, 250)); - }; - browser.setTimeout(wait_server, 1000); - } else { - load(); + await new Promise((resolve) => { + const waitForServer = (delay) => { + browser.setTimeout(async () => { + env.services + .rpc("/web/webclient/version_info", {}) + .then(resolve) + .catch(() => waitForServer(250)); + }, delay); + }; + waitForServer(1000); + }); } + browser.location.assign(url); } function getRoute() { - const { pathname, search, hash } = window.location; + const { pathname, search, hash } = browser.location; const searchQuery = parseSearchQuery(search); const hashQuery = parseHash(hash); return { pathname, search: searchQuery, hash: hashQuery }; } -/** - * @param {function} getCurrent function that returns the current route - * @returns {function} function to compute the next hash - */ -export function makePreProcessQuery(getCurrent) { - const lockedKeys = new Set(); - return (hash) => { - const newHash = {}; - Object.keys(hash).forEach((key) => { - if (lockedKeys.has(key)) { - return; - } - const k = key.split(" "); - let value; - if (k.length === 2) { - value = hash[key]; - key = k[1]; - if (k[0] === "lock") { - lockedKeys.add(key); - } else if (k[0] === "unlock") { - lockedKeys.delete(key); - } else { - return; - } - } - newHash[key] = value || hash[key]; - }); - const current = getCurrent(); - Object.keys(current.hash).forEach((key) => { - if (lockedKeys.has(key) && !(key in newHash)) { - newHash[key] = current.hash[key]; - } - }); - return newHash; - }; -} - function makeRouter(env) { - let bus = env.bus; + const bus = env.bus; + const lockedKeys = new Set(); let current = getRoute(); + let pushTimeout; browser.addEventListener("hashchange", () => { current = getRoute(); bus.trigger("ROUTE_CHANGE"); }); - function doPush(mode = "push", route) { - if (!shallowEqual(route.hash, current.hash)) { - const url = location.origin + routeToUrl(route); - if (mode === "push") { - window.history.pushState({}, url, url); - } else { - window.history.replaceState({}, url, url); + /** + * @param {string} mode + * @returns {(hash: string, options: any) => any} + */ + function makeDebouncedPush(mode) { + let allPushArgs = []; + function doPush() { + // Aggregates push/replace state arguments + const replace = allPushArgs.some(([, options]) => options && options.replace); + const newHash = allPushArgs.reduce((finalHash, [hash, options]) => { + hash = applyLocking(lockedKeys, hash, current.hash, options); + if (finalHash) { + hash = applyLocking(lockedKeys, hash, finalHash, options); + } + return Object.assign(finalHash || {}, hash); + }, null); + // Calculates new route based on aggregated hash and options + const newRoute = computeNewRoute(newHash, replace, current); + if (!newRoute) { + return; } + // If the route changed: pushes or replaces browser state + const url = browser.location.origin + routeToUrl(newRoute); + if (mode === "push") { + browser.history.pushState({}, "", url); + } else { + browser.history.replaceState({}, "", url); + } + current = getRoute(); } - current = getRoute(); + return function pushOrReplaceState(hash, options) { + allPushArgs.push([hash, options]); + browser.clearTimeout(pushTimeout); + pushTimeout = browser.setTimeout(() => { + doPush(); + pushTimeout = null; + allPushArgs = []; + }); + }; } - function getCurrent() { - return current; - } - - const preProcessQuery = makePreProcessQuery(getCurrent); return { get current() { - return getCurrent(); + return current; }, - pushState: makePushState(getCurrent, doPush.bind(null, "push"), preProcessQuery), - replaceState: makePushState(getCurrent, doPush.bind(null, "replace"), preProcessQuery), + pushState: makeDebouncedPush("push"), + replaceState: makeDebouncedPush("replace"), redirect: (url, wait) => redirect(env, url, wait), - }; -} - -export function makePushState(getCurrent, doPush, preProcessQuery) { - let _replace = false; - let timeoutId; - let tempHash; - return (hash, replace = false) => { - clearTimeout(timeoutId); - hash = preProcessQuery(hash); - _replace = _replace || replace; - tempHash = Object.assign(tempHash || {}, hash); - timeoutId = setTimeout(() => { - tempHash = sanitizeHash(tempHash); - const current = getCurrent(); - if (!_replace) { - tempHash = Object.assign({}, current.hash, tempHash); - } - const route = Object.assign({}, current, { hash: tempHash }); - doPush(route); - tempHash = undefined; - timeoutId = undefined; - _replace = false; - }); + cancelPushes: () => browser.clearTimeout(pushTimeout), }; } @@ -161,7 +188,7 @@ export const routerService = { export function objectToQuery(obj) { const query = {}; Object.entries(obj).forEach(([k, v]) => { - query[k] = v ? `${v}` : v; + query[k] = v ? String(v) : v; }); return query; } diff --git a/addons/web/static/src/core/user_service.js b/addons/web/static/src/core/user_service.js index 84f4f854e2b..9e4652480c5 100644 --- a/addons/web/static/src/core/user_service.js +++ b/addons/web/static/src/core/user_service.js @@ -91,7 +91,7 @@ export const userService = { }; const stringCIds = allowedCompanies.join(","); - router.replaceState({ "lock cids": stringCIds }); + router.replaceState({ cids: stringCIds }, { lock: true }); cookie.setCookie("cids", stringCIds); const setCompanies = makeSetCompanies(() => allowedCompanies); @@ -126,7 +126,7 @@ export const userService = { showEffect, setCompanies: (mode, companyId) => { const nextCompanyIds = setCompanies(mode, companyId).join(","); - router.pushState({ "lock cids": nextCompanyIds }); + router.pushState({ cids: nextCompanyIds }, { lock: true }); cookie.setCookie("cids", nextCompanyIds); browser.setTimeout(() => window.location.reload()); // history.pushState is a little async }, diff --git a/addons/web/static/src/webclient/actions/action_service.js b/addons/web/static/src/webclient/actions/action_service.js index 3b47d50d24b..12910e25694 100644 --- a/addons/web/static/src/webclient/actions/action_service.js +++ b/addons/web/static/src/webclient/actions/action_service.js @@ -1162,7 +1162,7 @@ function makeActionManager(env) { const newState = {}; const action = controller.action; if (action.id) { - newState.action = `${action.id}`; + newState.action = action.id; } else if (action.type === "ir.actions.client") { newState.action = action.tag; } @@ -1182,7 +1182,7 @@ function makeActionManager(env) { newState.view_type = props.type; newState.id = props.recordId ? `${props.recordId}` : undefined; } - env.services.router.pushState(newState, true); + env.services.router.pushState(newState, { replace: true }); } return { doAction, diff --git a/addons/web/static/src/webclient/menu_service.js b/addons/web/static/src/webclient/menu_service.js index 60ab2796517..92514101312 100644 --- a/addons/web/static/src/webclient/menu_service.js +++ b/addons/web/static/src/webclient/menu_service.js @@ -62,9 +62,7 @@ function makeMenus(env, menusData, fetchLoadMenus) { env.bus.trigger("MENUS:APP-CHANGED"); // FIXME: lock API: maybe do something like // pushState({menu_id: ...}, { lock: true}); ? - env.services.router.pushState({ - "lock menu_id": `${menu.id}`, - }); + env.services.router.pushState({ menu_id: menu.id }, { lock: true }); } }, async reload() { diff --git a/addons/web/static/src/webclient/user_menu/user_menu_items.xml b/addons/web/static/src/webclient/user_menu/user_menu_items.xml index 7665ed3f9e5..1a5c1b555c3 100644 --- a/addons/web/static/src/webclient/user_menu/user_menu_items.xml +++ b/addons/web/static/src/webclient/user_menu/user_menu_items.xml @@ -1,7 +1,7 @@ - + diff --git a/addons/web/static/tests/core/router_service_tests.js b/addons/web/static/tests/core/router_service_tests.js index acaa50d9f02..359eec146a1 100644 --- a/addons/web/static/tests/core/router_service_tests.js +++ b/addons/web/static/tests/core/router_service_tests.js @@ -1,15 +1,17 @@ /** @odoo-module **/ import { browser } from "@web/core/browser/browser"; -import { - parseHash, - parseSearchQuery, - redirect, - routeToUrl, -} from "@web/core/browser/router_service"; -import { patch, unpatch } from "@web/core/utils/patch"; +import { parseHash, parseSearchQuery, routeToUrl } from "@web/core/browser/router_service"; import { makeTestEnv } from "../helpers/mock_env"; -import { nextTick } from "../helpers/utils"; +import { makeFakeRouterService } from "../helpers/mock_services"; +import { nextTick, patchWithCleanup } from "../helpers/utils"; + +async function createRouter(params = {}) { + const env = params.env || {}; + env.bus = env.bus || new owl.core.EventBus(); + const router = await makeFakeRouterService(params).start(env); + return router; +} QUnit.module("Router"); @@ -76,47 +78,179 @@ QUnit.test("can parse URI encoded strings", (assert) => { QUnit.test("routeToUrl encodes URI compatible strings", (assert) => { const route = { pathname: "/asf", search: {}, hash: {} }; assert.strictEqual(routeToUrl(route), "/asf"); + route.search = { a: "11", g: "summer wine" }; assert.strictEqual(routeToUrl(route), "/asf?a=11&g=summer%20wine"); + route.hash = { b: "2", c: "", e: "kloug,gloubi" }; assert.strictEqual(routeToUrl(route), "/asf?a=11&g=summer%20wine#b=2&c&e=kloug%2Cgloubi"); }); QUnit.test("can redirect an URL", async (assert) => { - patch(browser, "test.location", { - location: { - assign(url) { - assert.step(url); - }, - }, - setTimeout: (handler, delay) => { + patchWithCleanup(browser, { + setTimeout(handler, delay) { handler(); - assert.step(String(delay)); + assert.step(`timeout: ${delay}`); }, }); let firstCheckServer = true; const env = await makeTestEnv({ - browser, - mockRPC(...args) { - if (args[0] === "/web/webclient/version_info") { + async mockRPC(route) { + if (route === "/web/webclient/version_info") { if (firstCheckServer) { firstCheckServer = false; - return; + } else { + return true; } - return Promise.resolve(true); } }, }); - redirect(env, "/my/test/url"); - assert.verifySteps(["/my/test/url"]); - redirect(env, "/my/test/url/2", true); + const onRedirect = (url) => assert.step(url); + const router = await createRouter({ env, onRedirect }); + + router.redirect("/my/test/url"); await nextTick(); - assert.verifySteps(["1000", "250", "/my/test/url/2"]); - unpatch(browser, "test.location"); + assert.verifySteps(["/my/test/url"]); + + router.redirect("/my/test/url/2", true); + await nextTick(); + assert.verifySteps(["timeout: 1000", "timeout: 250", "/my/test/url/2"]); }); -// TODO: test -// - push/replaceState requests occuring in the same tick are aggregated -// - we don't push twice the same url (different key orders) -// - undefined keys are not pushed in the url -// - lock stuff +QUnit.module("Router: Push state"); + +QUnit.test("can push in same timeout", async (assert) => { + const router = await createRouter(); + + assert.deepEqual(router.current.hash, {}); + + router.pushState({ k1: 2 }); + assert.deepEqual(router.current.hash, {}); + + router.pushState({ k1: 3 }); + assert.deepEqual(router.current.hash, {}); + await nextTick(); + assert.deepEqual(router.current.hash, { k1: 3 }); +}); + +QUnit.test("can lock keys", async (assert) => { + const router = await createRouter(); + + router.pushState({ k1: 2 }, { lock: true }); + await nextTick(); + assert.deepEqual(router.current.hash, { k1: 2 }); + + router.pushState({ k1: 3 }); + await nextTick(); + assert.deepEqual(router.current.hash, { k1: 2 }); + + router.pushState({ k1: 4 }, { lock: true }); + await nextTick(); + assert.deepEqual(router.current.hash, { k1: 4 }); + + router.pushState({ k1: 3 }); + await nextTick(); + assert.deepEqual(router.current.hash, { k1: 4 }); +}); + +QUnit.test("can re-lock keys in same final call", async (assert) => { + const router = await createRouter(); + + router.pushState({ k1: 2 }, { lock: true }); + await nextTick(); + router.pushState({ k1: 1 }, { lock: true }); + router.pushState({ k1: 4 }); + await nextTick(); + assert.deepEqual(router.current.hash, { k1: 1 }); +}); + +QUnit.test("can unlock keys", async (assert) => { + const router = await createRouter(); + + router.pushState({ k1: 2 }, { lock: true }); + await nextTick(); + assert.deepEqual(router.current.hash, { k1: 2 }); + + router.pushState({ k1: 3 }); + await nextTick(); + assert.deepEqual(router.current.hash, { k1: 2 }); + + router.pushState({ k1: 4 }, { lock: false }); + await nextTick(); + assert.deepEqual(router.current.hash, { k1: 4 }); + + router.pushState({ k1: 3 }); + await nextTick(); + assert.deepEqual(router.current.hash, { k1: 3 }); +}); + +QUnit.test("can replace hash", async (assert) => { + const router = await createRouter(); + + router.pushState({ k1: 2 }); + await nextTick(); + assert.deepEqual(router.current.hash, { k1: 2 }); + + router.pushState({ k2: 3 }, { replace: true }); + await nextTick(); + assert.deepEqual(router.current.hash, { k2: 3 }); +}); + +QUnit.test("can replace hash with locked keys", async (assert) => { + const router = await createRouter(); + + router.pushState({ k1: 2 }, { lock: true }); + await nextTick(); + assert.deepEqual(router.current.hash, { k1: 2 }); + + router.pushState({ k2: 3 }, { replace: true }); + await nextTick(); + assert.deepEqual(router.current.hash, { k1: 2, k2: 3 }); +}); + +QUnit.test("can merge hash", async (assert) => { + const router = await createRouter(); + + router.pushState({ k1: 2 }); + await nextTick(); + assert.deepEqual(router.current.hash, { k1: 2 }); + + router.pushState({ k2: 3 }); + await nextTick(); + assert.deepEqual(router.current.hash, { k1: 2, k2: 3 }); +}); + +QUnit.test("undefined keys are not pushed", async (assert) => { + const onPushState = () => assert.step("pushed state"); + const router = await createRouter({ onPushState }); + + router.pushState({ k1: undefined }); + await nextTick(); + assert.verifySteps([]); + assert.deepEqual(router.current.hash, {}); +}); + +QUnit.test("undefined keys destroy previous non locked keys", async (assert) => { + const router = await createRouter(); + + router.pushState({ k1: 1 }); + await nextTick(); + assert.deepEqual(router.current.hash, { k1: 1 }); + + router.pushState({ k1: undefined }); + await nextTick(); + assert.deepEqual(router.current.hash, {}); +}); + +QUnit.test("do not re-push when hash is same", async (assert) => { + const onPushState = () => assert.step("pushed state"); + const router = await createRouter({ onPushState }); + + router.pushState({ k1: 1, k2: 2 }); + await nextTick(); + assert.verifySteps(["pushed state"]); + + router.pushState({ k2: 2, k1: 1 }); + await nextTick(); + assert.verifySteps([]); +}); diff --git a/addons/web/static/tests/helpers/mock_services.js b/addons/web/static/tests/helpers/mock_services.js index 94402e3684d..4b6695604fc 100644 --- a/addons/web/static/tests/helpers/mock_services.js +++ b/addons/web/static/tests/helpers/mock_services.js @@ -1,12 +1,15 @@ /** @odoo-module **/ -import { makePreProcessQuery, makePushState, routeToUrl } from "@web/core/browser/router_service"; +import { browser } from "@web/core/browser/browser"; +import { routerService } from "@web/core/browser/router_service"; import { localization } from "@web/core/l10n/localization"; import { translatedTerms } from "@web/core/l10n/translation"; import { rpcService } from "@web/core/network/rpc_service"; import { SIZES } from "@web/core/ui_service"; import { computeAllowedCompanyIds, makeSetCompanies } from "@web/core/user_service"; import { effectService } from "@web/webclient/effects/effect_service"; +import { objectToUrlEncodedString } from "../../src/core/utils/urls"; +import { registerCleanup } from "./cleanup"; import { patchWithCleanup } from "./utils"; const { Component } = owl; @@ -27,7 +30,7 @@ export const defaultLocalization = { weekStart: 7, }; -export function makeFakeLocalizationService(config) { +export function makeFakeLocalizationService() { patchWithCleanup(localization, defaultLocalization); return { @@ -49,7 +52,7 @@ export function makeFakeUserService(values) { const { uid, name, username, is_admin, user_companies, partner_id, user_context } = sessionInfo; return { name: "user", - start(env) { + start() { let allowedCompanies = computeAllowedCompanyIds(); const setCompanies = makeSetCompanies(() => allowedCompanies); const context = { @@ -170,64 +173,70 @@ export function makeMockFetch(mockRPC) { }; } -function stripUndefinedQueryKey(query) { - const keyValArray = Array.from(Object.entries(query)).filter(([k, v]) => v !== undefined); - // transform to Object.fromEntries in es > 2019 - const newObj = {}; - keyValArray.forEach(([k, v]) => { - newObj[k] = v; +export function makeMockLocation() { + const locationLink = Object.assign(document.createElement("a"), { + href: window.location.origin + window.location.pathname, + assign(url) { + this.href = url; + }, + }); + return new Proxy(locationLink, { + get(target, p) { + return target[p]; + }, + set(target, p, value) { + target[p] = value; + if (p === "hash") { + window.dispatchEvent(new HashChangeEvent("hashchange")); + } + return true; + }, }); - return newObj; } -function getRoute(route) { - route.hash = stripUndefinedQueryKey(route.hash); - route.search = stripUndefinedQueryKey(route.search); - return route; -} - -export function makeFakeRouterService(params) { - let _current = { - pathname: "test.wowl", - search: {}, - hash: {}, - }; - if (params && params.initialRoute) { - Object.assign(_current, params.initialRoute); - } - let current = getRoute(_current); - return { - start(env) { - function loadState(hash) { - current.hash = hash; - env.bus.trigger("ROUTE_CHANGE"); - } - env.bus.on("test:hashchange", null, loadState); - function getCurrent() { - return current; - } - function doPush(mode = "push", route) { - const oldUrl = routeToUrl(current); - const newRoute = getRoute(route); - const newUrl = routeToUrl(newRoute); - if (params && params.onPushState && oldUrl !== newUrl) { - params.onPushState(mode, route.hash); +/** + * @param {Object} [params={}] + * @param {Object} [params.initialRoute] initial route object + * @param {Object} [params.onPushState] hook on the "pushState" method + * @param {Object} [params.onRedirect] hook on the "redirect" method + * @returns {RouterService} + */ +export function makeFakeRouterService(params = {}) { + const mockLocation = makeMockLocation(); + Object.assign(mockLocation, params.initialRoute); + patchWithCleanup(browser, { + location: mockLocation, + history: { + pushState(state, title, url) { + mockLocation.assign(url); + if (params.onPushState) { + params.onPushState(url); } - current = newRoute; - } - const preProcessQuery = makePreProcessQuery(getCurrent); - return { - get current() { - return getCurrent(); + }, + replaceState(state, title, url) { + mockLocation.assign(url); + if (params.onReplaceState) { + params.onReplaceState(url); + } + }, + }, + }); + return { + start({ bus }) { + const router = routerService.start(...arguments); + bus.on("test:hashchange", null, (hash) => { + mockLocation.hash = objectToUrlEncodedString(hash); + }); + registerCleanup(router.cancelPushes); + patchWithCleanup(router, { + async redirect() { + await this._super(...arguments); + if (params.onRedirect) { + params.onRedirect(...arguments); + } }, - pushState: makePushState(getCurrent, doPush.bind(null, "push"), preProcessQuery), - replaceState: makePushState( - getCurrent, - doPush.bind(null, "replace"), - preProcessQuery - ), - redirect: (params && params.redirect) || (() => {}), - }; + }); + return router; }, }; } @@ -275,7 +284,7 @@ export const fakeCookieService = { get current() { return cookie; }, - setCookie(key, value, ttl) { + setCookie(key, value) { if (value !== undefined) { cookie[key] = value; } diff --git a/addons/web/static/tests/legacy/services/service_provider_adapter_notification_tests.js b/addons/web/static/tests/legacy/services/service_provider_adapter_notification_tests.js index f1dbb99b0f7..5dc5755c596 100644 --- a/addons/web/static/tests/legacy/services/service_provider_adapter_notification_tests.js +++ b/addons/web/static/tests/legacy/services/service_provider_adapter_notification_tests.js @@ -1,12 +1,11 @@ /** @odoo-module **/ import { browser } from "@web/core/browser/browser"; -import { patch, unpatch } from "@web/core/utils/patch"; -import * as LegacyRegistry from "web.Registry"; -import core from "web.core"; import AbstractAction from "web.AbstractAction"; +import core from "web.core"; +import * as LegacyRegistry from "web.Registry"; import { registerCleanup } from "../../helpers/cleanup"; -import { nextTick } from "../../helpers/utils"; +import { nextTick, patchWithCleanup } from "../../helpers/utils"; import { createWebClient, doAction, getActionManagerTestConfig } from "../../webclient/actions/helpers"; let testConfig; @@ -26,11 +25,14 @@ QUnit.module("Service Provider Adapter Notification", (hooks) => { assert.expect(8); let notifId; let timeoutCB; - patch(browser, "mock.settimeout.cb", { - setTimeout: (cb, t) => { - timeoutCB = cb; - assert.step("time: " + t); - return 1; + patchWithCleanup(browser, { + setTimeout: (cb, delay) => { + if (!delay) { + return; // Timeouts from router service + } + timeoutCB = cb; + assert.step("time: " + delay); + return 1; }, }); const NotifyAction = AbstractAction.extend({ @@ -75,7 +77,6 @@ QUnit.module("Service Provider Adapter Notification", (hooks) => { await nextTick(); assert.containsNone(document.body, ".o_notification"); assert.verifySteps(["time: 3000"]); - unpatch(browser, "mock.settimeout.cb"); } ); }); diff --git a/addons/web/static/tests/webclient/actions/load_state_tests.js b/addons/web/static/tests/webclient/actions/load_state_tests.js index ce98b284f6c..93a940f52cb 100644 --- a/addons/web/static/tests/webclient/actions/load_state_tests.js +++ b/addons/web/static/tests/webclient/actions/load_state_tests.js @@ -58,14 +58,14 @@ QUnit.module("ActionManager", (hooks) => { ); assert.strictEqual(webClient.el.querySelector(".o_menu_brand").textContent, "App2"); assert.deepEqual(webClient.env.services.router.current.hash, { - action: "1001", - menu_id: "2", + action: 1001, + menu_id: 2, }); }); QUnit.test("initial loading with action id", async (assert) => { assert.expect(4); - const hash = { action: 1001 }; + const hash = "#action=1001"; serviceRegistry.add("router", makeFakeRouterService({ initialRoute: { hash } }), { force: true, }); @@ -82,7 +82,7 @@ QUnit.module("ActionManager", (hooks) => { QUnit.test("initial loading with action tag", async (assert) => { assert.expect(3); - const hash = { action: "__test__client__action__" }; + const hash = "#action=__test__client__action__"; serviceRegistry.add("router", makeFakeRouterService({ initialRoute: { hash } }), { force: true, }); @@ -112,11 +112,7 @@ QUnit.module("ActionManager", (hooks) => { QUnit.test("correctly sends additional context", async (assert) => { assert.expect(1); - const hash = { - action: 1001, - active_id: 4, - active_ids: "4,8", - }; + const hash = "#action=1001&active_id=4&active_ids=4,8"; serviceRegistry.add("router", makeFakeRouterService({ initialRoute: { hash } }), { force: true, }); @@ -248,7 +244,7 @@ QUnit.module("ActionManager", (hooks) => { 1: { id: 1, children: [], name: "App1", appID: 1, actionID: 1001, xmlid: "menu_1" }, 2: { id: 2, children: [], name: "App2", appID: 2, actionID: 1002, xmlid: "menu_2" }, }; - const hash = { id: 2, model: "partner" }; + const hash = "#id=2&model=partner"; serviceRegistry.add("router", makeFakeRouterService({ initialRoute: { hash } }), { force: true, }); @@ -533,7 +529,7 @@ QUnit.module("ActionManager", (hooks) => { await loadState(webClient, { action: 3 }); currentHash = webClient.env.services.router.current.hash; assert.deepEqual(currentHash, { - action: "3", + action: 3, model: "partner", view_type: "list", }); @@ -542,8 +538,8 @@ QUnit.module("ActionManager", (hooks) => { await legacyExtraNextTick(); currentHash = webClient.env.services.router.current.hash; assert.deepEqual(currentHash, { - action: "3", - id: "1", + action: 3, + id: 1, model: "partner", view_type: "form", }); @@ -585,7 +581,7 @@ QUnit.module("ActionManager", (hooks) => { await loadState(webClient, { action: 9 }); currentHash = webClient.env.services.router.current.hash; assert.deepEqual(currentHash, { - action: "9", + action: 9, }); assert.verifySteps([], "should not push the loaded state"); await testUtils.dom.click($(webClient.el).find("#client_action_button")); @@ -593,14 +589,14 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps(["push_state"], "should push the state of it changes afterwards"); currentHash = webClient.env.services.router.current.hash; assert.deepEqual(currentHash, { - action: "9", + action: 9, someValue: "X", }); delete core.action_registry.map.ClientAction; }); QUnit.test("change a param of an ir.actions.client in the url", async function (assert) { - assert.expect(13); + assert.expect(12); const ClientAction = AbstractAction.extend({ hasControlPanel: true, init: function (parent, action) { @@ -622,8 +618,8 @@ QUnit.module("ActionManager", (hooks) => { serviceRegistry.add( "router", makeFakeRouterService({ - onPushState(mode) { - assert.step(`push_state ${mode}`); + onPushState() { + assert.step("push_state"); }, }), { force: true } @@ -634,10 +630,10 @@ QUnit.module("ActionManager", (hooks) => { assert.deepEqual(currentHash, {}); // execute the client action await doAction(webClient, 9); - assert.verifySteps(["start", "push_state push"]); + assert.verifySteps(["start", "push_state"]); currentHash = webClient.env.services.router.current.hash; assert.deepEqual(currentHash, { - action: "9", + action: 9, a: "default value", }); assert.strictEqual( @@ -656,10 +652,10 @@ QUnit.module("ActionManager", (hooks) => { action: 9, a: "new value", }); - assert.verifySteps(["start", "push_state push"]); + assert.verifySteps(["start"]); // No push state since the hash hasn't changed currentHash = webClient.env.services.router.current.hash; assert.deepEqual(currentHash, { - action: "9", + action: 9, a: "new value", }); assert.strictEqual( @@ -783,15 +779,10 @@ QUnit.module("ActionManager", (hooks) => { root: { id: "root", children: [1], name: "root", appID: "root" }, 1: { id: 1, children: [], name: "App1", appID: 1, actionID: 1 }, }; - serviceRegistry.add( - "router", - makeFakeRouterService({ - initialRoute: { - hash: { home: 1 }, - }, - }), - { force: true } - ); + const hash = "#home=1"; + serviceRegistry.add("router", makeFakeRouterService({ initialRoute: { hash } }), { + force: true, + }); const mockRPC = async function (route) { assert.step(route); }; diff --git a/addons/web/static/tests/webclient/actions/misc_tests.js b/addons/web/static/tests/webclient/actions/misc_tests.js index 5974b53fe91..06cc388534d 100644 --- a/addons/web/static/tests/webclient/actions/misc_tests.js +++ b/addons/web/static/tests/webclient/actions/misc_tests.js @@ -1,10 +1,10 @@ /** @odoo-module **/ import { registry } from "@web/core/registry"; -import testUtils from "web.test_utils"; -import core from "web.core"; -import Widget from "web.Widget"; import AbstractAction from "web.AbstractAction"; +import core from "web.core"; +import testUtils from "web.test_utils"; +import Widget from "web.Widget"; import { makeTestEnv } from "../../helpers/mock_env"; import { legacyExtraNextTick } from "../../helpers/utils"; import { createWebClient, doAction, getActionManagerTestConfig } from "./helpers"; @@ -75,7 +75,7 @@ QUnit.module("ActionManager", (hooks) => { QUnit.test("action in handler registry", async (assert) => { assert.expect(2); const env = await makeTestEnv(testConfig); - actionHandlersRegistry.add("ir.action_in_handler_registry", ({ env, action, options }) => + actionHandlersRegistry.add("ir.action_in_handler_registry", ({ action }) => assert.step(action.type) ); await doAction(env, { @@ -145,7 +145,7 @@ QUnit.module("ActionManager", (hooks) => { QUnit.test("action cache: additionalContext is respected", async function (assert) { assert.expect(5); - const mockRPC = async (route, args) => { + const mockRPC = async (route) => { if (route === "/web/action/load") { assert.step("server loaded"); } @@ -326,7 +326,7 @@ QUnit.module("ActionManager", (hooks) => { this._super.apply(this, arguments); }, }); - const mockRPC = async function (route, args) { + const mockRPC = async function (route) { if (route === "/web/dataset/search_read") { await Promise.resolve(def); } @@ -378,7 +378,7 @@ QUnit.module("ActionManager", (hooks) => { action: "Partners Action 4", }); currentHash = webClient.env.services.router.current.hash; - assert.deepEqual(currentHash, { action: "4", model: "partner", view_type: "kanban" }); + assert.deepEqual(currentHash, { action: 4, model: "partner", view_type: "kanban" }); await doAction(webClient, 8); currentTitle = webClient.env.services.title.getParts(); assert.deepEqual(currentTitle, { @@ -386,7 +386,7 @@ QUnit.module("ActionManager", (hooks) => { action: "Favorite Ponies", }); currentHash = webClient.env.services.router.current.hash; - assert.deepEqual(currentHash, { action: "8", model: "pony", view_type: "list" }); + assert.deepEqual(currentHash, { action: 8, model: "pony", view_type: "list" }); await testUtils.dom.click($(webClient.el).find("tr.o_data_row:first")); await legacyExtraNextTick(); currentTitle = webClient.env.services.title.getParts(); @@ -395,7 +395,7 @@ QUnit.module("ActionManager", (hooks) => { action: "Twilight Sparkle", }); currentHash = webClient.env.services.router.current.hash; - assert.deepEqual(currentHash, { action: "8", id: "4", model: "pony", view_type: "form" }); + assert.deepEqual(currentHash, { action: 8, id: 4, model: "pony", view_type: "form" }); }); QUnit.test( diff --git a/addons/web/static/tests/webclient/actions/push_state_tests.js b/addons/web/static/tests/webclient/actions/push_state_tests.js index 5f276da5df4..e5861d65cac 100644 --- a/addons/web/static/tests/webclient/actions/push_state_tests.js +++ b/addons/web/static/tests/webclient/actions/push_state_tests.js @@ -30,8 +30,8 @@ QUnit.module("ActionManager", (hooks) => { await nextTick(); await nextTick(); urlState = webClient.env.services.router.current; - assert.strictEqual(urlState.hash.action, "1002"); - assert.strictEqual(urlState.hash.menu_id, "2"); + assert.strictEqual(urlState.hash.action, 1002); + assert.strictEqual(urlState.hash.menu_id, 2); assert.strictEqual( webClient.el.querySelector(".test_client_action").textContent.trim(), "ClientAction_Id 2" @@ -49,8 +49,8 @@ QUnit.module("ActionManager", (hooks) => { await nextTick(); await nextTick(); urlState = webClient.env.services.router.current; - assert.strictEqual(urlState.hash.action, "1002"); - assert.strictEqual(urlState.hash.menu_id, "2"); + assert.strictEqual(urlState.hash.action, 1002); + assert.strictEqual(urlState.hash.menu_id, 2); assert.strictEqual( webClient.el.querySelector(".test_client_action").textContent.trim(), "ClientAction_Id 2" @@ -58,8 +58,8 @@ QUnit.module("ActionManager", (hooks) => { assert.strictEqual(webClient.el.querySelector(".o_menu_brand").textContent, "App2"); await doAction(webClient, 1001, { clearBreadcrumbs: true }); urlState = webClient.env.services.router.current; - assert.strictEqual(urlState.hash.action, "1001"); - assert.strictEqual(urlState.hash.menu_id, "2"); + assert.strictEqual(urlState.hash.action, 1001); + assert.strictEqual(urlState.hash.menu_id, 2); assert.strictEqual( webClient.el.querySelector(".test_client_action").textContent.trim(), "ClientAction_Id 1" @@ -122,7 +122,7 @@ QUnit.module("ActionManager", (hooks) => { assert.strictEqual(urlState.hash.arbitrary, "actionPushed"); await doAction(webClient, 1001); urlState = webClient.env.services.router.current; - assert.strictEqual(urlState.hash.action, "1001"); + assert.strictEqual(urlState.hash.action, 1001); assert.strictEqual(urlState.hash.arbitrary, undefined); }); @@ -152,8 +152,8 @@ QUnit.module("ActionManager", (hooks) => { await nextTick(); await nextTick(); urlState = webClient.env.services.router.current; - assert.strictEqual(urlState.hash.action, "1002"); - assert.strictEqual(urlState.hash.menu_id, "2"); + assert.strictEqual(urlState.hash.action, 1002); + assert.strictEqual(urlState.hash.menu_id, 2); }); QUnit.test("action in target new do not push state", async (assert) => { @@ -162,7 +162,7 @@ QUnit.module("ActionManager", (hooks) => { serviceRegistry.add( "router", makeFakeRouterService({ - onPushState(mode, newState) { + onPushState() { throw new Error("should not push state"); }, }), @@ -178,30 +178,30 @@ QUnit.module("ActionManager", (hooks) => { const webClient = await createWebClient({ testConfig }); await doAction(webClient, 4); assert.deepEqual(webClient.env.services.router.current.hash, { - action: "4", + action: 4, model: "partner", view_type: "kanban", }); await doAction(webClient, 8); assert.deepEqual(webClient.env.services.router.current.hash, { - action: "8", + action: 8, model: "pony", view_type: "list", }); await testUtils.dom.click($(webClient.el).find("tr.o_data_row:first")); await legacyExtraNextTick(); assert.deepEqual(webClient.env.services.router.current.hash, { - action: "8", + action: 8, model: "pony", view_type: "form", - id: "4", + id: 4, }); }); QUnit.test("push state after action is loaded, not before", async function (assert) { assert.expect(2); const def = testUtils.makeTestPromise(); - const mockRPC = async function (route, args) { + const mockRPC = async function (route) { if (route === "/web/dataset/search_read") { await def; } @@ -215,7 +215,7 @@ QUnit.module("ActionManager", (hooks) => { await testUtils.nextTick(); await legacyExtraNextTick(); assert.deepEqual(webClient.env.services.router.current.hash, { - action: "4", + action: 4, model: "partner", view_type: "kanban", }); @@ -231,7 +231,7 @@ QUnit.module("ActionManager", (hooks) => { const webClient = await createWebClient({ testConfig, mockRPC }); await doAction(webClient, 8); assert.deepEqual(webClient.env.services.router.current.hash, { - action: "8", + action: 8, model: "pony", view_type: "list", }); @@ -240,7 +240,7 @@ QUnit.module("ActionManager", (hooks) => { // we make sure here that the list view is still in the dom assert.containsOnce(webClient, ".o_list_view", "there should still be a list view in dom"); assert.deepEqual(webClient.env.services.router.current.hash, { - action: "8", + action: 8, model: "pony", view_type: "list", }); diff --git a/addons/web/static/tests/webclient/actions/url_action_tests.js b/addons/web/static/tests/webclient/actions/url_action_tests.js index a4823c5ae28..71b03e8041b 100644 --- a/addons/web/static/tests/webclient/actions/url_action_tests.js +++ b/addons/web/static/tests/webclient/actions/url_action_tests.js @@ -19,7 +19,7 @@ QUnit.module("ActionManager", (hooks) => { serviceRegistry.add( "router", makeFakeRouterService({ - redirect: (url) => { + onRedirect(url) { assert.step(url); }, }),