[FIX] mail: clean up JS relations by storing records

Remove the support and the conversion from record to localId and back from
localId to record.

Part of task-2308312

closes odoo/odoo#55481

X-original-commit: fc5e8be4e65518770a281f0dffae07854d20073a
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Sébastien Theys
2020-08-05 15:09:34 +00:00
parent 9432d54705
commit 9216bdcb6f
24 changed files with 366 additions and 589 deletions
@@ -750,7 +750,7 @@ QUnit.test('focus next visible chat window when closing current chat window with
QUnit.test('[technical] chat window: composer state conservation on toggle home menu', async function (assert) {
// technical as show/hide home menu simulation are involved and home menu implementation
// have side-effects on DOM that may make chat window components not work
assert.expect(6);
assert.expect(7);
Object.assign(this.data.initMessaging, {
channel_slots: {
@@ -775,6 +775,11 @@ QUnit.test('[technical] chat window: composer state conservation on toggle home
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, 'XDU for the win !');
});
assert.containsNone(
document.body,
'.o_Composer .o_Attachment',
"composer should have no attachment initially"
);
// Set attachments of the composer
const files = [
await createFile({
@@ -803,7 +808,7 @@ QUnit.test('[technical] chat window: composer state conservation on toggle home
document.body,
'.o_Composer .o_Attachment',
2,
"verify chat window composer initial attachment count"
"composer should have 2 total attachments after adding 2 attachments"
);
await this.hideHomeMenu();
@@ -55,6 +55,7 @@ QUnit.module('chatter_tests.js', {
QUnit.test('base rendering when chatter has no attachment', async function (assert) {
assert.expect(6);
this.data['res.partner'].records.push({ id: 100 });
const messages = [...Array(60).keys()].map(id => {
return {
author_id: [10, "Demo User"],
@@ -157,6 +158,7 @@ QUnit.test('base rendering when chatter has no record', async function (assert)
QUnit.test('base rendering when chatter has attachments', async function (assert) {
assert.expect(3);
this.data['res.partner'].records.push({ id: 100 });
await this.start({
async mockRPC(route, args) {
if (route.includes('ir.attachment/search_read')) {
@@ -200,6 +202,7 @@ QUnit.test('base rendering when chatter has attachments', async function (assert
QUnit.test('show attachment box', async function (assert) {
assert.expect(6);
this.data['res.partner'].records.push({ id: 100 });
await this.start({
async mockRPC(route, args) {
if (route.includes('ir.attachment/search_read')) {
@@ -262,6 +265,7 @@ QUnit.test('show attachment box', async function (assert) {
QUnit.test('composer show/hide on log note/send message', async function (assert) {
assert.expect(10);
this.data['res.partner'].records.push({ id: 100 });
await this.start();
const chatter = this.env.models['mail.chatter'].create({
threadId: 100,
@@ -343,6 +347,7 @@ QUnit.test('composer show/hide on log note/send message', async function (assert
QUnit.test('should not display user notification messages in chatter', async function (assert) {
assert.expect(1);
this.data['res.partner'].records.push({ id: 100 });
this.data['mail.message'].records = [{
author_id: [7, "Demo"],
body: "<p>User notification</p>",
@@ -57,6 +57,7 @@ QUnit.module('chatter_topbar_tests.js', {
QUnit.test('base rendering', async function (assert) {
assert.expect(8);
this.data['res.partner'].records.push({ id: 100 });
await this.start();
const chatter = this.env.models['mail.chatter'].create({
threadId: 100,
@@ -155,6 +156,7 @@ QUnit.test('base disabled rendering', async function (assert) {
QUnit.test('attachment loading is delayed', async function (assert) {
assert.expect(4);
this.data['res.partner'].records.push({ id: 100 });
await this.start({
hasTimeControl: true,
loadingBaseDelayDuration: 100,
@@ -198,6 +200,7 @@ QUnit.test('attachment loading is delayed', async function (assert) {
QUnit.test('attachment counter while loading attachments', async function (assert) {
assert.expect(4);
this.data['res.partner'].records.push({ id: 100 });
await this.start({
async mockRPC(route) {
if (route.includes('ir.attachment/search_read')) {
@@ -237,6 +240,7 @@ QUnit.test('attachment counter while loading attachments', async function (asser
QUnit.test('attachment counter transition when attachments become loaded)', async function (assert) {
assert.expect(7);
this.data['res.partner'].records.push({ id: 100 });
const attachmentPromise = makeTestPromise();
await this.start({
async mockRPC(route) {
@@ -295,6 +299,7 @@ QUnit.test('attachment counter transition when attachments become loaded)', asyn
QUnit.test('attachment counter without attachments', async function (assert) {
assert.expect(4);
this.data['res.partner'].records.push({ id: 100 });
await this.start();
const chatter = this.env.models['mail.chatter'].create({
threadId: 100,
@@ -327,6 +332,7 @@ QUnit.test('attachment counter without attachments', async function (assert) {
QUnit.test('attachment counter with attachments', async function (assert) {
assert.expect(4);
this.data['res.partner'].records.push({ id: 100 });
await this.start({
async mockRPC(route) {
if (route.includes('ir.attachment/search_read')) {
@@ -376,6 +382,7 @@ QUnit.test('attachment counter with attachments', async function (assert) {
QUnit.test('composer state conserved when clicking on another topbar button', async function (assert) {
assert.expect(8);
this.data['res.partner'].records.push({ id: 100 });
await this.start();
const chatter = this.env.models['mail.chatter'].create({
threadId: 100,
@@ -586,6 +593,7 @@ QUnit.test('rendering with multiple channel followers', async function (assert)
QUnit.test('log note/send message switching', async function (assert) {
assert.expect(8);
this.data['res.partner'].records.push({ id: 100 });
await this.start();
const chatter = this.env.models['mail.chatter'].create({
threadId: 100,
@@ -645,6 +653,7 @@ QUnit.test('log note/send message switching', async function (assert) {
QUnit.test('log note toggling', async function (assert) {
assert.expect(4);
this.data['res.partner'].records.push({ id: 100 });
await this.start();
const chatter = this.env.models['mail.chatter'].create({
threadId: 100,
@@ -684,6 +693,7 @@ QUnit.test('log note toggling', async function (assert) {
QUnit.test('send message toggling', async function (assert) {
assert.expect(4);
this.data['res.partner'].records.push({ id: 100 });
await this.start();
const chatter = this.env.models['mail.chatter'].create({
threadId: 100,
@@ -82,7 +82,7 @@ class Dialog extends Component {
) {
return;
}
this.dialog.close();
this.dialog.delete();
}
/**
@@ -91,7 +91,7 @@ class Dialog extends Component {
*/
_onKeydownDocument(ev) {
if (ev.key === 'Escape') {
this.dialog.close();
this.dialog.delete();
}
}
@@ -3,7 +3,7 @@
<t t-name="mail.DialogManager" owl="1">
<div class="o_DialogManager">
<t t-foreach="env.messaging.dialogManager.allOrdered" t-as="dialog" t-key="dialog.localId">
<t t-foreach="env.messaging.dialogManager.dialogs" t-as="dialog" t-key="dialog.localId">
<Dialog
class="o_DialogManager_dialog"
dialogLocalId="dialog.localId"
@@ -175,7 +175,7 @@ QUnit.test('rendering when just one has seen the message', async function (asser
author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]],
body: "<p>Test</p>",
id: 100,
originThread: [['link', thread.main]],
originThread: [['link', thread]],
});
await this.createMessageSeenIndicatorComponent({ message, thread });
assert.containsOnce(
+1 -2
View File
@@ -1,7 +1,6 @@
odoo.define('mail/static/src/env/test_env.js', function (require) {
'use strict';
const ModelManager = require('mail/static/src/model/model_manager.js');
const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
const { nextTick } = require('mail/static/src/utils/utils.js');
@@ -74,7 +73,7 @@ function addMessagingToEnv(providedEnv = {}) {
messaging: undefined,
messagingInitializedDeferred: makeDeferred(),
messagingBus: new EventBus(),
modelManager: new ModelManager(),
modelManager: undefined,
store,
});
+1 -1
View File
@@ -43,7 +43,7 @@ Object.assign(env, {
loadingBaseDelayDuration: 400,
messaging: undefined,
messagingBus: new EventBus(),
modelManager: new ModelManager(),
modelManager: new ModelManager(env),
store,
});
+150 -377
View File
@@ -13,28 +13,21 @@ class ModelField {
//--------------------------------------------------------------------------
constructor({
autocreate = false,
compute,
default: def,
dependencies = [],
dependents = [],
env,
fieldName,
fieldType,
hashes: extraHashes = [],
inverse,
isCausal = false,
modelManager,
related,
relationType,
to,
} = {}) {
const id = _.uniqueId('field_');
/**
* This prop only makes sense for fields of type "relation". If set,
* it automatically creates a new record for this field on creation of
* record, and auto-links with this record.
*/
this.autocreate = autocreate;
/**
* If set, this field acts as a computed field, and this prop
* contains the name of the instance method that computes the value
@@ -62,6 +55,10 @@ class ModelField {
* `related`.
*/
this.dependents = dependents;
/**
* The messaging env.
*/
this.env = env;
/**
* Name of the field in the definition of fields on model.
*/
@@ -130,10 +127,6 @@ class ModelField {
* relation is removed, the related record is automatically deleted.
*/
this.isCausal = isCausal;
/**
* Reference to the model manager.
*/
this.modelManager = modelManager;
/**
* If set, this field acts as a related field, and this prop contains
* a string that references the related field. It should have the
@@ -161,6 +154,11 @@ class ModelField {
* model name this relation refers to.
*/
this.to = to;
if (!this.default && this.fieldType === 'relation') {
// default value for relational fields is the empty command
this.default = [];
}
}
/**
@@ -238,28 +236,18 @@ class ModelField {
* field.
*
* @param {mail.model} record
* @returns {boolean} whether the value changed for the current field
*/
doCompute(record) {
if (this.compute) {
record.update({ [this.fieldName]: record[this.compute]() });
return;
return record.update({ [this.fieldName]: record[this.compute]() });
}
if (this.related) {
record.update({ [this.fieldName]: this._computeRelated(record) });
return;
return record.update({ [this.fieldName]: this._computeRelated(record) });
}
throw new Error("No compute method defined on this field definition");
}
/**
* Get the env with messaging.
*
* @returns {mail/static/src/env/env.js}
*/
get env() {
return this.modelManager.env;
}
/**
* Get the value associated to this field. Relations must convert record
* local ids to records.
@@ -272,15 +260,10 @@ class ModelField {
return this.read(record);
}
if (this.fieldType === 'relation') {
const OtherModel = this.env.models[this.to];
if (['one2one', 'many2one'].includes(this.relationType)) {
return OtherModel.get(this.read(record));
return this.read(record);
}
const res = [];
for (const localId of this.read(record)) {
res.push(OtherModel.get(localId));
}
return res;
return [...this.read(record)];
}
throw new Error(`cannot get field with unsupported type ${this.fieldType}.`);
}
@@ -302,103 +285,63 @@ class ModelField {
*
* @param {mail.model} record
* @param {any} newVal
* @returns {boolean} whether the value changed for the current field
*/
set(record, newVal) {
const currentValue = this.read(record);
if (this.fieldType === 'attribute') {
if (this.read(record) === newVal) {
return;
if (currentValue === newVal) {
return false;
}
this.write(record, newVal);
record.__values[this.fieldName] = newVal;
return true;
}
if (this.fieldType === 'relation') {
let hasChanged = false;
for (const val of newVal) {
switch (val[0]) {
case 'create':
this._setRelationCreate(record, val[1]);
if (this._setRelationCreate(record, val[1])) {
hasChanged = true;
}
break;
case 'insert':
this._setRelationInsert(record, val[1]);
if (this._setRelationInsert(record, val[1])) {
hasChanged = true;
}
break;
case 'insert-and-replace':
this._setRelationInsertAndReplace(record, val[1]);
if (this._setRelationInsertAndReplace(record, val[1])) {
hasChanged = true;
}
break;
case 'link':
this._setRelationLink(record, val[1]);
if (this._setRelationLink(record, val[1])) {
hasChanged = true;
}
break;
case 'replace':
// TODO IMP replace should not unlink-all (task-2270780)
this._setRelationUnlink(record, null);
this._setRelationLink(record, val[1]);
if (this._setRelationUnlink(record, currentValue)) {
hasChanged = true;
}
if (this._setRelationLink(record, val[1])) {
hasChanged = true;
}
break;
case 'unlink':
this._setRelationUnlink(record, val[1]);
if (this._setRelationUnlink(record, val[1])) {
hasChanged = true;
}
break;
case 'unlink-all':
this._setRelationUnlink(record, null);
if (this._setRelationUnlink(record, currentValue)) {
hasChanged = true;
}
break;
}
}
}
}
/**
* Set a value in state associated to this field. Value corresponds exactly
* that what is stored on this field, like local id or list of local ids
* for a relational field. If the value changes, dependent fields are
* automatically registered for (re-)computation.
*
* @param {mail.model} record
* @param {any} newVal
* @param {Object} [param2={}]
* @param {Object} [param2.registerDependents=true] If set, write
* on this field with changed value registers dependent fields for compute.
* Of course, we almost always want to register them, so that they reflect
* the value with their dependencies. Disabling this feature prevents
* useless potentially heavy computation, like when setting default values.
*/
write(record, newVal, { registerDependents = true } = {}) {
if (this._containsRecords(newVal)) {
throw new Error("Forbidden write operation with records!!");
}
record.__values[this.fieldName] = newVal;
record.__state++;
if (!registerDependents) {
return;
}
// flag all dependent fields for compute
const Model = record.constructor;
for (const dependent of this.dependents) {
const [hash, currentFieldName, relatedFieldName] = dependent.split(
this.modelManager.DEPENDENT_INNER_SEPARATOR
);
const field = Model.fields[currentFieldName];
if (relatedFieldName) {
if (['one2many', 'many2many'].includes(field.relationType)) {
for (const otherRecord of record[currentFieldName]) {
const OtherModel = otherRecord.constructor;
const field = OtherModel.fields[relatedFieldName];
if (field && field.hashes.includes(hash)) {
this.modelManager.registerToComputeField(otherRecord, field);
}
}
} else {
const otherRecord = record[currentFieldName];
if (!otherRecord) {
continue;
}
const OtherModel = otherRecord.constructor;
const field = OtherModel.fields[relatedFieldName];
if (field && field.hashes.includes(hash)) {
this.modelManager.registerToComputeField(otherRecord, field);
}
}
} else {
if (field && field.hashes.includes(hash)) {
this.modelManager.registerToComputeField(record, field);
}
}
return hasChanged;
}
}
@@ -470,58 +413,23 @@ class ModelField {
}
}
/**
* Determines whether the provided value contains some records. Useful to
* prevent writing records directly in state of this field, which should be
* treated as buggy design. Indeed, state of field should only contain
* either a primitive type or a simple datastructure containing itself
* simple datastructures too.
*
* @private
* @param {any} val
* @returns {boolean}
*/
_containsRecords(val) {
if (!val) {
return false;
}
if (val instanceof this.env.models['mail.model']) {
return true;
}
if (!(val instanceof Array)) {
return false;
}
if (val.length > 0 && val[0] instanceof this.env.models['mail.model']) {
return true;
}
return false;
}
/**
* Converts given value to expected format for x2many processing, which is
* an array of localId.
* an iterable of records.
*
* @private
* @param {string|mail.model|<mail.model|string>[]} newValue
* @returns {string[]}
* @param {mail.model|mail.model[]} newValue
* @returns {mail.model[]}
*/
_setRelationConvertX2ManyValue(newValue) {
if (newValue instanceof Array) {
return newValue.map(v => this._setRelationConvertX2OneValue(v));
if (typeof newValue[Symbol.iterator] === 'function') {
for (const value of newValue) {
this._verifyRelationalValue(value);
}
return newValue;
}
return [this._setRelationConvertX2OneValue(newValue)];
}
/**
* Converts given value to expected format for x2one processing, which is
* a localId.
*
* @private
* @param {string|mail.model} newValue
* @returns {string}
*/
_setRelationConvertX2OneValue(newValue) {
return newValue instanceof this.env.models['mail.model'] ? newValue.localId : newValue;
this._verifyRelationalValue(newValue);
return [newValue];
}
/**
@@ -533,20 +441,12 @@ class ModelField {
* @private
* @param {mail.model} record
* @param {Object|Object[]} data
* @returns {boolean} whether the value changed for the current field
*/
_setRelationCreate(record, data) {
const OtherModel = this.env.models[this.to];
let other;
if (['one2one', 'many2one'].includes(this.relationType)) {
other = OtherModel.create(data);
} else {
if (data instanceof Array) {
other = data.map(d => OtherModel.create(d));
} else {
other = OtherModel.create(data);
}
}
this._setRelationLink(record, other);
const other = OtherModel.create(data);
return this._setRelationLink(record, other);
}
/**
@@ -558,20 +458,12 @@ class ModelField {
* @private
* @param {mail.model} record
* @param {Object|Object[]} data
* @returns {boolean} whether the value changed for the current field
*/
_setRelationInsert(record, data) {
const OtherModel = this.env.models[this.to];
let other;
if (['one2one', 'many2one'].includes(this.relationType)) {
other = OtherModel.insert(data);
} else {
if (data instanceof Array) {
other = data.map(d => OtherModel.insert(d));
} else {
other = OtherModel.insert(data);
}
}
this._setRelationLink(record, other);
const other = OtherModel.insert(data);
return this._setRelationLink(record, other);
}
/**
@@ -582,40 +474,40 @@ class ModelField {
* @private
* @param {mail.model} record
* @param {Object|Object[]} data
* @returns {boolean} whether the value changed for the current field
*/
_setRelationInsertAndReplace(record, data) {
// unlink must be done before insert:
// because unlink might trigger delete due to causality and new data
// shouldn't be deleted just after being inserted
// TODO IMP insert-and-replace should not unlink-all (task-2270780)
this._setRelationUnlink(record, null);
let hasChanged = false;
if (this._setRelationUnlink(record, this.read(record))) {
hasChanged = true;
}
const OtherModel = this.env.models[this.to];
const other = OtherModel.insert(data);
this._setRelationLink(record, other);
if (this._setRelationLink(record, other)) {
hasChanged = true;
}
return hasChanged;
}
/**
* Set a 'link' operation on this relational field.
*
* @private
* @param {string|string[]|mail.model|mail.model[]} newValue
* @param {mail.model|mail.model[]} newValue
* @returns {boolean} whether the value changed for the current field
*/
_setRelationLink(record, newValue) {
if (!record.exists()) {
// current record may be deleted due to causality
return;
}
switch (this.relationType) {
case 'many2many':
case 'one2many':
this._setRelationLinkX2Many(record, newValue);
break;
return this._setRelationLinkX2Many(record, newValue);
case 'many2one':
this._setRelationLinkMany2One(record, newValue);
break;
case 'one2one':
this._setRelationLinkOne2One(record, newValue);
break;
return this._setRelationLinkX2One(record, newValue);
}
}
@@ -624,141 +516,54 @@ class ModelField {
*
* @private
* @param {mail.model} record
* @param {string|mail.model|<mail.model|string>[]} newValue
* @param {mail.model|mail.model[]} newValue
* @returns {boolean} whether the value changed for the current field
*/
_setRelationLinkX2Many(record, newValue) {
// convert newValue to array of localId
const newLocalIds = this._setRelationConvertX2ManyValue(newValue);
const OtherModel = this.env.models[this.to];
const toAddOtherRecords = this._setRelationConvertX2ManyValue(newValue);
const otherRecords = this.read(record);
const prevLocalIds = this.read(record);
const newOtherRecords = newLocalIds
.map(newLocalId => OtherModel.get(newLocalId))
.filter(newOtherRecord => {
// other record may be deleted due to causality, avoid linking
// deleted records
if (!newOtherRecord) {
return false;
}
// other record already linked, avoid linking twice
if (prevLocalIds.has(newOtherRecord.localId)) {
return false;
}
return true;
});
if (newOtherRecords.length === 0) {
return;
}
// link other records to current record
for (const newOtherRecord of newOtherRecords) {
prevLocalIds.add(newOtherRecord.localId);
}
this.write(record, prevLocalIds);
// link current record to other records
for (const newOtherRecord of newOtherRecords) {
newOtherRecord.update({
let isAdding = false;
for (const toAddOtherRecord of toAddOtherRecords) {
// other record already linked, avoid linking twice
if (otherRecords.has(toAddOtherRecord)) {
continue;
}
isAdding = true;
// link other records to current record
otherRecords.add(toAddOtherRecord);
// link current record to other records
toAddOtherRecord.update({
[this.inverse]: [['link', record]],
});
}
return isAdding;
}
/**
* Handling of a `set` 'link' of a many2one relational field.
* Handling of a `set` 'link' of an x2one relational field.
*
* @private
* @param {mail.model} record
* @param {string|mail.model} newValue
* @param {mail.model} toAddOtherRecord
* @returns {boolean} whether the value changed for the current field
*/
_setRelationLinkMany2One(record, newValue) {
// convert newValue to localId
const newLocalId = this._setRelationConvertX2OneValue(newValue);
const OtherModel = this.env.models[this.to];
const prevLocalId = this.read(record);
_setRelationLinkX2One(record, toAddOtherRecord) {
this._verifyRelationalValue(toAddOtherRecord);
const prevOtherRecord = this.read(record);
// other record already linked, avoid linking twice
if (prevLocalId === newLocalId) {
return;
if (prevOtherRecord === toAddOtherRecord) {
return false;
}
// unlink previous other record from current record
this.write(record, undefined);
const prevOtherRecord = OtherModel.get(prevLocalId);
// there may be no previous other record or the previous other record
// may be deleted due to causality
if (prevOtherRecord) {
// unlink current record from previous other record
prevOtherRecord.update({
[this.inverse]: [['unlink', record]],
});
}
const newOtherRecord = OtherModel.get(newLocalId);
// other record may be deleted due to causality, avoid linking
// deleted records
if (!newOtherRecord) {
return;
}
// link other record to current records
this.write(record, newLocalId);
// link current record to other record
newOtherRecord.update({
[this.inverse]: [['link', record]],
});
}
/**
* Handling of a `set` 'link' of an one2one relational field.
*
* @private
* @param {mail.model} record
* @param {string|mail.model} value
*/
_setRelationLinkOne2One(record, newValue) {
// convert newValue to localId
const newLocalId = this._setRelationConvertX2OneValue(newValue);
const prevLocalId = this.read(record);
const OtherModel = this.env.models[this.to];
// other record already linked, avoid linking twice
if (prevLocalId === newLocalId) {
return;
}
// unlink previous other record from current record
this.write(record, undefined);
const prevOtherRecord = OtherModel.get(prevLocalId);
// there may be no previous other record or the previous other record
// may be deleted due to causality
if (prevOtherRecord) {
// unlink current record from previous other record
prevOtherRecord.update({
[this.inverse]: [['unlink', record]],
});
// apply causality
if (this.isCausal) {
prevOtherRecord.delete();
}
}
const newOtherRecord = OtherModel.get(newLocalId);
// other record may be deleted due to causality, avoid linking deleted
// records
if (!newOtherRecord) {
return;
}
// unlink to properly update previous inverse before linking new value
this._setRelationUnlinkX2One(record);
// link other record to current record
this.write(record, newLocalId);
record.__values[this.fieldName] = toAddOtherRecord;
// link current record to other record
newOtherRecord.update({
toAddOtherRecord.update({
[this.inverse]: [['link', record]],
});
return true;
}
/**
@@ -766,24 +571,17 @@ class ModelField {
*
* @private
* @param {mail.model} record
* @param {string|string[]|mail.model|mail.model[]|null} newValue
* @param {mail.model|mail.model[]} newValue
* @returns {boolean} whether the value changed for the current field
*/
_setRelationUnlink(record, newValue) {
if (!record.exists()) {
// current record may be deleted due to causality
return;
}
switch (this.relationType) {
case 'many2many':
case 'one2many':
this._setRelationUnlinkX2Many(record, newValue);
break;
return this._setRelationUnlinkX2Many(record, newValue);
case 'many2one':
this._setRelationUnlinkMany2One(record);
break;
case 'one2one':
this._setRelationUnlinkOne2One(record);
break;
return this._setRelationUnlinkX2One(record);
}
}
@@ -792,97 +590,72 @@ class ModelField {
*
* @private
* @param {mail.model} record
* @param {string|mail.model|<string|mail.model>[]|null} newValue
* @param {mail.model|mail.model[]} newValue
* @returns {boolean} whether the value changed for the current field
*/
_setRelationUnlinkX2Many(record, newValue) {
// convert newValue to array of localId, null is considered unlink all
const otherLocalIds = newValue === null
? [...this.read(record)]
: this._setRelationConvertX2ManyValue(newValue);
const OtherModel = this.env.models[this.to];
const toDeleteOtherRecords = this._setRelationConvertX2ManyValue(newValue);
const otherRecords = this.read(record);
const prevLocalIds = this.read(record);
let isDeleting = false;
for (const otherLocalId of otherLocalIds) {
const wasDeleted = prevLocalIds.delete(otherLocalId);
if (wasDeleted) {
isDeleting = true;
}
}
if (!isDeleting) {
return;
}
// unlink other record from current record
this.write(record, prevLocalIds);
for (const otherLocalId of otherLocalIds) {
const otherRecord = OtherModel.get(otherLocalId);
// other record may be deleted due to causality, avoid useless
// processing
if (otherRecord) {
// unlink current record from other record
otherRecord.update({
[this.inverse]: [['unlink', record]],
});
for (const toDeleteOtherRecord of toDeleteOtherRecords) {
// unlink other record from current record
const wasDeleted = otherRecords.delete(toDeleteOtherRecord);
if (!wasDeleted) {
continue;
}
isDeleting = true;
// unlink current record from other records
toDeleteOtherRecord.update({
[this.inverse]: [['unlink', record]],
});
// apply causality
if (this.isCausal) {
toDeleteOtherRecord.delete();
}
}
return isDeleting;
}
/**
* Handling of a `set` 'unlink' of a many2one relational field.
* Handling of a `set` 'unlink' of a x2one relational field.
*
* @private
* @param {mail.model} record
* @returns {boolean} whether the value changed for the current field
*/
_setRelationUnlinkMany2One(record) {
const otherLocalId = this.read(record);
const OtherModel = this.env.models[this.to];
_setRelationUnlinkX2One(record) {
const toDeleteOtherRecord = this.read(record);
// other record already unlinked, avoid useless processing
if (!otherLocalId) {
return;
if (!toDeleteOtherRecord) {
return false;
}
// unlink other record from current record
this.write(record, undefined);
const otherRecord = OtherModel.get(otherLocalId);
// other record may be deleted due to causality, avoid useless
// processing
if (otherRecord) {
// unlink current record from other record
otherRecord.update({
[this.inverse]: [['unlink', record]],
});
record.__values[this.fieldName] = undefined;
// unlink current record from other record
toDeleteOtherRecord.update({
[this.inverse]: [['unlink', record]],
});
// apply causality
if (this.isCausal) {
toDeleteOtherRecord.delete();
}
return true;
}
/**
* Handling of a `set` 'unlink' of an one2one relational field.
* Verifies the given relational value makes sense for the current field.
* In particular the given value must be a record, it must be non-deleted,
* and it must originates from relational `to` model (or its subclasses).
*
* @private
* @param {mail.model} record
* @throws {Error} if record does not satisfy related model
*/
_setRelationUnlinkOne2One(record) {
const otherLocalId = this.read(record);
_verifyRelationalValue(record) {
const OtherModel = this.env.models[this.to];
// other record already unlinked, avoid useless processing
if (!otherLocalId) {
return;
}
// unlink other record from current record
this.write(record, undefined);
const otherRecord = OtherModel.get(otherLocalId);
// other record may be deleted due to causality, avoid useless
// processing
if (otherRecord) {
// unlink current record from other record
otherRecord.update({
[this.inverse]: [['unlink', record]],
});
if (!OtherModel.get(record.localId, { isCheckingInheritance: true })) {
throw Error(`Record ${record.localId} is not valid for relational field ${this.fieldName}.`);
}
}
+120 -94
View File
@@ -30,13 +30,10 @@ class ModelManager {
* Set as public attribute so that it can be used by model field.
*/
this.DEPENDENT_INNER_SEPARATOR = DEPENDENT_INNER_SEPARATOR;
/**
* The messaging env. It is passed on start(), due to the way mocking
* of OWL env in tests work...
* The messaging env.
*/
this.env = undefined;
this.env = env;
/**
* Whether this is currently handling an "update after" on a record.
* Useful to determine if we should process computed/related fields.
@@ -73,8 +70,7 @@ class ModelManager {
* Called when all JS modules that register or patch models have been
* done. This launches generation of models.
*/
start(env) {
this.env = env;
start() {
/**
* Generate the models.
*/
@@ -154,36 +150,28 @@ class ModelManager {
Model.__records[record.localId] = record;
record.init();
// Make default values of its fields for newly created record.
// Ensure X2many relations are Set initially (other fields can stay undefined).
for (const field of fields) {
if (field.fieldType === 'attribute') {
field.write(record, field.default, { registerDependents: false });
}
if (field.fieldType === 'relation') {
if (['one2many', 'many2many'].includes(field.relationType)) {
// Ensure X2many relations are Set by defaults.
field.write(record, new Set(), { registerDependents: false });
} else {
field.write(record, undefined, { registerDependents: false });
record.__values[field.fieldName] = new Set();
}
}
}
const data2 = Object.assign({}, data);
const data2 = {};
for (const field of fields) {
if (field.fieldType !== 'relation') {
continue;
if (field.fieldName in data) {
data2[field.fieldName] = data[field.fieldName];
} else {
data2[field.fieldName] = field.default;
}
if (!field.autocreate) {
continue;
}
data2[field.fieldName] = [['create']];
}
for (const field of fields) {
if (field.compute || field.related) {
// new record should always invoke computed fields.
this.registerToComputeField(record, field);
this._registerToComputeField(record, field);
}
}
@@ -206,33 +194,16 @@ class ModelManager {
this._updateCycle(() => {
const Model = record.constructor;
if (!record.exists()) {
// Record has already been deleted.
// (e.g. unlinking one of its reverse relation was causal)
return;
throw Error(`Cannot delete already deleted record ${record.localId}.`);
}
const data = {};
const recordRelations = Object.values(Model.fields)
.filter(field => field.fieldType === 'relation');
for (const relation of recordRelations) {
if (relation.isCausal) {
switch (relation.relationType) {
case 'one2one':
case 'many2one':
if (record[relation.fieldName]) {
record[relation.fieldName].delete();
}
break;
case 'one2many':
case 'many2many':
for (const relatedRecord of record[relation.fieldName]) {
relatedRecord.delete();
}
break;
}
for (const field of Object.values(Model.fields)) {
if (field.fieldType === 'relation') {
// ensure inverses are properly unlinked
field.set(record, [['unlink-all']]);
}
data[relation.fieldName] = [['unlink-all']];
}
record.update(data);
this._toComputeFields.delete(record);
this._toUpdateAfters.delete(record);
delete Model.__records[record.localId];
});
}
@@ -283,9 +254,11 @@ class ModelManager {
*
* @param {mail.model} Model class
* @param {string} localId
* @param {Object} param2
* @param {boolean} [param2.isCheckingInheritance=false]
* @returns {mail.model|undefined} record, if exists
*/
get(Model, localId) {
get(Model, localId, { isCheckingInheritance = false } = {}) {
if (!localId) {
return;
}
@@ -293,13 +266,20 @@ class ModelManager {
if (record) {
return record;
}
if (!isCheckingInheritance) {
return;
}
// support for inherited models (eg. relation targeting `mail.model`)
for (const Model of Object.values(this.env.models)) {
const record = Model.__records[localId];
for (const SubModel of Object.values(this.env.models)) {
if (!(SubModel.prototype instanceof Model)) {
continue;
}
const record = SubModel.__records[localId];
if (record) {
return record;
}
}
return;
}
/**
@@ -339,27 +319,39 @@ class ModelManager {
*
* @param {mail.model} record
* @param {Object} data
* @returns {boolean} whether any value changed for the current record
*/
update(record, data) {
this._updateCycle(() => {
this._updateDirect(record, data);
return this._updateCycle(() => {
if (!record.exists()) {
throw Error(`Cannot update already deleted record ${record.localId}.`);
}
if (!this._toUpdateAfters.has(record)) {
// queue updateAfter before calling field.set to ensure previous
// contains the value at the start of update cycle
this._toUpdateAfters.set(record, record._updateBefore());
}
const Model = record.constructor;
let hasChanged = false;
for (const fieldName of Object.keys(data)) {
const field = Model.fields[fieldName];
if (!field) {
throw new Error(`Cannot create/update record with data unrelated to a field. (model: "${Model.modelName}", non-field attempted update: "${fieldName}")`);
}
if (!field.set(record, data[fieldName])) {
continue;
}
hasChanged = true;
// flag all dependent fields for compute
this._registerComputeOfDependents(record, field);
}
if (hasChanged) {
record.__state++;
}
return hasChanged;
});
}
/**
* Register a pair record/field for the compute step of the update cycle in
* progress.
*
* @param {mail.model} record
* @param {ModelField} field
*/
registerToComputeField(record, field) {
if (!this._toComputeFields.has(record)) {
this._toComputeFields.set(record, new Set());
}
this._toComputeFields.get(record).add(field);
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
@@ -408,7 +400,6 @@ class ModelManager {
if (field.fieldType === 'attribute') {
const invalidKeys = Object.keys(field).filter(key =>
![
'autocreate',
'compute',
'default',
'dependencies',
@@ -423,8 +414,8 @@ class ModelManager {
if (field.fieldType === 'relation') {
const invalidKeys = Object.keys(field).filter(key =>
![
'autocreate',
'compute',
'default',
'dependencies',
'fieldType',
'inverse',
@@ -740,6 +731,7 @@ class ModelManager {
{},
relFunc(Model.modelName, { inverse: field.fieldName }),
{
env: this.env,
fieldName: `_inverse_${Model.modelName}/${field.fieldName}`,
modelManager: this,
}
@@ -770,6 +762,7 @@ class ModelManager {
// Make fields aware of their field name.
for (const [fieldName, fieldData] of Object.entries(Model.fields)) {
Model.fields[fieldName] = new ModelField(Object.assign({}, fieldData, {
env: this.env,
fieldName,
modelManager: this,
}));
@@ -880,6 +873,61 @@ class ModelManager {
}
}
/**
* Registers compute of dependents for the given field, if applicable.
*
* @param {mail.model} record
* @param {ModelField} field
*/
_registerComputeOfDependents(record, field) {
const Model = record.constructor;
for (const dependent of field.dependents) {
const [hash, currentFieldName, relatedFieldName] = dependent.split(
this.DEPENDENT_INNER_SEPARATOR
);
const field = Model.fields[currentFieldName];
if (relatedFieldName) {
if (['one2many', 'many2many'].includes(field.relationType)) {
for (const otherRecord of record[currentFieldName]) {
const OtherModel = otherRecord.constructor;
const field = OtherModel.fields[relatedFieldName];
if (field && field.hashes.includes(hash)) {
this._registerToComputeField(otherRecord, field);
}
}
} else {
const otherRecord = record[currentFieldName];
if (!otherRecord) {
continue;
}
const OtherModel = otherRecord.constructor;
const field = OtherModel.fields[relatedFieldName];
if (field && field.hashes.includes(hash)) {
this._registerToComputeField(otherRecord, field);
}
}
} else {
if (field && field.hashes.includes(hash)) {
this._registerToComputeField(record, field);
}
}
}
}
/**
* Register a pair record/field for the compute step of the update cycle in
* progress.
*
* @param {mail.model} record
* @param {ModelField} field
*/
_registerToComputeField(record, field) {
if (!this._toComputeFields.has(record)) {
this._toComputeFields.set(record, new Set());
}
this._toComputeFields.get(record).add(field);
}
/**
* Process registered computed fields in the current update cycle.
*
@@ -890,7 +938,7 @@ class ModelManager {
for (const [record, fields] of this._toComputeFields) {
this._toComputeFields.delete(record);
if (!record.exists()) {
continue;
throw Error(`Cannot execute computes for already deleted record ${record.localId}.`);
}
while (fields.size > 0) {
for (const field of fields) {
@@ -924,9 +972,10 @@ class ModelManager {
while (this._toUpdateAfters.size > 0) {
for (const [record, previous] of this._toUpdateAfters) {
this._toUpdateAfters.delete(record);
if (record.exists()) {
record._updateAfter(previous);
if (!record.exists()) {
throw Error(`Cannot _updateAfter for already deleted record ${record.localId}.`);
}
record._updateAfter(previous);
}
}
this._isHandlingToUpdateAfters = false;
@@ -950,29 +999,6 @@ class ModelManager {
return res;
}
/**
* Process a direct update on given record with provided data.
*
* @private
* @param {mail.model} record
* @param {Object} data
*/
_updateDirect(record, data) {
if (!this._toUpdateAfters.has(record)) {
// queue updateAfter before calling field.set to ensure previous
// contains the value at the start of update cycle
this._toUpdateAfters.set(record, record._updateBefore());
}
const Model = record.constructor;
for (const fieldName of Object.keys(data)) {
const field = Model.fields[fieldName];
if (!field) {
throw new Error(`Cannot create/update record with data unrelated to a field. (model: "${Model.modelName}", non-field attempted update: "${fieldName}")`);
}
field.set(record, data[fieldName]);
}
}
}
return ModelManager;
@@ -87,7 +87,7 @@ function factory(dependencies) {
return;
}
this.env.messaging.dialogManager.open('mail.attachment_viewer', {
attachment: [['replace', attachment]],
attachment: [['link', attachment]],
attachments: [['replace', attachments]],
});
}
@@ -254,9 +254,9 @@ function factory(dependencies) {
_computeLastVisible() {
const { length: l, [l - 1]: lastVisible } = this.allOrderedVisible;
if (!lastVisible) {
return [['unlink-all']];
return [['unlink']];
}
return [['replace', lastVisible]];
return [['link', lastVisible]];
}
/**
@@ -268,7 +268,7 @@ function factory(dependencies) {
if (!chatWindow) {
return [['unlink']];
}
return [['replace', chatWindow]];
return [['link', chatWindow]];
}
/**
@@ -50,6 +50,10 @@ function factory(dependencies) {
}
async refreshActivities() {
if (!this.thread || this.thread.isTemporary) {
this.update({ activities: [['unlink-all']] });
return;
}
// A bit "extreme", may be improved
const [{ activity_ids: newActivityIds }] = await this.async(() => this.env.services.rpc({
model: this.thread.model,
@@ -166,18 +170,24 @@ function factory(dependencies) {
}
}
if (previous.activityIds.join(',') !== this.activityIds.join(',')) {
if (
!previous.activityIds ||
previous.activityIds.join(',') !== this.activityIds.join(',')
) {
this.refreshActivities();
}
if (
previous.followerIds.join(',') !== this.followerIds.join(',') &&
!this.thread.isTemporary
!previous.followerIds ||
previous.followerIds.join(',') !== this.followerIds.join(',')
) {
this.thread.refreshFollowers();
if (this.thread) {
this.thread.refreshFollowers();
}
}
if (
!previous.messageIds ||
previous.thread !== this.thread ||
(this.thread && this.messageIds.join(',') !== previous.messageIds.join(','))
this.messageIds.join(',') !== previous.messageIds.join(',')
) {
this.refresh();
}
@@ -316,7 +326,7 @@ function factory(dependencies) {
threadId: attr(),
threadModel: attr(),
threadViewer: one2one('mail.thread_viewer', {
autocreate: true,
default: [['create']],
}),
todayActivities: one2many('mail.activity', {
compute: '_computeTodayActivities',
+1 -21
View File
@@ -6,27 +6,7 @@ const { many2one, one2one } = require('mail/static/src/model/model_field.js');
function factory(dependencies) {
class Dialog extends dependencies['mail.model'] {
/**
* @override
*/
delete() {
if (this.manager) {
this.manager.unregister(this);
}
super.delete();
}
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
close() {
this.delete();
}
}
class Dialog extends dependencies['mail.model'] {}
Dialog.fields = {
manager: many2one('mail.dialog_manager', {
@@ -2,7 +2,7 @@ odoo.define('mail/static/src/models/dialog_manager/dialog_manager.js', function
'use strict';
const { registerNewModel } = require('mail/static/src/model/model_core.js');
const { attr, one2many } = require('mail/static/src/model/model_field.js');
const { one2many } = require('mail/static/src/model/model_field.js');
function factory(dependencies) {
@@ -12,22 +12,11 @@ function factory(dependencies) {
// Public
//----------------------------------------------------------------------
/**
* @param {mail.dialog} dialog
*/
close(dialog) {
this.unregister(dialog);
dialog.delete();
}
/**
* @param {string} modelName
* @param {Object} [recordData]
*/
open(modelName, recordData) {
const dialog = this.env.models['mail.dialog'].create({
manager: [['link', this]],
});
if (!modelName) {
throw new Error("Dialog should have a link to a model");
}
@@ -36,53 +25,17 @@ function factory(dependencies) {
throw new Error(`No model exists with name ${modelName}`);
}
const record = Model.create(recordData);
dialog.update({ record: [['link', record]] });
this.update({ _ordered: this._ordered.concat([dialog.localId]) });
const dialog = this.env.models['mail.dialog'].create({
manager: [['link', this]],
record: [['link', record]],
});
return dialog;
}
/**
* @param {mail.dialog} dialog
*/
unregister(dialog) {
if (!this.allOrdered.includes(dialog)) {
return;
}
this.update({
_ordered: this._ordered.filter(
dialogLocalId => dialogLocalId !== dialog.localId
),
dialogs: [['unlink', dialog]],
});
}
//----------------------------------------------------------------------
// Private
//----------------------------------------------------------------------
/**
* FIXME: dependent on implementation that uses arbitrary order in relations!!
*
* @private
* @returns {mail.dialog}
*/
_computeAllOrdered() {
return [['replace', this._ordered.map(dialogLocalId =>
this.env.models['mail.dialog'].get(dialogLocalId)
)]];
}
}
DialogManager.fields = {
_ordered: attr({ default: [] }),
// FIXME: dependent on implementation that uses arbitrary order in relations!!
allOrdered: one2many('mail.dialog', {
compute: '_computeAllOrdered',
dependencies: [
'_ordered',
'dialogs',
],
}),
// FIXME: dependent on implementation that uses insert order in relations!!
dialogs: one2many('mail.dialog', {
inverse: 'manager',
isCausal: true,
@@ -462,7 +462,7 @@ function factory(dependencies) {
related: 'thread.model',
}),
threadViewer: one2one('mail.thread_viewer', {
autocreate: true,
default: [['create']],
isCausal: true,
}),
};
@@ -111,7 +111,7 @@ function factory(dependencies) {
}
}
this._subtypesListDialog = this.env.messaging.dialogManager.open('mail.follower_subtype_list', {
follower: [['replace', this]],
follower: [['link', this]],
});
}
@@ -20,8 +20,8 @@ function factory(dependencies) {
*/
static computeId(message, thread) {
if (message && thread) {
const messageId = Number.isInteger(message) ? message : message.id;
const threadId = Number.isInteger(thread) ? thread : thread.id;
const messageId = typeof message !== 'object' ? message : message.id;
const threadId = typeof thread !== 'object' ? thread : thread.id;
return [messageId, threadId].join('-');
}
return undefined;
@@ -132,7 +132,7 @@ function factory(dependencies) {
default: {},
}),
chatWindowManager: one2one('mail.chat_window_manager', {
autocreate: true,
default: [['create']],
inverse: 'messaging',
isCausal: true,
}),
@@ -142,15 +142,15 @@ function factory(dependencies) {
currentPartner: one2one('mail.partner'),
currentUser: one2one('mail.user'),
device: one2one('mail.device', {
autocreate: true,
default: [['create']],
isCausal: true,
}),
dialogManager: one2one('mail.dialog_manager', {
autocreate: true,
default: [['create']],
isCausal: true,
}),
discuss: one2one('mail.discuss', {
autocreate: true,
default: [['create']],
inverse: 'messaging',
isCausal: true,
}),
@@ -163,7 +163,7 @@ function factory(dependencies) {
*/
inbox: one2one('mail.thread'),
initializer: one2one('mail.messaging_initializer', {
autocreate: true,
default: [['create']],
inverse: 'messaging',
isCausal: true,
}),
@@ -171,11 +171,11 @@ function factory(dependencies) {
default: false,
}),
locale: one2one('mail.locale', {
autocreate: true,
default: [['create']],
isCausal: true,
}),
messagingMenu: one2one('mail.messaging_menu', {
autocreate: true,
default: [['create']],
inverse: 'messaging',
isCausal: true,
}),
@@ -184,11 +184,11 @@ function factory(dependencies) {
*/
moderation: one2one('mail.thread'),
notificationGroupManager: one2one('mail.notification_group_manager', {
autocreate: true,
default: [['create']],
isCausal: true,
}),
notificationHandler: one2one('mail.messaging_notification_handler', {
autocreate: true,
default: [['create']],
inverse: 'messaging',
isCausal: true,
}),
+4 -2
View File
@@ -89,10 +89,12 @@ function factory() {
*
* @static
* @param {string} localId
* @param {Object} param1
* @param {boolean} [param1.isCheckingInheritance]
* @returns {mail.model|undefined}
*/
static get(localId) {
return this.env.modelManager.get(this, localId);
static get(localId, { isCheckingInheritance } = {}) {
return this.env.modelManager.get(this, localId, { isCheckingInheritance });
}
/**
+14 -4
View File
@@ -542,7 +542,7 @@ function factory(dependencies) {
openExpanded() {
const discuss = this.env.messaging.discuss;
if (['mail.channel', 'mail.box'].includes(this.model)) {
discuss.threadViewer.update({ thread: [['replace', this]] });
discuss.threadViewer.update({ thread: [['link', this]] });
this.env.bus.trigger('do-action', {
action: 'mail.action_discuss',
options: {
@@ -581,6 +581,10 @@ function factory(dependencies) {
* Refresh followers information from server.
*/
async refreshFollowers() {
if (this.isTemporary) {
this.update({ followers: [['unlink-all']] });
return;
}
// FIXME Do that with only one RPC (see task-2243180)
const [{ message_follower_ids: followerIds }] = await this.async(() => this.env.services.rpc({
model: this.model,
@@ -895,7 +899,7 @@ function factory(dependencies) {
) {
return [['unlink-all']];
}
return [['replace', currentPartnerOrderedSeenMessages.slice().pop()]];
return [['link', currentPartnerOrderedSeenMessages.slice().pop()]];
}
/**
@@ -907,7 +911,10 @@ function factory(dependencies) {
length: l,
[l - 1]: lastMessage,
} = this.orderedMessages;
return [['replace', lastMessage]];
if (lastMessage) {
return [['link', lastMessage]];
}
return [['unlink']];
}
/**
@@ -922,7 +929,10 @@ function factory(dependencies) {
length: l,
[l - 1]: lastNeedactionMessage,
} = orderedNeedactionMessages;
return [['replace', lastNeedactionMessage]];
if (lastNeedactionMessage) {
return [['link', lastNeedactionMessage]];
}
return [['unlink']];
}
/**
@@ -142,9 +142,9 @@ function factory(dependencies) {
[l - 1]: lastFetchedMessage,
} = this.orderedFetchedMessages;
if (!lastFetchedMessage) {
return [['unlink-all']];
return [['unlink']];
}
return [['replace', lastFetchedMessage]];
return [['link', lastFetchedMessage]];
}
/**
@@ -157,9 +157,9 @@ function factory(dependencies) {
[l - 1]: lastMessage,
} = this.orderedMessages;
if (!lastMessage) {
return [['unlink-all']];
return [['unlink']];
}
return [['replace', lastMessage]];
return [['link', lastMessage]];
}
/**
@@ -72,7 +72,7 @@ function factory(dependencies) {
*/
handleVisibleMessage(message) {
if (!this.lastVisibleMessage || this.lastVisibleMessage.id < message.id) {
this.update({ lastVisibleMessage: [['replace', message]] });
this.update({ lastVisibleMessage: [['link', message]] });
}
}
@@ -102,7 +102,7 @@ function factory(dependencies) {
if (!this.thread) {
return [];
}
return [['replace', this.thread.cache(this.stringifiedDomain)]];
return [['link', this.thread.cache(this.stringifiedDomain)]];
}
/**
@@ -7,6 +7,7 @@ const {
addMessagingToEnv,
addTimeControlToEnv,
} = require('mail/static/src/env/test_env.js');
const ModelManager = require('mail/static/src/model/model_manager.js');
const ChatWindowService = require('mail/static/src/services/chat_window_service/chat_window_service.js');
const DialogService = require('mail/static/src/services/dialog_service/dialog_service.js');
const { nextTick } = require('mail/static/src/utils/utils.js');
@@ -754,6 +755,9 @@ async function start(param0 = {}) {
}
testEnv = Component.env;
Object.assign(testEnv, {
modelManager: new ModelManager(testEnv),
});
/**
* Components cannot use web.bus, because they cannot use