[Gekidou] Fetch (and save) groups on @mention auto-complete (#6323)

* WIP
* Actions updated to fetch remote first, and local on error
* Groups fetch and save
* PR Feedback: prepare vs store and undefined fix
* Forgot to add file
This commit is contained in:
Shaz MJ
2022-06-07 07:55:28 +10:00
committed by GitHub
parent 851a2f7b19
commit fe354ee396
10 changed files with 405 additions and 77 deletions

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fetchFilteredChannelGroups, fetchFilteredTeamGroups, fetchGroupsForAutocomplete} from '@actions/remote/groups';
import DatabaseManager from '@database/manager';
import {prepareGroups, queryGroupsByName, queryGroupsByNameInChannel, queryGroupsByNameInTeam} from '@queries/servers/group';
import type GroupModel from '@typings/database/models/servers/group';
export const searchGroupsByName = async (serverUrl: string, name: string): Promise<GroupModel[]> => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
throw new Error(`${serverUrl} operator not found`);
}
try {
const groups = await fetchGroupsForAutocomplete(serverUrl, name);
if (groups && Array.isArray(groups)) {
return groups;
}
throw groups.error;
} catch (e) {
// eslint-disable-next-line no-console
console.log('searchGroupsByName - ERROR', e);
return queryGroupsByName(operator.database, name).fetch();
}
};
export const searchGroupsByNameInTeam = async (serverUrl: string, name: string, teamId: string): Promise<GroupModel[]> => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
throw new Error(`${serverUrl} operator not found`);
}
try {
const groups = await fetchFilteredTeamGroups(serverUrl, name, teamId);
if (groups && Array.isArray(groups)) {
return groups;
}
throw groups.error;
} catch (e) {
// eslint-disable-next-line no-console
console.log('searchGroupsByNameInTeam - ERROR', e);
return queryGroupsByNameInTeam(operator.database, name, teamId).fetch();
}
};
export const searchGroupsByNameInChannel = async (serverUrl: string, name: string, channelId: string): Promise<GroupModel[]> => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
throw new Error(`${serverUrl} operator not found`);
}
try {
const groups = await fetchFilteredChannelGroups(serverUrl, name, channelId);
if (groups && Array.isArray(groups)) {
return groups;
}
throw groups.error;
} catch (e) {
// eslint-disable-next-line no-console
console.log('searchGroupsByNameInChannel - ERROR', e);
return queryGroupsByNameInChannel(operator.database, name, channelId).fetch();
}
};
/**
* Store fetched groups locally
*
* @param serverUrl string - The Server URL
* @param groups Group[] - The groups fetched from the API
* @param prepareRecordsOnly boolean - Wether to only prepare records without saving
*/
export const storeGroups = async (serverUrl: string, groups: Group[]) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
throw new Error(`${serverUrl} operator not found`);
}
try {
const preparedGroups = await prepareGroups(operator, groups);
if (preparedGroups.length) {
operator.batchRecords(preparedGroups);
}
return preparedGroups;
} catch (e) {
return {error: e};
}
};

View File

