diff --git a/addons/web/static/src/core/name_service.js b/addons/web/static/src/core/name_service.js new file mode 100644 index 00000000000..615c17d5050 --- /dev/null +++ b/addons/web/static/src/core/name_service.js @@ -0,0 +1,101 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { unique, zip } from "@web/core/utils/arrays"; +import { Deferred } from "@web/core/utils/concurrency"; + +export const ERROR_INACCESSIBLE_OR_MISSING = Symbol("INACCESSIBLE OR MISSING RECORD ID"); + +function isId(val) { + return Number.isInteger(val) && val >= 1; +} + +/** + * @typedef {Record} DisplayNames + */ + +export const nameService = { + dependencies: ["orm"], + async: ["loadDisplayNames"], + start(env, { orm }) { + let cache = {}; + const batches = {}; + + function clearCache() { + cache = {}; + } + + env.bus.addEventListener("ACTION_MANAGER:UPDATE", clearCache); + + function getMapping(resModel) { + if (!cache[resModel]) { + cache[resModel] = {}; + } + return cache[resModel]; + } + + /** + * @param {string} resModel valid resModel name + * @param {DisplayNames} displayNames + */ + function addDisplayNames(resModel, displayNames) { + const mapping = getMapping(resModel); + for (const resId in displayNames) { + mapping[resId] = new Deferred(); + mapping[resId].resolve(displayNames[resId]); + } + } + + /** + * @param {string} resModel valid resModel name + * @param {number[]} resIds valid ids + * @returns {Promise} + */ + async function loadDisplayNames(resModel, resIds) { + const mapping = getMapping(resModel); + const proms = []; + const resIdsToFetch = []; + for (const resId of unique(resIds)) { + if (!isId(resId)) { + throw new Error(`Invalid ID: ${resId}`); + } + if (!(resId in mapping)) { + mapping[resId] = new Deferred(); + resIdsToFetch.push(resId); + } + proms.push(mapping[resId]); + } + if (resIdsToFetch.length) { + if (batches[resModel]) { + batches[resModel].push(...resIdsToFetch); + } else { + batches[resModel] = resIdsToFetch; + await Promise.resolve(); + const idsInBatch = unique(batches[resModel]); + delete batches[resModel]; + + orm.silent + .webSearchRead(resModel, [["id", "in", idsInBatch]], ["display_name"]) + .then(({ records }) => { + const displayNames = Object.fromEntries( + records.map((rec) => [rec.id, rec.display_name]) + ); + for (const resId of idsInBatch) { + mapping[resId].resolve( + resId in displayNames + ? displayNames[resId] + : ERROR_INACCESSIBLE_OR_MISSING + ); + } + }); + } + } + const names = await Promise.all(proms); + return Object.fromEntries(zip(resIds, names)); + } + + return { addDisplayNames, clearCache, loadDisplayNames }; + }, +}; + +registry.category("services").add("name", nameService); diff --git a/addons/web/static/tests/core/name_service_tests.js b/addons/web/static/tests/core/name_service_tests.js new file mode 100644 index 00000000000..b439c14413a --- /dev/null +++ b/addons/web/static/tests/core/name_service_tests.js @@ -0,0 +1,142 @@ +/** @odoo-module **/ + +import { ERROR_INACCESSIBLE_OR_MISSING, nameService } from "@web/core/name_service"; +import { makeTestEnv } from "@web/../tests/helpers/mock_env"; +import { registry } from "@web/core/registry"; + +const serviceRegistry = registry.category("services"); + +let serverData; +QUnit.module("Name Service", { + async beforeEach() { + serverData = { + models: { + dev: { + fields: {}, + records: [ + { id: 1, display_name: "Julien" }, + { id: 2, display_name: "Pierre" }, + ], + }, + }, + }; + serviceRegistry.add("name", nameService); + }, +}); + +QUnit.test("single loadDisplayNames", async (assert) => { + const env = await makeTestEnv({ serverData }); + const displayNames = await env.services.name.loadDisplayNames("dev", [1, 2]); + assert.deepEqual(displayNames, { 1: "Julien", 2: "Pierre" }); +}); + +QUnit.test("loadDisplayNames is done in silent mode", async (assert) => { + assert.expect(2); + const env = await makeTestEnv({ serverData }); + env.bus.addEventListener("RPC:REQUEST", (ev) => { + const silent = ev.detail.settings.silent; + assert.step("RPC:REQUEST" + (silent ? " (silent)" : "")); + }); + await env.services.name.loadDisplayNames("dev", [1]); + assert.verifySteps(["RPC:REQUEST (silent)"]); +}); + +QUnit.test("single loadDisplayNames following addDisplayNames", async (assert) => { + const mockRPC = (_, { method }) => { + assert.step(method); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + env.services.name.addDisplayNames("dev", { 1: "JUM", 2: "PIPU" }); + const displayNames = await env.services.name.loadDisplayNames("dev", [1, 2]); + assert.deepEqual(displayNames, { 1: "JUM", 2: "PIPU" }); + assert.verifySteps([]); +}); + +QUnit.test("single loadDisplayNames following addDisplayNames (2)", async (assert) => { + const mockRPC = (_, { kwargs, method }) => { + assert.step(method); + const ids = kwargs.domain[0][2]; + assert.step(`id(s): ${ids.join(", ")}`); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + env.services.name.addDisplayNames("dev", { 1: "JUM" }); + const displayNames = await env.services.name.loadDisplayNames("dev", [1, 2]); + assert.deepEqual(displayNames, { 1: "JUM", 2: "Pierre" }); + assert.verifySteps(["web_search_read", "id(s): 2"]); +}); + +QUnit.test("loadDisplayNames in batch", async (assert) => { + const mockRPC = (_, { kwargs, method }) => { + assert.step(method); + const ids = kwargs.domain[0][2]; + assert.step(`id(s): ${ids.join(", ")}`); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const prom1 = env.services.name.loadDisplayNames("dev", [1]); + assert.verifySteps([]); + const prom2 = env.services.name.loadDisplayNames("dev", [2]); + assert.verifySteps([]); + const [displayNames1, displayNames2] = await Promise.all([prom1, prom2]); + assert.deepEqual(displayNames1, { 1: "Julien" }); + assert.deepEqual(displayNames2, { 2: "Pierre" }); + assert.verifySteps(["web_search_read", "id(s): 1, 2"]); +}); + +QUnit.test("loadDisplayNames on different models", async (assert) => { + serverData.models.PO = { + fields: {}, + records: [{ id: 1, display_name: "Damien" }], + }; + const mockRPC = (_, { kwargs, method, model }) => { + assert.step(method); + assert.step(model); + const ids = kwargs.domain[0][2]; + assert.step(`id(s): ${ids.join(", ")}`); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const prom1 = env.services.name.loadDisplayNames("dev", [1]); + assert.verifySteps([]); + const prom2 = env.services.name.loadDisplayNames("PO", [1]); + assert.verifySteps([]); + const [displayNames1, displayNames2] = await Promise.all([prom1, prom2]); + assert.deepEqual(displayNames1, { 1: "Julien" }); + assert.deepEqual(displayNames2, { 1: "Damien" }); + assert.verifySteps(["web_search_read", "dev", "id(s): 1", "web_search_read", "PO", "id(s): 1"]); +}); + +QUnit.test("invalid id", async (assert) => { + assert.expect(1); + const env = await makeTestEnv({ serverData }); + try { + await env.services.name.loadDisplayNames("dev", ["a"]); + } catch (e) { + assert.strictEqual(e.message, "Invalid ID: a"); + } +}); + +QUnit.test("inaccessible or missing id", async (assert) => { + const mockRPC = (_, { method }) => { + assert.step(method); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const displayNames = await env.services.name.loadDisplayNames("dev", [3]); + assert.deepEqual(displayNames, { 3: ERROR_INACCESSIBLE_OR_MISSING }); + assert.verifySteps(["web_search_read"]); +}); + +QUnit.test("batch + inaccessible/missing", async (assert) => { + const mockRPC = (_, { method, kwargs }) => { + assert.step(method); + const ids = kwargs.domain[0][2]; + assert.step(`id(s): ${ids.join(", ")}`); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const prom1 = env.services.name.loadDisplayNames("dev", [1, 3]); + assert.verifySteps([]); + const prom2 = env.services.name.loadDisplayNames("dev", [2, 4]); + assert.verifySteps([]); + const [displayNames1, displayNames2] = await Promise.all([prom1, prom2]); + assert.deepEqual(displayNames1, { 1: "Julien", 3: ERROR_INACCESSIBLE_OR_MISSING }); + assert.deepEqual(displayNames2, { 2: "Pierre", 4: ERROR_INACCESSIBLE_OR_MISSING }); + assert.verifySteps(["web_search_read", "id(s): 1, 3, 2, 4"]); +}); diff --git a/addons/web/tooling/types/services.d.ts b/addons/web/tooling/types/services.d.ts index ad6df81dbac..bbf4c2a99b3 100644 --- a/addons/web/tooling/types/services.d.ts +++ b/addons/web/tooling/types/services.d.ts @@ -9,6 +9,7 @@ declare module "services" { import { hotkeyService } from "@web/core/hotkeys/hotkey_service"; import { httpService } from "@web/core/network/http_service"; import { menuService } from "@web/webclient/menus/menu_service"; + import { nameService } from "@web/core/name_service"; import { notificationService } from "@web/core/notifications/notification_service"; import { ormService } from "@web/core/orm_service"; import { popoverService } from "@web/core/popover/popover_service"; @@ -30,6 +31,7 @@ declare module "services" { hotkey: ReturnType; http: ReturnType; menu: Awaited>; + name: ReturnType; notification: ReturnType; orm: ReturnType; popover: ReturnType;