diff --git a/addons/mail/static/src/components/discuss/tests/discuss_pinned_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_pinned_tests.js index ae6af0995b6..a373b25cd49 100644 --- a/addons/mail/static/src/components/discuss/tests/discuss_pinned_tests.js +++ b/addons/mail/static/src/components/discuss/tests/discuss_pinned_tests.js @@ -81,7 +81,11 @@ QUnit.test('sidebar: pinned channel 3: open pinned channel and unpin it', async // channel that is expected to be found in the sidebar // with a random unique id that will be referenced in the test - this.data['mail.channel'].records.push({ id: 20 }); + this.data['mail.channel'].records.push({ + id: 20, + is_minimized: true, + state: 'open', + }); await this.start({ async mockRPC(route, args) { if (args.method === 'execute_command') { diff --git a/addons/mail/static/src/components/discuss/tests/discuss_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_tests.js index e5885d6f7d4..bc97a8c004b 100644 --- a/addons/mail/static/src/components/discuss/tests/discuss_tests.js +++ b/addons/mail/static/src/components/discuss/tests/discuss_tests.js @@ -1834,7 +1834,7 @@ QUnit.test('new messages separator [REQUIRE FOCUS]', async function (assert) { }); QUnit.test('restore thread scroll position', async function (assert) { - assert.expect(5); + assert.expect(6); // channels expected to be rendered, with random unique id that will be referenced in the test this.data['mail.channel'].records.push({ id: 11 }, { id: 12 }); for (let i = 1; i <= 25; i++) { @@ -1872,6 +1872,15 @@ QUnit.test('restore thread scroll position', async function (assert) { 25, "should have 25 messages in channel 11" ); + const initialMessageList = document.querySelector(` + .o_Discuss_thread + .o_ThreadView_messageList + `); + assert.strictEqual( + initialMessageList.scrollTop + initialMessageList.clientHeight, + initialMessageList.scrollHeight, + "should have scrolled to bottom of channel 11 initially" + ); await this.afterEvent({ eventName: 'o-component-message-list-scrolled', diff --git a/addons/mail/static/src/model/model_field.js b/addons/mail/static/src/model/model_field.js index f82b6bf8d06..e4cd3ee878e 100644 --- a/addons/mail/static/src/model/model_field.js +++ b/addons/mail/static/src/model/model_field.js @@ -1,6 +1,8 @@ odoo.define('mail/static/src/model/model_field.js', function (require) { 'use strict'; +const { FieldCommand } = require('mail/static/src/model/model_field_command.js'); + /** * Class whose instances represent field on a model. * These field definitions are generated from declared fields in static prop @@ -215,6 +217,30 @@ class ModelField { return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'one2one' })); } + /** + * Clears the value of this field on the given record. It consists of + * setting this to its default value. In particular, using `clear` is the + * only way to write `undefined` on a field, as long as `undefined` is its + * default value. Relational fields are always unlinked before the default + * is applied. + * + * @param {mail.model} record + * @param {options} [options] + * @returns {boolean} whether the value changed for the current field + */ + clear(record, options) { + let hasChanged = false; + if (this.fieldType === 'relation') { + if (this.parseAndExecuteCommands(record, [['unlink-all']], options)) { + hasChanged = true; + } + } + if (this.parseAndExecuteCommands(record, this.default, options)) { + hasChanged = true; + } + return hasChanged; + } + /** * Combine current field definition with provided field definition and * return the combined field definition. Useful to track list of hashes of @@ -303,6 +329,33 @@ class ModelField { throw new Error(`cannot get field with unsupported type ${this.fieldType}.`); } + /** + * Parses newVal for command(s) and executes them. + * + * @param {mail.model} record + * @param {any} newVal + * @param {Object} [options] + * @returns {boolean} whether the value changed for the current field + */ + parseAndExecuteCommands(record, newVal, options) { + if (newVal instanceof FieldCommand) { + // single command given + return newVal.execute(this, record, options); + } + if (typeof newVal instanceof Array && newVal[0] instanceof FieldCommand) { + // multi command given + let hasChanged = false; + for (const command of newVal) { + if (command.execute(this, record, options)) { + hasChanged = true; + } + } + return hasChanged; + } + // not a command + return this.set(record, newVal, options); + } + /** * Get the raw value associated to this field. For relations, this means * the local id or list of local ids of records in this relational field. diff --git a/addons/mail/static/src/model/model_field_command.js b/addons/mail/static/src/model/model_field_command.js new file mode 100644 index 00000000000..89f42651ad9 --- /dev/null +++ b/addons/mail/static/src/model/model_field_command.js @@ -0,0 +1,45 @@ +odoo.define('mail/static/src/model/model_field_command.js', function (require) { +'use strict'; + +/** + * Allows field update to detect if the value it received is a command to + * execute (in which was it will be an instance of this class) or an actual + * value to set (in all other cases). + */ +class FieldCommand { + /** + * @constructor + * @param {function} func function to call when executing this command + */ + constructor(func) { + this.func = func; + } + + /** + * @param {ModelField} field + * @param {mail.model} record + * @param {options} [options] + * @returns {boolean} whether the value changed for the current field + */ + execute(field, record, options) { + return this.func(field, record, options); + } +} + +/** + * Returns a clear command to give to the model manager at create/update. + */ +function clear() { + return new FieldCommand((field, record, options) => + field.clear(record, options) + ); +} + +return { + // class + FieldCommand, + // shortcuts + clear, +}; + +}); diff --git a/addons/mail/static/src/model/model_manager.js b/addons/mail/static/src/model/model_manager.js index 003e4fd8067..73c3c313ed4 100644 --- a/addons/mail/static/src/model/model_manager.js +++ b/addons/mail/static/src/model/model_manager.js @@ -594,7 +594,8 @@ class ModelManager { */ const data2 = {}; for (const field of Model.__fieldList) { - if (field.fieldName in data) { + // `undefined` should have the same effect as not passing the field + if (data[field.fieldName] !== undefined) { data2[field.fieldName] = data[field.fieldName]; } else { data2[field.fieldName] = field.default; @@ -630,7 +631,7 @@ class ModelManager { for (const field of Model.__fieldList) { if (field.fieldType === 'relation') { // ensure inverses are properly unlinked - field.set(record, [['unlink-all']]); + field.parseAndExecuteCommands(record, [['unlink-all']]); } } this._hasAnyChangeDuringCycle = true; @@ -1066,12 +1067,16 @@ class ModelManager { const Model = record.constructor; let hasChanged = false; for (const fieldName of Object.keys(data)) { + if (data[fieldName] === undefined) { + // `undefined` should have the same effect as not passing the field + continue; + } const field = Model.__fieldMap[fieldName]; if (!field) { throw new Error(`Cannot create/update record with data unrelated to a field. (model: "${Model.modelName}", non-field attempted update: "${fieldName}")`); } const newVal = data[fieldName]; - if (!field.set(record, newVal, options)) { + if (!field.parseAndExecuteCommands(record, newVal, options)) { continue; } hasChanged = true; diff --git a/addons/mail/static/src/models/activity/activity.js b/addons/mail/static/src/models/activity/activity.js index 1e17185fdf2..4e654e3c456 100644 --- a/addons/mail/static/src/models/activity/activity.js +++ b/addons/mail/static/src/models/activity/activity.js @@ -3,6 +3,7 @@ odoo.define('mail/static/src/models/activity/activity/js', function (require) { const { registerNewModel } = require('mail/static/src/model/model_core.js'); const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js'); +const { clear } = require('mail/static/src/model/model_field_command.js'); function factory(dependencies) { @@ -245,7 +246,7 @@ function factory(dependencies) { */ _computeNote() { if (this.note === '