From 46e35941189017ea5c139798bdf0adac05009d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Mon, 23 Oct 2023 17:50:48 +0200 Subject: [PATCH] [REF] mail: introduce Record.attr() on discuss JS models In preparation to simplify mail.record/insert processing (Message.body markup) Part-of: odoo/odoo#139501 --- .../static/src/core/common/message_model.js | 3 +- addons/mail/static/src/core/common/record.js | 105 ++++++++++++------ .../static/src/core/common/store_service.js | 62 ++++++----- 3 files changed, 105 insertions(+), 65 deletions(-) diff --git a/addons/mail/static/src/core/common/message_model.js b/addons/mail/static/src/core/common/message_model.js index 6d7e2b664ce..e1bd11bbccf 100644 --- a/addons/mail/static/src/core/common/message_model.js +++ b/addons/mail/static/src/core/common/message_model.js @@ -89,8 +89,7 @@ export class Message extends Record { attachments = Record.many("Attachment", { inverse: "message" }); author = Record.one("Persona"); - /** @type {string} */ - body; + body = Record.attr(""); composer = Record.one("Composer", { inverse: "message", onDelete: (r) => r.delete() }); /** @type {string} */ default_subject; diff --git a/addons/mail/static/src/core/common/record.js b/addons/mail/static/src/core/common/record.js index 235a357fcb1..3771b8045a1 100644 --- a/addons/mail/static/src/core/common/record.js +++ b/addons/mail/static/src/core/common/record.js @@ -5,12 +5,15 @@ import { registry } from "@web/core/registry"; export const modelRegistry = registry.category("discuss.model"); +const ATTR_SYM = Symbol("attr"); const MANY_SYM = Symbol("many"); const ONE_SYM = Symbol("one"); const OR_SYM = Symbol("or"); const AND_SYM = Symbol("and"); const IS_RECORD_SYM = Symbol("isRecord"); +/** @typedef {ATTR_SYM|MANY_SYM|ONE_SYM} FIELD_SYM */ + export function AND(...args) { return [AND_SYM, ...args]; } @@ -101,18 +104,18 @@ export class RecordList extends Array { if (r2 && r2.notEq(r3)) { r2.__uses__.delete(receiver); } - const { inverse, onDelete } = receiver.owner.Model.__rels__[receiver.name]; + const { inverse, onDelete } = receiver.owner.Model._fields[receiver.name]; onDelete?.call(receiver.owner, r2); if (inverse) { - r2.__rels__[inverse].delete(receiver); + r2._fields[inverse].delete(receiver); } receiver.data[index] = r3?.localId; if (r3) { r3.__uses__.add(receiver); - const { inverse, onAdd } = receiver.owner.Model.__rels__[receiver.name]; + const { inverse, onAdd } = receiver.owner.Model._fields[receiver.name]; onAdd?.call(receiver.owner, r3); if (inverse) { - r3.__rels__[inverse].add(receiver); + r3._fields[inverse].add(receiver); } } }); @@ -143,7 +146,7 @@ export class RecordList extends Array { * comes from deletion, we want to "DELETE". */ _insert(val, fn, { inv = true, mode = "ADD" } = {}) { - const { inverse } = this.owner.Model.__rels__[this.name]; + const { inverse } = this.owner.Model._fields[this.name]; if (inverse && inv) { // special command to call _addNoinv/_deleteNoInv, to prevent infinite loop val[inverse] = [[mode === "ADD" ? "ADD.noinv" : "DELETE.noinv", this.owner]]; @@ -151,7 +154,7 @@ export class RecordList extends Array { /** @type {R} */ let r3; if (!Record.isRecord(val)) { - const { targetModel } = this.owner.Model.__rels__[this.name]; + const { targetModel } = this.owner.Model._fields[this.name]; r3 = this.owner.Model.store[targetModel].preinsert(val); } else { r3 = val; @@ -159,7 +162,7 @@ export class RecordList extends Array { fn(r3); if (!Record.isRecord(val)) { // was preinserted, fully insert now - const { targetModel } = this.owner.Model.__rels__[this.name]; + const { targetModel } = this.owner.Model._fields[this.name]; this.owner.Model.store[targetModel].insert(val); } return r3; @@ -178,10 +181,10 @@ export class RecordList extends Array { this.data.push(r3.localId); r3.__uses__.add(this); }); - const { inverse, onAdd } = this.owner.Model.__rels__[this.name]; + const { inverse, onAdd } = this.owner.Model._fields[this.name]; onAdd?.call(this.owner, r); if (inverse) { - r.__rels__[inverse].add(this.owner); + r._fields[inverse].add(this.owner); } } return this.data.length; @@ -198,11 +201,11 @@ export class RecordList extends Array { shift() { const r2 = this.store.get(this.data.shift()); r2?.__uses__.delete(this); - const { inverse, onDelete } = this.owner.Model.__rels__[this.name]; + const { inverse, onDelete } = this.owner.Model._fields[this.name]; if (r2) { onDelete?.call(this.owner, r2); if (inverse) { - r2.__rels__[inverse].delete(this.owner); + r2._fields[inverse].delete(this.owner); } } return r2; @@ -214,10 +217,10 @@ export class RecordList extends Array { this.data.unshift(r3.localId); r3.__uses__.add(this); }); - const { inverse, onAdd } = this.owner.Model.__rels__[this.name]; + const { inverse, onAdd } = this.owner.Model._fields[this.name]; onAdd?.call(this.owner, r); if (inverse) { - r.__rels__[inverse].add(this.owner); + r._fields[inverse].add(this.owner); } } return this.data.length; @@ -287,18 +290,18 @@ export class RecordList extends Array { this.data.splice(start, deleteCount, ...newRecords.map((r) => r.localId)); for (const r of oldRecords) { r.__uses__.delete(this); - const { inverse, onDelete } = this.owner.Model.__rels__[this.name]; + const { inverse, onDelete } = this.owner.Model._fields[this.name]; onDelete?.call(this.owner, r); if (inverse) { - r.__rels__[inverse].delete(this.owner); + r._fields[inverse].delete(this.owner); } } for (const r of newRecords) { r.__uses__.add(this); - const { inverse, onAdd } = this.owner.Model.__rels__[this.name]; + const { inverse, onAdd } = this.owner.Model._fields[this.name]; onAdd?.call(this.owner, r); if (inverse) { - r.__rels__[inverse].add(this.owner); + r._fields[inverse].add(this.owner); } } } @@ -435,12 +438,20 @@ export class RecordList extends Array { } /** - * @typedef {Object} RelationDefinition - * @property {string} [targetModel] - * @property {Function} [compute] - * @property {string} [inverse] - * @property {Function} [onAdd] - * @property {Function} [onDelete] + * @typedef {Object} FieldDefinition + * @property {boolean} [ATTR_SYM] true when this is an attribute, i.e. a non-relational field. + * @property {boolean} [MANY_SYM] true when this is a many relation. + * @property {boolean} [ONE_SYM] true when this is a one relation. + * @property {any} [default] the default value of this attribute. + * @property {string} [targetModel] model name of records contained in this relational field. + * @property {Function} [compute] if set the field is computed based on provided function. + * The `this` of function is the record, and the function is recalled whenever any field + * in models used by this compute function is changed. + * @property {string} [inverse] name of inverse relational field in targetModel. + * @property {Function} [onAdd] hook that is called when relation is updated + * with a record being added. Callback param is record being added into relation. + * @property {Function} [onDelete] hook that is called when relation is updated + * with a record being deleted. Callback param is record being deleted from relation. */ export class Record { @@ -450,16 +461,27 @@ export class Record { /** @type {import("@mail/core/common/store_service").Store} */ static store; /** - * Contains tracked relational fields of the model: - * - key : (relational) field name - * - value: Value contains definition of relational field + * Contains field definitions of the model: + * - key : field name + * - value: Value contains definition of field * - * @type {Object.} + * @type {Object.} */ - static __rels__ = {}; + static _fields = {}; static isRecord(record) { return Boolean(record?.[IS_RECORD_SYM]); } + /** @param {FIELD_SYM|RecordList} val */ + static isRelation(val) { + if ([MANY_SYM, ONE_SYM].includes(val)) { + return true; + } + return RecordList.isOne(val) || RecordList.isMany(val); + } + /** @param {FIELD_SYM} SYM */ + static isField(SYM) { + return [MANY_SYM, ONE_SYM, ATTR_SYM].includes(SYM); + } static get(data) { return this.records[this.localId(data)]; } @@ -480,10 +502,13 @@ export class Record { } static _localId(expr, data, { brackets = false } = {}) { if (!Array.isArray(expr)) { - if (expr in this.__rels__) { - if (RecordList.isMany(this.__rels__[expr])) { + if (expr in this._fields) { + if (RecordList.isMany(this._fields[expr])) { throw new Error("Using a Record.Many() as id is not (yet) supported"); } + if (!Record.isRelation(this._fields[expr])) { + return data[expr]; + } if (this.isCommand(data[expr])) { // Note: only Record.one() is supported const [cmd, data2] = data[expr].at(-1); @@ -634,6 +659,14 @@ export class Record { static many(targetModel, { compute, inverse, onAdd, onDelete } = {}) { return [MANY_SYM, { targetModel, compute, inverse, onAdd, onDelete }]; } + /** + * @template T + * @param {T} def; + * @returns {T} + */ + static attr(def) { + return [ATTR_SYM, { default: def }]; + } /** @returns {Record} */ static insert(data) { const res = this.preinsert(data); @@ -677,7 +710,7 @@ export class Record { * * @type {Object} */ - __rels__ = {}; + _fields = {}; __uses__ = new RecordUses(); get _store() { return this.Model.store; @@ -711,7 +744,7 @@ export class Record { Object.assign(this, data); } else { // update on single-id data - if (this.Model.id in this.Model.__rels__) { + if (this.Model.id in this.Model._fields) { this[this.Model.id] = data; } } @@ -719,7 +752,7 @@ export class Record { delete() { const r1 = this; - for (const name in r1.__rels__) { + for (const name in r1._fields) { r1[name] = undefined; } for (const [localId, names] of r1.__uses__.data.entries()) { @@ -730,7 +763,7 @@ export class Record { r1.__uses__.data.delete(localId); continue; } - const l2 = r2.__rels__[name2]; + const l2 = r2._fields[name2]; if (RecordList.isMany(l2)) { for (let c = 0; c < count; c++) { r2[name2].delete(r1); @@ -772,7 +805,7 @@ export class Record { toData() { const data = { ...this }; - for (const [name, val] of Object.entries(this.__rels__)) { + for (const [name, val] of Object.entries(this._fields)) { if (RecordList.isMany(val)) { data[name] = val.map((r) => r.toIdData()); } else { @@ -780,7 +813,7 @@ export class Record { } } delete data._store; - delete data.__rels__; + delete data._fields; delete data.__uses__; delete data.Model; return data; diff --git a/addons/mail/static/src/core/common/store_service.js b/addons/mail/static/src/core/common/store_service.js index 5cafdd9998b..0df2e31e09b 100644 --- a/addons/mail/static/src/core/common/store_service.js +++ b/addons/mail/static/src/core/common/store_service.js @@ -151,8 +151,8 @@ export function makeStore(env) { const proxy = new Proxy(this, { /** @param {Record} receiver */ get(target, name, receiver) { - if (name !== "__rels__" && name in receiver.__rels__) { - const l1 = receiver.__rels__[name]; + if (name !== "_fields" && name in receiver._fields) { + const l1 = receiver._fields[name]; if (RecordList.isMany(l1)) { return l1; } @@ -161,9 +161,9 @@ export function makeStore(env) { return Reflect.get(target, name, receiver); }, deleteProperty(target, name) { - if (name !== "__rels__" && name in target.__rels__) { + if (name !== "_fields" && name in target._fields) { const r1 = target; - const l1 = r1.__rels__[name]; + const l1 = r1._fields[name]; l1.clear(); return true; } @@ -172,12 +172,12 @@ export function makeStore(env) { }, /** @param {Record} receiver */ set(target, name, val, receiver) { - if (!(name in receiver.__rels__)) { + if (!(name in receiver._fields)) { Reflect.set(target, name, val, receiver); return true; } /** @type {RecordList} */ - const l1 = receiver.__rels__[name]; + const l1 = receiver._fields[name]; if (RecordList.isMany(l1)) { // [Record.many] = if (Record.isCommand(val)) { @@ -253,23 +253,27 @@ export function makeStore(env) { if (this instanceof BaseStore) { res.store = proxy; } - for (const name in Model.__rels__) { - // Relational fields contain symbols for detection in original class. - // This constructor is called on genuine records: - // - 'one' fields => undefined - // - 'many' fields => RecordList - // this[name]?.[0] is ONE_SYM or MANY_SYM - const newVal = new RecordList(this[name]?.[0]); - if (this instanceof BaseStore) { - newVal.store = proxy; + for (const name in Model._fields) { + if (Record.isRelation(this[name]?.[0])) { + // Relational fields contain symbols for detection in original class. + // This constructor is called on genuine records: + // - 'one' fields => undefined + // - 'many' fields => RecordList + // this[name]?.[0] is ONE_SYM or MANY_SYM + const newVal = new RecordList(this[name]?.[0]); + if (this instanceof BaseStore) { + newVal.store = proxy; + } else { + newVal.store = res.store; + } + newVal.name = name; + newVal.owner = proxy; + this._fields[name] = newVal; + this.__uses__ = new RecordUses(); + this[name] = newVal; } else { - newVal.store = res.store; + this[name] = Model._fields[name].default; } - newVal.name = name; - newVal.owner = proxy; - this.__rels__[name] = newVal; - this.__uses__ = new RecordUses(); - this[name] = newVal; } for (const [name, fn] of Object.entries(Model.__computes__)) { let boundFn; @@ -284,19 +288,19 @@ export function makeStore(env) { Object.assign(Model, { Class, records: JSON.parse(JSON.stringify(OgClass.records)), - __rels__: {}, + _fields: {}, __computes__: {}, }); Models[name] = Model; res.store[name] = Model; - // Detect relational fields with a dummy record and setup getter/setters on them + // Detect fields with a dummy record and setup getter/setters on them const obj = new OgClass(); for (const [name, val] of Object.entries(obj)) { const SYM = val?.[0]; - if (![Record.one()[0], Record.many()[0]].includes(SYM)) { + if (!Record.isField(SYM)) { continue; } - Model.__rels__[name] = { [SYM]: true, ...val[1] }; + Model._fields[name] = { [SYM]: true, ...val[1] }; if (val[1].compute) { Model.__computes__[name] = val[1].compute; } @@ -304,12 +308,16 @@ export function makeStore(env) { } // Sync inverse fields for (const Model of Object.values(Models)) { - for (const [name, { targetModel, inverse }] of Object.entries(Model.__rels__)) { + for (const [name, definition] of Object.entries(Model._fields)) { + if (!Record.isRelation(definition)) { + continue; + } + const { targetModel, inverse } = definition; if (targetModel && !Models[targetModel]) { throw new Error(`No target model ${targetModel} exists`); } if (inverse) { - const rel2 = Models[targetModel].__rels__[inverse]; + const rel2 = Models[targetModel]._fields[inverse]; if (rel2.targetModel && rel2.targetModel !== Model.name) { throw new Error( `Fields ${Models[targetModel].name}.${inverse} has wrong targetModel. Expected: "${Model.name}" Actual: "${rel2.targetModel}"`