[IMP] web: new field service

We move the function loadFields from the view service to a newly created
field service. That service can also be used to load model fields for a
given path (loadPath), i.e. load all model fields for the models
traversed by the given path.
We use loadPath in useModelField and DomainSelector.

closes odoo/odoo#117882

Related: odoo/enterprise#39418
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
This commit is contained in:
Mathieu Duckerts-Antoine
2023-04-07 19:43:20 +02:00
parent 6c5d54e1d9
commit d0b9c092bd
12 changed files with 398 additions and 200 deletions
@@ -3,6 +3,8 @@
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import * as testUtils from "@web/../tests/helpers/utils";
import weTestUtils from "web_editor.test_utils";
import { fieldService } from "@web/core/field_service";
import { registry } from "@web/core/registry";
let fixture;
let serverData;
@@ -107,6 +109,7 @@ QUnit.module('favorite filter widget', (hooks) => {
});
serverData = { models };
setupViewRegistries();
registry.category("services").add("field", fieldService);
});
QUnit.test('create favorite filter', async (assert) => {
@@ -41,7 +41,7 @@ export class DomainSelector extends Component {
};
setup() {
this.view = useService("view");
this.fieldService = useService("field");
this.treeBuilder = new DomainTreeBuilder();
this.tree = useState({
isSupported: false,
@@ -107,16 +107,16 @@ export class DomainSelector extends Component {
if ("01".includes(field.toString())) {
return { type: "integer" };
}
const names = field.length ? field.split(".") : [];
let currentModel = resModel;
let fieldDef = null;
for (const name of names) {
const model = await this.view.loadFields(currentModel);
fieldDef = model[name];
currentModel = fieldDef.relation;
if (typeof field !== "string" || !field) {
return null;
}
return fieldDef;
const { isInvalid, names, modelsInfo } = await this.fieldService.loadPath(resModel, field);
if (isInvalid) {
return null;
}
const name = names.at(-1);
const { fieldDefs } = modelsInfo.at(-1);
return fieldDefs[name];
}
createNewLeaf() {
+102
View File
@@ -0,0 +1,102 @@
/** @odoo-module **/
import { Cache } from "@web/core/utils/cache";
import { registry } from "@web/core/registry";
/**
* @typedef {Object} LoadFieldsOptions
* @property {string[]|false} [fieldNames]
* @property {string[]} [attributes]
*/
export const fieldService = {
dependencies: ["orm"],
start(env, { orm }) {
const cache = new Cache(
(resModel, options) => {
return orm
.call(resModel, "fields_get", [options.fieldNames, options.attributes])
.catch((error) => {
cache.clear(resModel, options);
return Promise.reject(error);
});
},
(resModel, options) =>
JSON.stringify([resModel, options.fieldNames, options.attributes])
);
env.bus.addEventListener("CLEAR-CACHES", () => cache.invalidate());
/**
* @param {string} resModel
* @param {LoadFieldsOptions} [options]
* @returns {Promise<object>}
*/
async function loadFields(resModel, options = {}) {
if (typeof resModel !== "string" || !resModel) {
throw new Error(`Invalid model name: ${resModel}`);
}
return cache.read(resModel, options);
}
/**
* @param {string} resModel valid model name
* @param {Object} fieldDefs
* @param {string[]} names
*/
async function _loadPath(resModel, fieldDefs, names) {
const [name, ...remainingNames] = names;
const modelsInfo = [{ resModel, fieldDefs }];
const fieldDef = fieldDefs[name];
if (name !== "*" && !fieldDef) {
return { isInvalid: "path", names, modelsInfo };
}
if (!remainingNames.length) {
return { names, modelsInfo };
}
if (name === "*") {
return { isInvalid: "path", names, modelsInfo };
}
if (fieldDef.relation) {
const subResult = await _loadPath(
fieldDef.relation,
await loadFields(fieldDef.relation),
remainingNames
);
const result = {
names,
modelsInfo: [...modelsInfo, ...subResult.modelsInfo],
};
if (subResult.isInvalid) {
result.isInvalid = "path";
}
return result;
}
return { isInvalid: "path", names, modelsInfo };
}
/**
* Note: the symbol * can be used at the end of path (e.g path="*" or path="user_id.*").
* It says to load the fields of the appropriate model.
* @param {string} resModel
* @param {string} path
* @returns {Promise<Object>}
*/
async function loadPath(resModel, path = "*") {
const fieldDefs = await loadFields(resModel);
if (typeof path !== "string" || !path) {
throw new Error(`Invalid path: ${path}`);
}
return _loadPath(resModel, fieldDefs, path.split("."));
}
return { loadFields, loadPath };
},
};
registry.category("services").add("field", fieldService);
@@ -1,33 +1,32 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { zipWith } from "@web/core/utils/arrays";
export function useModelField() {
const view = useService("view");
const fieldService = useService("field");
const loadModelFields = (resModel) => {
return view.loadFields(resModel);
return fieldService.loadFields(resModel);
};
const loadChain = async (resModel, fieldName) => {
const fieldNameChain = fieldName.length ? fieldName.split(".") : [];
let currentNode = {
resModel,
field: null,
};
const chain = [currentNode];
for (const fieldName of fieldNameChain) {
const fieldsInfo = await loadModelFields(currentNode.resModel);
Object.assign(currentNode, {
field: { ...fieldsInfo[fieldName], name: fieldName },
});
if (fieldsInfo[fieldName].relation) {
currentNode = {
resModel: fieldsInfo[fieldName].relation,
field: null,
};
chain.push(currentNode);
}
const loadChain = async (resModel, path) => {
if ("01".includes(path.toString())) {
return [{ resModel, field: null }];
}
if (typeof path !== "string" || !path) {
return [{ resModel, field: null }];
}
const { isInvalid, names, modelsInfo } = await fieldService.loadPath(resModel, path);
if (isInvalid) {
return [{ resModel, field: null }];
}
const chain = zipWith(names, modelsInfo, (name, { resModel, fieldDefs }) => {
return { resModel, field: fieldDefs[name] };
});
const lastField = chain.at(-1)?.field;
if (lastField.relation) {
chain.push({ resModel: lastField.relation, field: null });
}
return chain;
};
+1 -36
View File
@@ -41,12 +41,6 @@ import { UPDATE_METHODS } from "@web/core/orm_service";
* @property {boolean} loadIrFilters
*/
/**
* @typedef {Object} LoadFieldsOptions
* @property {string[] | false} [fieldNames]
* @property {string[]} [attributes]
*/
export const viewService = {
dependencies: ["orm"],
start(env, { orm }) {
@@ -69,31 +63,6 @@ export const viewService = {
}
});
/**
* Loads fields information
*
* @param {string} resModel
* @param {LoadFieldsOptions} [options]
* @returns {Promise<object>}
*/
async function loadFields(resModel, options = {}) {
const key = JSON.stringify([
"fields",
resModel,
options.fieldNames,
options.attributes,
]);
if (!cache[key]) {
cache[key] = orm
.call(resModel, "fields_get", [options.fieldNames, options.attributes])
.catch((error) => {
delete cache[key];
return Promise.reject(error);
});
}
return cache[key];
}
/**
* Loads various information concerning views: fields_view for each view,
* fields of the corresponding model, and optionally the filters.
@@ -128,10 +97,6 @@ export const viewService = {
relatedModels: models,
views: {},
};
for (const [resModel, fields] of Object.entries(modelsCopy)) {
const key = JSON.stringify(["fields", resModel, undefined, undefined]);
cache[key] = Promise.resolve(fields);
}
for (const viewType in views) {
const { arch, toolbar, id, filters, custom_view_id } = views[viewType];
const viewDescription = { arch, id, custom_view_id };
@@ -152,7 +117,7 @@ export const viewService = {
}
return cache[key];
}
return { loadViews, loadFields };
return { loadViews };
},
};
@@ -3,13 +3,13 @@
import { Component, xml } from "@odoo/owl";
import { DomainSelector } from "@web/core/domain_selector/domain_selector";
import { OPERATOR_DESCRIPTIONS } from "@web/core/domain_selector/domain_selector_operators";
import { fieldService } from "@web/core/field_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { ormService } from "@web/core/orm_service";
import { popoverService } from "@web/core/popover/popover_service";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { viewService } from "@web/views/view_service";
import { registerCleanup } from "../helpers/cleanup";
import { makeTestEnv } from "../helpers/mock_env";
import { makeFakeLocalizationService } from "../helpers/mock_services";
@@ -64,7 +64,7 @@ QUnit.module("Components", (hooks) => {
registry.category("services").add("ui", uiService);
registry.category("services").add("hotkey", hotkeyService);
registry.category("services").add("localization", makeFakeLocalizationService());
registry.category("services").add("view", viewService);
registry.category("services").add("field", fieldService);
target = getFixture();
});
@@ -0,0 +1,187 @@
/** @odoo-module **/
import { fieldService } from "@web/core/field_service";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { registry } from "@web/core/registry";
const serviceRegistry = registry.category("services");
function getModelInfo(resModel) {
return {
resModel,
fieldDefs: serverData.models[resModel].fields,
};
}
let serverData;
QUnit.module("Field Service", {
async beforeEach() {
serverData = {
models: {
tortoise: {
fields: {
id: { string: "ID", type: "integer" },
display_name: { string: "Display Name", type: "char" },
name: { string: "Name", type: "char", default: "name" },
write_date: { string: "Last Modified on", type: "datetime" },
age: { type: "integer", string: "Age" },
location_id: { type: "many2one", string: "Location", relation: "location" },
},
},
location: {
fields: {
id: { string: "ID", type: "integer" },
display_name: { string: "Display Name", type: "char" },
name: { string: "Name", type: "char", default: "name" },
write_date: { string: "Last Modified on", type: "datetime" },
tortoise_ids: { type: "one2many", string: "Turtles", relation: "tortoise" },
},
},
},
};
serviceRegistry.add("field", fieldService);
},
});
QUnit.test("loadPath", async (assert) => {
const toTest = [
{
resModel: "tortoise",
path: "*",
expectedResult: {
names: ["*"],
modelsInfo: [getModelInfo("tortoise")],
},
},
{
resModel: "tortoise",
path: "*.a",
expectedResult: {
isInvalid: "path",
names: ["*", "a"],
modelsInfo: [getModelInfo("tortoise")],
},
},
{
resModel: "tortoise",
path: "location_id.*",
expectedResult: {
names: ["location_id", "*"],
modelsInfo: [getModelInfo("tortoise"), getModelInfo("location")],
},
},
{
resModel: "tortoise",
path: "age",
expectedResult: {
names: ["age"],
modelsInfo: [getModelInfo("tortoise")],
},
},
{
resModel: "tortoise",
path: "location_id",
expectedResult: {
names: ["location_id"],
modelsInfo: [getModelInfo("tortoise")],
},
},
{
resModel: "tortoise",
path: "location_id.tortoise_ids",
expectedResult: {
names: ["location_id", "tortoise_ids"],
modelsInfo: [getModelInfo("tortoise"), getModelInfo("location")],
},
},
{
resModel: "tortoise",
path: "location_id.tortoise_ids.age",
expectedResult: {
names: ["location_id", "tortoise_ids", "age"],
modelsInfo: [
getModelInfo("tortoise"),
getModelInfo("location"),
getModelInfo("tortoise"),
],
},
},
{
resModel: "tortoise",
path: "location_id.tortoise_ids.age",
expectedResult: {
names: ["location_id", "tortoise_ids", "age"],
modelsInfo: [
getModelInfo("tortoise"),
getModelInfo("location"),
getModelInfo("tortoise"),
],
},
},
];
const env = await makeTestEnv({ serverData });
for (const { resModel, path, expectedResult } of toTest) {
const result = await env.services.field.loadPath(resModel, path);
assert.deepEqual(result, expectedResult);
}
const errorToTest = [
{ resModel: "notAModel" },
{ resModel: "tortoise", path: {} },
{ resModel: "tortoise", path: "" },
];
for (const { resModel, path } of errorToTest) {
try {
await env.services.field.loadPath(resModel, path);
} catch {
assert.step("error");
}
}
assert.verifySteps(errorToTest.map(() => "error"));
});
QUnit.test("store loadFields calls in cache in success", async (assert) => {
assert.expect(2);
const mockRPC = (route) => {
if (route.includes("fields_get")) {
assert.step("fields_get");
}
};
const env = await makeTestEnv({ serverData, mockRPC });
await env.services.field.loadFields("tortoise");
await env.services.field.loadFields("tortoise");
assert.verifySteps(["fields_get"]);
});
QUnit.test("does not store loadFields calls in cache when failed", async (assert) => {
assert.expect(5);
const mockRPC = (route) => {
if (route.includes("fields_get")) {
assert.step("fields_get");
return Promise.reject("my little error");
}
};
const env = await makeTestEnv({ serverData, mockRPC });
try {
await env.services.field.loadFields("take.five");
} catch (error) {
assert.strictEqual(error, "my little error");
}
try {
await env.services.field.loadFields("take.five");
} catch (error) {
assert.strictEqual(error, "my little error");
}
assert.verifySteps(["fields_get", "fields_get"]);
});
@@ -6,12 +6,12 @@ import { ormService } from "@web/core/orm_service";
import { popoverService } from "@web/core/popover/popover_service";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { viewService } from "@web/views/view_service";
import { makeTestEnv } from "../helpers/mock_env";
import { click, getFixture, triggerEvent, mount, editInput } from "../helpers/utils";
import { makeFakeLocalizationService } from "../helpers/mock_services";
import { Component, xml } from "@odoo/owl";
import { fieldService } from "@web/core/field_service";
let target;
let serverData;
@@ -60,7 +60,7 @@ QUnit.module("Components", (hooks) => {
registry.category("services").add("orm", ormService);
registry.category("services").add("localization", makeFakeLocalizationService());
registry.category("services").add("ui", uiService);
registry.category("services").add("view", viewService);
registry.category("services").add("field", fieldService);
target = getFixture();
});
@@ -13,6 +13,8 @@ import {
triggerEvent,
} from "@web/../tests/helpers/utils";
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
import { fieldService } from "@web/core/field_service";
import { registry } from "@web/core/registry";
let serverData;
let target;
@@ -91,6 +93,7 @@ QUnit.module("Fields", (hooks) => {
};
setupViewRegistries();
registry.category("services").add("field", fieldService);
});
QUnit.module("DomainField");
@@ -815,7 +818,7 @@ QUnit.module("Fields", (hooks) => {
assert.verifySteps(["/web/webclient/load_menus"]);
await doAction(webClient, 1);
assert.verifySteps(["/web/action/load", "get_views", "read", "search_count"]);
assert.verifySteps(["/web/action/load", "get_views", "read", "search_count", "fields_get"]);
assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain);
assert.containsOnce(target, ".o_datepicker", "there should be a datepicker");
@@ -12,6 +12,8 @@ import {
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { browser } from "@web/core/browser/browser";
import { fieldService } from "@web/core/field_service";
import { registry } from "@web/core/registry";
let serverData;
let target;
@@ -133,10 +135,10 @@ QUnit.module("Fields", (hooks) => {
id: 3,
display_name: "third partner",
properties: [
{name: "property_1", type: "char"},
{name: "property_2", type: "char", definition_deleted: true},
{name: "property_3", type: "char", definition_changed: true},
{name: "property_4", type: "char"},
{ name: "property_1", type: "char" },
{ name: "property_2", type: "char", definition_deleted: true },
{ name: "property_3", type: "char", definition_changed: true },
{ name: "property_4", type: "char" },
],
company_id: 37,
},
@@ -144,8 +146,8 @@ QUnit.module("Fields", (hooks) => {
id: 4,
display_name: "fourth partner",
properties: [
{name: "property_2", type: "char", definition_deleted: true},
{name: "property_3", type: "char", definition_deleted: true},
{ name: "property_2", type: "char", definition_deleted: true },
{ name: "property_3", type: "char", definition_deleted: true },
],
company_id: 37,
},
@@ -220,6 +222,7 @@ QUnit.module("Fields", (hooks) => {
};
setupViewRegistries();
registry.category("services").add("field", fieldService);
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
@@ -1308,18 +1311,20 @@ QUnit.module("Fields", (hooks) => {
* Check the behavior of the domain (properies with "definition_deleted" should be ignored).
* In that case, some properties start without the flag "definition_deleted".
*/
QUnit.test("properties: form view and falsy domain, properties are not empty", async function (assert) {
async function mockRPC(route, { method, model, kwargs }) {
if (method === "check_access_rights") {
return true;
QUnit.test(
"properties: form view and falsy domain, properties are not empty",
async function (assert) {
async function mockRPC(route, { method, model, kwargs }) {
if (method === "check_access_rights") {
return true;
}
}
}
await makeView({
type: "form",
resModel: "partner",
resId: 3,
serverData,
arch: `
await makeView({
type: "form",
resModel: "partner",
resId: 3,
serverData,
arch: `
<form>
<sheet>
<group>
@@ -1332,45 +1337,48 @@ QUnit.module("Fields", (hooks) => {
</group>
</sheet>
</form>`,
mockRPC,
});
assert.ok(target.querySelector(".o_test_properties_not_empty"));
mockRPC,
});
assert.ok(target.querySelector(".o_test_properties_not_empty"));
// delete a property, 2 properties left
await click(target, ".o_property_field:first-child .o_field_property_open_popover");
await click(target, ".o_field_property_definition_delete");
await click(target, ".modal-content .btn-primary");
assert.ok(target.querySelector(".o_test_properties_not_empty"));
// delete a property, 2 properties left
await click(target, ".o_property_field:first-child .o_field_property_open_popover");
await click(target, ".o_field_property_definition_delete");
await click(target, ".modal-content .btn-primary");
assert.ok(target.querySelector(".o_test_properties_not_empty"));
// delete a property, 1 property left
await click(target, ".o_property_field:first-child .o_field_property_open_popover");
await click(target, ".o_field_property_definition_delete");
await click(target, ".modal-content .btn-primary");
assert.ok(target.querySelector(".o_test_properties_not_empty"));
// delete a property, 1 property left
await click(target, ".o_property_field:first-child .o_field_property_open_popover");
await click(target, ".o_field_property_definition_delete");
await click(target, ".modal-content .btn-primary");
assert.ok(target.querySelector(".o_test_properties_not_empty"));
// delete a property, no property left
await click(target, ".o_property_field:first-child .o_field_property_open_popover");
await click(target, ".o_field_property_definition_delete");
await click(target, ".modal-content .btn-primary");
assert.notOk(target.querySelector(".o_test_properties_not_empty"));
});
// delete a property, no property left
await click(target, ".o_property_field:first-child .o_field_property_open_popover");
await click(target, ".o_field_property_definition_delete");
await click(target, ".modal-content .btn-primary");
assert.notOk(target.querySelector(".o_test_properties_not_empty"));
}
);
/**
* Check the behavior of the domain (properties with "definition_deleted" should be ignored).
* In that case, all properties start with the flag "definition_deleted".
*/
QUnit.test("properties: form view and falsy domain, properties are empty", async function (assert) {
async function mockRPC(route, { method, model, kwargs }) {
if (method === "check_access_rights") {
return true;
QUnit.test(
"properties: form view and falsy domain, properties are empty",
async function (assert) {
async function mockRPC(route, { method, model, kwargs }) {
if (method === "check_access_rights") {
return true;
}
}
}
await makeView({
type: "form",
resModel: "partner",
resId: 4,
serverData,
arch: `
await makeView({
type: "form",
resModel: "partner",
resId: 4,
serverData,
arch: `
<form>
<sheet>
<group>
@@ -1383,12 +1391,13 @@ QUnit.module("Fields", (hooks) => {
</group>
</sheet>
</form>`,
mockRPC,
});
assert.notOk(target.querySelector(".o_test_properties_not_empty"));
mockRPC,
});
assert.notOk(target.querySelector(".o_test_properties_not_empty"));
// create the first property
await click(target, ".o_field_property_add button");
assert.ok(target.querySelector(".o_test_properties_not_empty"));
});
// create the first property
await click(target, ".o_field_property_add button");
assert.ok(target.querySelector(".o_test_properties_not_empty"));
}
);
});
@@ -19,7 +19,8 @@ QUnit.module("View service", (hooks) => {
records: [],
},
"ir.ui.view": {
fields: {}, records: []
fields: {},
records: [],
},
};
@@ -142,77 +143,4 @@ QUnit.module("View service", (hooks) => {
await loadView();
assert.verifySteps([]); // cache was not invalidated
});
QUnit.test("loadViews stores fields in cache", async (assert) => {
assert.expect(2);
const mockRPC = (route, args) => {
if (route.includes("get_views")) {
assert.step("get_views");
}
if (route.includes("fields_get")) {
assert.step("fields_get");
}
};
await makeMockServer(serverData, mockRPC);
const env = await makeTestEnv();
await env.services.views.loadViews(
{
resModel: "take.five",
views: [[99, "list"]],
context: { default_field_value: 1 },
},
{}
);
await env.services.views.loadFields("take.five");
assert.verifySteps(["get_views"]);
});
QUnit.test("store loadFields calls in cache in success", async (assert) => {
assert.expect(2);
const mockRPC = (route, args) => {
if (route.includes("fields_get")) {
assert.step("fields_get");
}
};
await makeMockServer(serverData, mockRPC);
const env = await makeTestEnv();
await env.services.views.loadFields("take.five");
await env.services.views.loadFields("take.five");
assert.verifySteps(["fields_get"]);
});
QUnit.test("store loadFields calls in cache when failed", async (assert) => {
assert.expect(5);
const mockRPC = (route, args) => {
if (route.includes("fields_get")) {
assert.step("fields_get");
return Promise.reject("my little error");
}
};
await makeMockServer(serverData, mockRPC);
const env = await makeTestEnv();
try {
await env.services.views.loadFields("take.five");
} catch (error) {
assert.strictEqual(error, "my little error");
}
try {
await env.services.views.loadFields("take.five");
} catch (error) {
assert.strictEqual(error, "my little error");
}
assert.verifySteps(["fields_get", "fields_get"]);
});
});
+2
View File
@@ -5,6 +5,7 @@ declare module "services" {
import { cookieService } from "@web/core/browser/cookie_service";
import { dialogService } from "@web/core/dialog/dialog_service";
import { effectService } from "@web/core/effects/effect_service";
import { fieldService } from "@web/core/field_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { httpService } from "@web/core/network/http_service";
import { menuService } from "@web/webclient/menus/menu_service";
@@ -25,6 +26,7 @@ declare module "services" {
cookie: ReturnType<typeof cookieService.start>;
dialog: ReturnType<typeof dialogService.start>;
effect: ReturnType<typeof effectService.start>;
field: ReturnType<typeof fieldService.start>;
hotkey: ReturnType<typeof hotkeyService.start>;
http: ReturnType<typeof httpService.start>;
menu: Awaited<ReturnType<typeof menuService.start>>;