diff --git a/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.xml b/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.xml index 689c2061dea..a2e30d5f7e3 100644 --- a/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.xml +++ b/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.xml @@ -3,7 +3,7 @@ - + - + { + // access all useful values of current record (and relations) to mark them as dependencies + this.record.modelManager.startListening(listener); + const compareDefinition = this.record[this.field.sort](); + const relatedPathSet = new Set(compareDefinition.map(operation => operation[1])); // only keep unique paths to avoid unnecessary listeners + for (const relatedPath of relatedPathSet) { + followRelations(value, relatedPath); + } + this.record.modelManager.stopListening(listener); + // sort outside of listening to avoid registering listeners for all other items (they already added their own listeners) + const compareFunction = (a, b) => { + for (const [compareMethod, relatedPath] of compareDefinition) { + const valA = followRelations(a, relatedPath); + const valB = followRelations(b, relatedPath); + switch (compareMethod) { + case 'defined-first': { + if (!valA && !valB) { + return 0; + } + if (!valA) { + return 1; + } + if (!valB) { + return -1; + } + break; + } + case 'case-insensitive-asc': + if (valA.toLowerCase() === valB.toLowerCase()) { + break; + } + return valA.toLowerCase() < valB.toLowerCase() ? -1 : 1; + case 'smaller-first': + if (valA === valB) { + break; + } + return valA - valB; + case 'greater-first': + if (valA === valB) { + break; + } + return valB - valA; + default: + throw Error(`sort compare method "${compareMethod}" is not supported.`); + } + } + return 0; + }; + // Naive method: re-sort the complete array every time. Ideally each item should + // be inserted/moved at its correct place immediately, but this can be optimized + // eventually if necessary. + this.sortArray.sort(compareFunction); + // Similarly naive approach: the field is marked as changed even if sort didn't + // actually move any record. + this.record.modelManager._markRecordFieldAsChanged(this.record, this.field); + }, + }); + this.sortListenerByValue.set(value, listener); + listener.onChange({ reason: 'initial call' }); + } + } + + /** + * Removes all elements. + */ + clear() { + for (const record of this.set) { + this.delete(record); + } + } + + /** + * @param {*} value + * @returns {boolean} whether the value was present + */ + delete(value) { + const wasPresent = this.set.delete(value); + if (!wasPresent) { + return false; + } + if (this.field.sort) { + // remove sort of current value + const index = this.sortArray.indexOf(value); + this.sortArray.splice(index, 1); + // remove listener on current value + const listener = this.sortListenerByValue.get(value); + this.sortListenerByValue.delete(listener); + this.record.modelManager.removeListener(listener); + } + return true; + } + + /** + * @param {*} value + * @returns {boolean} whether the value is present + */ + has(value) { + return this.set.has(value); + } + + /** + * @returns {iterator} + */ + [Symbol.iterator]() { + if (this.field.sort) { + return this.sortArray[Symbol.iterator](); + } + return this.set[Symbol.iterator](); + } + +} diff --git a/addons/mail/static/src/model/model_manager.js b/addons/mail/static/src/model/model_manager.js index c6783d3f045..37ba41a5630 100644 --- a/addons/mail/static/src/model/model_manager.js +++ b/addons/mail/static/src/model/model_manager.js @@ -3,7 +3,9 @@ import { registry } from '@mail/model/model_core'; import { ModelField } from '@mail/model/model_field'; import { FieldCommand, unlinkAll } from '@mail/model/model_field_command'; +import { RelationSet } from '@mail/model/model_field_relation_set'; import { Listener } from '@mail/model/model_listener'; +import { followRelations } from '@mail/model/model_utils'; import { makeDeferred } from '@mail/utils/deferred/deferred'; /** @@ -506,6 +508,7 @@ export class ModelManager { 'related', 'relationType', 'required', + 'sort', 'to', ].includes(key) ); @@ -521,6 +524,9 @@ export class ModelManager { if (field.required && !(['one2one', 'many2one'].includes(field.relationType))) { throw new Error(`Relational field(${fieldName}) on ${Model} has "required" true with a relation of type "${field.relationType}" but "required" is only supported for "one2one" and "many2one".`); } + if (field.sort && !(['one2many', 'many2many'].includes(field.relationType))) { + throw new Error(`Relational field "${Model}/${fieldName}" has "sort" with a relation of type "${field.relationType}" but "sort" is only supported for "one2many" and "many2many".`); + } } // 3. Computed field. if (field.compute && !(typeof field.compute === 'string')) { @@ -618,7 +624,7 @@ export class ModelManager { throw new Error(`${field} on ${Model} defines its inverse as field(${field.inverse}) on ${RelatedModel}, but it does not exist.`); } if (inverseField.inverse !== fieldName) { - throw new Error(`The name of ${field} on ${Model} does not match with the name defined in its inverse ${inverseField} on ${RelatedModel}.`) + throw new Error(`The name of ${field} on ${Model} does not match with the name defined in its inverse ${inverseField} on ${RelatedModel}.`); } if (![Model.name, 'mail.model'].includes(inverseField.to)) { throw new Error(`${field} on ${Model} has its inverse ${inverseField} on ${RelatedModel} referring to an invalid model (model(${inverseField.to})).`); @@ -693,7 +699,7 @@ export class ModelManager { record.__values[field.fieldName] = undefined; if (field.fieldType === 'relation') { if (['one2many', 'many2many'].includes(field.relationType)) { - record.__values[field.fieldName] = new Set(); + record.__values[field.fieldName] = new RelationSet(record, field); } } } @@ -885,16 +891,7 @@ export class ModelManager { onChange: (info) => { this.startListening(listener); for (const dependency of onChange.dependencies) { - let target = record; - for (const field of dependency.split('.')) { - if (!target.constructor.__fieldMap[field]) { - throw Error(`onChange dependency "${field}" does not exist on ${record.constructor}.`); - } - target = target[field]; - if (!target) { - break; - } - } + followRelations(record, dependency); } this.stopListening(listener); record[onChange.methodName](); @@ -1108,6 +1105,22 @@ export class ModelManager { } } + /** + * Marks the given field of the given record as changed. + * + * @param {mail.model} record + * @param {ModelField} field + */ + _markRecordFieldAsChanged(record, field) { + for (const [listener, infoList] of this._listenersObservingFieldOfLocalId.get(record.localId).get(field)) { + this._markListenerToNotify(listener, { + listener, + reason: `_update: ${field} of ${record}`, + infoList, + }); + } + } + /** * Notifies the listeners that have been flagged to be notified and for * which the `onChange` function was defined to be called after the update @@ -1304,13 +1317,7 @@ export class ModelManager { throw new Error(`read-only ${field} on ${record} was updated`); } hasChanged = true; - for (const [listener, infoList] of this._listenersObservingFieldOfLocalId.get(record.localId).get(field)) { - this._markListenerToNotify(listener, { - listener, - reason: `_update: ${field} of ${record}`, - infoList, - }); - } + this._markRecordFieldAsChanged(record, field); } if (hasChanged) { this._updatedRecordsCheckRequired.add(record); diff --git a/addons/mail/static/src/model/model_utils.js b/addons/mail/static/src/model/model_utils.js new file mode 100644 index 00000000000..73ff4a2bdd5 --- /dev/null +++ b/addons/mail/static/src/model/model_utils.js @@ -0,0 +1,24 @@ +/** @odoo-module **/ + +/** + * Follows the given related path starting from the given record, and returns + * the resulting value, or undefined if a relation can't be followed because it + * is undefined. + * + * @param {mail.model} record + * @param {string} relatedPath field names, dot separated to follow relations + * @returns {any} + */ +export function followRelations(record, relatedPath) { + let target = record; + for (const field of relatedPath.split('.')) { + if (!target.constructor.__fieldMap[field]) { + throw Error(`field(${field}) does not exist on ${target}`); + } + target = target[field]; + if (!target) { + break; + } + } + return target; +} diff --git a/addons/mail/static/src/models/discuss_sidebar_category/discuss_sidebar_category.js b/addons/mail/static/src/models/discuss_sidebar_category/discuss_sidebar_category.js index eb7b88648d8..6aa06b0f689 100644 --- a/addons/mail/static/src/models/discuss_sidebar_category/discuss_sidebar_category.js +++ b/addons/mail/static/src/models/discuss_sidebar_category/discuss_sidebar_category.js @@ -73,22 +73,6 @@ registerModel({ } return clear(); }, - /** - * @private - * @returns {mail.discuss_sidebar_category_item[]} - */ - _computeCategoryItems() { - let channels = this.selectedSortedChannels; - const searchValue = this.messaging.discuss.sidebarQuickSearchValue; - if (searchValue) { - const qsVal = searchValue.toLowerCase(); - channels = channels.filter(t => { - const nameVal = t.displayName.toLowerCase(); - return nameVal.includes(qsVal); - }); - } - return insertAndReplace(channels.map(c => ({ channel: replace(c) }))); - }, /** * @private * @returns {integer} @@ -96,11 +80,27 @@ registerModel({ _computeCounter() { switch (this.counterComputeMethod) { case 'needaction': - return this.selectedChannels.filter(thread => thread.message_needaction_counter > 0).length; + return this.categoryItems.filter(categoryItem => categoryItem.channel.message_needaction_counter > 0).length; case 'unread': - return this.selectedChannels.filter(thread => thread.localMessageUnreadCounter > 0).length; + return this.categoryItems.filter(categoryItem => categoryItem.channel.localMessageUnreadCounter > 0).length; } }, + /** + * @private + * @returns {FieldCommand} + */ + _computeFilteredCategoryItems() { + let categoryItems = this.categoryItems; + const searchValue = this.messaging.discuss.sidebarQuickSearchValue; + if (searchValue) { + const qsVal = searchValue.toLowerCase(); + categoryItems = categoryItems.filter(categoryItem => { + const nameVal = categoryItem.channel.displayName.toLowerCase(); + return nameVal.includes(qsVal); + }); + } + return replace(categoryItems); + }, /** * @private * @returns {boolean} @@ -110,68 +110,26 @@ registerModel({ }, /** * @private - * @returns {mail.thread[]} + * @returns {Array[]} */ - _computeSelectedChannels() { - return replace(this.messaging.models['mail.thread'].all(thread => - thread.model === 'mail.channel' && - thread.isPinned && - this.supportedChannelTypes.includes(thread.channel_type)) - ); - }, - /** - * - * @private - * @returns {mail.thread[]} - */ - _computeSelectedSortedChannels() { + _sortDefinitionCategoryItems() { switch (this.sortComputeMethod) { case 'name': - return replace(this._sortByDisplayName()); + return [ + ['defined-first', 'channel'], + ['defined-first', 'channel.displayName'], + ['case-insensitive-asc', 'channel.displayName'], + ['smaller-first', 'channel.id'], + ]; case 'last_action': - return replace(this._sortByLastInterestDateTime()); + return [ + ['defined-first', 'channel'], + ['defined-first', 'channel.lastInterestDateTime'], + ['greater-first', 'channel.lastInterestDateTime'], + ['greater-first', 'channel.id'], + ]; } }, - /** - * Sorts `selectedChannels` by `displayName` in - * case-insensitive alphabetical order. - * - * @private - * @returns {mail.thread[]} - */ - _sortByDisplayName() { - return this.selectedChannels.sort((t1, t2) => { - if (t1.displayName && !t2.displayName) { - return -1; - } else if (!t1.displayName && t2.displayName) { - return 1; - } else if (t1.displayName && t2.displayName && t1.displayName !== t2.displayName) { - return t1.displayName.toLowerCase() < t2.displayName.toLowerCase() ? -1 : 1; - } else { - return t1.id - t2.id; - } - }); - }, - /** - * Sorts `selectedChannels` by `lastInterestDateTime`. - * The most recent one will come first. - * - * @private - * @returns {mail.thread[]} - */ - _sortByLastInterestDateTime() { - return this.selectedChannels.sort((t1, t2) => { - if (t1.lastInterestDateTime && !t2.lastInterestDateTime) { - return -1; - } else if (!t1.lastInterestDateTime && t2.lastInterestDateTime) { - return 1; - } else if (t1.lastInterestDateTime && t2.lastInterestDateTime && t1.lastInterestDateTime !== t2.lastInterestDateTime) { - return t2.lastInterestDateTime - t1.lastInterestDateTime; - } else { - return t2.id - t1.id; - } - }); - }, /** * Changes the category open states when clicked. */ @@ -270,13 +228,13 @@ registerModel({ */ commandAddTitleText: attr(), /** - * The sorted category items which belong to the category. - * These items are also filtered by `sidebarQuickSearchValue`. + * Determines the discuss sidebar category items that are displayed by + * this discuss sidebar category. */ categoryItems: one2many('mail.discuss_sidebar_category_item', { - compute: '_computeCategoryItems', inverse: 'category', isCausal: true, + sort: '_sortDefinitionCategoryItems', }), /** * The total amount unread/action-needed threads in the category. @@ -299,6 +257,14 @@ registerModel({ inverse: 'categoryChat', readonly: true, }), + /** + * Determines the filtered and sorted discuss sidebar category items + * that are displayed by this discuss sidebar category. + */ + filteredCategoryItems: one2many('mail.discuss_sidebar_category_item', { + compute: '_computeFilteredCategoryItems', + readonly: true, + }), /** * Display name of the category. */ @@ -343,19 +309,6 @@ registerModel({ * The placeholder text used when a new item is being added in UI. */ newItemPlaceholderText: attr(), - /** - * Channels which belong to the category, - */ - selectedChannels: one2many('mail.thread', { - compute: '_computeSelectedChannels', - }), - /** - * Channels which belongs to the category, - * and sorted based on the `supported_channel_type`. - */ - selectedSortedChannels: one2many('mail.thread', { - compute: '_computeSelectedSortedChannels', - }), /** * The key used in the server side for the category state */ diff --git a/addons/mail/static/src/models/discuss_sidebar_category_item/discuss_sidebar_category_item.js b/addons/mail/static/src/models/discuss_sidebar_category_item/discuss_sidebar_category_item.js index df2aa9e1736..cebb2272316 100644 --- a/addons/mail/static/src/models/discuss_sidebar_category_item/discuss_sidebar_category_item.js +++ b/addons/mail/static/src/models/discuss_sidebar_category_item/discuss_sidebar_category_item.js @@ -1,9 +1,8 @@ /** @odoo-module **/ import { registerModel } from '@mail/model/model_core'; -import { attr, many2one } from '@mail/model/model_field'; +import { attr, many2one, one2one } from '@mail/model/model_field'; import { clear, link } from '@mail/model/model_field_command'; -import { isEventHandled } from '@mail/utils/utils'; import Dialog from 'web.Dialog'; @@ -213,7 +212,7 @@ registerModel({ compute: '_computeAvatarUrl', }), /** - * States the discuss sidebar category displaying this. + * Determines the discuss sidebar category displaying this item. */ category: many2one('mail.discuss_sidebar_category', { inverse: 'categoryItems', @@ -265,7 +264,7 @@ registerModel({ /** * The related channel thread. */ - channel: many2one('mail.thread', { + channel: one2one('mail.thread', { inverse: 'discussSidebarCategoryItem', readonly: true, required: true, diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js index 71a0cbef636..2d8cb490403 100644 --- a/addons/mail/static/src/models/thread/thread.js +++ b/addons/mail/static/src/models/thread/thread.js @@ -1259,6 +1259,23 @@ registerModel({ } return unlink(); }, + /** + * @private + * @returns {FieldCommand} + */ + _computeDiscussSidebarCategoryItem() { + if (this.model !== 'mail.channel') { + return clear(); + } + if (!this.isPinned) { + return clear(); + } + const discussSidebarCategory = this._getDiscussSidebarCategory(); + if (!discussSidebarCategory) { + return clear(); + } + return insertAndReplace({ category: replace(discussSidebarCategory) }); + }, /** * @private * @returns {string} @@ -1705,6 +1722,22 @@ registerModel({ _computeVideoCount() { return this.rtcSessions.filter(session => session.videoStream).length; }, + /** + * Returns the discuss sidebar category that corresponds to this channel + * type. + * + * @private + * @returns {mail.discuss_sidebar_category} + */ + _getDiscussSidebarCategory() { + switch (this.channel_type) { + case 'channel': + return this.messaging.discuss.categoryChannel; + case 'chat': + case 'group': + return this.messaging.discuss.categoryChat; + } + }, /** * @private * @param {Object} param0 @@ -1941,9 +1974,15 @@ registerModel({ * States the description of this thread. Only applies to channels. */ description: attr(), - discussSidebarCategoryItem: one2many('mail.discuss_sidebar_category_item', { + /** + * Determines the discuss sidebar category item that displays this + * thread (if any). Only applies to channels. + */ + discussSidebarCategoryItem: one2one('mail.discuss_sidebar_category_item', { + compute: '_computeDiscussSidebarCategoryItem', inverse: 'channel', isCausal: true, + readonly: true, }), displayName: attr({ compute: '_computeDisplayName',