From fc29b4b9748195e0733039b96245e21b0c6d21da Mon Sep 17 00:00:00 2001 From: Shaz Amjad Date: Tue, 1 Feb 2022 00:30:10 +1100 Subject: [PATCH] [Gekidou] Sidebar Categories (Database only) (#5909) --- app/constants/database.ts | 2 + app/database/manager/__mocks__/index.ts | 4 +- app/database/manager/index.ts | 4 +- app/database/models/server/category.ts | 105 ++++++++++++++++++ .../models/server/category_channel.ts | 54 +++++++++ app/database/models/server/channel.ts | 4 + app/database/models/server/index.ts | 2 + app/database/models/server/my_channel.ts | 3 +- .../server_data_operator/comparators/index.ts | 10 ++ .../handlers/category.test.ts | 85 ++++++++++++++ .../server_data_operator/handlers/category.ts | 89 +++++++++++++++ .../operator/server_data_operator/index.ts | 4 +- .../transformers/category.test.ts | 63 +++++++++++ .../transformers/category.ts | 77 +++++++++++++ app/database/schema/server/index.ts | 4 + .../schema/server/table_schemas/category.ts | 21 ++++ .../server/table_schemas/category_channel.ts | 17 +++ .../schema/server/table_schemas/index.ts | 2 + app/database/schema/server/test.ts | 65 +++++++++++ types/api/category.d.ts | 32 ++++++ types/database/database.d.ts | 8 ++ types/database/models/servers/category.d.ts | 57 ++++++++++ .../models/servers/category_channel.d.ts | 35 ++++++ types/database/raw_values.d.ts | 2 + 24 files changed, 743 insertions(+), 6 deletions(-) create mode 100644 app/database/models/server/category.ts create mode 100644 app/database/models/server/category_channel.ts create mode 100644 app/database/operator/server_data_operator/handlers/category.test.ts create mode 100644 app/database/operator/server_data_operator/handlers/category.ts create mode 100644 app/database/operator/server_data_operator/transformers/category.test.ts create mode 100644 app/database/operator/server_data_operator/transformers/category.ts create mode 100644 app/database/schema/server/table_schemas/category.ts create mode 100644 app/database/schema/server/table_schemas/category_channel.ts create mode 100644 types/api/category.d.ts create mode 100644 types/database/models/servers/category.d.ts create mode 100644 types/database/models/servers/category_channel.d.ts diff --git a/app/constants/database.ts b/app/constants/database.ts index 04ff10a3f..8a3e63f0d 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -10,6 +10,8 @@ export const MM_TABLES = { SERVERS: 'Servers', }, SERVER: { + CATEGORY: 'Category', + CATEGORY_CHANNEL: 'CategoryChannel', CHANNEL: 'Channel', CHANNEL_INFO: 'ChannelInfo', CHANNEL_MEMBERSHIP: 'ChannelMembership', diff --git a/app/database/manager/__mocks__/index.ts b/app/database/manager/__mocks__/index.ts index 0bf319136..7c1cb1528 100644 --- a/app/database/manager/__mocks__/index.ts +++ b/app/database/manager/__mocks__/index.ts @@ -11,7 +11,7 @@ import {MIGRATION_EVENTS, MM_TABLES} from '@constants/database'; import AppDatabaseMigrations from '@database/migration/app'; import ServerDatabaseMigrations from '@database/migration/server'; import {InfoModel, GlobalModel, ServersModel} from '@database/models/app'; -import {ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, +import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupMembershipModel, GroupsChannelModel, GroupsTeamModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, @@ -47,7 +47,7 @@ class DatabaseManager { constructor() { this.appModels = [InfoModel, GlobalModel, ServersModel]; this.serverModels = [ - ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, + CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupMembershipModel, GroupsChannelModel, GroupsTeamModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, diff --git a/app/database/manager/index.ts b/app/database/manager/index.ts index 2f28960a6..6a62db8ba 100644 --- a/app/database/manager/index.ts +++ b/app/database/manager/index.ts @@ -12,7 +12,7 @@ import {MIGRATION_EVENTS, MM_TABLES} from '@constants/database'; import AppDatabaseMigrations from '@database/migration/app'; import ServerDatabaseMigrations from '@database/migration/server'; import {InfoModel, GlobalModel, ServersModel} from '@database/models/app'; -import {ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, +import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupMembershipModel, GroupsChannelModel, GroupsTeamModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, @@ -42,7 +42,7 @@ class DatabaseManager { constructor() { this.appModels = [InfoModel, GlobalModel, ServersModel]; this.serverModels = [ - ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, + CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupMembershipModel, GroupsChannelModel, GroupsTeamModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, diff --git a/app/database/models/server/category.ts b/app/database/models/server/category.ts new file mode 100644 index 000000000..d748d5c86 --- /dev/null +++ b/app/database/models/server/category.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Relation, Query, Q} from '@nozbe/watermelondb'; +import {children, field, immutableRelation, lazy} from '@nozbe/watermelondb/decorators'; +import Model, {Associations} from '@nozbe/watermelondb/Model'; +import {map, distinctUntilChanged} from 'rxjs'; + +import {MM_TABLES} from '@constants/database'; + +import type CategoryInterface from '@typings/database/models/servers/category'; +import type CategoryChannelModel from '@typings/database/models/servers/category_channel'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type MyChannelModel from '@typings/database/models/servers/my_channel'; +import type TeamModel from '@typings/database/models/servers/team'; + +const { + CATEGORY, + CATEGORY_CHANNEL, + CHANNEL, + MY_CHANNEL, + TEAM, +} = MM_TABLES.SERVER; + +/** + * A Category holds channels for a given user in a team + */ +export default class CategoryModel extends Model implements CategoryInterface { + /** table (name) : Category */ + static table = CATEGORY; + + /** associations : Describes every relationship to this table. */ + static associations: Associations = { + + /** A CATEGORY has a 1:N relationship with CHANNEL. A CATEGORY can possess multiple channels */ + [CATEGORY_CHANNEL]: {type: 'has_many', foreignKey: 'category_id'}, + + /** A TEAM can be associated to CATEGORY (relationship is 1:N) */ + [TEAM]: {type: 'belongs_to', key: 'team_id'}, + }; + + /** display_name : The display name for the category */ + @field('display_name') displayName!: string; + + /** type : The type of category ('channels' | 'direct_messages' | 'favorites' | 'custom') */ + @field('type') type!: CategoryType; + + /** sort_order : The index on which to sort and display categories */ + @field('sort_order') sortOrder!: number; + + /** sorting : The type of sorting applied to the category channels (alpha, recent, manual) */ + @field('sorting') sorting!: CategorySorting; + + /** collapsed : Boolean flag indicating if the category is collapsed */ + @field('collapsed') collapsed!: boolean; + + /** muted : Boolean flag indicating if the category is muted */ + @field('muted') muted!: boolean; + + /** teamId : The team in which this category lives */ + @field('team_id') teamId!: string; + + /** team : Retrieves information about the team that this category is a part of. */ + @immutableRelation(TEAM, 'id') team!: Relation; + + /** categoryChannels : All the CategoryChannels associated with this team */ + @children(CATEGORY_CHANNEL) categoryChannels!: Query; + + /** categoryChannelsBySortOrder : Retrieves assocated category channels sorted by sort_order */ + @lazy categoryChannelsBySortOrder = this.categoryChannels.collection.query(Q.sortBy('sort_order', Q.asc)); + + /** channels : Retrieves all the channels that are part of this category */ + @lazy channels = this.collections. + get(CHANNEL). + query( + Q.experimentalJoinTables([CHANNEL, CATEGORY_CHANNEL]), + Q.on(CATEGORY_CHANNEL, + Q.and( + Q.on(CHANNEL, Q.where('delete_at', Q.eq(0))), + Q.where('category_id', this.id), + ), + ), + Q.sortBy('display_name'), + ); + + /** myChannels : Retrieves all myChannels that are part of this category */ + @lazy myChannels = this.collections. + get(MY_CHANNEL). + query( + Q.experimentalJoinTables([CHANNEL, CATEGORY_CHANNEL]), + Q.on(CATEGORY_CHANNEL, + Q.and( + Q.on(CHANNEL, Q.where('delete_at', Q.eq(0))), + Q.where('category_id', this.id), + ), + ), + Q.sortBy('last_post_at', Q.desc), + ); + + /** hasChannels : Returns a boolean indicating if the category has channels */ + @lazy hasChannels = this.categoryChannels.observeCount().pipe( + map((c) => c > 0), + distinctUntilChanged(), + ); +} diff --git a/app/database/models/server/category_channel.ts b/app/database/models/server/category_channel.ts new file mode 100644 index 000000000..5b9563c91 --- /dev/null +++ b/app/database/models/server/category_channel.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Relation} from '@nozbe/watermelondb'; +import {field, immutableRelation, relation} from '@nozbe/watermelondb/decorators'; +import Model, {Associations} from '@nozbe/watermelondb/Model'; + +import {MM_TABLES} from '@constants/database'; + +import type CategoryModel from '@typings/database/models/servers/category'; +import type CategoryChannelInterface from '@typings/database/models/servers/category_channel'; +import type ChannelModel from '@typings/database/models/servers/channel'; + +const {CATEGORY_CHANNEL, CATEGORY, MY_CHANNEL, CHANNEL} = MM_TABLES.SERVER; + +/** + * The CategoryChannel model represents the 'association table' where many categories have channels and many channels are in + * categories (relationship type N:N) + */ +export default class CategoryChannelModel extends Model implements CategoryChannelInterface { + /** table (name) : CategoryChannel */ + static table = CATEGORY_CHANNEL; + + /** associations : Describes every relationship to this table. */ + static associations: Associations = { + + /** A CategoryChannel belongs to a CATEGORY */ + [CATEGORY]: {type: 'belongs_to', key: 'category_id'}, + + /** A CategoryChannel has a Channel */ + [CHANNEL]: {type: 'belongs_to', key: 'channel_id'}, + + /** A CategoryChannel has a MyChannel */ + [MY_CHANNEL]: {type: 'belongs_to', key: 'channel_id'}, + }; + + /** category_id : The foreign key to the related Category record */ + @field('category_id') categoryId!: string; + + /** channel_id : The foreign key to the related Channel record */ + @field('channel_id') channelId!: string; + + /* sort_order: The sort order for the channel in category */ + @field('sort_order') sortOrder!: number; + + /** category : The related category */ + @relation(CATEGORY, 'category_id') category!: Relation; + + /** channel : The related channel */ + @immutableRelation(CHANNEL, 'channel_id') channel!: Relation; + + /** myChannel : The related myChannel */ + @immutableRelation(MY_CHANNEL, 'channel_id') myChannel!: Relation; +} diff --git a/app/database/models/server/channel.ts b/app/database/models/server/channel.ts index baaea2bd3..7394e83a4 100644 --- a/app/database/models/server/channel.ts +++ b/app/database/models/server/channel.ts @@ -19,6 +19,7 @@ import type TeamModel from '@typings/database/models/servers/team'; import type UserModel from '@typings/database/models/servers/user'; const { + CATEGORY_CHANNEL, CHANNEL, CHANNEL_INFO, CHANNEL_MEMBERSHIP, @@ -45,6 +46,9 @@ export default class ChannelModel extends Model { /** A CHANNEL can be associated with multiple CHANNEL_MEMBERSHIP (relationship is 1:N) */ [CHANNEL_MEMBERSHIP]: {type: 'has_many', foreignKey: 'channel_id'}, + /** A CHANNEL can be associated with multiple CATEGORY_CHANNEL (relationship is 1:N) */ + [CATEGORY_CHANNEL]: {type: 'has_many', foreignKey: 'channel_id'}, + /** A CHANNEL can be associated with multiple DRAFT (relationship is 1:N) */ [DRAFT]: {type: 'has_many', foreignKey: 'channel_id'}, diff --git a/app/database/models/server/index.ts b/app/database/models/server/index.ts index 17ae6a5b8..44e9d2108 100644 --- a/app/database/models/server/index.ts +++ b/app/database/models/server/index.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +export {default as CategoryModel} from './category'; +export {default as CategoryChannelModel} from './category_channel'; export {default as ChannelInfoModel} from './channel_info'; export {default as ChannelMembershipModel} from './channel_membership'; export {default as ChannelModel} from './channel'; diff --git a/app/database/models/server/my_channel.ts b/app/database/models/server/my_channel.ts index 343ea2a65..c78660ec8 100644 --- a/app/database/models/server/my_channel.ts +++ b/app/database/models/server/my_channel.ts @@ -9,7 +9,7 @@ import {MM_TABLES} from '@constants/database'; import type ChannelModel from '@typings/database/models/servers/channel'; -const {CHANNEL, MY_CHANNEL} = MM_TABLES.SERVER; +const {CATEGORY_CHANNEL, CHANNEL, MY_CHANNEL} = MM_TABLES.SERVER; /** * MyChannel is an extension of the Channel model but it lists only the Channels the app's user belongs to @@ -20,6 +20,7 @@ export default class MyChannelModel extends Model { static associations: Associations = { [CHANNEL]: {type: 'belongs_to', key: 'id'}, + [CATEGORY_CHANNEL]: {type: 'has_many', foreignKey: 'channel_id'}, }; /** last_post_at : The timestamp for any last post on this channel */ diff --git a/app/database/operator/server_data_operator/comparators/index.ts b/app/database/operator/server_data_operator/comparators/index.ts index 999d2b96c..0b52726e4 100644 --- a/app/database/operator/server_data_operator/comparators/index.ts +++ b/app/database/operator/server_data_operator/comparators/index.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type CategoryModel from '@typings/database/models/servers/category'; +import type CategoryChannelModel from '@typings/database/models/servers/category_channel'; import type ChannelModel from '@typings/database/models/servers/channel'; import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership'; @@ -33,6 +35,14 @@ import type UserModel from '@typings/database/models/servers/user'; * 'record' and the 'raw' */ +export const isRecordCategoryEqualToRaw = (record: CategoryModel, raw: Category) => { + return raw.id === record.id; +}; + +export const isRecordCategoryChannelEqualToRaw = (record: CategoryChannelModel, raw: CategoryChannel) => { + return (record.id === raw.id); +}; + export const isRecordRoleEqualToRaw = (record: RoleModel, raw: Role) => { return raw.id === record.id; }; diff --git a/app/database/operator/server_data_operator/handlers/category.test.ts b/app/database/operator/server_data_operator/handlers/category.test.ts new file mode 100644 index 000000000..da87d2825 --- /dev/null +++ b/app/database/operator/server_data_operator/handlers/category.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import { + isRecordCategoryEqualToRaw, + isRecordCategoryChannelEqualToRaw, +} from '@database/operator/server_data_operator/comparators'; +import { + transformCategoryRecord, + transformCategoryChannelRecord, +} from '@database/operator/server_data_operator/transformers/category'; + +import ServerDataOperator from '..'; + +describe('*** Operator: Category Handlers tests ***', () => { + let operator: ServerDataOperator; + beforeAll(async () => { + await DatabaseManager.init(['baseHandler.test.com']); + operator = DatabaseManager.serverDatabases['baseHandler.test.com'].operator; + }); + + it('=> handleCategories: should write to the CATEGORY table', async () => { + expect.assertions(2); + + const spyOnHandleRecords = jest.spyOn(operator, 'handleRecords'); + const categories: Category[] = [ + { + id: 'kjlw9j1ttnxwig7tnqgebg7dtipno', + collapsed: false, + display_name: 'Test', + muted: false, + sort_order: 1, + sorting: 'recent', + team_id: '', + type: 'direct_messages', + }, + ]; + + await operator.handleCategories({ + categories, + prepareRecordsOnly: false, + }); + + expect(spyOnHandleRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleRecords).toHaveBeenCalledWith({ + fieldName: 'id', + createOrUpdateRawValues: categories, + tableName: MM_TABLES.SERVER.CATEGORY, + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordCategoryEqualToRaw, + transformer: transformCategoryRecord, + }); + }); + + it('=> handleCategoryChannels: should write to the CATEGORY_CHANNEL table', async () => { + expect.assertions(2); + + const spyOnHandleRecords = jest.spyOn(operator, 'handleRecords'); + const categoryChannels: CategoryChannel[] = [ + { + id: 'team_id-channel_id', + category_id: 'kjlw9j1ttnxwig7tnqgebg7dtipno', + channel_id: 'channel-id', + sort_order: 1, + }, + ]; + + await operator.handleCategoryChannels({ + categoryChannels, + prepareRecordsOnly: false, + }); + + expect(spyOnHandleRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleRecords).toHaveBeenCalledWith({ + fieldName: 'id', + createOrUpdateRawValues: categoryChannels, + tableName: MM_TABLES.SERVER.CATEGORY_CHANNEL, + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordCategoryChannelEqualToRaw, + transformer: transformCategoryChannelRecord, + }); + }); +}); diff --git a/app/database/operator/server_data_operator/handlers/category.ts b/app/database/operator/server_data_operator/handlers/category.ts new file mode 100644 index 000000000..8f25b46e2 --- /dev/null +++ b/app/database/operator/server_data_operator/handlers/category.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import DataOperatorException from '@database/exceptions/data_operator_exception'; +import { + isRecordCategoryChannelEqualToRaw, + isRecordCategoryEqualToRaw, +} from '@database/operator/server_data_operator/comparators'; +import { + transformCategoryChannelRecord, + transformCategoryRecord, +} from '@database/operator/server_data_operator/transformers/category'; +import {getUniqueRawsBy} from '@database/operator/utils/general'; + +import type { + HandleCategoryChannelArgs, + HandleCategoryArgs, +} from '@typings/database/database'; +import type CategoryModel from '@typings/database/models/servers/category'; +import type CategoryChannelModel from '@typings/database/models/servers/category_channel'; + +const { + CATEGORY, + CATEGORY_CHANNEL, +} = MM_TABLES.SERVER; + +export interface CategoryHandlerMix { + handleCategoryChannels: ({categoryChannels, prepareRecordsOnly}: HandleCategoryChannelArgs) => Promise; + handleCategories: ({categories, prepareRecordsOnly}: HandleCategoryArgs) => Promise; +} + +const CategoryHandler = (superclass: any) => class extends superclass { + /** + * handleCategories: Handler responsible for the Create/Update operations occurring on the Category table from the 'Server' schema + * @param {HandleCategoryArgs} categoriesArgs + * @param {Category[]} categoriesArgs.categories + * @param {boolean} categoriesArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {Promise} + */ + handleCategories = async ({categories, prepareRecordsOnly = true}: HandleCategoryArgs): Promise => { + if (!categories.length) { + throw new DataOperatorException( + 'An empty "categories" array has been passed to the handleCategories method', + ); + } + + const createOrUpdateRawValues = getUniqueRawsBy({raws: categories, key: 'id'}); + + return this.handleRecords({ + fieldName: 'id', + findMatchingRecordBy: isRecordCategoryEqualToRaw, + transformer: transformCategoryRecord, + createOrUpdateRawValues, + tableName: CATEGORY, + prepareRecordsOnly, + }); + }; + + /** + * handleCategoryChannels: Handler responsible for the Create/Update operations occurring on the CategoryChannel table from the 'Server' schema + * @param {HandleCategoryChannelArgs} categoriesArgs + * @param {CategoryChannel[]} categoriesArgs.categorychannels + * @param {boolean} categoriesArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {Promise} + */ + handleCategoryChannels = async ({categoryChannels, prepareRecordsOnly = true}: HandleCategoryChannelArgs): Promise => { + if (!categoryChannels.length) { + throw new DataOperatorException( + 'An empty "categoryChannels" array has been passed to the handleCategories method', + ); + } + + const createOrUpdateRawValues = getUniqueRawsBy({raws: categoryChannels, key: 'id'}); + + return this.handleRecords({ + fieldName: 'id', + findMatchingRecordBy: isRecordCategoryChannelEqualToRaw, + transformer: transformCategoryChannelRecord, + createOrUpdateRawValues, + tableName: CATEGORY_CHANNEL, + prepareRecordsOnly, + }); + }; +}; + +export default CategoryHandler; diff --git a/app/database/operator/server_data_operator/index.ts b/app/database/operator/server_data_operator/index.ts index ea4f780ed..b7dff4120 100644 --- a/app/database/operator/server_data_operator/index.ts +++ b/app/database/operator/server_data_operator/index.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import ServerDataOperatorBase from '@database/operator/server_data_operator/handlers'; +import CategoryHandler, {CategoryHandlerMix} from '@database/operator/server_data_operator/handlers/category'; import ChannelHandler, {ChannelHandlerMix} from '@database/operator/server_data_operator/handlers/channel'; import GroupHandler, {GroupHandlerMix} from '@database/operator/server_data_operator/handlers/group'; import PostHandler, {PostHandlerMix} from '@database/operator/server_data_operator/handlers/post'; @@ -14,9 +15,10 @@ import mix from '@utils/mix'; import type {Database} from '@nozbe/watermelondb'; interface ServerDataOperator extends ServerDataOperatorBase, PostHandlerMix, PostsInChannelHandlerMix, - PostsInThreadHandlerMix, UserHandlerMix, GroupHandlerMix, ChannelHandlerMix, TeamHandlerMix {} + PostsInThreadHandlerMix, UserHandlerMix, GroupHandlerMix, ChannelHandlerMix, CategoryHandlerMix, TeamHandlerMix {} class ServerDataOperator extends mix(ServerDataOperatorBase).with( + CategoryHandler, ChannelHandler, GroupHandler, PostHandler, diff --git a/app/database/operator/server_data_operator/transformers/category.test.ts b/app/database/operator/server_data_operator/transformers/category.test.ts new file mode 100644 index 000000000..47bdac4da --- /dev/null +++ b/app/database/operator/server_data_operator/transformers/category.test.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import { + transformCategoryRecord, + transformCategoryChannelRecord, +} from '@database/operator/server_data_operator/transformers/category'; +import {createTestConnection} from '@database/operator/utils/create_test_connection'; +import {OperationType} from '@typings/database/enums'; + +describe('*** CATEGORY Prepare Records Test ***', () => { + it('=> transformCategoryRecord: should return an array of type CategoryModel', async () => { + // expect.assertions(3); + + const database = await createTestConnection({databaseName: 'category_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await transformCategoryRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'kow9j1ttnxwig7tnqgebg7dtipno', + display_name: 'Test', + sorting: 'recent', + sort_order: 0, + muted: false, + collapsed: false, + type: 'custom', + team_id: '', + } as Category, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords.collection.modelClass.name).toBe('CategoryModel'); + }); + + it('=> transformCategoryChannelRecord: should return an array of type CategoryChannelModel', async () => { + // expect.assertions(3); + + const database = await createTestConnection({databaseName: 'category_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await transformCategoryChannelRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'team_id-channel_id', + category_id: 'kow9j1ttnxwig7tnqgebg7dtipno', + channel_id: 'channel_id', + sort_order: 0, + } as CategoryChannel, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords.collection.modelClass.name).toBe('CategoryChannelModel'); + }); +}); diff --git a/app/database/operator/server_data_operator/transformers/category.ts b/app/database/operator/server_data_operator/transformers/category.ts new file mode 100644 index 000000000..f2ca14f32 --- /dev/null +++ b/app/database/operator/server_data_operator/transformers/category.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import {prepareBaseRecord} from '@database/operator/server_data_operator/transformers/index'; +import {OperationType} from '@typings/database/enums'; + +import type {TransformerArgs} from '@typings/database/database'; +import type CategoryModel from '@typings/database/models/servers/category'; +import type CategoryChannelModel from '@typings/database/models/servers/category_channel'; + +const { + CATEGORY, + CATEGORY_CHANNEL, +} = MM_TABLES.SERVER; + +/** + * transformCategoryRecord: Prepares a record of the SERVER database 'Category' table for update or create actions. + * @param {TransformerArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const transformCategoryRecord = ({action, database, value}: TransformerArgs): Promise => { + const raw = value.raw as Category; + const record = value.record as CategoryModel; + const isCreateAction = action === OperationType.CREATE; + + // id of category comes from server response + const fieldsMapper = (category: CategoryModel) => { + category._raw.id = isCreateAction ? (raw?.id ?? category.id) : record.id; + category.displayName = raw.display_name; + category.sorting = raw.sorting || 'recent'; + category.sortOrder = raw.sort_order === 0 ? 0 : raw.sort_order / 10; // Sort order from server is in multiples of 10 + category.muted = raw.muted ?? false; + category.collapsed = isCreateAction ? false : record.collapsed; + category.type = raw.type; + category.teamId = raw.team_id; + }; + + return prepareBaseRecord({ + action, + database, + tableName: CATEGORY, + value, + fieldsMapper, + }) as Promise; +}; + +/** + * transformCategoryChannelRecord: Prepares a record of the SERVER database 'CategoryChannel' table for update or create actions. + * @param {TransformerArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const transformCategoryChannelRecord = ({action, database, value}: TransformerArgs): Promise => { + const raw = value.raw as CategoryChannel; + const record = value.record as CategoryChannelModel; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const fieldsMapper = (categoryChannel: CategoryChannelModel) => { + categoryChannel._raw.id = isCreateAction ? (raw?.id ?? categoryChannel.id) : record.id; + categoryChannel.channelId = raw.channel_id; + categoryChannel.categoryId = raw.category_id; + categoryChannel.sortOrder = raw.sort_order; + }; + + return prepareBaseRecord({ + action, + database, + tableName: CATEGORY_CHANNEL, + value, + fieldsMapper, + }) as Promise; +}; diff --git a/app/database/schema/server/index.ts b/app/database/schema/server/index.ts index 92c32206f..76f10c33e 100644 --- a/app/database/schema/server/index.ts +++ b/app/database/schema/server/index.ts @@ -4,6 +4,8 @@ import {AppSchema, appSchema} from '@nozbe/watermelondb'; import { + CategorySchema, + CategoryChannelSchema, ChannelInfoSchema, ChannelMembershipSchema, ChannelSchema, @@ -36,6 +38,8 @@ import { export const serverSchema: AppSchema = appSchema({ version: 1, tables: [ + CategorySchema, + CategoryChannelSchema, ChannelInfoSchema, ChannelMembershipSchema, ChannelSchema, diff --git a/app/database/schema/server/table_schemas/category.ts b/app/database/schema/server/table_schemas/category.ts new file mode 100644 index 000000000..9727f57a3 --- /dev/null +++ b/app/database/schema/server/table_schemas/category.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {tableSchema} from '@nozbe/watermelondb'; + +import {MM_TABLES} from '@constants/database'; + +const {CATEGORY} = MM_TABLES.SERVER; + +export default tableSchema({ + name: CATEGORY, + columns: [ + {name: 'display_name', type: 'string'}, + {name: 'type', type: 'string'}, + {name: 'sort_order', type: 'number'}, + {name: 'sorting', type: 'string'}, + {name: 'muted', type: 'boolean'}, + {name: 'collapsed', type: 'boolean'}, + {name: 'team_id', type: 'string', isIndexed: true}, + ], +}); diff --git a/app/database/schema/server/table_schemas/category_channel.ts b/app/database/schema/server/table_schemas/category_channel.ts new file mode 100644 index 000000000..ecd9daeb6 --- /dev/null +++ b/app/database/schema/server/table_schemas/category_channel.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {tableSchema} from '@nozbe/watermelondb'; + +import {MM_TABLES} from '@constants/database'; + +const {CATEGORY_CHANNEL} = MM_TABLES.SERVER; + +export default tableSchema({ + name: CATEGORY_CHANNEL, + columns: [ + {name: 'category_id', type: 'string', isIndexed: true}, + {name: 'channel_id', type: 'string', isIndexed: true}, + {name: 'sort_order', type: 'number'}, + ], +}); diff --git a/app/database/schema/server/table_schemas/index.ts b/app/database/schema/server/table_schemas/index.ts index 2456b1482..c38d1d3e3 100644 --- a/app/database/schema/server/table_schemas/index.ts +++ b/app/database/schema/server/table_schemas/index.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +export {default as CategorySchema} from './category'; +export {default as CategoryChannelSchema} from './category_channel'; export {default as ChannelInfoSchema} from './channel_info'; export {default as ChannelMembershipSchema} from './channel_membership'; export {default as ChannelSchema} from './channel'; diff --git a/app/database/schema/server/test.ts b/app/database/schema/server/test.ts index 172796ee2..f78e881fd 100644 --- a/app/database/schema/server/test.ts +++ b/app/database/schema/server/test.ts @@ -8,6 +8,8 @@ import {MM_TABLES} from '@constants/database'; import {serverSchema} from './index'; const { + CATEGORY, + CATEGORY_CHANNEL, CHANNEL, CHANNEL_INFO, CHANNEL_MEMBERSHIP, @@ -42,8 +44,45 @@ describe('*** Test schema for SERVER database ***', () => { expect(serverSchema).toEqual({ version: 1, tables: { + [CATEGORY]: { + name: CATEGORY, + unsafeSql: undefined, + columns: { + display_name: {name: 'display_name', type: 'string'}, + type: {name: 'type', type: 'string'}, + sort_order: {name: 'sort_order', type: 'number'}, + sorting: {name: 'sorting', type: 'string'}, + muted: {name: 'muted', type: 'boolean'}, + collapsed: {name: 'collapsed', type: 'boolean'}, + team_id: {name: 'team_id', type: 'string', isIndexed: true}, + }, + columnArray: [ + {name: 'display_name', type: 'string'}, + {name: 'type', type: 'string'}, + {name: 'sort_order', type: 'number'}, + {name: 'sorting', type: 'string'}, + {name: 'muted', type: 'boolean'}, + {name: 'collapsed', type: 'boolean'}, + {name: 'team_id', type: 'string', isIndexed: true}, + ], + }, + [CATEGORY_CHANNEL]: { + name: CATEGORY_CHANNEL, + unsafeSql: undefined, + columns: { + category_id: {name: 'category_id', type: 'string', isIndexed: true}, + channel_id: {name: 'channel_id', type: 'string', isIndexed: true}, + sort_order: {name: 'sort_order', type: 'number'}, + }, + columnArray: [ + {name: 'category_id', type: 'string', isIndexed: true}, + {name: 'channel_id', type: 'string', isIndexed: true}, + {name: 'sort_order', type: 'number'}, + ], + }, [CHANNEL_INFO]: { name: CHANNEL_INFO, + unsafeSql: undefined, columns: { guest_count: {name: 'guest_count', type: 'number'}, header: {name: 'header', type: 'string'}, @@ -61,6 +100,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [CHANNEL]: { name: CHANNEL, + unsafeSql: undefined, columns: { create_at: {name: 'create_at', type: 'number'}, creator_id: {name: 'creator_id', type: 'string', isIndexed: true}, @@ -92,6 +132,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [CHANNEL_MEMBERSHIP]: { name: CHANNEL_MEMBERSHIP, + unsafeSql: undefined, columns: { channel_id: {name: 'channel_id', type: 'string', isIndexed: true}, user_id: {name: 'user_id', type: 'string', isIndexed: true}, @@ -103,6 +144,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [CUSTOM_EMOJI]: { name: CUSTOM_EMOJI, + unsafeSql: undefined, columns: { name: {name: 'name', type: 'string'}, }, @@ -110,6 +152,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [MY_CHANNEL]: { name: MY_CHANNEL, + unsafeSql: undefined, columns: { last_post_at: {name: 'last_post_at', type: 'number'}, last_viewed_at: {name: 'last_viewed_at', type: 'number'}, @@ -133,6 +176,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [MY_CHANNEL_SETTINGS]: { name: MY_CHANNEL_SETTINGS, + unsafeSql: undefined, columns: { notify_props: {name: 'notify_props', type: 'string'}, }, @@ -142,6 +186,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [POSTS_IN_CHANNEL]: { name: POSTS_IN_CHANNEL, + unsafeSql: undefined, columns: { channel_id: {name: 'channel_id', type: 'string', isIndexed: true}, earliest: {name: 'earliest', type: 'number'}, @@ -155,6 +200,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [DRAFT]: { name: DRAFT, + unsafeSql: undefined, columns: { channel_id: {name: 'channel_id', type: 'string', isIndexed: true}, files: {name: 'files', type: 'string'}, @@ -170,6 +216,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [FILE]: { name: FILE, + unsafeSql: undefined, columns: { extension: {name: 'extension', type: 'string'}, height: {name: 'height', type: 'number'}, @@ -195,6 +242,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [POSTS_IN_THREAD]: { name: POSTS_IN_THREAD, + unsafeSql: undefined, columns: { root_id: {name: 'root_id', type: 'string', isIndexed: true}, earliest: {name: 'earliest', type: 'number'}, @@ -208,6 +256,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [POST]: { name: POST, + unsafeSql: undefined, columns: { channel_id: {name: 'channel_id', type: 'string', isIndexed: true}, create_at: {name: 'create_at', type: 'number'}, @@ -245,6 +294,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [GROUP]: { name: GROUP, + unsafeSql: undefined, columns: { allow_reference: {name: 'allow_reference', type: 'boolean'}, delete_at: {name: 'delete_at', type: 'number'}, @@ -260,6 +310,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [GROUPS_CHANNEL]: { name: GROUPS_CHANNEL, + unsafeSql: undefined, columns: { channel_id: {name: 'channel_id', type: 'string', isIndexed: true}, group_id: {name: 'group_id', type: 'string', isIndexed: true}, @@ -275,6 +326,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [GROUPS_TEAM]: { name: GROUPS_TEAM, + unsafeSql: undefined, columns: { group_id: {name: 'group_id', type: 'string', isIndexed: true}, team_id: {name: 'team_id', type: 'string', isIndexed: true}, @@ -286,6 +338,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [GROUP_MEMBERSHIP]: { name: GROUP_MEMBERSHIP, + unsafeSql: undefined, columns: { group_id: {name: 'group_id', type: 'string', isIndexed: true}, user_id: {name: 'user_id', type: 'string', isIndexed: true}, @@ -297,6 +350,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [PREFERENCE]: { name: PREFERENCE, + unsafeSql: undefined, columns: { category: {name: 'category', type: 'string', isIndexed: true}, name: {name: 'name', type: 'string'}, @@ -312,6 +366,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [REACTION]: { name: REACTION, + unsafeSql: undefined, columns: { create_at: {name: 'create_at', type: 'number'}, emoji_name: {name: 'emoji_name', type: 'string'}, @@ -327,6 +382,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [MY_TEAM]: { name: MY_TEAM, + unsafeSql: undefined, columns: { roles: {name: 'roles', type: 'string'}, }, @@ -336,6 +392,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [ROLE]: { name: ROLE, + unsafeSql: undefined, columns: { name: {name: 'name', type: 'string'}, permissions: {name: 'permissions', type: 'string'}, @@ -347,6 +404,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [SLASH_COMMAND]: { name: SLASH_COMMAND, + unsafeSql: undefined, columns: { is_auto_complete: {name: 'is_auto_complete', type: 'boolean'}, description: {name: 'description', type: 'string'}, @@ -372,6 +430,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [SYSTEM]: { name: SYSTEM, + unsafeSql: undefined, columns: { value: {name: 'value', type: 'string'}, }, @@ -381,6 +440,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [TEAM]: { name: TEAM, + unsafeSql: undefined, columns: { is_allow_open_invite: { name: 'is_allow_open_invite', @@ -415,6 +475,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [TEAM_CHANNEL_HISTORY]: { name: TEAM_CHANNEL_HISTORY, + unsafeSql: undefined, columns: { channel_ids: {name: 'channel_ids', type: 'string'}, }, @@ -424,6 +485,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [TEAM_MEMBERSHIP]: { name: TEAM_MEMBERSHIP, + unsafeSql: undefined, columns: { team_id: {name: 'team_id', type: 'string', isIndexed: true}, user_id: {name: 'user_id', type: 'string', isIndexed: true}, @@ -435,6 +497,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [TEAM_SEARCH_HISTORY]: { name: TEAM_SEARCH_HISTORY, + unsafeSql: undefined, columns: { created_at: {name: 'created_at', type: 'number'}, display_term: {name: 'display_term', type: 'string'}, @@ -450,6 +513,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [TERMS_OF_SERVICE]: { name: TERMS_OF_SERVICE, + unsafeSql: undefined, columns: { accepted_at: {name: 'accepted_at', type: 'number'}, }, @@ -457,6 +521,7 @@ describe('*** Test schema for SERVER database ***', () => { }, [USER]: { name: USER, + unsafeSql: undefined, columns: { auth_service: {name: 'auth_service', type: 'string'}, update_at: {name: 'update_at', type: 'number'}, diff --git a/types/api/category.d.ts b/types/api/category.d.ts new file mode 100644 index 000000000..118a37092 --- /dev/null +++ b/types/api/category.d.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type CategorySorting = 'alpha' | 'manual' | 'recent' +type CategoryType = 'channels' | 'direct_messages' | 'favorites' | 'custom' + +type CategoryChannel = { + id?: string; + category_id: string; + channel_id: string; + sort_order: number; +} + +type Category = { + id: string; + team_id: string; + display_name: string; + sort_order: number; + sorting: CategorySorting; + type: CategoryType; + muted: boolean; + collapsed: boolean; +} + +type CategoryWithChannels = Category & { + channel_ids: string[]; +} + +type CategoriesWithOrder = { + categories: CategoryWithChannels[]; + order: string[]; +} diff --git a/types/database/database.d.ts b/types/database/database.d.ts index 65a4ef08f..d4b9f4192 100644 --- a/types/database/database.d.ts +++ b/types/database/database.d.ts @@ -190,6 +190,14 @@ export type HandleChannelArgs = PrepareOnly & { channels: Channel[]; }; +export type HandleCategoryArgs = PrepareOnly & { + categories: Category[]; +}; + +export type HandleCategoryChannelArgs = PrepareOnly & { + categoryChannels: CategoryChannel[]; +}; + export type HandleMyTeamArgs = PrepareOnly & { myTeams: MyTeam[]; }; diff --git a/types/database/models/servers/category.d.ts b/types/database/models/servers/category.d.ts new file mode 100644 index 000000000..5aac68eff --- /dev/null +++ b/types/database/models/servers/category.d.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Query, Relation} from '@nozbe/watermelondb'; +import {lazy} from '@nozbe/watermelondb/decorators'; +import Model, {Associations} from '@nozbe/watermelondb/Model'; +import {Observable} from 'rxjs'; + +/** + * A Category groups together channels for a user in a team. + */ +export default class CategoryModel extends Model { + /** table (name) : Category */ + static table: string; + + /** associations : Describes every relationship to this table. */ + static associations: Associations; + + /** display_name : The display name for the category */ + displayName: string; + + /** type : The type of category */ + type: string; + + /** sort_order : The sort order for this category */ + sortOrder: number; + + /** sorting : One of manual, alphabetical, or recent. */ + sorting: CategorySorting; + + /** muted : If the category is muted */ + muted: boolean; + + /** collapsed : If the category is collapsed (visible channels) */ + collapsed: boolean; + + /** team_id : The team in which this category resides */ + teamId: string; + + /** team : The team in which this category resides */ + team: Relation; + + /** categoryChannels : The join table for channels */ + categoryChannels: Query; + + /** categoryChannelsBySortOrder : The sorted join table for channels */ + @lazy categoryChannelsBySortOrder: Query; + + /** channels : All the channels associated with this category */ + @lazy channels: Query; + + /** myChannels : All the myChannels associated with this category */ + @lazy myChannels: Query; + + /** hasChannels : Whether the category has any channels */ + @lazy hasChannels: Observable; +} diff --git a/types/database/models/servers/category_channel.d.ts b/types/database/models/servers/category_channel.d.ts new file mode 100644 index 000000000..00284134f --- /dev/null +++ b/types/database/models/servers/category_channel.d.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Relation} from '@nozbe/watermelondb'; +import Model, {Associations} from '@nozbe/watermelondb/Model'; + +/** + * The CategoryChannel model represents the 'association table' where many categories have channels and many channels are in + * categories (relationship type N:N) + */ +export default class CategoryChannelModel extends Model { + /** table (name) : CategoryChannel */ + static table: string; + + /** associations : Describes every relationship to this table. */ + static associations: Associations; + + /** category_id : The foreign key to the related Category record */ + categoryId: string; + + /* channel_id : The foreign key to the related User record*/ + channelId: string; + + /* sort_order : The order in which the channel displays in the category, if the order is manually set */ + sortOrder: number; + + /** category : The related category */ + category: Relation; + + /** channel : The related channel */ + channel: Relation; + + /** myChannel : The related myChannel */ + myChannel: Relation; +} diff --git a/types/database/raw_values.d.ts b/types/database/raw_values.d.ts index c39c74cd4..b4f27a58a 100644 --- a/types/database/raw_values.d.ts +++ b/types/database/raw_values.d.ts @@ -95,6 +95,8 @@ type TermsOfService = { type RawValue = | AppInfo + | Category + | CategoryChannel | Channel | ChannelInfo | ChannelMember