[FIX] mail: ignore undefined in JS fields create/write

Passing a field containing `undefined` should lead to the same result as not
passing the field.

Fields that relied on `undefined` as a value have been changed to use the newly
introduced `clear` command, that resets a field to its default value (often
that is equal to `undefined`).

task-2336491

closes odoo/odoo#58314

X-original-commit: b18824485483564d2ab80f41914a3548aa528ae0
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Sébastien Theys
2020-09-23 10:19:14 +00:00
parent 45dce9ce00
commit 44b557777f
13 changed files with 158 additions and 42 deletions
@@ -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') {
@@ -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',
@@ -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.
@@ -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,
};
});
@@ -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;
@@ -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 === '<p><br></p>') {
return undefined;
return clear();
}
return this.note;
}
@@ -3,6 +3,7 @@ odoo.define('mail/static/src/models/attachment/attachment.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) {
@@ -142,7 +143,7 @@ function factory(dependencies) {
/**
* @private
* @returns {string}
* @returns {string|undefined}
*/
_computeDefaultSource() {
if (this.fileType === 'image') {
@@ -169,15 +170,19 @@ function factory(dependencies) {
if (this.fileType === 'video') {
return `/web/image/${this.id}?model=ir.attachment`;
}
return undefined;
return clear();
}
/**
* @private
* @returns {string}
* @returns {string|undefined}
*/
_computeDisplayName() {
return this.name || this.filename;
const displayName = this.name || this.filename;
if (displayName) {
return displayName;
}
return clear();
}
/**
@@ -185,7 +190,11 @@ function factory(dependencies) {
* @returns {string|undefined}
*/
_computeExtension() {
return this.filename && this.filename.split('.').pop();
const extension = this.filename && this.filename.split('.').pop();
if (extension) {
return extension;
}
return clear();
}
/**
@@ -194,15 +203,15 @@ function factory(dependencies) {
*/
_computeFileType() {
if (this.type === 'url' && !this.url) {
return undefined;
return clear();
} else if (!this.mimetype) {
return undefined;
return clear();
}
const match = this.type === 'url'
? this.url.match('(youtu|.png|.jpg|.gif)')
: this.mimetype.match('(image|video|application/pdf|text)');
if (!match) {
return undefined;
return clear();
}
if (match[1].match('(.png|.jpg|.gif)')) {
return 'image';
@@ -3,6 +3,7 @@ odoo.define('mail/static/src/models/chat_window/chat_window.js', function (requi
const { registerNewModel } = require('mail/static/src/model/model_core.js');
const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
const { clear } = require('mail/static/src/model/model_field_command.js');
function factory(dependencies) {
@@ -188,14 +189,6 @@ function factory(dependencies) {
return this.isFolded;
}
/**
* @private
* @returns {boolean}
*/
_computeIsVisible() {
return this.manager.allOrderedVisible.includes(this);
}
/**
* @private
* @returns {boolean}
@@ -224,12 +217,12 @@ function factory(dependencies) {
*/
_computeVisibleIndex() {
if (!this.manager) {
return undefined;
return clear();
}
const visible = this.manager.visual.visible;
const index = visible.findIndex(visible => visible.chatWindowLocalId === this.localId);
if (index === -1) {
return undefined;
return clear();
}
return index;
}
@@ -383,17 +376,7 @@ function factory(dependencies) {
default: false,
}),
/**
* States whether `this` is visible or not.
*/
isVisible: attr({
compute: '_computeIsVisible',
dependencies: [
'managerAllOrderedVisible',
],
default: false,
}),
/**
* Whether this chat window is visible or not. Should be considered
* States whether `this` is visible or not. Should be considered
* read-only. Setting this value manually will not make it visible.
* @see `makeVisible`
*/
@@ -3,6 +3,7 @@ odoo.define('mail/static/src/models/discuss.discuss.js', function (require) {
const { registerNewModel } = require('mail/static/src/model/model_core.js');
const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
const { clear } = require('mail/static/src/model/model_field_command.js');
function factory(dependencies) {
@@ -235,7 +236,7 @@ function factory(dependencies) {
*/
_computeActiveId() {
if (!this.thread) {
return undefined;
return clear();
}
return this.threadToActiveId(this.thread);
}
@@ -387,7 +387,7 @@ function factory(dependencies) {
* @returns {boolean}
*/
_computeIsCurrentPartnerAuthor() {
return (
return !!(
this.author &&
this.messagingCurrentPartner &&
this.messagingCurrentPartner === this.author
@@ -3,6 +3,7 @@ odoo.define('mail/static/src/models/thread/thread.js', function (require) {
const { registerNewModel } = require('mail/static/src/model/model_core.js');
const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
const { clear } = require('mail/static/src/model/model_field_command.js');
const throttle = require('mail/static/src/utils/throttle/throttle.js');
const Timer = require('mail/static/src/utils/timer/timer.js');
const mailUtils = require('mail.utils');
@@ -1235,7 +1236,7 @@ function factory(dependencies) {
if (
this.serverFoldState === this.pendingFoldState
) {
this.update({ pendingFoldState: undefined });
this.update({ pendingFoldState: clear() });
}
if (
this.isPendingPinned !== undefined &&
@@ -1246,7 +1247,7 @@ function factory(dependencies) {
if (
this.isServerPinned === this.isPendingPinned
) {
this.update({ isPendingPinned: undefined });
this.update({ isPendingPinned: clear() });
}
// TODO FIXME prevent to open/close a channel on mobile when you
@@ -4,6 +4,7 @@ odoo.define('mail/static/src/models/thread_view/thread_view.js', function (requi
const { registerNewModel } = require('mail/static/src/model/model_core.js');
const { RecordDeletedError } = require('mail/static/src/model/model_errors.js');
const { attr, many2many, many2one, one2one } = require('mail/static/src/model/model_field.js');
const { clear } = require('mail/static/src/model/model_field_command.js');
function factory(dependencies) {
@@ -77,9 +78,13 @@ function factory(dependencies) {
*/
_computeThreadCacheInitialScrollPosition() {
if (!this.threadCache) {
return;
return clear();
}
return this.threadCacheInitialScrollPositions[this.threadCache.localId];
const threadCacheInitialScrollPosition = this.threadCacheInitialScrollPositions[this.threadCache.localId];
if (threadCacheInitialScrollPosition !== undefined) {
return threadCacheInitialScrollPosition;
}
return clear();
}
/**
@@ -102,7 +107,6 @@ function factory(dependencies) {
}
});
}
return true;
}
/**
+1
View File
@@ -120,6 +120,7 @@
<script type="text/javascript" src="/mail/static/src/model/model_core.js"></script>
<script type="text/javascript" src="/mail/static/src/model/model_errors.js"></script>
<script type="text/javascript" src="/mail/static/src/model/model_field.js"></script>
<script type="text/javascript" src="/mail/static/src/model/model_field_command.js"></script>
<script type="text/javascript" src="/mail/static/src/model/model_manager.js"></script>
<script type="text/javascript" src="/mail/static/src/models/activity/activity.js"></script>
<script type="text/javascript" src="/mail/static/src/models/activity_type/activity_type.js"></script>