[IMP] web: name service

We define a new service "name". That service makes possible to load in
batch display names and maintains a cache. Some known display names
(fetched otherwise) can be added to the cache. The cache is cleared at
least each time the UI is updated via _updateUI (action service).

Part-of: odoo/odoo#124090
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Lucas Lefevre <lul@odoo.com>
This commit is contained in:
Mathieu Duckerts-Antoine
2023-06-09 17:00:47 +02:00
co-authored by Lucas Lefevre
parent c8bd18ff80
commit f2edbb27f4
3 changed files with 245 additions and 0 deletions
+101
View File
@@ -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<string, (string|ERROR_INACCESSIBLE_OR_MISSING)>} 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<DisplayNames>}
*/
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);
@@ -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"]);
});
+2
View File
@@ -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<typeof hotkeyService.start>;
http: ReturnType<typeof httpService.start>;
menu: Awaited<ReturnType<typeof menuService.start>>;
name: ReturnType<typeof nameService.start>;
notification: ReturnType<typeof notificationService.start>;
orm: ReturnType<typeof ormService.start>;
popover: ReturnType<typeof popoverService.start>;