@@ -1,104 +1,104 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {storeGroups} from '@actions/local/group';
import {prepareGroups} from '@app/queries/servers/group';
import {Client} from '@client/rest';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {forceLogoutIfNecessary} from './session';
export const fetchGroupsForChannel = async (serverUrl: string, channelId: string) => {
let client: Client;
export const fetchGroupsForAutocomplete = async (serverUrl: string, query: string, fetchOnly = false) => {
try {
client = NetworkManager.getClient(serverUrl);
return client.getAllGroupsAssociatedToChannel(channelId);
const client: Client = NetworkManager.getClient(serverUrl);
const response = await client.getGroups(query);
// Save locally
if (!fetchOnly) {
return await storeGroups(serverUrl, response);
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
throw new Error(`${serverUrl} operator not found`);
}
return await prepareGroups(operator, response);
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const fetchGroupsForMembership = async (serverUrl: string, userId: string) => {
let client: Client;
export const fetchGroupsForChannel = async (serverUrl: string, channelId: string, fetchOnly = false) => {
try {
client = NetworkManager.getClient(serverUrl);
return client.getAllGroupsAssociatedToMembership(userId);
const client = NetworkManager.getClient(serverUrl);
const response = await client.getAllGroupsAssociatedToChannel(channelId);
if (!fetchOnly) {
return await storeGroups(serverUrl, response.groups);
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
throw new Error(`${serverUrl} operator not found`);
}
return await prepareGroups(operator, response.groups);
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const fetchGroupsForTeam = async (serverUrl: string, teamId: string) => {
let client: Client;
export const fetchGroupsForTeam = async (serverUrl: string, teamId: string, fetchOnly = false) => {
try {
client = NetworkManager.getClient(serverUrl);
return client.getAllGroupsAssociatedToTeam(teamId);
const client: Client = NetworkManager.getClient(serverUrl);
const response = await client.getAllGroupsAssociatedToTeam(teamId);
if (!fetchOnly) {
return await storeGroups(serverUrl, response.groups);
}
// return await storeGroups(serverUrl, response.groups, true);
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
throw new Error(`${serverUrl} operator not found`);
}
return await prepareGroups(operator, response.groups);
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const fetchGroupsForAutocomplete = async (serverUrl: string, query: string) => {
let client: Client;
export const fetchFilteredTeamGroups = async (serverUrl: string, searchTerm: string, teamId: string) => {
try {
client = NetworkManager.getClient(serverUrl);
return client.getGroups(query);
const groups = await fetchGroupsForTeam(serverUrl, teamId);
if (groups && Array.isArray(groups)) {
return groups.filter((g) => g.name.toLowerCase().includes(searchTerm.toLowerCase()));
}
throw groups.error;
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const fetchMembershipsForGroup = async (serverUrl: string, groupId: string) => {
let client: Client;
export const fetchFilteredChannelGroups = async (serverUrl: string, searchTerm: string, channelId: string) => {
try {
client = NetworkManager.getClient(serverUrl);
return client.getAllMembershipsAssociatedToGroup(groupId);
const groups = await fetchGroupsForChannel(serverUrl, channelId);
if (groups && Array.isArray(groups)) {
return groups.filter((g) => g.name.toLowerCase().includes(searchTerm.toLowerCase()));
}
throw groups.error;
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const fetchTeamsForGroup = async (serverUrl: string, groupId: string) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
return client.getAllTeamsAssociatedToGroup(groupId);
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const fetchChannelsForGroup = async (serverUrl: string, groupId: string) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
return client.getAllChannelsAssociatedToGroup(groupId);
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const fetchFilteredTeamGroups = async (serverUrl: string, teamId: string, searchTerm: string) => {
const response = await fetchGroupsForTeam(serverUrl, teamId);
if (response && 'groups' in response) {
return response.groups.filter((g) => g.name.toLowerCase().includes(searchTerm.toLowerCase()));
}
return [];
};
export const fetchFilteredChannelGroups = async (serverUrl: string, channelId: string, searchTerm: string) => {
const response = await fetchGroupsForChannel(serverUrl, channelId);
if (response && 'groups' in response) {
return response.groups.filter((g) => g.name.toLowerCase().includes(searchTerm.toLowerCase()));
}
return [];
};

View File

@@ -5,7 +5,7 @@ import {debounce} from 'lodash';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Platform, SectionList, SectionListData, SectionListRenderItemInfo} from 'react-native';
import {fetchFilteredChannelGroups, fetchFilteredTeamGroups, fetchGroupsForAutocomplete} from '@actions/remote/groups';
import {searchGroupsByName, searchGroupsByNameInChannel, searchGroupsByNameInTeam} from '@actions/local/group';
import {searchUsers} from '@actions/remote/user';
import GroupMentionItem from '@components/autocomplete/at_mention_group/at_mention_group';
import AtMentionItem from '@components/autocomplete/at_mention_item';
@@ -19,6 +19,7 @@ import {t} from '@i18n';
import {queryAllUsers} from '@queries/servers/user';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type GroupModel from '@typings/database/models/servers/group';
import type UserModel from '@typings/database/models/servers/user';
const SECTION_KEY_TEAM_MEMBERS = 'teamMembers';
@@ -33,7 +34,7 @@ type SpecialMention = {
defaultMessage: string;
}
type UserMentionSections = Array<SectionListData<UserProfile|UserModel|Group|SpecialMention>>
type UserMentionSections = Array<SectionListData<UserProfile|UserModel|GroupModel|SpecialMention>>
const getMatchTermForAtMention = (() => {
let lastMatchTerm: string | null = null;
@@ -93,7 +94,7 @@ const filterLocalResults = (users: UserModel[], term: string) => {
);
};
const makeSections = (teamMembers: Array<UserProfile | UserModel>, usersInChannel: Array<UserProfile | UserModel>, usersOutOfChannel: Array<UserProfile | UserModel>, groups: Group[], showSpecialMentions: boolean, isLocal = false, isSearch = false) => {
const makeSections = (teamMembers: Array<UserProfile | UserModel>, usersInChannel: Array<UserProfile | UserModel>, usersOutOfChannel: Array<UserProfile | UserModel>, groups: GroupModel[], showSpecialMentions: boolean, isLocal = false, isSearch = false) => {
const newSections: UserMentionSections = [];
if (isSearch) {
@@ -200,7 +201,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
const emptyProfileList: UserProfile[] = [];
const emptyModelList: UserModel[] = [];
const emptySectionList: UserMentionSections = [];
const emptyGroupList: Group[] = [];
const emptyGroupList: GroupModel[] = [];
const getAllUsers = async (serverUrl: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
@@ -233,7 +234,7 @@ const AtMention = ({
const [sections, setSections] = useState<UserMentionSections>(emptySectionList);
const [usersInChannel, setUsersInChannel] = useState<UserProfile[]>(emptyProfileList);
const [usersOutOfChannel, setUsersOutOfChannel] = useState<UserProfile[]>(emptyProfileList);
const [groups, setGroups] = useState<Group[]>(emptyGroupList);
const [groups, setGroups] = useState<GroupModel[]>(emptyGroupList);
const [loading, setLoading] = useState(false);
const [noResultsTerm, setNoResultsTerm] = useState<string|null>(null);
const [localCursorPosition, setLocalCursorPosition] = useState(cursorPosition); // To avoid errors due to delay between value changes and cursor position changes.
@@ -311,12 +312,12 @@ const AtMention = ({
);
}, [completeMention]);
const renderGroupMentions = useCallback((item: Group) => {
const renderGroupMentions = useCallback((item: GroupModel) => {
return (
<GroupMentionItem
key={`autocomplete-group-${item.name}`}
name={item.name}
displayName={item.display_name}
displayName={item.displayName}
onPress={completeMention}
/>
);
@@ -332,12 +333,12 @@ const AtMention = ({
);
}, [completeMention]);
const renderItem = useCallback(({item, section}: SectionListRenderItemInfo<SpecialMention | Group | UserProfile>) => {
const renderItem = useCallback(({item, section}: SectionListRenderItemInfo<SpecialMention | GroupModel | UserProfile>) => {
switch (section.key) {
case SECTION_KEY_SPECIAL:
return renderSpecialMentions(item as SpecialMention);
case SECTION_KEY_GROUPS:
return renderGroupMentions(item as Group);
return renderGroupMentions(item as GroupModel);
default:
return renderAtMentions(item as UserProfile);
}
@@ -363,7 +364,7 @@ const AtMention = ({
if (useGroupMentions && matchTerm && matchTerm !== '') {
// If the channel is constrained, we only show groups for that channel
if (isChannelConstrained && channelId) {
fetchFilteredChannelGroups(serverUrl, channelId, matchTerm).then((g) => {
searchGroupsByNameInChannel(serverUrl, matchTerm, channelId).then((g) => {
setGroups(g.length ? g : emptyGroupList);
}).catch(() => {
setGroups(emptyGroupList);
@@ -372,7 +373,7 @@ const AtMention = ({
// If there is no channel constraint, but a team constraint - only show groups for team
if (isTeamConstrained && !isChannelConstrained) {
fetchFilteredTeamGroups(serverUrl, teamId!, matchTerm).then((g) => {
searchGroupsByNameInTeam(serverUrl, matchTerm, teamId!).then((g) => {
setGroups(g.length ? g : emptyGroupList);
}).catch(() => {
setGroups(emptyGroupList);
@@ -381,7 +382,7 @@ const AtMention = ({
// No constraints? Search all groups
if (!isTeamConstrained && !isChannelConstrained) {
fetchGroupsForAutocomplete(serverUrl, matchTerm || '').then((g) => {
searchGroupsByName(serverUrl, matchTerm || '').then((g) => {
setGroups(Array.isArray(g) ? g : emptyGroupList);
}).catch(() => {
setGroups(emptyGroupList);

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// See LICENSE.txt for license information.
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import {
transformGroupRecord,
} from '@database/operator/server_data_operator/transformers/group';
import ServerDataOperator from '..';
describe('*** Operator: Group Handlers tests ***', () => {
let operator: ServerDataOperator;
beforeAll(async () => {
await DatabaseManager.init(['baseHandler.test.com']);
operator = DatabaseManager.serverDatabases['baseHandler.test.com'].operator;
});
it('=> handleGroups: should write to the GROUP table', async () => {
expect.assertions(2);
const spyOnHandleRecords = jest.spyOn(operator, 'handleRecords');
const groups: Group[] = [
{
id: 'kjlw9j1ttnxwig7tnqgebg7dtipno',
name: 'test',
display_name: 'Test',
source: 'custom',
remote_id: 'iuh4r89egnslnvakjsdjhg',
description: 'Test description',
member_count: 0,
allow_reference: true,
create_at: 0,
update_at: 0,
delete_at: 0,
},
];
await operator.handleGroups({
groups,
prepareRecordsOnly: false,
});
expect(spyOnHandleRecords).toHaveBeenCalledTimes(1);
expect(spyOnHandleRecords).toHaveBeenCalledWith({
fieldName: 'id',
createOrUpdateRawValues: groups,
tableName: MM_TABLES.SERVER.GROUP,
prepareRecordsOnly: false,
transformer: transformGroupRecord,
});
});
});

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {MM_TABLES} from '@constants/database';
import {transformGroupRecord} from '@database/operator/server_data_operator/transformers/group';
import {getUniqueRawsBy} from '@database/operator/utils/general';
import type {HandleGroupArgs} from '@typings/database/database';
import type GroupModel from '@typings/database/models/servers/group';
const {GROUP} = MM_TABLES.SERVER;
export interface GroupHandlerMix {
handleGroups: ({groups, prepareRecordsOnly}: HandleGroupArgs) => Promise<GroupModel[]>;
}
const GroupHandler = (superclass: any) => class extends superclass implements GroupHandlerMix {
/**
* handleGroups: Handler responsible for the Create/Update operations occurring on the Group table from the 'Server' schema
* @param {HandleGroupArgs} groupsArgs
* @param {Group[]} groupsArgs.groups
* @param {boolean} groupsArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {Promise<GroupModel[]>}
*/
handleGroups = async ({groups, prepareRecordsOnly = true}: HandleGroupArgs): Promise<GroupModel[]> => {
if (!groups?.length) {
// eslint-disable-next-line no-console
console.warn(
'An empty or undefined "groups" array has been passed to the handleGroups method',
);
return [];
}
const createOrUpdateRawValues = getUniqueRawsBy({raws: groups, key: 'id'});
return this.handleRecords({
fieldName: 'id',
transformer: transformGroupRecord,
createOrUpdateRawValues,
tableName: GROUP,
prepareRecordsOnly,
});
};
};
export default GroupHandler;

View File

@@ -4,6 +4,7 @@
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';
import PostsInChannelHandler, {PostsInChannelHandlerMix} from '@database/operator/server_data_operator/handlers/posts_in_channel';
import PostsInThreadHandler, {PostsInThreadHandlerMix} from '@database/operator/server_data_operator/handlers/posts_in_thread';
@@ -16,12 +17,25 @@ import mix from '@utils/mix';
import type {Database} from '@nozbe/watermelondb';
interface ServerDataOperator extends ServerDataOperatorBase, PostHandlerMix, PostsInChannelHandlerMix,
PostsInThreadHandlerMix, ReactionHandlerMix, UserHandlerMix, ChannelHandlerMix, CategoryHandlerMix, TeamHandlerMix, ThreadHandlerMix, ThreadInTeamHandlerMix {}
interface ServerDataOperator extends
CategoryHandlerMix,
ChannelHandlerMix,
GroupHandlerMix,
PostHandlerMix,
PostsInChannelHandlerMix,
PostsInThreadHandlerMix,
ReactionHandlerMix,
ServerDataOperatorBase,
TeamHandlerMix,
ThreadHandlerMix,
ThreadInTeamHandlerMix,
UserHandlerMix
{}
class ServerDataOperator extends mix(ServerDataOperatorBase).with(
CategoryHandler,
ChannelHandler,
GroupHandler,
PostHandler,
PostsInChannelHandler,
PostsInThreadHandler,

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
transformGroupRecord,
} from '@database/operator/server_data_operator/transformers/group';
import {createTestConnection} from '@database/operator/utils/create_test_connection';
import {OperationType} from '@typings/database/enums';
describe('*** GROUP Prepare Records Test ***', () => {
it('=> transformGroupRecord: should return an array of type GroupModel', async () => {
// expect.assertions(3);
const database = await createTestConnection({databaseName: 'group_prepare_records', setActive: true});
expect(database).toBeTruthy();
const preparedRecords = await transformGroupRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
id: 'kow9j1ttnxwig7tnqgebg7dtipno',
display_name: 'Test',
name: 'recent',
source: 'custom',
remote_id: 'custom',
} as Group,
},
});
expect(preparedRecords).toBeTruthy();
expect(preparedRecords.collection.modelClass.name).toBe('GroupModel');
});
});

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// 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 GroupModel from '@typings/database/models/servers/group';
const {
GROUP,
} = MM_TABLES.SERVER;
/**
* transformGroupRecord: Prepares a record of the SERVER database 'Group' table for update or create actions.
* @param {TransformerArgs} operator
* @param {Database} operator.database
* @param {RecordPair} operator.value
* @returns {Promise<GroupModel>}
*/
export const transformGroupRecord = ({action, database, value}: TransformerArgs): Promise<GroupModel> => {
const raw = value.raw as Group;
const record = value.record as GroupModel;
const isCreateAction = action === OperationType.CREATE;
// id of group comes from server response
const fieldsMapper = (group: GroupModel) => {
group._raw.id = isCreateAction ? (raw?.id ?? group.id) : record.id;
group.name = raw.name;
group.displayName = raw.display_name;
group.source = raw.source;
group.remoteId = raw.remote_id;
};
return prepareBaseRecord({
action,
database,
tableName: GROUP,
value,
fieldsMapper,
}) as Promise<GroupModel>;
};

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database, Q} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type GroupModel from '@typings/database/models/servers/group';
const {SERVER: {GROUP, GROUP_CHANNEL, GROUP_TEAM}} = MM_TABLES;
export const queryGroupsByName = (database: Database, name: string) => {
return database.collections.get<GroupModel>(GROUP).query(
Q.where('name', Q.like(`%${Q.sanitizeLikeString(name)}%`)),
);
};
export const queryGroupsByNameInTeam = (database: Database, name: string, teamId: string) => {
return database.collections.get<GroupModel>(GROUP).query(
Q.on(GROUP_TEAM, 'team_id', teamId),
Q.where('name', Q.like(`%${Q.sanitizeLikeString(name)}%`)),
);
};
export const queryGroupsByNameInChannel = (database: Database, name: string, channelId: string) => {
return database.collections.get<GroupModel>(GROUP).query(
Q.on(GROUP_CHANNEL, 'channel_id', channelId),
Q.where('name', Q.like(`%${Q.sanitizeLikeString(name)}%`)),
);
};
export const prepareGroups = (operator: ServerDataOperator, groups: Group[]) => {
return operator.handleGroups({groups, prepareRecordsOnly: true});
};

View File

@@ -218,6 +218,10 @@ export type HandleCategoryArgs = PrepareOnly & {
categories?: Category[];
};
export type HandleGroupArgs = PrepareOnly & {
groups?: Group[];
};
export type HandleCategoryChannelArgs = PrepareOnly & {
categoryChannels?: CategoryChannel[];
};