From b6eb3b934907695f47cfc391f30a7cc0ebe323f4 Mon Sep 17 00:00:00 2001 From: Hossein Ahmadian-Yazdi Date: Wed, 8 Jul 2020 07:31:58 -0400 Subject: [PATCH] [MM-23158] Group Mentions & Invites - Display group mentions in suggestions list in the main channel textbox (#4118) * groups in group mention * fix sorting issues * address PR comments * fix tests * Update groups.ts * fix nock * fix translations * Adding test * fix linting * update redux functions * Add license check * adddress PR comments * remove lodash import * fix lint problems * revert package.json changes * Address PR comments * address PR comments * fix naming * address PR comments * address PR comments * Address comments found in second PR * getAllGroups updated * address PR comments * Update app/mm-redux/utils/group_utils.ts Co-authored-by: Elias Nahum * revert change * remove unneeded actions * Use correct server version * MM-26631: Fix order of group displaying * MM-26633: Fix group mentions not showing up in group constrained team * MM-26636: Group mentions not updating when role updates * MM-26637: Group name not updating right away * Address PR comments * TRY AND catch * address PR comments Co-authored-by: Elias Nahum --- app/actions/views/channel.js | 73 +- app/actions/websocket.ts | 52 +- .../autocomplete/at_mention/at_mention.js | 27 +- .../autocomplete/at_mention/index.js | 18 +- .../at_mention_group/at_mention_group.js | 87 +++ app/constants/websocket.ts | 2 + app/mm-redux/action_types/groups.ts | 5 + app/mm-redux/actions/groups.test.js | 693 ------------------ app/mm-redux/actions/groups.ts | 258 ------- app/mm-redux/actions/index.ts | 2 - app/mm-redux/client/client4.ts | 30 +- app/mm-redux/reducers/entities/channels.ts | 16 + app/mm-redux/reducers/entities/groups.ts | 13 + .../selectors/entities/groups.test.js | 28 +- app/mm-redux/selectors/entities/groups.ts | 79 +- app/mm-redux/types/groups.ts | 1 + app/mm-redux/utils/group_utils.test.js | 92 +++ app/mm-redux/utils/group_utils.ts | 35 + assets/base/i18n/en.json | 1 + 19 files changed, 536 insertions(+), 976 deletions(-) create mode 100644 app/components/autocomplete/at_mention_group/at_mention_group.js delete mode 100644 app/mm-redux/actions/groups.test.js delete mode 100644 app/mm-redux/actions/groups.ts create mode 100644 app/mm-redux/utils/group_utils.test.js create mode 100644 app/mm-redux/utils/group_utils.ts diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js index 47785d37b..57e0c6cf2 100644 --- a/app/actions/views/channel.js +++ b/app/actions/views/channel.js @@ -5,17 +5,19 @@ import {batchActions} from 'redux-batched-actions'; import {ViewTypes} from 'app/constants'; -import {ChannelTypes, RoleTypes} from '@mm-redux/action_types'; +import {ChannelTypes, RoleTypes, GroupTypes} from '@mm-redux/action_types'; import { fetchMyChannelsAndMembers, getChannelByNameAndTeamName, leaveChannel as serviceLeaveChannel, } from '@mm-redux/actions/channels'; import {savePreferences} from '@mm-redux/actions/preferences'; +import {getLicense} from '@mm-redux/selectors/entities/general'; import {selectTeam} from '@mm-redux/actions/teams'; import {Client4} from '@mm-redux/client'; import {General, Preferences} from '@mm-redux/constants'; import {getPostIdsInChannel} from '@mm-redux/selectors/entities/posts'; +import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; import { getCurrentChannelId, getRedirectChannelNameForTeam, @@ -23,7 +25,7 @@ import { isManuallyUnread, } from '@mm-redux/selectors/entities/channels'; import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; -import {getTeamByName} from '@mm-redux/selectors/entities/teams'; +import {getTeamByName, getCurrentTeam} from '@mm-redux/selectors/entities/teams'; import {getChannelByName as selectChannelByName, getChannelsIdForTeam} from '@mm-redux/utils/channel_utils'; import EventEmitter from '@mm-redux/utils/event_emitter'; @@ -572,6 +574,71 @@ function setLoadMorePostsVisible(visible) { }; } +function loadGroupData() { + return async (dispatch, getState) => { + const state = getState(); + const actions = []; + const team = getCurrentTeam(state); + const serverVersion = state.entities.general.serverVersion; + const license = getLicense(state); + const hasLicense = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true'; + + if (hasLicense && team && isMinimumServerVersion(serverVersion, 5, 24)) { + for (let i = 0; i <= MAX_RETRIES; i++) { + try { + if (team.group_constrained) { + const [getAllGroupsAssociatedToChannelsInTeam, getAllGroupsAssociatedToTeam] = await Promise.all([ //eslint-disable-line no-await-in-loop + Client4.getAllGroupsAssociatedToChannelsInTeam(team.id, true), + Client4.getAllGroupsAssociatedToTeam(team.id, true), + ]); + + if (getAllGroupsAssociatedToChannelsInTeam.groups) { + actions.push({ + type: GroupTypes.RECEIVED_ALL_GROUPS_ASSOCIATED_TO_CHANNELS_IN_TEAM, + data: {groupsByChannelId: getAllGroupsAssociatedToChannelsInTeam.groups}, + }); + } + + if (getAllGroupsAssociatedToTeam) { + actions.push({ + type: GroupTypes.RECEIVED_ALL_GROUPS_ASSOCIATED_TO_TEAM, + data: {...getAllGroupsAssociatedToTeam, teamID: team.id}, + }); + } + } else { + const [getAllGroupsAssociatedToChannelsInTeam, getGroups] = await Promise.all([ //eslint-disable-line no-await-in-loop + Client4.getAllGroupsAssociatedToChannelsInTeam(team.id, true), + Client4.getGroups(true), + ]); + + if (getAllGroupsAssociatedToChannelsInTeam.groups) { + actions.push({ + type: GroupTypes.RECEIVED_ALL_GROUPS_ASSOCIATED_TO_CHANNELS_IN_TEAM, + data: {groupsByChannelId: getAllGroupsAssociatedToChannelsInTeam.groups}, + }); + } + + if (getGroups) { + actions.push({ + type: GroupTypes.RECEIVED_GROUPS, + data: getGroups, + }); + } + } + } catch (err) { + return {error: err}; + } + } + } + + if (actions.length) { + dispatch(batchActions(actions, 'BATCH_GROUP_DATA')); + } + + return {data: true}; + }; +} + export function loadChannelsForTeam(teamId, skipDispatch = false) { return async (dispatch, getState) => { const state = getState(); @@ -642,6 +709,8 @@ export function loadChannelsForTeam(teamId, skipDispatch = false) { dispatch(loadUnreadChannelPosts(data.channels, data.channelMembers)); } + + dispatch(loadGroupData()); } return {data}; diff --git a/app/actions/websocket.ts b/app/actions/websocket.ts index 3e437fede..96f29215b 100644 --- a/app/actions/websocket.ts +++ b/app/actions/websocket.ts @@ -3,8 +3,7 @@ import {Client4} from '@mm-redux/client'; import websocketClient from '@websocket'; - -import {ChannelTypes, GeneralTypes, EmojiTypes, PostTypes, PreferenceTypes, TeamTypes, UserTypes, RoleTypes, IntegrationTypes} from '@mm-redux/action_types'; +import {ChannelTypes, GeneralTypes, EmojiTypes, PostTypes, PreferenceTypes, TeamTypes, UserTypes, RoleTypes, IntegrationTypes, GroupTypes} from '@mm-redux/action_types'; import {General, Preferences} from '@mm-redux/constants'; import { getAllChannels, @@ -298,6 +297,8 @@ function handleEvent(msg: WebSocketMessage) { return dispatch(handleRoleUpdatedEvent(msg)); case WebsocketEvents.USER_ROLE_UPDATED: return dispatch(handleUserRoleUpdated(msg)); + case WebsocketEvents.MEMBERROLE_UPDATED: + return dispatch(handleUpdateMemberRoleEvent(msg)); case WebsocketEvents.CHANNEL_CREATED: return dispatch(handleChannelCreatedEvent(msg)); case WebsocketEvents.CHANNEL_DELETED: @@ -341,6 +342,8 @@ function handleEvent(msg: WebSocketMessage) { return dispatch(handleConfigChangedEvent(msg)); case WebsocketEvents.OPEN_DIALOG: return dispatch(handleOpenDialogEvent(msg)); + case WebsocketEvents.RECEIVED_GROUP: + return dispatch(handleGroupUpdatedEvent(msg)); } return {data: true}; @@ -958,6 +961,34 @@ function handleChannelSchemeUpdatedEvent(msg: WebSocketMessage) { }; } +function handleUpdateMemberRoleEvent(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc) => { + const memberData = JSON.parse(msg.data.member); + const roles = memberData.roles.split(' '); + const actions = []; + + try { + const newRoles = await Client4.getRolesByNames(roles); + if (newRoles.length) { + actions.push({ + type: RoleTypes.RECEIVED_ROLES, + data: newRoles, + }); + } + } catch (error) { + return {error}; + } + + actions.push({ + type: TeamTypes.RECEIVED_MY_TEAM_MEMBER, + data: memberData, + }); + + dispatch(batchActions(actions)); + return {data: true}; + }; +} + function handleDirectAddedEvent(msg: WebSocketMessage) { return async (dispatch: DispatchFunc) => { const channelActions = await fetchChannelAndMyMember(msg.broadcast.channel_id); @@ -1142,6 +1173,23 @@ function handleOpenDialogEvent(msg: WebSocketMessage) { }; } +function handleGroupUpdatedEvent(msg: WebSocketMessage) { + return (dispatch: DispatchFunc) => { + const data = JSON.parse(msg.data.group); + dispatch(batchActions([ + { + type: GroupTypes.RECEIVED_GROUP, + data, + }, + { + type: GroupTypes.RECEIVED_MY_GROUPS, + data: [data], + }, + ])); + return {data: true}; + }; +} + // Helpers export async function notVisibleUsersActions(state: GlobalState): Promise> { if (!isMinimumServerVersion(Client4.getServerVersion(), 5, 23)) { diff --git a/app/components/autocomplete/at_mention/at_mention.js b/app/components/autocomplete/at_mention/at_mention.js index 49a3eb05a..016211414 100644 --- a/app/components/autocomplete/at_mention/at_mention.js +++ b/app/components/autocomplete/at_mention/at_mention.js @@ -12,6 +12,7 @@ import AtMentionItem from 'app/components/autocomplete/at_mention_item'; import AutocompleteDivider from 'app/components/autocomplete/autocomplete_divider'; import AutocompleteSectionHeader from 'app/components/autocomplete/autocomplete_section_header'; import SpecialMentionItem from 'app/components/autocomplete/special_mention_item'; +import GroupMentionItem from 'app/components/autocomplete/at_mention_group/at_mention_group'; import {makeStyleSheetFromTheme} from 'app/utils/theme'; import {t} from 'app/utils/i18n'; @@ -38,6 +39,7 @@ export default class AtMention extends PureComponent { isLandscape: PropTypes.bool.isRequired, nestedScrollEnabled: PropTypes.bool, useChannelMentions: PropTypes.bool.isRequired, + groups: PropTypes.array, }; static defaultProps = { @@ -106,7 +108,7 @@ export default class AtMention extends PureComponent { } buildSections = (props) => { - const {isSearch, inChannel, outChannel, teamMembers, matchTerm} = props; + const {isSearch, inChannel, outChannel, teamMembers, matchTerm, groups} = props; const sections = []; if (isSearch) { @@ -126,6 +128,16 @@ export default class AtMention extends PureComponent { }); } + if (groups.length) { + sections.push({ + id: t('suggestion.mention.groups'), + defaultMessage: 'Group Mentions', + data: groups, + key: 'groups', + renderItem: this.renderGroupMentions, + }); + } + if (this.props.useChannelMentions && this.checkSpecialMentions(matchTerm)) { sections.push({ id: t('suggestion.mention.special'), @@ -186,7 +198,6 @@ export default class AtMention extends PureComponent { } else { completedDraft = mentionPart.replace(AT_MENTION_REGEX, `@${mention} `); } - if (value.length > cursorPosition) { completedDraft += value.substring(cursorPosition); } @@ -228,10 +239,20 @@ export default class AtMention extends PureComponent { ); }; + renderGroupMentions = ({item}) => { + return ( + + ); + }; + render() { const {maxListHeight, theme, nestedScrollEnabled} = this.props; const {mentionComplete, sections} = this.state; - if (sections.length === 0 || mentionComplete) { // If we are not in an active state or the mention has been completed return null so nothing is rendered // other components are not blocked. diff --git a/app/components/autocomplete/at_mention/index.js b/app/components/autocomplete/at_mention/index.js index efed90ceb..73cacc8c0 100644 --- a/app/components/autocomplete/at_mention/index.js +++ b/app/components/autocomplete/at_mention/index.js @@ -6,7 +6,9 @@ import {connect} from 'react-redux'; import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; import {autocompleteUsers} from '@mm-redux/actions/users'; +import {getLicense} from '@mm-redux/selectors/entities/general'; import {getCurrentChannelId, getDefaultChannel} from '@mm-redux/selectors/entities/channels'; +import {getAssociatedGroupsForReference, searchAssociatedGroupsForReferenceLocal} from '@mm-redux/selectors/entities/groups'; import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; import {isLandscape} from 'app/selectors/device'; @@ -26,7 +28,9 @@ import AtMention from './at_mention'; function mapStateToProps(state, ownProps) { const {cursorPosition, isSearch} = ownProps; const currentChannelId = getCurrentChannelId(state); - + const currentTeamId = getCurrentTeamId(state); + const license = getLicense(state); + const hasLicense = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true'; let useChannelMentions = true; if (isMinimumServerVersion(state.entities.general.serverVersion, 5, 22)) { useChannelMentions = haveIChannelPermission( @@ -45,6 +49,7 @@ function mapStateToProps(state, ownProps) { let teamMembers; let inChannel; let outChannel; + let groups = []; if (isSearch) { teamMembers = filterMembersInCurrentTeam(state, matchTerm); } else { @@ -52,9 +57,17 @@ function mapStateToProps(state, ownProps) { outChannel = filterMembersNotInChannel(state, matchTerm); } + if (hasLicense && isMinimumServerVersion(state.entities.general.serverVersion, 5, 24)) { + if (matchTerm) { + groups = searchAssociatedGroupsForReferenceLocal(state, matchTerm, currentTeamId, currentChannelId); + } else { + groups = getAssociatedGroupsForReference(state, currentTeamId, currentChannelId); + } + } + return { currentChannelId, - currentTeamId: getCurrentTeamId(state), + currentTeamId, defaultChannel: getDefaultChannel(state), matchTerm, teamMembers, @@ -64,6 +77,7 @@ function mapStateToProps(state, ownProps) { theme: getTheme(state), isLandscape: isLandscape(state), useChannelMentions, + groups, }; } diff --git a/app/components/autocomplete/at_mention_group/at_mention_group.js b/app/components/autocomplete/at_mention_group/at_mention_group.js new file mode 100644 index 000000000..fab32663e --- /dev/null +++ b/app/components/autocomplete/at_mention_group/at_mention_group.js @@ -0,0 +1,87 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import { + Text, + View, +} from 'react-native'; +import Icon from 'react-native-vector-icons/FontAwesome'; + +import TouchableWithFeedback from 'app/components/touchable_with_feedback'; +import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; + +export default class GroupMentionItem extends PureComponent { + static propTypes = { + completeHandle: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired, + theme: PropTypes.object.isRequired, + }; + + completeMention = () => { + const {onPress, completeHandle} = this.props; + onPress(completeHandle); + }; + + render() { + const { + completeHandle, + theme, + } = this.props; + + const style = getStyleFromTheme(theme); + + return ( + + + + + {`@${completeHandle}`} + {' - '} + {`${completeHandle}`} + + ); + } +} +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + row: { + paddingVertical: 8, + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + }, + rowPicture: { + marginHorizontal: 8, + width: 20, + alignItems: 'center', + justifyContent: 'center', + }, + rowIcon: { + color: changeOpacity(theme.centerChannelColor, 0.7), + fontSize: 14, + }, + rowUsername: { + fontSize: 13, + color: theme.centerChannelColor, + }, + rowFullname: { + color: theme.centerChannelColor, + flex: 1, + opacity: 0.6, + }, + textWrapper: { + flex: 1, + flexWrap: 'wrap', + paddingRight: 8, + }, + }; +}); diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index 7adb29c5d..bc84f88d9 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -41,5 +41,7 @@ const WebsocketEvents = { PLUGIN_STATUSES_CHANGED: 'plugin_statuses_changed', OPEN_DIALOG: 'open_dialog', INCREASE_POST_VISIBILITY_BY_ONE: 'increase_post_visibility_by_one', + MEMBERROLE_UPDATED: 'memberrole_updated', + RECEIVED_GROUP: 'received_group', }; export default WebsocketEvents; diff --git a/app/mm-redux/action_types/groups.ts b/app/mm-redux/action_types/groups.ts index 695b18f0c..d2636c4a8 100644 --- a/app/mm-redux/action_types/groups.ts +++ b/app/mm-redux/action_types/groups.ts @@ -11,9 +11,14 @@ export default keyMirror({ RECEIVED_GROUP_TEAMS: null, RECEIVED_GROUP_CHANNELS: null, + RECEIVED_MY_GROUPS: null, RECEIVED_GROUP_MEMBERS: null, + RECEIVED_GROUP_ASSOCIATED_TO_TEAM: null, + + RECEIVED_ALL_GROUPS_ASSOCIATED_TO_CHANNELS_IN_TEAM: null, + RECEIVED_GROUP: null, RECEIVED_GROUPS: null, diff --git a/app/mm-redux/actions/groups.test.js b/app/mm-redux/actions/groups.test.js deleted file mode 100644 index c36656fbf..000000000 --- a/app/mm-redux/actions/groups.test.js +++ /dev/null @@ -1,693 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import assert from 'assert'; -import nock from 'nock'; - -import * as Actions from '@mm-redux/actions/groups'; -import {Client4} from '@mm-redux/client'; - -import {Groups} from '../constants'; -import TestHelper from 'test/test_helper'; -import configureStore from 'test/test_store'; - -describe('Actions.Groups', () => { - let store; - - beforeEach(async () => { - await TestHelper.initBasic(Client4); - store = await configureStore(); - }); - - afterEach(async () => { - await TestHelper.tearDown(); - }); - - it('getGroupSyncables', async () => { - const groupID = '5rgoajywb3nfbdtyafbod47rya'; - - const groupTeams = [ - { - team_id: 'ge63nq31sbfy3duzq5f7yqn1kh', - team_display_name: 'dolphins', - team_type: 'O', - group_id: '5rgoajywb3nfbdtyafbod47rya', - auto_add: true, - create_at: 1542643748412, - delete_at: 0, - update_at: 1542643748412, - }, - { - team_id: 'tdjrcr3hg7yazyos17a53jduna', - team_display_name: 'developers', - team_type: 'O', - group_id: '5rgoajywb3nfbdtyafbod47rya', - auto_add: true, - create_at: 1542643825026, - delete_at: 0, - update_at: 1542643825026, - }, - ]; - - const groupChannels = [ - { - channel_id: 'o3tdawqxot8kikzq8bk54zggbc', - channel_display_name: 'standup', - channel_type: 'P', - team_id: 'tdjrcr3hg7yazyos17a53jduna', - team_display_name: 'developers', - team_type: 'O', - group_id: '5rgoajywb3nfbdtyafbod47rya', - auto_add: true, - create_at: 1542644105041, - delete_at: 0, - update_at: 1542644105041, - }, - { - channel_id: 's6oxu3embpdepyprx1fn5gjhea', - channel_display_name: 'swimming', - channel_type: 'P', - team_id: 'ge63nq31sbfy3duzq5f7yqn1kh', - team_display_name: 'dolphins', - team_type: 'O', - group_id: '5rgoajywb3nfbdtyafbod47rya', - auto_add: true, - create_at: 1542644105042, - delete_at: 0, - update_at: 1542644105042, - }, - ]; - - nock(Client4.getBaseRoute()). - get(`/groups/${groupID}/teams`). - reply(200, groupTeams); - - nock(Client4.getBaseRoute()). - get(`/groups/${groupID}/channels`). - reply(200, groupChannels); - - await Actions.getGroupSyncables(groupID, Groups.SYNCABLE_TYPE_TEAM)(store.dispatch, store.getState); - await Actions.getGroupSyncables(groupID, Groups.SYNCABLE_TYPE_CHANNEL)(store.dispatch, store.getState); - - const state = store.getState(); - - const groupSyncables = state.entities.groups.syncables[groupID]; - assert.ok(groupSyncables); - - for (let i = 0; i < 2; i++) { - assert.ok(JSON.stringify(groupSyncables.teams[i]) === JSON.stringify(groupTeams[i])); - assert.ok(JSON.stringify(groupSyncables.channels[i]) === JSON.stringify(groupChannels[i])); - } - }); - - it('getGroupMembers', async () => { - const groupID = '5rgoajywb3nfbdtyafbod47rya'; - - const response = { - members: [ - { - id: 'ok1mtgwrn7gbzetzfdgircykir', - create_at: 1542658437708, - update_at: 1542658441412, - delete_at: 0, - username: 'test.161927', - auth_data: 'test.161927', - auth_service: 'ldap', - email: 'success+test.161927@simulator.amazonses.com', - email_verified: true, - nickname: '', - first_name: 'test', - last_name: 'test.161927', - position: '', - roles: 'system_user', - notify_props: { - channel: 'true', - comments: 'never', - desktop: 'mention', - desktop_sound: 'true', - email: 'true', - first_name: 'false', - mention_keys: 'test.161927,@test.161927', - push: 'mention', - push_status: 'away', - }, - last_password_update: 1542658437708, - locale: 'en', - timezone: { - automaticTimezone: '', - manualTimezone: '', - useAutomaticTimezone: 'true', - }, - }, - ], - total_member_count: 1, - }; - - nock(Client4.getBaseRoute()). - get(`/groups/${groupID}/members?page=0&per_page=100`). - reply(200, response); - - await Actions.getGroupMembers(groupID, 0, 100)(store.dispatch, store.getState); - - const state = store.getState(); - - const groupMembers = state.entities.groups.members; - assert.ok(groupMembers); - assert.ok(groupMembers[groupID].totalMemberCount === response.total_member_count); - - assert.ok(JSON.stringify(response.members[0]) === JSON.stringify(groupMembers[groupID].members[0])); - }); - - it('getGroup', async () => { - const groupID = '5rgoajywb3nfbdtyafbod47rya'; - - const response = { - id: '5rgoajywb3nfbdtyafbod47rya', - name: '8b7ks7ngqbgndqutka48gfzaqh', - display_name: 'Test Group 0', - description: '', - type: 'ldap', - remote_id: '\\eb\\80\\94\\cd\\d4\\32\\7c\\45\\87\\79\\1b\\fe\\45\\d9\\ac\\7b', - create_at: 1542399032816, - update_at: 1542399032816, - delete_at: 0, - has_syncables: false, - }; - - nock(Client4.getBaseRoute()). - get(`/groups/${groupID}`). - reply(200, response); - - await Actions.getGroup(groupID)(store.dispatch, store.getState); - - const state = store.getState(); - - const groups = state.entities.groups.groups; - assert.ok(groups); - assert.ok(groups[groupID]); - assert.ok(JSON.stringify(response) === JSON.stringify(groups[groupID])); - }); - - it('linkGroupSyncable', async () => { - const groupID = '5rgoajywb3nfbdtyafbod47rya'; - const teamID = 'ge63nq31sbfy3duzq5f7yqn1kh'; - const channelID = 'o3tdawqxot8kikzq8bk54zggbc'; - - const groupTeamResponse = { - team_id: 'ge63nq31sbfy3duzq5f7yqn1kh', - group_id: '5rgoajywb3nfbdtyafbod47rya', - auto_add: true, - create_at: 1542643748412, - delete_at: 0, - update_at: 1542660566032, - }; - - const groupChannelResponse = { - channel_id: 'o3tdawqxot8kikzq8bk54zggbc', - group_id: '5rgoajywb3nfbdtyafbod47rya', - auto_add: true, - create_at: 1542644105041, - delete_at: 0, - update_at: 1542662607342, - }; - - nock(Client4.getBaseRoute()). - post(`/groups/${groupID}/teams/${teamID}/link`). - reply(200, groupTeamResponse); - - nock(Client4.getBaseRoute()). - post(`/groups/${groupID}/channels/${channelID}/link`). - reply(200, groupChannelResponse); - - await Actions.linkGroupSyncable(groupID, teamID, Groups.SYNCABLE_TYPE_TEAM)(store.dispatch, store.getState); - await Actions.linkGroupSyncable(groupID, channelID, Groups.SYNCABLE_TYPE_CHANNEL)(store.dispatch, store.getState); - - const state = store.getState(); - const syncables = state.entities.groups.syncables; - assert.ok(syncables[groupID]); - - assert.ok(JSON.stringify(syncables[groupID].teams[0]) === JSON.stringify(groupTeamResponse)); - assert.ok(JSON.stringify(syncables[groupID].channels[0]) === JSON.stringify(groupChannelResponse)); - }); - - it('unlinkGroupSyncable', async () => { - const groupID = '5rgoajywb3nfbdtyafbod47rya'; - const teamID = 'ge63nq31sbfy3duzq5f7yqn1kh'; - const channelID = 'o3tdawqxot8kikzq8bk54zggbc'; - - const groupTeamResponse = { - team_id: 'ge63nq31sbfy3duzq5f7yqn1kh', - group_id: '5rgoajywb3nfbdtyafbod47rya', - auto_add: true, - create_at: 1542643748412, - delete_at: 0, - update_at: 1542660566032, - }; - - const groupChannelResponse = { - channel_id: 'o3tdawqxot8kikzq8bk54zggbc', - group_id: '5rgoajywb3nfbdtyafbod47rya', - auto_add: true, - create_at: 1542644105041, - delete_at: 0, - update_at: 1542662607342, - }; - - nock(Client4.getBaseRoute()). - post(`/groups/${groupID}/teams/${teamID}/link`). - reply(200, groupTeamResponse); - - nock(Client4.getBaseRoute()). - post(`/groups/${groupID}/channels/${channelID}/link`). - reply(200, groupChannelResponse); - - await Actions.linkGroupSyncable(groupID, teamID, Groups.SYNCABLE_TYPE_TEAM)(store.dispatch, store.getState); - await Actions.linkGroupSyncable(groupID, channelID, Groups.SYNCABLE_TYPE_CHANNEL)(store.dispatch, store.getState); - - let state = store.getState(); - let syncables = state.entities.groups.syncables; - assert.ok(syncables[groupID]); - - assert.ok(JSON.stringify(syncables[groupID].teams[0]) === JSON.stringify(groupTeamResponse)); - assert.ok(JSON.stringify(syncables[groupID].channels[0]) === JSON.stringify(groupChannelResponse)); - - const beforeTeamsLength = syncables[groupID].teams.length; - const beforeChannelsLength = syncables[groupID].channels.length; - - nock(Client4.getBaseRoute()). - delete(`/groups/${groupID}/teams/${teamID}/link`). - reply(204, {ok: true}); - - nock(Client4.getBaseRoute()). - delete(`/groups/${groupID}/channels/${channelID}/link`). - reply(204, {ok: true}); - - await Actions.unlinkGroupSyncable(groupID, teamID, Groups.SYNCABLE_TYPE_TEAM)(store.dispatch, store.getState); - await Actions.unlinkGroupSyncable(groupID, channelID, Groups.SYNCABLE_TYPE_CHANNEL)(store.dispatch, store.getState); - - state = store.getState(); - syncables = state.entities.groups.syncables; - - assert.ok(syncables[groupID]); - assert.ok(syncables[groupID].teams.length === beforeTeamsLength - 1); - assert.ok(syncables[groupID].channels.length === beforeChannelsLength - 1); - }); - - it('getAllGroupsAssociatedToTeam', async () => { - const teamID = '5rgoajywb3nfbdtyafbod47ryb'; - - const response = { - groups: [ - { - id: 'xh585kyz3tn55q6ipfo57btwnc', - name: '9uobsi3xb3y5tfjb3ze7umnh1o', - display_name: 'abc', - description: '', - source: 'ldap', - remote_id: 'abc', - create_at: 1553808969975, - update_at: 1553808969975, - delete_at: 0, - has_syncables: false, - member_count: 2, - }, - { - id: 'tnd8zod9f3fdtqosxjmhwucbth', - name: 'nobctj4brfgtpj3a1peiyq47tc', - display_name: 'engineering', - description: '', - source: 'ldap', - remote_id: 'engineering', - create_at: 1553808971099, - update_at: 1553808971099, - delete_at: 0, - has_syncables: false, - member_count: 8, - }, - { - id: 'qhdp6g7aubbpiyja7c4sgpe7tc', - name: 'x5bjwa4kwirpmqudhp5dterine', - display_name: 'qa', - description: '', - source: 'ldap', - remote_id: 'qa', - create_at: 1553808971548, - update_at: 1553808971548, - delete_at: 0, - has_syncables: false, - member_count: 2, - }, - ], - total_group_count: 3, - }; - - nock(Client4.getBaseRoute()). - get(`/teams/${teamID}/groups?paginate=false`). - reply(200, response); - - await Actions.getAllGroupsAssociatedToTeam(teamID)(store.dispatch, store.getState); - - const state = store.getState(); - - const groupIDs = state.entities.teams.groupsAssociatedToTeam[teamID].ids; - assert.strictEqual(groupIDs.length, response.groups.length); - groupIDs.forEach((id) => { - assert.ok(response.groups.map((group) => group.id).includes(id)); - }); - }); - - it('getGroupsAssociatedToTeam', async () => { - const teamID = '5rgoajywb3nfbdtyafbod47ryb'; - - store = await configureStore({ - entities: { - teams: { - groupsAssociatedToTeam: { - [teamID]: ['tnd8zod9f3fdtqosxjmhwucbth', 'qhdp6g7aubbpiyja7c4sgpe7tc'], - }, - }, - }, - }); - - const response = { - groups: [ - { - id: 'tnd8zod9f3fdtqosxjmhwucbth', - name: 'nobctj4brfgtpj3a1peiyq47tc', - display_name: 'engineering', - description: '', - source: 'ldap', - remote_id: 'engineering', - create_at: 1553808971099, - update_at: 1553808971099, - delete_at: 0, - has_syncables: false, - member_count: 8, - }, - { - id: 'qhdp6g7aubbpiyja7c4sgpe7tc', - name: 'x5bjwa4kwirpmqudhp5dterine', - display_name: 'qa', - description: '', - source: 'ldap', - remote_id: 'qa', - create_at: 1553808971548, - update_at: 1553808971548, - delete_at: 0, - has_syncables: false, - member_count: 2, - }, - ], - total_group_count: 3, - }; - - nock(Client4.getBaseRoute()). - get(`/teams/${teamID}/groups?page=100&per_page=60&q=0&include_member_count=true`). - reply(200, response); - - await Actions.getGroupsAssociatedToTeam(teamID, 0, 100)(store.dispatch, store.getState); - - const state = store.getState(); - - const groupIDs = state.entities.teams.groupsAssociatedToTeam[teamID].ids; - const expectedIDs = ['tnd8zod9f3fdtqosxjmhwucbth', 'qhdp6g7aubbpiyja7c4sgpe7tc']; - assert.strictEqual(groupIDs.length, expectedIDs.length); - groupIDs.forEach((id) => { - assert.ok(expectedIDs.includes(id)); - assert.ok(state.entities.groups.groups[id]); - }); - - const count = state.entities.teams.groupsAssociatedToTeam[teamID].totalCount; - assert.equal(count, response.total_group_count); - }); - - it('getGroupsNotAssociatedToTeam', async () => { - const teamID = '5rgoajywb3nfbdtyafbod47ryb'; - - store = await configureStore({ - entities: { - teams: { - groupsAssociatedToTeam: { - [teamID]: {ids: ['existing1', 'existing2']}, - }, - }, - }, - }); - - const response = [ - { - id: 'existing1', - name: 'nobctj4brfgtpj3a1peiyq47tc', - display_name: 'engineering', - description: '', - source: 'ldap', - remote_id: 'engineering', - create_at: 1553808971099, - update_at: 1553808971099, - delete_at: 0, - has_syncables: false, - member_count: 8, - }, - ]; - - nock(Client4.getBaseRoute()). - get(`/groups?not_associated_to_team=${teamID}&page=100&per_page=60&q=0&include_member_count=true`). - reply(200, response); - - await Actions.getGroupsNotAssociatedToTeam(teamID, 0, 100)(store.dispatch, store.getState); - - const state = store.getState(); - const groupIDs = state.entities.teams.groupsAssociatedToTeam[teamID].ids; - const expectedIDs = ['existing2'].concat(response.map((group) => group.id)); - assert.strictEqual(groupIDs.length, expectedIDs.length); - groupIDs.forEach((id) => { - assert.ok(expectedIDs.includes(id)); - }); - }); - - it('getAllGroupsAssociatedToChannel', async () => { - const channelID = '5rgoajywb3nfbdtyafbod47ryb'; - - const response = { - groups: [ - { - id: 'xh585kyz3tn55q6ipfo57btwnc', - name: '9uobsi3xb3y5tfjb3ze7umnh1o', - display_name: 'abc', - description: '', - source: 'ldap', - remote_id: 'abc', - create_at: 1553808969975, - update_at: 1553808969975, - delete_at: 0, - has_syncables: false, - member_count: 2, - }, - { - id: 'tnd8zod9f3fdtqosxjmhwucbth', - name: 'nobctj4brfgtpj3a1peiyq47tc', - display_name: 'engineering', - description: '', - source: 'ldap', - remote_id: 'engineering', - create_at: 1553808971099, - update_at: 1553808971099, - delete_at: 0, - has_syncables: false, - member_count: 8, - }, - { - id: 'qhdp6g7aubbpiyja7c4sgpe7tc', - name: 'x5bjwa4kwirpmqudhp5dterine', - display_name: 'qa', - description: '', - source: 'ldap', - remote_id: 'qa', - create_at: 1553808971548, - update_at: 1553808971548, - delete_at: 0, - has_syncables: false, - member_count: 2, - }, - ], - total_group_count: 3, - }; - - nock(Client4.getBaseRoute()). - get(`/channels/${channelID}/groups?paginate=false`). - reply(200, response); - - await Actions.getAllGroupsAssociatedToChannel(channelID)(store.dispatch, store.getState); - - const state = store.getState(); - - const groupIDs = state.entities.channels.groupsAssociatedToChannel[channelID].ids; - assert.strictEqual(groupIDs.length, response.groups.length); - groupIDs.forEach((id) => { - assert.ok(response.groups.map((group) => group.id).includes(id)); - }); - }); - - it('getGroupsAssociatedToChannel', async () => { - const channelID = '5rgoajywb3nfbdtyafbod47ryb'; - - store = await configureStore({ - entities: { - channels: { - groupsAssociatedToChannel: { - [channelID]: ['tnd8zod9f3fdtqosxjmhwucbth', 'qhdp6g7aubbpiyja7c4sgpe7tc'], - }, - }, - }, - }); - - const response = { - groups: [ - { - id: 'tnd8zod9f3fdtqosxjmhwucbth', - name: 'nobctj4brfgtpj3a1peiyq47tc', - display_name: 'engineering', - description: '', - source: 'ldap', - remote_id: 'engineering', - create_at: 1553808971099, - update_at: 1553808971099, - delete_at: 0, - has_syncables: false, - member_count: 8, - }, - { - id: 'qhdp6g7aubbpiyja7c4sgpe7tc', - name: 'x5bjwa4kwirpmqudhp5dterine', - display_name: 'qa', - description: '', - source: 'ldap', - remote_id: 'qa', - create_at: 1553808971548, - update_at: 1553808971548, - delete_at: 0, - has_syncables: false, - member_count: 2, - }, - ], - total_group_count: 3, - }; - - nock(Client4.getBaseRoute()). - get(`/channels/${channelID}/groups?page=100&per_page=60&q=0&include_member_count=true`). - reply(200, response); - - await Actions.getGroupsAssociatedToChannel(channelID, 0, 100)(store.dispatch, store.getState); - - const state = store.getState(); - - const groupIDs = state.entities.channels.groupsAssociatedToChannel[channelID].ids; - const expectedIDs = ['tnd8zod9f3fdtqosxjmhwucbth', 'qhdp6g7aubbpiyja7c4sgpe7tc']; - assert.strictEqual(groupIDs.length, expectedIDs.length); - groupIDs.forEach((id) => { - assert.ok(expectedIDs.includes(id)); - assert.ok(state.entities.groups.groups[id]); - }); - - const count = state.entities.channels.groupsAssociatedToChannel[channelID].totalCount; - assert.equal(count, response.total_group_count); - }); - - it('getGroupsNotAssociatedToChannel', async () => { - const channelID = '5rgoajywb3nfbdtyafbod47ryb'; - - store = await configureStore({ - entities: { - channels: { - groupsAssociatedToChannel: { - [channelID]: {ids: ['existing1', 'existing2']}, - }, - }, - }, - }); - - const response = [ - { - id: 'existing1', - name: 'nobctj4brfgtpj3a1peiyq47tc', - display_name: 'engineering', - description: '', - source: 'ldap', - remote_id: 'engineering', - create_at: 1553808971099, - update_at: 1553808971099, - delete_at: 0, - has_syncables: false, - member_count: 8, - }, - ]; - - nock(Client4.getBaseRoute()). - get(`/groups?not_associated_to_channel=${channelID}&page=100&per_page=60&q=0&include_member_count=true`). - reply(200, response); - - await Actions.getGroupsNotAssociatedToChannel(channelID, 0, 100)(store.dispatch, store.getState); - - const state = store.getState(); - - const groupIDs = state.entities.channels.groupsAssociatedToChannel[channelID].ids; - const expectedIDs = ['existing2'].concat(response.map((group) => group.id)); - assert.strictEqual(groupIDs.length, expectedIDs.length); - groupIDs.forEach((id) => { - assert.ok(expectedIDs.includes(id)); - }); - }); - - it('patchGroupSyncable', async () => { - const groupID = '5rgoajywb3nfbdtyafbod47rya'; - const teamID = 'ge63nq31sbfy3duzq5f7yqn1kh'; - const channelID = 'o3tdawqxot8kikzq8bk54zggbc'; - - const groupSyncablePatch = { - auto_add: true, - scheme_admin: true, - }; - - const groupTeamResponse = { - team_id: 'ge63nq31sbfy3duzq5f7yqn1kh', - group_id: '5rgoajywb3nfbdtyafbod47rya', - auto_add: true, - scheme_admin: true, - create_at: 1542643748412, - delete_at: 0, - update_at: 1542660566032, - }; - - const groupChannelResponse = { - channel_id: 'o3tdawqxot8kikzq8bk54zggbc', - group_id: '5rgoajywb3nfbdtyafbod47rya', - auto_add: true, - scheme_admin: true, - create_at: 1542644105041, - delete_at: 0, - update_at: 1542662607342, - }; - - nock(Client4.getBaseRoute()). - put(`/groups/${groupID}/teams/${teamID}/patch`). - reply(200, groupTeamResponse); - - nock(Client4.getBaseRoute()). - put(`/groups/${groupID}/channels/${channelID}/patch`). - reply(200, groupChannelResponse); - - await Actions.patchGroupSyncable(groupID, teamID, Groups.SYNCABLE_TYPE_TEAM, groupSyncablePatch)(store.dispatch, store.getState); - await Actions.patchGroupSyncable(groupID, channelID, Groups.SYNCABLE_TYPE_CHANNEL, groupSyncablePatch)(store.dispatch, store.getState); - - const state = store.getState(); - const groupSyncables = state.entities.groups.syncables[groupID]; - assert.ok(groupSyncables); - - assert.ok(groupSyncables.teams[0].auto_add === groupSyncablePatch.auto_add); - assert.ok(groupSyncables.channels[0].auto_add === groupSyncablePatch.auto_add); - - assert.ok(groupSyncables.teams[0].scheme_admin === groupSyncablePatch.scheme_admin); - assert.ok(groupSyncables.channels[0].scheme_admin === groupSyncablePatch.scheme_admin); - }); -}); - diff --git a/app/mm-redux/actions/groups.ts b/app/mm-redux/actions/groups.ts deleted file mode 100644 index 268715fb8..000000000 --- a/app/mm-redux/actions/groups.ts +++ /dev/null @@ -1,258 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. -import {GroupTypes} from '@mm-redux/action_types'; -import {General, Groups} from '../constants'; -import {Client4} from '@mm-redux/client'; - -import {Action, ActionFunc, batchActions, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions'; -import {SyncableType, SyncablePatch} from '@mm-redux/types/groups'; - -import {logError} from './errors'; -import {bindClientFunc, forceLogoutIfNecessary} from './helpers'; - -export function linkGroupSyncable(groupID: string, syncableID: string, syncableType: SyncableType, patch: SyncablePatch): ActionFunc { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - let data; - try { - data = await Client4.linkGroupSyncable(groupID, syncableID, syncableType, patch); - } catch (error) { - forceLogoutIfNecessary(error, dispatch, getState); - dispatch(logError(error)); - return {error}; - } - - const dispatches: Action[] = []; - - let type = ''; - switch (syncableType) { - case Groups.SYNCABLE_TYPE_TEAM: - dispatches.push({type: GroupTypes.RECEIVED_GROUPS_ASSOCIATED_TO_TEAM, data: {teamID: syncableID, groups: [{id: groupID}]}}); - type = GroupTypes.LINKED_GROUP_TEAM; - break; - case Groups.SYNCABLE_TYPE_CHANNEL: - type = GroupTypes.LINKED_GROUP_CHANNEL; - break; - default: - console.warn(`unhandled syncable type ${syncableType}`); // eslint-disable-line no-console - } - - dispatches.push({type, data}); - dispatch(batchActions(dispatches)); - - return {data: true}; - }; -} - -export function unlinkGroupSyncable(groupID: string, syncableID: string, syncableType: SyncableType): ActionFunc { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - try { - await Client4.unlinkGroupSyncable(groupID, syncableID, syncableType); - } catch (error) { - forceLogoutIfNecessary(error, dispatch, getState); - dispatch(logError(error)); - return {error}; - } - - const dispatches: Action[] = []; - - let type = ''; - const data = {group_id: groupID, syncable_id: syncableID}; - switch (syncableType) { - case Groups.SYNCABLE_TYPE_TEAM: - type = GroupTypes.UNLINKED_GROUP_TEAM; - data.syncable_id = syncableID; - dispatches.push({type: GroupTypes.RECEIVED_GROUPS_NOT_ASSOCIATED_TO_TEAM, data: {teamID: syncableID, groups: [{id: groupID}]}}); - break; - case Groups.SYNCABLE_TYPE_CHANNEL: - type = GroupTypes.UNLINKED_GROUP_CHANNEL; - data.syncable_id = syncableID; - break; - default: - console.warn(`unhandled syncable type ${syncableType}`); // eslint-disable-line no-console - } - - dispatches.push({type, data}); - dispatch(batchActions(dispatches)); - - return {data: true}; - }; -} - -export function getGroupSyncables(groupID: string, syncableType: SyncableType): ActionFunc { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - let data; - try { - data = await Client4.getGroupSyncables(groupID, syncableType); - } catch (error) { - forceLogoutIfNecessary(error, dispatch, getState); - dispatch(logError(error)); - return {error}; - } - - let type = ''; - switch (syncableType) { - case Groups.SYNCABLE_TYPE_TEAM: - type = GroupTypes.RECEIVED_GROUP_TEAMS; - break; - case Groups.SYNCABLE_TYPE_CHANNEL: - type = GroupTypes.RECEIVED_GROUP_CHANNELS; - break; - default: - console.warn(`unhandled syncable type ${syncableType}`); // eslint-disable-line no-console - } - - dispatch(batchActions([ - {type, data, group_id: groupID}, - ])); - - return {data: true}; - }; -} - -export function patchGroupSyncable(groupID: string, syncableID: string, syncableType: SyncableType, patch: SyncablePatch): ActionFunc { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - let data; - try { - data = await Client4.patchGroupSyncable(groupID, syncableID, syncableType, patch); - } catch (error) { - forceLogoutIfNecessary(error, dispatch, getState); - return {error}; - } - - const dispatches: Action[] = []; - - let type = ''; - switch (syncableType) { - case Groups.SYNCABLE_TYPE_TEAM: - type = GroupTypes.PATCHED_GROUP_TEAM; - break; - case Groups.SYNCABLE_TYPE_CHANNEL: - type = GroupTypes.PATCHED_GROUP_CHANNEL; - break; - default: - console.warn(`unhandled syncable type ${syncableType}`); // eslint-disable-line no-console - } - - dispatches.push( - {type, data}, - ); - dispatch(batchActions(dispatches)); - - return {data: true}; - }; -} - -export function getGroupMembers(groupID: string, page = 0, perPage: number = General.PAGE_SIZE_DEFAULT): ActionFunc { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - let data; - try { - data = await Client4.getGroupMembers(groupID, page, perPage); - } catch (error) { - forceLogoutIfNecessary(error, dispatch, getState); - dispatch(logError(error)); - return {error}; - } - - dispatch(batchActions([ - {type: GroupTypes.RECEIVED_GROUP_MEMBERS, group_id: groupID, data}, - ])); - - return {data: true}; - }; -} - -export function getGroup(id: string): ActionFunc { - return bindClientFunc({ - clientFunc: Client4.getGroup, - onSuccess: [GroupTypes.RECEIVED_GROUP], - params: [ - id, - ], - }); -} - -export function getGroupsNotAssociatedToTeam(teamID: string, q = '', page = 0, perPage: number = General.PAGE_SIZE_DEFAULT): ActionFunc { - return bindClientFunc({ - clientFunc: Client4.getGroupsNotAssociatedToTeam, - onSuccess: [GroupTypes.RECEIVED_GROUPS], - params: [ - teamID, - q, - page, - perPage, - ], - }); -} - -export function getGroupsNotAssociatedToChannel(channelID: string, q = '', page = 0, perPage: number = General.PAGE_SIZE_DEFAULT): ActionFunc { - return bindClientFunc({ - clientFunc: Client4.getGroupsNotAssociatedToChannel, - onSuccess: [GroupTypes.RECEIVED_GROUPS], - params: [ - channelID, - q, - page, - perPage, - ], - }); -} - -export function getAllGroupsAssociatedToTeam(teamID: string): ActionFunc { - return bindClientFunc({ - clientFunc: async (param1) => { - const result = await Client4.getAllGroupsAssociatedToTeam(param1); - result.teamID = param1; - return result; - }, - onSuccess: [GroupTypes.RECEIVED_ALL_GROUPS_ASSOCIATED_TO_TEAM], - params: [ - teamID, - ], - }); -} - -export function getAllGroupsAssociatedToChannel(channelID: string): ActionFunc { - return bindClientFunc({ - clientFunc: async (param1) => { - const result = await Client4.getAllGroupsAssociatedToChannel(param1); - result.channelID = param1; - return result; - }, - onSuccess: [GroupTypes.RECEIVED_ALL_GROUPS_ASSOCIATED_TO_CHANNEL], - params: [ - channelID, - ], - }); -} - -export function getGroupsAssociatedToTeam(teamID: string, q = '', page = 0, perPage: number = General.PAGE_SIZE_DEFAULT): ActionFunc { - return bindClientFunc({ - clientFunc: async (param1, param2, param3, param4) => { - const result = await Client4.getGroupsAssociatedToTeam(param1, param2, param3, param4); - return {groups: result.groups, totalGroupCount: result.total_group_count, teamID: param1}; - }, - onSuccess: [GroupTypes.RECEIVED_GROUPS_ASSOCIATED_TO_TEAM], - params: [ - teamID, - q, - page, - perPage, - ], - }); -} - -export function getGroupsAssociatedToChannel(channelID: string, q = '', page = 0, perPage: number = General.PAGE_SIZE_DEFAULT): ActionFunc { - return bindClientFunc({ - clientFunc: async (param1, param2, param3, param4) => { - const result = await Client4.getGroupsAssociatedToChannel(param1, param2, param3, param4); - return {groups: result.groups, totalGroupCount: result.total_group_count, channelID: param1}; - }, - onSuccess: [GroupTypes.RECEIVED_GROUPS_ASSOCIATED_TO_CHANNEL], - params: [ - channelID, - q, - page, - perPage, - ], - }); -} diff --git a/app/mm-redux/actions/index.ts b/app/mm-redux/actions/index.ts index ed26c3e87..178979b75 100644 --- a/app/mm-redux/actions/index.ts +++ b/app/mm-redux/actions/index.ts @@ -7,7 +7,6 @@ import * as errors from './errors'; import * as emojis from './emojis'; import * as files from './files'; import * as general from './general'; -import * as groups from './groups'; import * as gifs from './gifs'; import * as helpers from './helpers'; import * as integrations from './integrations'; @@ -28,7 +27,6 @@ export { emojis, files, general, - groups, gifs, integrations, helpers, diff --git a/app/mm-redux/client/client4.ts b/app/mm-redux/client/client4.ts index 41cc64b73..c65c77f8f 100644 --- a/app/mm-redux/client/client4.ts +++ b/app/mm-redux/client/client4.ts @@ -2847,6 +2847,13 @@ export default class Client4 { ); }; + getGroups = async (filterAllowReference = false) => { + return this.doFetch( + `${this.getBaseRoute()}/groups${buildQueryString({filter_allow_reference: filterAllowReference})}`, + {method: 'get'}, + ); + }; + getGroupsNotAssociatedToTeam = async (teamID: string, q = '', page = 0, perPage = PER_PAGE_DEFAULT) => { this.trackEvent('api', 'api_groups_get_not_associated_to_team', {team_id: teamID}); return this.doFetch( @@ -2863,34 +2870,41 @@ export default class Client4 { ); }; - getGroupsAssociatedToTeam = async (teamID: string, q = '', page = 0, perPage = PER_PAGE_DEFAULT) => { + getGroupsAssociatedToTeam = async (teamID: string, q = '', page = 0, perPage = PER_PAGE_DEFAULT, filterAllowReference = false) => { this.trackEvent('api', 'api_groups_get_associated_to_team', {team_id: teamID}); return this.doFetch( - `${this.getBaseRoute()}/teams/${teamID}/groups${buildQueryString({page, per_page: perPage, q, include_member_count: true})}`, + `${this.getBaseRoute()}/teams/${teamID}/groups${buildQueryString({page, per_page: perPage, q, include_member_count: true, filter_allow_reference: filterAllowReference})}`, {method: 'get'}, ); }; - getGroupsAssociatedToChannel = async (channelID: string, q = '', page = 0, perPage = PER_PAGE_DEFAULT) => { + getGroupsAssociatedToChannel = async (channelID: string, q = '', page = 0, perPage = PER_PAGE_DEFAULT, filterAllowReference = false) => { this.trackEvent('api', 'api_groups_get_associated_to_channel', {channel_id: channelID}); return this.doFetch( - `${this.getBaseRoute()}/channels/${channelID}/groups${buildQueryString({page, per_page: perPage, q, include_member_count: true})}`, + `${this.getBaseRoute()}/channels/${channelID}/groups${buildQueryString({page, per_page: perPage, q, include_member_count: true, filter_allow_reference: filterAllowReference})}`, {method: 'get'}, ); }; - getAllGroupsAssociatedToTeam = async (teamID: string) => { + getAllGroupsAssociatedToTeam = async (teamID: string, filterAllowReference = false) => { return this.doFetch( - `${this.getBaseRoute()}/teams/${teamID}/groups?paginate=false`, + `${this.getBaseRoute()}/teams/${teamID}/groups${buildQueryString({paginate: false, filter_allow_reference: filterAllowReference})}`, {method: 'get'}, ); }; - getAllGroupsAssociatedToChannel = async (channelID: string) => { + getAllGroupsAssociatedToChannelsInTeam = async (teamID: string, filterAllowReference = false) => { return this.doFetch( - `${this.getBaseRoute()}/channels/${channelID}/groups?paginate=false`, + `${this.getBaseRoute()}/teams/${teamID}/groups_by_channels${buildQueryString({paginate: false, filter_allow_reference: filterAllowReference})}`, + {method: 'get'}, + ); + }; + + getAllGroupsAssociatedToChannel = async (channelID: string, filterAllowReference = false) => { + return this.doFetch( + `${this.getBaseRoute()}/channels/${channelID}/groups${buildQueryString({paginate: false, filter_allow_reference: filterAllowReference})}`, {method: 'get'}, ); }; diff --git a/app/mm-redux/reducers/entities/channels.ts b/app/mm-redux/reducers/entities/channels.ts index 80d6bbe69..fb2e8ed65 100644 --- a/app/mm-redux/reducers/entities/channels.ts +++ b/app/mm-redux/reducers/entities/channels.ts @@ -547,6 +547,22 @@ function stats(state: RelationOneToOne = {}, action: Gene function groupsAssociatedToChannel(state: any = {}, action: GenericAction) { switch (action.type) { + case GroupTypes.RECEIVED_ALL_GROUPS_ASSOCIATED_TO_CHANNELS_IN_TEAM: { + const {groupsByChannelId} = action.data; + const nextState = {...state}; + + for (const channelID of Object.keys(groupsByChannelId)) { + if (groupsByChannelId[channelID]) { + const associatedGroupIDs = new Set([]); + for (const group of groupsByChannelId[channelID]) { + associatedGroupIDs.add(group.id); + } + const ids = Array.from(associatedGroupIDs); + nextState[channelID] = {ids, totalCount: ids.length}; + } + } + return nextState; + } case GroupTypes.RECEIVED_GROUPS_ASSOCIATED_TO_CHANNEL: { const {channelID, groups, totalGroupCount} = action.data; const nextState = {...state}; diff --git a/app/mm-redux/reducers/entities/groups.ts b/app/mm-redux/reducers/entities/groups.ts index c3c781850..f54b281ad 100644 --- a/app/mm-redux/reducers/entities/groups.ts +++ b/app/mm-redux/reducers/entities/groups.ts @@ -194,7 +194,20 @@ function groups(state: Dictionary = {}, action: GenericAction) { } return nextState; } + case GroupTypes.RECEIVED_ALL_GROUPS_ASSOCIATED_TO_CHANNELS_IN_TEAM: { + const nextState = {...state}; + const {groupsByChannelId} = action.data; + + for (const group of Object.values(groupsByChannelId) as Group[]) { + if (group) { + nextState[group.id] = group; + } + } + return nextState; + } case GroupTypes.RECEIVED_GROUPS_ASSOCIATED_TO_TEAM: + case GroupTypes.RECEIVED_ALL_GROUPS_ASSOCIATED_TO_TEAM: + case GroupTypes.RECEIVED_ALL_GROUPS_ASSOCIATED_TO_CHANNEL: case GroupTypes.RECEIVED_GROUPS_ASSOCIATED_TO_CHANNEL: { const nextState = {...state}; for (const group of action.data.groups) { diff --git a/app/mm-redux/selectors/entities/groups.test.js b/app/mm-redux/selectors/entities/groups.test.js index c1f781ce2..bfdd1b8cc 100644 --- a/app/mm-redux/selectors/entities/groups.test.js +++ b/app/mm-redux/selectors/entities/groups.test.js @@ -34,6 +34,7 @@ describe('Selectors.Groups', () => { delete_at: 0, has_syncables: false, member_count: 2, + allow_reference: true, }, [expectedAssociatedGroupID3]: { id: expectedAssociatedGroupID3, @@ -47,9 +48,10 @@ describe('Selectors.Groups', () => { delete_at: 0, has_syncables: false, member_count: 5, + allow_reference: false, }, [expectedAssociatedGroupID4]: { - id: [expectedAssociatedGroupID4], + id: expectedAssociatedGroupID4, name: 'nobctj4brfgtpj3a1peiyq47tc', display_name: 'engineering', description: '', @@ -60,6 +62,7 @@ describe('Selectors.Groups', () => { delete_at: 0, has_syncables: false, member_count: 8, + allow_reference: true, }, [expectedAssociatedGroupID2]: { id: expectedAssociatedGroupID2, @@ -73,6 +76,7 @@ describe('Selectors.Groups', () => { delete_at: 0, has_syncables: false, member_count: 2, + allow_reference: false, }, }, }, @@ -114,4 +118,26 @@ describe('Selectors.Groups', () => { const expected = Object.entries(testState.entities.groups.groups).filter(([groupID]) => !channelAssociatedGroupIDs.includes(groupID)).map(([, group]) => group); assert.deepEqual(Selectors.getGroupsNotAssociatedToChannel(testState, channelID), expected); }); + + it('getGroupsAssociatedToTeamForReference', () => { + const expected = [ + testState.entities.groups.groups[expectedAssociatedGroupID1], + ]; + assert.deepEqual(Selectors.getGroupsAssociatedToTeamForReference(testState, teamID), expected); + }); + + it('getGroupsAssociatedToChannelForReference', () => { + const expected = [ + testState.entities.groups.groups[expectedAssociatedGroupID4], + ]; + assert.deepEqual(Selectors.getGroupsAssociatedToChannelForReference(testState, channelID), expected); + }); + + it('getAllAssociatedGroupsForReference', () => { + const expected = [ + testState.entities.groups.groups[expectedAssociatedGroupID1], + testState.entities.groups.groups[expectedAssociatedGroupID4], + ]; + assert.deepEqual(Selectors.getAllAssociatedGroupsForReference(testState, channelID), expected); + }); }); diff --git a/app/mm-redux/selectors/entities/groups.ts b/app/mm-redux/selectors/entities/groups.ts index d7fba9e35..aa4d6fd01 100644 --- a/app/mm-redux/selectors/entities/groups.ts +++ b/app/mm-redux/selectors/entities/groups.ts @@ -2,6 +2,14 @@ // See LICENSE.txt for license information. import * as reselect from 'reselect'; import {GlobalState} from '@mm-redux/types/store'; +import {Group} from '@mm-redux/types/groups'; +import {filterGroupsMatchingTerm} from '@mm-redux/utils/group_utils'; +import {getCurrentUserLocale} from '@mm-redux/selectors/entities/i18n'; +import {getChannel} from '@mm-redux/selectors/entities/channels'; +import {haveIChannelPermission} from '@mm-redux/selectors/entities/roles'; +import {getTeam} from '@mm-redux/selectors/entities/teams'; +import {Permissions} from '@mm-redux/constants'; + const emptyList: any[] = []; const emptySyncables = { teams: [], @@ -9,7 +17,7 @@ const emptySyncables = { }; export function getAllGroups(state: GlobalState) { - return state.entities.groups.groups; + return state.entities.groups?.groups || []; } export function getGroup(state: GlobalState, id: string) { @@ -45,6 +53,44 @@ export function getGroupMembers(state: GlobalState, id: string) { return groupMemberData.members; } +export function searchAssociatedGroupsForReferenceLocal(state: GlobalState, term: string, teamId: string, channelId: string): Array { + const groups = getAssociatedGroupsForReference(state, teamId, channelId); + if (!groups || groups.length === 0) { + return emptyList; + } + const filteredGroups = filterGroupsMatchingTerm(groups, term); + return filteredGroups; +} + +export function getAssociatedGroupsForReference(state: GlobalState, teamId: string, channelId: string): Array { + const team = getTeam(state, teamId); + const channel = getChannel(state, channelId); + const locale = getCurrentUserLocale(state); + + if (!haveIChannelPermission(state, { + permission: Permissions.USE_GROUP_MENTIONS, + channel: channelId, + team: teamId, + default: true, + })) { + return emptyList; + } + + let groupsForReference = []; + if (team && team.group_constrained && channel && channel.group_constrained) { + const groupsFromChannel = getGroupsAssociatedToChannelForReference(state, channelId); + const groupsFromTeam = getGroupsAssociatedToTeamForReference(state, teamId); + groupsForReference = groupsFromChannel.concat(groupsFromTeam.filter((item) => groupsFromChannel.indexOf(item) < 0)); + } else if (team && team.group_constrained) { + groupsForReference = getGroupsAssociatedToTeamForReference(state, teamId); + } else if (channel && channel.group_constrained) { + groupsForReference = getGroupsAssociatedToChannelForReference(state, channelId); + } else { + groupsForReference = getAllAssociatedGroupsForReference(state); + } + return groupsForReference.sort((groupA: Group, groupB: Group) => groupA.name.localeCompare(groupB.name, locale)); +} + const teamGroupIDs = (state: GlobalState, teamID: string) => state.entities.teams.groupsAssociatedToTeam[teamID]?.ids || []; const channelGroupIDs = (state: GlobalState, channelID: string) => state.entities.channels.groupsAssociatedToChannel[channelID]?.ids || []; @@ -63,7 +109,7 @@ export const getGroupsNotAssociatedToTeam = reselect.createSelector( getAllGroups, (state: GlobalState, teamID: string) => getTeamGroupIDSet(state, teamID), (allGroups, teamGroupIDSet) => { - return Object.entries(allGroups).filter(([groupID]) => !teamGroupIDSet.has(groupID)).map((entry) => entry[1]); + return Object.values(allGroups).filter((group) => !teamGroupIDSet.has(group.id)); }, ); @@ -71,7 +117,7 @@ export const getGroupsAssociatedToTeam = reselect.createSelector( getAllGroups, (state: GlobalState, teamID: string) => getTeamGroupIDSet(state, teamID), (allGroups, teamGroupIDSet) => { - return Object.entries(allGroups).filter(([groupID]) => teamGroupIDSet.has(groupID)).map((entry) => entry[1]); + return Object.values(allGroups).filter((group) => teamGroupIDSet.has(group.id)); }, ); @@ -79,7 +125,7 @@ export const getGroupsNotAssociatedToChannel = reselect.createSelector( getAllGroups, (state: GlobalState, channelID: string) => getChannelGroupIDSet(state, channelID), (allGroups, channelGroupIDSet) => { - return Object.entries(allGroups).filter(([groupID]) => !channelGroupIDSet.has(groupID)).map((entry) => entry[1]); + return Object.values(allGroups).filter((group) => !channelGroupIDSet.has(group.id)); }, ); @@ -87,6 +133,29 @@ export const getGroupsAssociatedToChannel = reselect.createSelector( getAllGroups, (state: GlobalState, channelID: string) => getChannelGroupIDSet(state, channelID), (allGroups, channelGroupIDSet) => { - return Object.entries(allGroups).filter(([groupID]) => channelGroupIDSet.has(groupID)).map((entry) => entry[1]); + return Object.values(allGroups).filter((group) => channelGroupIDSet.has(group.id)); + }, +); + +export const getGroupsAssociatedToTeamForReference = reselect.createSelector( + getAllGroups, + (state: GlobalState, teamID: string) => getTeamGroupIDSet(state, teamID), + (allGroups, teamGroupIDSet) => { + return Object.values(allGroups).filter((group) => teamGroupIDSet.has(group.id) && group.allow_reference && group.delete_at === 0); + }, +); + +export const getGroupsAssociatedToChannelForReference = reselect.createSelector( + getAllGroups, + (state: GlobalState, channelID: string) => getChannelGroupIDSet(state, channelID), + (allGroups, channelGroupIDSet) => { + return Object.values(allGroups).filter((group) => channelGroupIDSet.has(group.id) && group.allow_reference && group.delete_at === 0); + }, +); + +export const getAllAssociatedGroupsForReference = reselect.createSelector( + getAllGroups, + (allGroups) => { + return Object.values(allGroups).filter((group) => group.allow_reference && group.delete_at === 0); }, ); diff --git a/app/mm-redux/types/groups.ts b/app/mm-redux/types/groups.ts index f361a944f..15279921d 100644 --- a/app/mm-redux/types/groups.ts +++ b/app/mm-redux/types/groups.ts @@ -18,6 +18,7 @@ export type Group = { has_syncables: boolean; member_count: number; scheme_admin: boolean; + allow_reference: boolean; }; export type GroupTeam = { team_id: string; diff --git a/app/mm-redux/utils/group_utils.test.js b/app/mm-redux/utils/group_utils.test.js new file mode 100644 index 000000000..41901726d --- /dev/null +++ b/app/mm-redux/utils/group_utils.test.js @@ -0,0 +1,92 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import assert from 'assert'; + +import { + filterGroupsMatchingTerm, +} from './group_utils'; + +describe('group utils', () => { + describe('filterGroupsMatchingTerm', () => { + const groupA = { + id: 'groupid1', + name: 'board-group', + description: 'group1 description', + display_name: 'board-group', + type: 'ldap', + remote_id: 'group1', + create_at: 1, + update_at: 2, + delete_at: 0, + has_syncables: true, + member_count: 3, + scheme_admin: false, + allow_reference: true, + }; + const groupB = { + id: 'groupid2', + name: 'developers-group', + description: 'group2 description', + display_name: 'developers-group', + type: 'ldap', + remote_id: 'group2', + create_at: 1, + update_at: 2, + delete_at: 0, + has_syncables: true, + member_count: 3, + scheme_admin: false, + allow_reference: true, + }; + const groupC = { + id: 'groupid3', + name: 'software-engineers', + description: 'group3 description', + display_name: 'software engineers', + type: 'ldap', + remote_id: 'group3', + create_at: 1, + update_at: 2, + delete_at: 0, + has_syncables: true, + member_count: 3, + scheme_admin: false, + allow_reference: true, + }; + const groups = [groupA, groupB, groupC]; + + it('should match all for empty filter', () => { + assert.deepEqual(filterGroupsMatchingTerm(groups, ''), [groupA, groupB, groupC]); + }); + + it('should filter out results which do not match', () => { + assert.deepEqual(filterGroupsMatchingTerm(groups, 'testBad'), []); + }); + + it('should match by name', () => { + assert.deepEqual(filterGroupsMatchingTerm(groups, 'software-engineers'), [groupC]); + }); + + it('should match by split part of the name', () => { + assert.deepEqual(filterGroupsMatchingTerm(groups, 'group'), [groupA, groupB]); + assert.deepEqual(filterGroupsMatchingTerm(groups, 'board'), [groupA]); + }); + + it('should match by display_name fully', () => { + assert.deepEqual(filterGroupsMatchingTerm(groups, 'software engineers'), [groupC]); + }); + + it('should match by display_name case-insensitive', () => { + assert.deepEqual(filterGroupsMatchingTerm(groups, 'software ENGINEERS'), [groupC]); + }); + + it('should ignore leading @ for name', () => { + assert.deepEqual(filterGroupsMatchingTerm(groups, '@developers'), [groupB]); + }); + + it('should ignore leading @ for display_name', () => { + assert.deepEqual(filterGroupsMatchingTerm(groups, '@software'), [groupC]); + }); + }); +}); \ No newline at end of file diff --git a/app/mm-redux/utils/group_utils.ts b/app/mm-redux/utils/group_utils.ts new file mode 100644 index 000000000..f881a6840 --- /dev/null +++ b/app/mm-redux/utils/group_utils.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {General} from '../constants'; +import {Group} from '@mm-redux/types/groups'; +import {getSuggestionsSplitByMultiple} from './user_utils'; + +export function filterGroupsMatchingTerm(groups: Array, term: string): Array { + const lowercasedTerm = term.toLowerCase(); + let trimmedTerm = lowercasedTerm; + + if (trimmedTerm.startsWith('@')) { + trimmedTerm = trimmedTerm.substr(1); + } + + if (!trimmedTerm) { + return groups; + } + + return groups.filter((group: Group) => { + if (!group) { + return false; + } + + const groupSuggestions: string[] = []; + + const groupnameSuggestions = getSuggestionsSplitByMultiple((group.name || '').toLowerCase(), General.AUTOCOMPLETE_SPLIT_CHARACTERS); + + groupSuggestions.push(...groupnameSuggestions); + const displayname = (group.display_name || '').toLowerCase(); + groupSuggestions.push(displayname); + + return groupSuggestions. + some((suggestion) => suggestion.startsWith(trimmedTerm)); + }); +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index c910949a3..0c6fb8f53 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -571,6 +571,7 @@ "suggestion.mention.all": "Notifies everyone in this channel", "suggestion.mention.channel": "Notifies everyone in this channel", "suggestion.mention.channels": "My Channels", + "suggestion.mention.groups": "Group Mentions", "suggestion.mention.here": "Notifies everyone online in this channel", "suggestion.mention.members": "Channel Members", "suggestion.mention.morechannels": "Other Channels",