diff --git a/server/channels/api4/group.go b/server/channels/api4/group.go index 8f923476fe..3e3df44009 100644 --- a/server/channels/api4/group.go +++ b/server/channels/api4/group.go @@ -986,14 +986,19 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { includeTimezones := r.URL.Query().Get("include_timezones") == "true" + // Include archived groups + includeArchived := r.URL.Query().Get("include_archived") == "true" + opts := model.GroupSearchOpts{ Q: c.Params.Q, IncludeMemberCount: c.Params.IncludeMemberCount, FilterAllowReference: c.Params.FilterAllowReference, + FilterArchived: c.Params.FilterArchived, FilterParentTeamPermitted: c.Params.FilterParentTeamPermitted, Source: source, FilterHasMember: c.Params.FilterHasMember, IncludeTimezones: includeTimezones, + IncludeArchived: includeArchived, } if teamID != "" { @@ -1145,15 +1150,19 @@ func deleteGroup(c *Context, w http.ResponseWriter, r *http.Request) { defer c.LogAuditRec(auditRec) audit.AddEventParameter(auditRec, "group_id", c.Params.GroupId) - _, err = c.App.DeleteGroup(c.Params.GroupId) + group, err = c.App.DeleteGroup(c.Params.GroupId) if err != nil { c.Err = err return } + b, jsonErr := json.Marshal(group) + if jsonErr != nil { + c.Err = model.NewAppError("Api4.deleteGroup", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr) + return + } auditRec.Success() - - ReturnStatusOK(w) + w.Write(b) } func restoreGroup(c *Context, w http.ResponseWriter, r *http.Request) { @@ -1194,15 +1203,20 @@ func restoreGroup(c *Context, w http.ResponseWriter, r *http.Request) { defer c.LogAuditRec(auditRec) audit.AddEventParameter(auditRec, "group_id", c.Params.GroupId) - _, err = c.App.RestoreGroup(c.Params.GroupId) + restoredGroup, err := c.App.RestoreGroup(c.Params.GroupId) if err != nil { c.Err = err return } - auditRec.Success() + b, jsonErr := json.Marshal(restoredGroup) + if jsonErr != nil { + c.Err = model.NewAppError("Api4.restoreGroup", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr) + return + } - ReturnStatusOK(w) + auditRec.Success() + w.Write(b) } func addGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { @@ -1223,13 +1237,13 @@ func addGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { } if group.Source != model.GroupSourceCustom { - c.Err = model.NewAppError("Api4.deleteGroup", "app.group.crud_permission", nil, "", http.StatusBadRequest) + c.Err = model.NewAppError("Api4.addGroupMembers", "app.group.crud_permission", nil, "", http.StatusBadRequest) return } appErr = licensedAndConfiguredForGroupBySource(c.App, model.GroupSourceCustom) if appErr != nil { - appErr.Where = "Api4.deleteGroup" + appErr.Where = "Api4.addGroupMembers" c.Err = appErr return } @@ -1282,13 +1296,13 @@ func deleteGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { } if group.Source != model.GroupSourceCustom { - c.Err = model.NewAppError("Api4.deleteGroup", "app.group.crud_permission", nil, "", http.StatusBadRequest) + c.Err = model.NewAppError("Api4.deleteGroupMembers", "app.group.crud_permission", nil, "", http.StatusBadRequest) return } appErr = licensedAndConfiguredForGroupBySource(c.App, model.GroupSourceCustom) if appErr != nil { - appErr.Where = "Api4.deleteGroup" + appErr.Where = "Api4.deleteGroupMembers" c.Err = appErr return } diff --git a/server/channels/api4/group_test.go b/server/channels/api4/group_test.go index 1585ce3deb..56a9db6f51 100644 --- a/server/channels/api4/group_test.go +++ b/server/channels/api4/group_test.go @@ -1291,6 +1291,21 @@ func TestGetGroups(t *testing.T) { // make sure it returned th.Group,not group assert.Equal(t, groups[0].Id, th.Group.Id) + // Test include_archived parameter + opts.IncludeArchived = true + groups, _, err = th.Client.GetGroups(context.Background(), opts) + assert.NoError(t, err) + assert.Len(t, groups, 2) + opts.IncludeArchived = false + + // Test returning only archived groups + opts.FilterArchived = true + groups, _, err = th.Client.GetGroups(context.Background(), opts) + assert.NoError(t, err) + assert.Len(t, groups, 1) + assert.Equal(t, groups[0].Id, group.Id) + opts.FilterArchived = false + opts.Source = model.GroupSourceCustom groups, _, err = th.Client.GetGroups(context.Background(), opts) assert.NoError(t, err) diff --git a/server/channels/app/group.go b/server/channels/app/group.go index c337252f48..3cad7fdc89 100644 --- a/server/channels/app/group.go +++ b/server/channels/app/group.go @@ -213,6 +213,22 @@ func (a *App) DeleteGroup(groupID string) (*model.Group, *model.AppError) { } } + count, err := a.Srv().Store().Group().GetMemberCount(groupID) + if err != nil { + return nil, model.NewAppError("DeleteGroup", "app.group.id.app_error", nil, "", http.StatusBadRequest).Wrap(err) + } + + deletedGroup.MemberCount = model.NewInt(int(count)) + + messageWs := model.NewWebSocketEvent(model.WebsocketEventReceivedGroup, "", "", "", nil, "") + + groupJSON, err := json.Marshal(deletedGroup) + if err != nil { + return nil, model.NewAppError("DeleteGroup", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + messageWs.Add("group", string(groupJSON)) + a.Publish(messageWs) + return deletedGroup, nil } @@ -228,6 +244,22 @@ func (a *App) RestoreGroup(groupID string) (*model.Group, *model.AppError) { } } + count, err := a.Srv().Store().Group().GetMemberCount(groupID) + if err != nil { + return nil, model.NewAppError("RestoreGroup", "app.group.id.app_error", nil, "", http.StatusBadRequest).Wrap(err) + } + + restoredGroup.MemberCount = model.NewInt(int(count)) + + messageWs := model.NewWebSocketEvent(model.WebsocketEventReceivedGroup, "", "", "", nil, "") + + groupJSON, err := json.Marshal(restoredGroup) + if err != nil { + return nil, model.NewAppError("RestoreGroup", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + messageWs.Add("group", string(groupJSON)) + a.Publish(messageWs) + return restoredGroup, nil } diff --git a/server/channels/store/sqlstore/group_store.go b/server/channels/store/sqlstore/group_store.go index f0902cfa51..5be3a64eef 100644 --- a/server/channels/store/sqlstore/group_store.go +++ b/server/channels/store/sqlstore/group_store.go @@ -384,9 +384,11 @@ func (s *SqlGroupStore) Delete(groupID string) (*model.Group, error) { } time := model.GetMillis() + group.DeleteAt = time + group.UpdateAt = time if _, err := s.GetMasterX().Exec(`UPDATE UserGroups SET DeleteAt=?, UpdateAt=? - WHERE Id=? AND DeleteAt=0`, time, time, groupID); err != nil { + WHERE Id=? AND DeleteAt=0`, group.DeleteAt, group.UpdateAt, groupID); err != nil { return nil, errors.Wrapf(err, "failed to update Group with id=%s", groupID) } @@ -410,10 +412,11 @@ func (s *SqlGroupStore) Restore(groupID string) (*model.Group, error) { return nil, errors.Wrapf(err, "failed to get Group with id=%s", groupID) } - time := model.GetMillis() + group.UpdateAt = model.GetMillis() + group.DeleteAt = 0 if _, err := s.GetMasterX().Exec(`UPDATE UserGroups SET DeleteAt=0, UpdateAt=? - WHERE Id=? AND DeleteAt!=0`, time, groupID); err != nil { + WHERE Id=? AND DeleteAt!=0`, group.UpdateAt, groupID); err != nil { return nil, errors.Wrapf(err, "failed to update Group with id=%s", groupID) } @@ -1570,17 +1573,27 @@ func (s *SqlGroupStore) GetGroups(page, perPage int, opts model.GroupSearchOpts, } groupsQuery = groupsQuery. - From("UserGroups g"). - OrderBy("g.DisplayName") + From("UserGroups g") if opts.Since > 0 { groupsQuery = groupsQuery.Where(sq.Gt{ "g.UpdateAt": opts.Since, }) - } else { + } + + if opts.FilterArchived { + groupsQuery = groupsQuery.Where("g.DeleteAt > 0") + } else if !opts.IncludeArchived && opts.Since <= 0 { + // Mobile needs to return archived groups when the since parameter is set, will need to keep this for backwards compatibility groupsQuery = groupsQuery.Where("g.DeleteAt = 0") } + if opts.IncludeArchived { + groupsQuery = groupsQuery.OrderBy("CASE WHEN g.DeleteAt = 0 THEN g.DisplayName end, CASE WHEN g.DeleteAt != 0 THEN g.DisplayName END") + } else { + groupsQuery = groupsQuery.OrderBy("g.DisplayName") + } + if perPage != 0 { groupsQuery = groupsQuery. Limit(uint64(perPage)). diff --git a/server/channels/store/storetest/group_store.go b/server/channels/store/storetest/group_store.go index a36ed75003..427600d608 100644 --- a/server/channels/store/storetest/group_store.go +++ b/server/channels/store/storetest/group_store.go @@ -3962,6 +3962,26 @@ func testGetGroups(t *testing.T, ss store.Store) { }, Restrictions: nil, }, + { + Name: "Include archived groups", + Opts: model.GroupSearchOpts{IncludeArchived: true, Q: "group-deleted"}, + Page: 0, + PerPage: 1, + Resultf: func(groups []*model.Group) bool { + return len(groups) == 1 + }, + Restrictions: nil, + }, + { + Name: "Only return archived groups", + Opts: model.GroupSearchOpts{FilterArchived: true, Q: "group-1"}, + Page: 0, + PerPage: 1, + Resultf: func(groups []*model.Group) bool { + return len(groups) == 0 + }, + Restrictions: nil, + }, } for _, tc := range testCases { diff --git a/server/channels/web/params.go b/server/channels/web/params.go index 4de487ee9b..040d94b342 100644 --- a/server/channels/web/params.go +++ b/server/channels/web/params.go @@ -83,6 +83,7 @@ type Params struct { IncludeTotalCount bool IncludeDeleted bool FilterAllowReference bool + FilterArchived bool FilterParentTeamPermitted bool CategoryId string WarnMetricId string @@ -208,6 +209,7 @@ func ParamsFromRequest(r *http.Request) *Params { params.NotAssociatedToTeam = query.Get("not_associated_to_team") params.NotAssociatedToChannel = query.Get("not_associated_to_channel") params.FilterAllowReference, _ = strconv.ParseBool(query.Get("filter_allow_reference")) + params.FilterArchived, _ = strconv.ParseBool(query.Get("filter_archived")) params.FilterParentTeamPermitted, _ = strconv.ParseBool(query.Get("filter_parent_team_permitted")) params.IncludeChannelMemberCount = query.Get("include_channel_member_count") diff --git a/server/public/model/client4.go b/server/public/model/client4.go index dfe1dd8bf6..65626ca3fb 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -5510,7 +5510,7 @@ func (c *Client4) GetGroupsAssociatedToChannelsByTeam(ctx context.Context, teamI // GetGroups retrieves Mattermost Groups func (c *Client4) GetGroups(ctx context.Context, opts GroupSearchOpts) ([]*Group, *Response, error) { path := fmt.Sprintf( - "%s?include_member_count=%v¬_associated_to_team=%v¬_associated_to_channel=%v&filter_allow_reference=%v&q=%v&filter_parent_team_permitted=%v&group_source=%v&include_channel_member_count=%v&include_timezones=%v", + "%s?include_member_count=%v¬_associated_to_team=%v¬_associated_to_channel=%v&filter_allow_reference=%v&q=%v&filter_parent_team_permitted=%v&group_source=%v&include_channel_member_count=%v&include_timezones=%v&include_archived=%v&filter_archived=%v", c.groupsRoute(), opts.IncludeMemberCount, opts.NotAssociatedToTeam, @@ -5521,6 +5521,8 @@ func (c *Client4) GetGroups(ctx context.Context, opts GroupSearchOpts) ([]*Group opts.Source, opts.IncludeChannelMemberCount, opts.IncludeTimezones, + opts.IncludeArchived, + opts.FilterArchived, ) if opts.Since > 0 { path = fmt.Sprintf("%s&since=%v", path, opts.Since) diff --git a/server/public/model/group.go b/server/public/model/group.go index 6a56b52e8a..93cbe25b89 100644 --- a/server/public/model/group.go +++ b/server/public/model/group.go @@ -133,6 +133,12 @@ type GroupSearchOpts struct { IncludeChannelMemberCount string IncludeTimezones bool + + // Include archived groups + IncludeArchived bool + + // Only return archived groups + FilterArchived bool } type GetGroupOpts struct { diff --git a/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_list/__snapshots__/product_menu_list.test.tsx.snap b/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_list/__snapshots__/product_menu_list.test.tsx.snap index 06e734c14b..224874b855 100644 --- a/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_list/__snapshots__/product_menu_list.test.tsx.snap +++ b/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_list/__snapshots__/product_menu_list.test.tsx.snap @@ -117,10 +117,13 @@ exports[`components/global/product_switcher_menu should match snapshot with id 1 } dialogType={ Object { - "$$typeof": Symbol(react.memo), - "WrappedComponent": [Function], - "compare": null, - "type": [Function], + "$$typeof": Symbol(react.forward_ref), + "WrappedComponent": Object { + "$$typeof": Symbol(react.memo), + "compare": null, + "type": [Function], + }, + "render": [Function], } } disabled={false} @@ -312,10 +315,13 @@ exports[`components/global/product_switcher_menu should match snapshot with most } dialogType={ Object { - "$$typeof": Symbol(react.memo), - "WrappedComponent": [Function], - "compare": null, - "type": [Function], + "$$typeof": Symbol(react.forward_ref), + "WrappedComponent": Object { + "$$typeof": Symbol(react.memo), + "compare": null, + "type": [Function], + }, + "render": [Function], } } disabled={false} @@ -399,10 +405,13 @@ exports[`components/global/product_switcher_menu should match userGroups snapsho } dialogType={ Object { - "$$typeof": Symbol(react.memo), - "WrappedComponent": [Function], - "compare": null, - "type": [Function], + "$$typeof": Symbol(react.forward_ref), + "WrappedComponent": Object { + "$$typeof": Symbol(react.memo), + "compare": null, + "type": [Function], + }, + "render": [Function], } } disabled={false} @@ -428,10 +437,13 @@ exports[`components/global/product_switcher_menu should match userGroups snapsho } dialogType={ Object { - "$$typeof": Symbol(react.memo), - "WrappedComponent": [Function], - "compare": null, - "type": [Function], + "$$typeof": Symbol(react.forward_ref), + "WrappedComponent": Object { + "$$typeof": Symbol(react.memo), + "compare": null, + "type": [Function], + }, + "render": [Function], } } disabled={true} @@ -470,10 +482,13 @@ exports[`components/global/product_switcher_menu should match userGroups snapsho } dialogType={ Object { - "$$typeof": Symbol(react.memo), - "WrappedComponent": [Function], - "compare": null, - "type": [Function], + "$$typeof": Symbol(react.forward_ref), + "WrappedComponent": Object { + "$$typeof": Symbol(react.memo), + "compare": null, + "type": [Function], + }, + "render": [Function], } } disabled={false} @@ -624,10 +639,13 @@ exports[`components/global/product_switcher_menu should show integrations should } dialogType={ Object { - "$$typeof": Symbol(react.memo), - "WrappedComponent": [Function], - "compare": null, - "type": [Function], + "$$typeof": Symbol(react.forward_ref), + "WrappedComponent": Object { + "$$typeof": Symbol(react.memo), + "compare": null, + "type": [Function], + }, + "render": [Function], } } disabled={false} diff --git a/webapp/channels/src/components/no_results_indicator/no_results_indicator.tsx b/webapp/channels/src/components/no_results_indicator/no_results_indicator.tsx index c435f9110d..1b7a441c28 100644 --- a/webapp/channels/src/components/no_results_indicator/no_results_indicator.tsx +++ b/webapp/channels/src/components/no_results_indicator/no_results_indicator.tsx @@ -37,6 +37,7 @@ const iconMap: {[key in NoResultsVariant]: React.ReactNode } = { [NoResultsVariant.ChannelFilesFiltered]: , [NoResultsVariant.UserGroups]: , [NoResultsVariant.UserGroupMembers]: , + [NoResultsVariant.UserGroupsArchived]: , }; const titleMap: {[key in NoResultsVariant]: MessageDescriptor} = { @@ -64,6 +65,9 @@ const titleMap: {[key in NoResultsVariant]: MessageDescriptor} = { [NoResultsVariant.UserGroupMembers]: { id: t('no_results.user_group_members.title'), }, + [NoResultsVariant.UserGroupsArchived]: { + id: t('no_results.user_groups.archived.title'), + }, }; const subtitleMap: {[key in NoResultsVariant]: MessageDescriptor} = { @@ -91,6 +95,9 @@ const subtitleMap: {[key in NoResultsVariant]: MessageDescriptor} = { [NoResultsVariant.UserGroupMembers]: { id: t('no_results.user_group_members.subtitle'), }, + [NoResultsVariant.UserGroupsArchived]: { + id: t('no_results.user_groups.archived.subtitle'), + }, }; import './no_results_indicator.scss'; diff --git a/webapp/channels/src/components/no_results_indicator/types.ts b/webapp/channels/src/components/no_results_indicator/types.ts index 598621b74f..2c302d8fd1 100644 --- a/webapp/channels/src/components/no_results_indicator/types.ts +++ b/webapp/channels/src/components/no_results_indicator/types.ts @@ -9,6 +9,7 @@ export enum NoResultsVariant { ChannelFiles = 'ChannelFiles', ChannelFilesFiltered = 'ChannelFilesFiltered', UserGroups = 'UserGroups', + UserGroupsArchived = 'UserGroupsArchived', UserGroupMembers = 'UserGroupMembers', } diff --git a/webapp/channels/src/components/post_markdown/index.ts b/webapp/channels/src/components/post_markdown/index.ts index 9a39082f9f..e1b54b68ae 100644 --- a/webapp/channels/src/components/post_markdown/index.ts +++ b/webapp/channels/src/components/post_markdown/index.ts @@ -37,7 +37,7 @@ export function makeGetMentionKeysForPost(): ( getCurrentUserMentionKeys, (state: GlobalState, post?: Post) => post, (state: GlobalState, post?: Post, channel?: Channel) => - (channel ? getMyGroupMentionKeysForChannel(state, channel.team_id, channel.id) : getMyGroupMentionKeys(state)), + (channel ? getMyGroupMentionKeysForChannel(state, channel.team_id, channel.id) : getMyGroupMentionKeys(state, false)), (mentionKeysWithoutGroups, post, groupMentionKeys) => { let mentionKeys = mentionKeysWithoutGroups; if (!post?.props?.disable_group_highlight) { diff --git a/webapp/channels/src/components/team_controller/actions/index.ts b/webapp/channels/src/components/team_controller/actions/index.ts index bad6acb522..0501d033cd 100644 --- a/webapp/channels/src/components/team_controller/actions/index.ts +++ b/webapp/channels/src/components/team_controller/actions/index.ts @@ -21,6 +21,7 @@ import LocalStorageStore from 'stores/local_storage_store'; import {Team} from '@mattermost/types/teams'; import {ServerError} from '@mattermost/types/errors'; +import {GetGroupsForUserParams, GetGroupsParams} from '@mattermost/types/groups'; export function initializeTeam(team: Team): ActionFunc { return async (dispatch, getState) => { @@ -50,8 +51,20 @@ export function initializeTeam(team: Team): ActionFunc { if (license && license.IsLicensed === 'true' && (license.LDAPGroups === 'true' || customGroupEnabled)) { + const groupsParams: GetGroupsParams = { + filter_allow_reference: false, + page: 0, + per_page: 60, + include_member_count: true, + include_archived: false, + }; + const myGroupsParams: GetGroupsForUserParams = { + ...groupsParams, + filter_has_member: currentUser.id, + }; + if (currentUser) { - dispatch(getGroupsByUserIdPaginated(currentUser.id, false, 0, 60, true)); + dispatch(getGroupsByUserIdPaginated(myGroupsParams)); } if (license.LDAPGroups === 'true') { @@ -61,7 +74,7 @@ export function initializeTeam(team: Team): ActionFunc { if (team.group_constrained && license.LDAPGroups === 'true') { dispatch(getAllGroupsAssociatedToTeam(team.id, true)); } else { - dispatch(getGroups('', false, 0, 60, true)); + dispatch(getGroups(groupsParams)); } } diff --git a/webapp/channels/src/components/user_groups_modal/__snapshots__/user_groups_modal.test.tsx.snap b/webapp/channels/src/components/user_groups_modal/__snapshots__/user_groups_modal.test.tsx.snap index 13c423adbe..5aa8f70708 100644 --- a/webapp/channels/src/components/user_groups_modal/__snapshots__/user_groups_modal.test.tsx.snap +++ b/webapp/channels/src/components/user_groups_modal/__snapshots__/user_groups_modal.test.tsx.snap @@ -56,49 +56,10 @@ exports[`component/user_groups_modal should match snapshot with groups 1`] = ` value="" /> -
- - - - Show: All Groups - - - - - - } - show={true} - text="All Groups" - /> - - - -
+ `; -exports[`component/user_groups_modal should match snapshot with groups, myGroups selected 1`] = ` +exports[`component/user_groups_modal should match snapshot without groups 1`] = ` -
- - - - Show: My Groups - - - - - - - } - show={true} - text="My Groups" - /> - - -
- - -
-`; - -exports[`component/user_groups_modal should match snapshot without groups 1`] = ` - - - - + `; diff --git a/webapp/channels/src/components/user_groups_modal/ad_ldap_upsell_banner.tsx b/webapp/channels/src/components/user_groups_modal/ad_ldap_upsell_banner.tsx index 6a4cf2fa7b..38413d5afd 100644 --- a/webapp/channels/src/components/user_groups_modal/ad_ldap_upsell_banner.tsx +++ b/webapp/channels/src/components/user_groups_modal/ad_ldap_upsell_banner.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useState} from 'react'; +import React, {memo, useEffect, useState} from 'react'; import {useDispatch, useSelector} from 'react-redux'; import moment from 'moment'; import {useIntl} from 'react-intl'; @@ -146,4 +146,4 @@ function ADLDAPUpsellBanner() { ); } -export default ADLDAPUpsellBanner; +export default memo(ADLDAPUpsellBanner); diff --git a/webapp/channels/src/components/user_groups_modal/hooks.ts b/webapp/channels/src/components/user_groups_modal/hooks.ts new file mode 100644 index 0000000000..9269fbaa0a --- /dev/null +++ b/webapp/channels/src/components/user_groups_modal/hooks.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useState} from 'react'; + +export function usePagingMeta(groupType: string): [number, (page: number) => void] { + const [page, setPage] = useState(0); + const [myGroupsPage, setMyGroupsPage] = useState(0); + const [archivedGroupsPage, setArchivedGroupsPage] = useState(0); + if (groupType === 'all') { + return [page, setPage]; + } else if (groupType === 'my') { + return [myGroupsPage, setMyGroupsPage]; + } + + return [ + archivedGroupsPage, + setArchivedGroupsPage, + ]; +} diff --git a/webapp/channels/src/components/user_groups_modal/index.ts b/webapp/channels/src/components/user_groups_modal/index.ts index 606b752e43..6e101caa13 100644 --- a/webapp/channels/src/components/user_groups_modal/index.ts +++ b/webapp/channels/src/components/user_groups_modal/index.ts @@ -9,9 +9,9 @@ import {ActionFunc, GenericAction} from 'mattermost-redux/types/actions'; import {GlobalState} from 'types/store'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; -import {getAllAssociatedGroupsForReference, getMyAllowReferencedGroups, searchAllowReferencedGroups, searchMyAllowReferencedGroups} from 'mattermost-redux/selectors/entities/groups'; +import {makeGetAllAssociatedGroupsForReference, makeGetMyAllowReferencedGroups, searchAllowReferencedGroups, searchMyAllowReferencedGroups, searchArchivedGroups, getArchivedGroups} from 'mattermost-redux/selectors/entities/groups'; import {getGroups, getGroupsByUserIdPaginated, searchGroups} from 'mattermost-redux/actions/groups'; -import {Group, GroupSearachParams} from '@mattermost/types/groups'; +import {GetGroupsForUserParams, GetGroupsParams, Group, GroupSearchParams} from '@mattermost/types/groups'; import {ModalIdentifiers} from 'utils/constants'; import {isModalOpen} from 'selectors/views/modals'; import {setModalSearchTerm} from 'actions/views/search'; @@ -20,43 +20,45 @@ import UserGroupsModal from './user_groups_modal'; type Actions = { getGroups: ( - filterAllowReference?: boolean, - page?: number, - perPage?: number, - includeMemberCount?: boolean + groupsParams: GetGroupsParams, ) => Promise<{data: Group[]}>; setModalSearchTerm: (term: string) => void; getGroupsByUserIdPaginated: ( - userId: string, - filterAllowReference?: boolean, - page?: number, - perPage?: number, - includeMemberCount?: boolean + opts: GetGroupsForUserParams, ) => Promise<{data: Group[]}>; searchGroups: ( - params: GroupSearachParams, + params: GroupSearchParams, ) => Promise<{data: Group[]}>; }; -function mapStateToProps(state: GlobalState) { - const searchTerm = state.views.search.modalSearch; +function makeMapStateToProps() { + const getAllAssociatedGroupsForReference = makeGetAllAssociatedGroupsForReference(); + const getMyAllowReferencedGroups = makeGetMyAllowReferencedGroups(); - let groups: Group[] = []; - let myGroups: Group[] = []; - if (searchTerm) { - groups = searchAllowReferencedGroups(state, searchTerm); - myGroups = searchMyAllowReferencedGroups(state, searchTerm); - } else { - groups = getAllAssociatedGroupsForReference(state); - myGroups = getMyAllowReferencedGroups(state); - } + return function mapStateToProps(state: GlobalState) { + const searchTerm = state.views.search.modalSearch; - return { - showModal: isModalOpen(state, ModalIdentifiers.USER_GROUPS), - groups, - searchTerm, - myGroups, - currentUserId: getCurrentUserId(state), + let groups: Group[] = []; + let myGroups: Group[] = []; + let archivedGroups: Group[] = []; + if (searchTerm) { + groups = searchAllowReferencedGroups(state, searchTerm, true); + myGroups = searchMyAllowReferencedGroups(state, searchTerm, true); + archivedGroups = searchArchivedGroups(state, searchTerm); + } else { + groups = getAllAssociatedGroupsForReference(state, true); + myGroups = getMyAllowReferencedGroups(state, true); + archivedGroups = getArchivedGroups(state); + } + + return { + showModal: isModalOpen(state, ModalIdentifiers.USER_GROUPS), + groups, + searchTerm, + myGroups, + archivedGroups, + currentUserId: getCurrentUserId(state), + }; }; } @@ -71,4 +73,4 @@ function mapDispatchToProps(dispatch: Dispatch) { }; } -export default connect(mapStateToProps, mapDispatchToProps)(UserGroupsModal); +export default connect(makeMapStateToProps, mapDispatchToProps, null, {forwardRef: true})(UserGroupsModal); diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_filter/user_groups_filter.tsx b/webapp/channels/src/components/user_groups_modal/user_groups_filter/user_groups_filter.tsx new file mode 100644 index 0000000000..3a82f11160 --- /dev/null +++ b/webapp/channels/src/components/user_groups_modal/user_groups_filter/user_groups_filter.tsx @@ -0,0 +1,88 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; + +import MenuWrapper from 'components/widgets/menu/menu_wrapper'; +import Menu from 'components/widgets/menu/menu'; + +type Props = { + selectedFilter: string; + getGroups: (page: number, groupType: string) => void; +} + +const UserGroupsFilter = (props: Props) => { + const { + selectedFilter, + getGroups, + } = props; + + const intl = useIntl(); + + const allGroupsOnClick = useCallback(() => { + getGroups(0, 'all'); + }, [getGroups]); + + const myGroupsOnClick = useCallback(() => { + getGroups(0, 'my'); + }, [getGroups]); + + const archivedGroupsOnClick = useCallback(() => { + getGroups(0, 'archived'); + }, [getGroups]); + + const filterLabel = useCallback(() => { + if (selectedFilter === 'all') { + return intl.formatMessage({id: 'user_groups_modal.showAllGroups', defaultMessage: 'Show: All Groups'}); + } else if (selectedFilter === 'my') { + return intl.formatMessage({id: 'user_groups_modal.showMyGroups', defaultMessage: 'Show: My Groups'}); + } else if (selectedFilter === 'archived') { + return intl.formatMessage({id: 'user_groups_modal.showArchivedGroups', defaultMessage: 'Show: Archived Groups'}); + } + return ''; + }, [selectedFilter]); + + return ( +
+ + + {filterLabel()} + + + + + } + /> + } + /> + + + } + /> + + + +
+ ); +}; + +export default React.memo(UserGroupsFilter); diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_list/__snapshots__/user_groups_list.test.tsx.snap b/webapp/channels/src/components/user_groups_modal/user_groups_list/__snapshots__/user_groups_list.test.tsx.snap index ac37569b62..89b9d52a79 100644 --- a/webapp/channels/src/components/user_groups_modal/user_groups_list/__snapshots__/user_groups_list.test.tsx.snap +++ b/webapp/channels/src/components/user_groups_modal/user_groups_list/__snapshots__/user_groups_list.test.tsx.snap @@ -3,276 +3,39 @@ exports[`component/user_groups_modal should match snapshot with groups 1`] = `
-
- - Group 0 - - - @ - group0 - -
- -
-
- - - - - - } - onClick={[Function]} - show={true} - text="View Group" - /> - - - - } - isDangerous={true} - onClick={[Function]} - show={true} - text="Archive Group" - /> - - - -
-
-
- - Group 1 - - - @ - group1 - -
- -
-
- - - - - - } - onClick={[Function]} - show={true} - text="View Group" - /> - - - - } - isDangerous={true} - onClick={[Function]} - show={true} - text="Archive Group" - /> - - - -
-
-
- - Group 2 - - - @ - group2 - -
- -
-
- - - - - - } - onClick={[Function]} - show={true} - text="View Group" - /> - - - - } - isDangerous={true} - onClick={[Function]} - show={true} - text="Archive Group" - /> - - - -
-
- + + +
`; exports[`component/user_groups_modal should match snapshot without groups 1`] = `
- + + + +
`; diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_list/index.ts b/webapp/channels/src/components/user_groups_modal/user_groups_list/index.ts index 0a81338d8d..0459f03b10 100644 --- a/webapp/channels/src/components/user_groups_modal/user_groups_list/index.ts +++ b/webapp/channels/src/components/user_groups_modal/user_groups_list/index.ts @@ -8,7 +8,7 @@ import {ActionFunc, ActionResult, GenericAction} from 'mattermost-redux/types/ac import {GlobalState} from 'types/store'; -import {archiveGroup} from 'mattermost-redux/actions/groups'; +import {archiveGroup, restoreGroup} from 'mattermost-redux/actions/groups'; import {ModalData} from 'types/actions'; import {openModal} from 'actions/views/modals'; import {getGroupListPermissions} from 'mattermost-redux/selectors/entities/roles'; @@ -18,6 +18,7 @@ import UserGroupsList from './user_groups_list'; type Actions = { openModal:

(modalData: ModalData

) => void; archiveGroup: (groupId: string) => Promise; + restoreGroup: (groupId: string) => Promise; }; function mapStateToProps(state: GlobalState) { @@ -32,6 +33,7 @@ function mapDispatchToProps(dispatch: Dispatch) { actions: bindActionCreators, Actions>({ openModal, archiveGroup, + restoreGroup, }, dispatch), }; } diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_list/user_groups_list.test.tsx b/webapp/channels/src/components/user_groups_modal/user_groups_list/user_groups_list.test.tsx index 4e758992f7..e6b927d3e9 100644 --- a/webapp/channels/src/components/user_groups_modal/user_groups_list/user_groups_list.test.tsx +++ b/webapp/channels/src/components/user_groups_modal/user_groups_list/user_groups_list.test.tsx @@ -18,9 +18,12 @@ describe('component/user_groups_modal', () => { backButtonAction: jest.fn(), groupPermissionsMap: {}, loading: false, + loadMoreGroups: jest.fn(), + hasNextPage: false, actions: { openModal: jest.fn(), archiveGroup: jest.fn(), + restoreGroup: jest.fn(), }, }; @@ -53,6 +56,7 @@ describe('component/user_groups_modal', () => { groupPermissionsMap[g.id] = { can_delete: true, can_manage_members: true, + can_restore: true, }; }); diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_list/user_groups_list.tsx b/webapp/channels/src/components/user_groups_modal/user_groups_list/user_groups_list.tsx index efc3bdab62..d871b42660 100644 --- a/webapp/channels/src/components/user_groups_modal/user_groups_list/user_groups_list.tsx +++ b/webapp/channels/src/components/user_groups_modal/user_groups_list/user_groups_list.tsx @@ -1,9 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {FormattedMessage} from 'react-intl'; +import {VariableSizeList, ListChildComponentProps} from 'react-window'; +import InfiniteLoader from 'react-window-infinite-loader'; import NoResultsIndicator from 'components/no_results_indicator'; import {NoResultsVariant} from 'components/no_results_indicator/types'; @@ -25,27 +27,33 @@ export type Props = { searchTerm: string; loading: boolean; groupPermissionsMap: Record; - onScroll: () => void; + loadMoreGroups: () => void; onExited: () => void; backButtonAction: () => void; + hasNextPage: boolean; actions: { archiveGroup: (groupId: string) => Promise; + restoreGroup: (groupId: string) => Promise; openModal:

(modalData: ModalData

) => void; }; } -const UserGroupsList = React.forwardRef((props: Props, ref?: React.Ref) => { +const UserGroupsList = (props: Props) => { const { groups, searchTerm, loading, groupPermissionsMap, - onScroll, + hasNextPage, + loadMoreGroups, backButtonAction, onExited, actions, } = props; + const infiniteLoaderRef = useRef(null); + const variableSizeListRef = useRef(null); + const [hasMounted, setHasMounted] = useState(false); const [overflowState, setOverflowState] = useState('overlay'); useEffect(() => { @@ -54,10 +62,34 @@ const UserGroupsList = React.forwardRef((props: Props, ref?: React.Ref { + if (hasMounted) { + if (infiniteLoaderRef.current) { + infiniteLoaderRef.current.resetloadMoreItemsCache(); + } + if (variableSizeListRef.current) { + variableSizeListRef.current.resetAfterIndex(0); + } + } + setHasMounted(true); + }, [searchTerm, groups.length, hasMounted]); + + const itemCount = hasNextPage ? groups.length + 1 : groups.length; + + const loadMoreItems = loading ? () => {} : loadMoreGroups; + + const isItemLoaded = (index: number) => { + return !hasNextPage || index < groups.length; + }; + const archiveGroup = useCallback(async (groupId: string) => { await actions.archiveGroup(groupId); }, [actions.archiveGroup]); + const restoreGroup = useCallback(async (groupId: string) => { + await actions.restoreGroup(groupId); + }, [actions.restoreGroup]); + const goToViewGroupModal = useCallback((group: Group) => { actions.openModal({ modalId: ModalIdentifiers.VIEW_USER_GROUP, @@ -74,100 +106,139 @@ const UserGroupsList = React.forwardRef((props: Props, ref?: React.Ref { - if (groups.length > 1 && groupListItemIndex === 0) { + if (groupListItemIndex === 0) { return false; } - return true; }; + const Item = ({index, style}: ListChildComponentProps) => { + if (groups.length === 0 && searchTerm) { + return ( + + ); + } + if (isItemLoaded(index)) { + const group = groups[index] as Group; + if (!group) { + return null; + } + + return ( +

{ + goToViewGroupModal(group); + }} + > + + { + group.delete_at > 0 && + + } + {group.display_name} + + + {'@'}{group.name} + +
+ +
+
+ + + + + { + goToViewGroupModal(group); + }} + icon={} + text={Utils.localizeMessage('user_groups_modal.viewGroup', 'View Group')} + disabled={false} + /> + + + { + archiveGroup(group.id); + }} + icon={} + text={Utils.localizeMessage('user_groups_modal.archiveGroup', 'Archive Group')} + disabled={false} + isDangerous={true} + /> + { + restoreGroup(group.id); + }} + icon={} + text={Utils.localizeMessage('user_groups_modal.restoreGroup', 'Restore Group')} + disabled={false} + /> + + + +
+
+ ); + } + if (loading) { + return ; + } + return null; + }; + return (
- {(groups.length === 0 && searchTerm) && - - } - {groups.map((group, i) => { - return ( -
{ - goToViewGroupModal(group); - }} + + {({onItemsRendered, ref}) => ( + 52} + height={groups.length >= 8 ? 416 : Math.max(groups.length, 3) * 52} + width={'100%'} > - - {group.display_name} - - - {'@'}{group.name} - -
- -
-
- - - - - { - goToViewGroupModal(group); - }} - icon={} - text={Utils.localizeMessage('user_groups_modal.viewGroup', 'View Group')} - disabled={false} - /> - - - { - archiveGroup(group.id); - }} - icon={} - text={Utils.localizeMessage('user_groups_modal.archiveGroup', 'Archive Group')} - disabled={false} - isDangerous={true} - /> - - - -
-
- ); - })} - { - (loading) && - - } + {Item} + )} +
); -}); +}; export default React.memo(UserGroupsList); diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_modal.scss b/webapp/channels/src/components/user_groups_modal/user_groups_modal.scss index cb37bb7a38..5ffa777135 100644 --- a/webapp/channels/src/components/user_groups_modal/user_groups_modal.scss +++ b/webapp/channels/src/components/user_groups_modal/user_groups_modal.scss @@ -162,11 +162,11 @@ } .modal-body { - overflow: hidden; max-height: 100%; padding: 0; .no-results__wrapper { + max-width: 350px; padding-bottom: 60px; } @@ -242,7 +242,7 @@ &.user-groups-list { position: relative; - max-height: 450px; + max-height: 460px; padding-top: 24px; padding-bottom: 16px; overflow-y: scroll; // for Firefox and browsers that doesn't support overflow-y:overlay property @@ -284,6 +284,11 @@ max-width: 200px; text-overflow: ellipsis; white-space: nowrap; + + i { + color: rgba(var(--center-channel-color-rgb), 0.56); + font-size: 16px; + } } .group-name { @@ -305,6 +310,13 @@ .MenuWrapper { width: 24px; margin-left: auto; + + .Menu { + position: absolute; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + margin-top: 8px; + border-radius: 4px; + } } .group-actions-menu { diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_modal.test.tsx b/webapp/channels/src/components/user_groups_modal/user_groups_modal.test.tsx index 4789134124..2a1cb07796 100644 --- a/webapp/channels/src/components/user_groups_modal/user_groups_modal.test.tsx +++ b/webapp/channels/src/components/user_groups_modal/user_groups_modal.test.tsx @@ -2,7 +2,6 @@ // See LICENSE.txt for license information. import React from 'react'; - import {shallow} from 'enzyme'; import {Group} from '@mattermost/types/groups'; @@ -14,6 +13,7 @@ describe('component/user_groups_modal', () => { onExited: jest.fn(), groups: [], myGroups: [], + archivedGroups: [], searchTerm: '', currentUserId: '', backButtonAction: jest.fn(), @@ -70,52 +70,4 @@ describe('component/user_groups_modal', () => { ); expect(wrapper).toMatchSnapshot(); }); - - test('should match snapshot with groups, myGroups selected', () => { - const groups = getGroups(3); - const myGroups = getGroups(1); - - const wrapper = shallow( - , - ); - - wrapper.setState({selectedFilter: 'my'}); - - expect(wrapper).toMatchSnapshot(); - }); - - test('should match snapshot with groups, search group1', () => { - const groups = getGroups(3); - const myGroups = getGroups(1); - - const wrapper = shallow( - , - ); - - const instance = wrapper.instance() as UserGroupsModal; - - const e = { - target: { - value: '', - }, - }; - instance.handleSearch(e as React.ChangeEvent); - expect(baseProps.actions.setModalSearchTerm).toHaveBeenCalledTimes(1); - expect(baseProps.actions.setModalSearchTerm).toBeCalledWith(''); - - e.target.value = 'group1'; - instance.handleSearch(e as React.ChangeEvent); - expect(wrapper.state('loading')).toEqual(true); - expect(baseProps.actions.setModalSearchTerm).toHaveBeenCalledTimes(2); - expect(baseProps.actions.setModalSearchTerm).toBeCalledWith(e.target.value); - }); }); diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_modal.tsx b/webapp/channels/src/components/user_groups_modal/user_groups_modal.tsx index 766b633413..5488166025 100644 --- a/webapp/channels/src/components/user_groups_modal/user_groups_modal.tsx +++ b/webapp/channels/src/components/user_groups_modal/user_groups_modal.tsx @@ -1,26 +1,25 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {createRef, RefObject} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {Modal} from 'react-bootstrap'; import Constants from 'utils/constants'; import * as Utils from 'utils/utils'; -import {Group, GroupSearachParams} from '@mattermost/types/groups'; +import {GetGroupsForUserParams, GetGroupsParams, Group, GroupSearchParams} from '@mattermost/types/groups'; import './user_groups_modal.scss'; -import MenuWrapper from 'components/widgets/menu/menu_wrapper'; -import Menu from 'components/widgets/menu/menu'; -import {debounce} from 'mattermost-redux/actions/helpers'; import Input from 'components/widgets/inputs/input/input'; import NoResultsIndicator from 'components/no_results_indicator'; import {NoResultsVariant} from 'components/no_results_indicator/types'; import UserGroupsList from './user_groups_list'; +import UserGroupsFilter from './user_groups_filter/user_groups_filter'; import UserGroupsModalHeader from './user_groups_modal_header'; import ADLDAPUpsellBanner from './ad_ldap_upsell_banner'; +import {usePagingMeta} from './hooks'; const GROUPS_PER_PAGE = 60; @@ -28,269 +27,208 @@ export type Props = { onExited: () => void; groups: Group[]; myGroups: Group[]; + archivedGroups: Group[]; searchTerm: string; currentUserId: string; backButtonAction: () => void; actions: { getGroups: ( - filterAllowReference?: boolean, - page?: number, - perPage?: number, - includeMemberCount?: boolean + opts: GetGroupsParams, ) => Promise<{data: Group[]}>; setModalSearchTerm: (term: string) => void; getGroupsByUserIdPaginated: ( - userId: string, - filterAllowReference?: boolean, - page?: number, - perPage?: number, - includeMemberCount?: boolean + opts: GetGroupsForUserParams, ) => Promise<{data: Group[]}>; searchGroups: ( - params: GroupSearachParams, + params: GroupSearchParams, ) => Promise<{data: Group[]}>; }; } -type State = { - page: number; - myGroupsPage: number; - loading: boolean; - show: boolean; - selectedFilter: string; - allGroupsFull: boolean; - myGroupsFull: boolean; -} +const UserGroupsModal = (props: Props) => { + const [searchTimeoutId, setSearchTimeoutId] = useState(0); + const [loading, setLoading] = useState(false); + const [show, setShow] = useState(true); + const [selectedFilter, setSelectedFilter] = useState('all'); + const [groupsFull, setGroupsFull] = useState(false); + const [groups, setGroups] = useState(props.groups); -export default class UserGroupsModal extends React.PureComponent { - divScrollRef: RefObject; - private searchTimeoutId: number; + const [page, setPage] = usePagingMeta(selectedFilter); - constructor(props: Props) { - super(props); - this.divScrollRef = createRef(); - this.searchTimeoutId = 0; + useEffect(() => { + if (selectedFilter === 'all') { + setGroups(props.groups); + } + if (selectedFilter === 'my') { + setGroups(props.myGroups); + } + if (selectedFilter === 'archived') { + setGroups(props.archivedGroups); + } + }, [selectedFilter, props.groups, props.myGroups]); - this.state = { - page: 0, - myGroupsPage: 0, - loading: true, - show: true, - selectedFilter: 'all', - allGroupsFull: false, - myGroupsFull: false, + const doHide = () => { + setShow(false); + }; + + const getGroups = useCallback(async (page: number, groupType: string) => { + const {actions, currentUserId} = props; + setLoading(true); + const groupsParams: GetGroupsParams = { + filter_allow_reference: false, + page, + per_page: GROUPS_PER_PAGE, + include_member_count: true, }; - } + let data: {data: Group[]} = {data: []}; - doHide = () => { - this.setState({show: false}); - }; - - async componentDidMount() { - const { - actions, - } = this.props; - await Promise.all([ - actions.getGroups(false, this.state.page, GROUPS_PER_PAGE, true), - actions.getGroupsByUserIdPaginated(this.props.currentUserId, false, this.state.myGroupsPage, GROUPS_PER_PAGE, true), - ]); - this.loadComplete(); - } - - componentWillUnmount() { - this.props.actions.setModalSearchTerm(''); - } - - componentDidUpdate(prevProps: Props) { - if (prevProps.searchTerm !== this.props.searchTerm) { - clearTimeout(this.searchTimeoutId); - const searchTerm = this.props.searchTerm; - - if (searchTerm === '') { - this.loadComplete(); - this.searchTimeoutId = 0; - return; - } - - const searchTimeoutId = window.setTimeout( - async () => { - const params: GroupSearachParams = { - q: searchTerm, - filter_allow_reference: true, - page: this.state.page, - per_page: GROUPS_PER_PAGE, - include_member_count: true, - }; - if (this.state.selectedFilter === 'all') { - await prevProps.actions.searchGroups(params); - } else { - params.user_id = this.props.currentUserId; - await prevProps.actions.searchGroups(params); - } - }, - Constants.SEARCH_TIMEOUT_MILLISECONDS, - ); - - this.searchTimeoutId = searchTimeoutId; + if (groupType === 'all') { + groupsParams.include_archived = true; + data = await actions.getGroups(groupsParams); + } else if (groupType === 'my') { + const groupsUserParams = { + ...groupsParams, + filter_has_member: currentUserId, + include_archived: true, + } as GetGroupsForUserParams; + data = await actions.getGroupsByUserIdPaginated(groupsUserParams); + } else if (groupType === 'archived') { + groupsParams.filter_archived = true; + data = await actions.getGroups(groupsParams); } - } - startLoad = () => { - this.setState({loading: true}); - }; - - loadComplete = () => { - this.setState({loading: false}); - }; - - handleSearch = (e: React.ChangeEvent) => { - const term = e.target.value; - this.props.actions.setModalSearchTerm(term); - }; - - scrollGetGroups = debounce( - async () => { - const {page} = this.state; - const newPage = page + 1; - - this.setState({page: newPage}); - this.getGroups(newPage); - }, - 500, - false, - (): void => {}, - ); - scrollGetMyGroups = debounce( - async () => { - const {myGroupsPage} = this.state; - const newPage = myGroupsPage + 1; - - this.setState({myGroupsPage: newPage}); - this.getMyGroups(newPage); - }, - 500, - false, - (): void => {}, - ); - - onScroll = () => { - const scrollHeight = this.divScrollRef.current?.scrollHeight || 0; - const scrollTop = this.divScrollRef.current?.scrollTop || 0; - const clientHeight = this.divScrollRef.current?.clientHeight || 0; - - if ((scrollTop + clientHeight + 30) >= scrollHeight) { - if (this.state.selectedFilter === 'all' && this.state.loading === false && !this.state.allGroupsFull) { - this.scrollGetGroups(); - } - if (this.state.selectedFilter !== 'all' && this.props.myGroups.length % GROUPS_PER_PAGE === 0 && this.state.loading === false) { - this.scrollGetMyGroups(); - } + if (data && data.data.length === 0) { + setGroupsFull(true); + } else { + setGroupsFull(false); } - }; + setLoading(false); + setSelectedFilter(groupType); + }, [props.actions.getGroups, props.actions.getGroupsByUserIdPaginated, props.currentUserId]); - getMyGroups = async (page: number) => { - const {actions} = this.props; + useEffect(() => { + getGroups(0, 'all'); + return () => { + props.actions.setModalSearchTerm(''); + }; + }, []); - this.startLoad(); - const data = await actions.getGroupsByUserIdPaginated(this.props.currentUserId, false, page, GROUPS_PER_PAGE, true); - if (data.data.length === 0) { - this.setState({myGroupsFull: true}); + useEffect(() => { + clearTimeout(searchTimeoutId); + const searchTerm = props.searchTerm; + + if (searchTerm === '') { + setLoading(false); + setSearchTimeoutId(0); + return; } - this.loadComplete(); - this.setState({selectedFilter: 'my'}); - }; - getGroups = async (page: number) => { - const {actions} = this.props; - - this.startLoad(); - const data = await actions.getGroups(false, page, GROUPS_PER_PAGE, true); - if (data.data.length === 0) { - this.setState({allGroupsFull: true}); - } - this.loadComplete(); - this.setState({selectedFilter: 'all'}); - }; - - render() { - const groups = this.state.selectedFilter === 'all' ? this.props.groups : this.props.myGroups; - - return ( - - - - {(groups.length === 0 && !this.props.searchTerm) ? <> - - - : <> -
- } - /> -
-
- - - {this.state.selectedFilter === 'all' ? Utils.localizeMessage('user_groups_modal.showAllGroups', 'Show: All Groups') : Utils.localizeMessage('user_groups_modal.showMyGroups', 'Show: My Groups')} - - - - { - this.getGroups(0); - }} - text={Utils.localizeMessage('user_groups_modal.allGroups', 'All Groups')} - rightDecorator={this.state.selectedFilter === 'all' && } - /> - { - this.getMyGroups(0); - }} - text={Utils.localizeMessage('user_groups_modal.myGroups', 'My Groups')} - rightDecorator={this.state.selectedFilter !== 'all' && } - /> - - -
- - - } -
-
+ const timeoutId = window.setTimeout( + async () => { + const params: GroupSearchParams = { + q: searchTerm, + filter_allow_reference: true, + page, + per_page: GROUPS_PER_PAGE, + include_archived: true, + include_member_count: true, + }; + if (selectedFilter === 'all') { + await props.actions.searchGroups(params); + } else if (selectedFilter === 'my') { + params.filter_has_member = props.currentUserId; + await props.actions.searchGroups(params); + } else if (selectedFilter === 'archived') { + params.filter_archived = true; + await props.actions.searchGroups(params); + } + }, + Constants.SEARCH_TIMEOUT_MILLISECONDS, ); - } -} + + setSearchTimeoutId(timeoutId); + }, [props.searchTerm, setSearchTimeoutId]); + + const handleSearch = useCallback((e: React.ChangeEvent) => { + const term = e.target.value; + props.actions.setModalSearchTerm(term); + }, [props.actions.setModalSearchTerm]); + + const loadMoreGroups = useCallback(() => { + const newPage = page + 1; + setPage(newPage); + if (selectedFilter === 'all' && !loading) { + getGroups(newPage, 'all'); + } + if (selectedFilter === 'my' && !loading) { + getGroups(newPage, 'my'); + } + if (selectedFilter === 'archived' && !loading) { + getGroups(newPage, 'archived'); + } + }, [selectedFilter, page, getGroups, loading]); + + const inputPrefix = useMemo(() => { + return ; + }, []); + + const noResultsType = useMemo(() => { + if (selectedFilter === 'archived') { + return NoResultsVariant.UserGroupsArchived; + } + return NoResultsVariant.UserGroups; + }, [selectedFilter]); + + return ( + + + +
+ +
+ + {(groups.length === 0 && !props.searchTerm) ? <> + + + : <> + + + } +
+
+ ); +}; + +export default React.memo(UserGroupsModal); diff --git a/webapp/channels/src/components/view_user_group_modal/__snapshots__/view_user_group_modal.test.tsx.snap b/webapp/channels/src/components/view_user_group_modal/__snapshots__/view_user_group_modal.test.tsx.snap index 9e1ac6603e..96ced89b87 100644 --- a/webapp/channels/src/components/view_user_group_modal/__snapshots__/view_user_group_modal.test.tsx.snap +++ b/webapp/channels/src/components/view_user_group_modal/__snapshots__/view_user_group_modal.test.tsx.snap @@ -48,7 +48,7 @@ exports[`component/view_user_group_modal should match snapshot 1`] = ` - @ group + @group
- {`@ ${group.name}`} + {`@${group.name}`} { group.source.toLowerCase() === GroupSource.Ldap && diff --git a/webapp/channels/src/components/view_user_group_modal/view_user_group_modal_header/index.ts b/webapp/channels/src/components/view_user_group_modal/view_user_group_modal_header/index.ts index 1b9c73dce9..aed769e15c 100644 --- a/webapp/channels/src/components/view_user_group_modal/view_user_group_modal_header/index.ts +++ b/webapp/channels/src/components/view_user_group_modal/view_user_group_modal_header/index.ts @@ -10,9 +10,8 @@ import {GlobalState} from 'types/store'; import {ModalData} from 'types/actions'; import {openModal} from 'actions/views/modals'; import {getGroup as getGroupById, isMyGroup} from 'mattermost-redux/selectors/entities/groups'; -import {addUsersToGroup, archiveGroup, removeUsersFromGroup} from 'mattermost-redux/actions/groups'; +import {addUsersToGroup, archiveGroup, removeUsersFromGroup, restoreGroup} from 'mattermost-redux/actions/groups'; import {haveIGroupPermission} from 'mattermost-redux/selectors/entities/roles'; -import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {Permissions} from 'mattermost-redux/constants'; import ViewUserGroupModalHeader from './view_user_group_modal_header'; @@ -22,6 +21,7 @@ type Actions = { removeUsersFromGroup: (groupId: string, userIds: string[]) => Promise; addUsersToGroup: (groupId: string, userIds: string[]) => Promise; archiveGroup: (groupId: string) => Promise; + restoreGroup: (groupId: string) => Promise; }; type OwnProps = { @@ -36,15 +36,16 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) { const permissionToJoinGroup = haveIGroupPermission(state, ownProps.groupId, Permissions.MANAGE_CUSTOM_GROUP_MEMBERS); const permissionToLeaveGroup = haveIGroupPermission(state, ownProps.groupId, Permissions.MANAGE_CUSTOM_GROUP_MEMBERS); const permissionToArchiveGroup = haveIGroupPermission(state, ownProps.groupId, Permissions.DELETE_CUSTOM_GROUP); + const permissionToRestoreGroup = haveIGroupPermission(state, ownProps.groupId, Permissions.RESTORE_CUSTOM_GROUP); return { permissionToEditGroup, permissionToJoinGroup, permissionToLeaveGroup, permissionToArchiveGroup, + permissionToRestoreGroup, isGroupMember, group, - currentUserId: getCurrentUserId(state), }; } @@ -55,6 +56,7 @@ function mapDispatchToProps(dispatch: Dispatch) { removeUsersFromGroup, addUsersToGroup, archiveGroup, + restoreGroup, }, dispatch), }; } diff --git a/webapp/channels/src/components/view_user_group_modal/view_user_group_modal_header/view_user_group_modal_header.tsx b/webapp/channels/src/components/view_user_group_modal/view_user_group_modal_header/view_user_group_modal_header.tsx index c911f3fc01..4a41f42389 100644 --- a/webapp/channels/src/components/view_user_group_modal/view_user_group_modal_header/view_user_group_modal_header.tsx +++ b/webapp/channels/src/components/view_user_group_modal/view_user_group_modal_header/view_user_group_modal_header.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useCallback} from 'react'; import {Modal} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; @@ -24,8 +24,8 @@ export type Props = { permissionToJoinGroup: boolean; permissionToLeaveGroup: boolean; permissionToArchiveGroup: boolean; + permissionToRestoreGroup: boolean; isGroupMember: boolean; - currentUserId: string; incrementMemberCount: () => void; decrementMemberCount: () => void; actions: { @@ -33,39 +33,50 @@ export type Props = { removeUsersFromGroup: (groupId: string, userIds: string[]) => Promise; addUsersToGroup: (groupId: string, userIds: string[]) => Promise; archiveGroup: (groupId: string) => Promise; + restoreGroup: (groupId: string) => Promise; }; } -const ViewUserGroupModalHeader = (props: Props) => { - const goToAddPeopleModal = () => { - const {actions, groupId} = props; - +const ViewUserGroupModalHeader = ({ + groupId, + group, + onExited, + backButtonCallback, + backButtonAction, + permissionToEditGroup, + permissionToJoinGroup, + permissionToLeaveGroup, + permissionToArchiveGroup, + permissionToRestoreGroup, + isGroupMember, + incrementMemberCount, + decrementMemberCount, + actions, +}: Props) => { + const goToAddPeopleModal = useCallback(() => { actions.openModal({ modalId: ModalIdentifiers.ADD_USERS_TO_GROUP, dialogType: AddUsersToGroupModal, dialogProps: { groupId, - backButtonCallback: props.backButtonAction, + backButtonCallback: backButtonAction, }, }); - props.onExited(); - }; + onExited(); + }, [actions.openModal, groupId, onExited, backButtonAction]); - const showSubMenu = (source: string) => { - const {permissionToEditGroup, permissionToJoinGroup, permissionToLeaveGroup, permissionToArchiveGroup} = props; + const restoreGroup = useCallback(async () => { + await actions.restoreGroup(groupId); + }, [actions.restoreGroup, groupId]); - return source.toLowerCase() !== 'ldap' && - ( - permissionToEditGroup || + const showSubMenu = useCallback(() => { + return permissionToEditGroup || permissionToJoinGroup || permissionToLeaveGroup || - permissionToArchiveGroup - ); - }; - - const modalTitle = () => { - const {group} = props; + permissionToArchiveGroup; + }, [permissionToEditGroup, permissionToJoinGroup, permissionToLeaveGroup, permissionToArchiveGroup]); + const modalTitle = useCallback(() => { if (group) { return ( { id='userGroupsModalLabel' > {group.display_name} + { + group.delete_at > 0 && + + } ); } return (<>); - }; + }, [group]); - const addPeopleButton = () => { - const {group, permissionToJoinGroup} = props; - - if (group?.source.toLowerCase() !== 'ldap' && permissionToJoinGroup) { + const addPeopleButton = useCallback(() => { + if (permissionToJoinGroup) { return ( ); } return (<>); - }; + }, [permissionToJoinGroup, goToAddPeopleModal]); + + const restoreGroupButton = useCallback(() => { + if (permissionToRestoreGroup) { + return ( + + ); + } + return (<>); + }, [permissionToRestoreGroup, restoreGroup]); const subMenuButton = () => { - const {group} = props; - - if (group && showSubMenu(group?.source)) { + if (group && showSubMenu()) { return ( ); } return null; }; + const goBack = useCallback(() => { + backButtonCallback(); + onExited(); + }, [backButtonCallback, onExited]); + return ( {modalTitle()} {addPeopleButton()} + {restoreGroupButton()} {subMenuButton()} ); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 651150968f..24672e7385 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1665,7 +1665,7 @@ "admin.permissions.permission.read_user_access_token.name": "Read user access token", "admin.permissions.permission.remove_user_from_team.description": "Remove user from team", "admin.permissions.permission.remove_user_from_team.name": "Remove user from team", - "admin.permissions.permission.restore_custom_group.description": "Restore deleted user groups.", + "admin.permissions.permission.restore_custom_group.description": "Restore archived user groups.", "admin.permissions.permission.restore_custom_group.name": "Restore", "admin.permissions.permission.revoke_user_access_token.description": "Revoke user access token", "admin.permissions.permission.revoke_user_access_token.name": "Revoke user access token", @@ -4133,6 +4133,8 @@ "no_results.pinned_posts.title": "No pinned posts yet", "no_results.user_group_members.subtitle": "There are currently no members in this group, please add one.", "no_results.user_group_members.title": "No members yet", + "no_results.user_groups.archived.subtitle": "Groups that are no longer relevant or are not being used can be archived", + "no_results.user_groups.archived.title": "No archived groups", "no_results.user_groups.subtitle": "Groups are a custom collection of users that can be used for mentions and invites.", "no_results.user_groups.title": "No groups yet", "notification.crt": "Reply in {title}", @@ -5131,10 +5133,12 @@ "user_group_popover.memberCount": "{member_count} {member_count, plural, one {Member} other {Members}}", "user_group_popover.openGroupModal": "View full group info", "user_group_popover.searchGroupMembers": "Search members", - "user_groups_modal.addPeople": "Add People", + "user_groups_modal.addPeople": "Add people", "user_groups_modal.addPeopleTitle": "Add people to {group}", "user_groups_modal.allGroups": "All Groups", + "user_groups_modal.archivedGroups": "Archived Groups", "user_groups_modal.archiveGroup": "Archive Group", + "user_groups_modal.button.restoreGroup": "Restore Group", "user_groups_modal.createNew": "Create Group", "user_groups_modal.createTitle": "Create Group", "user_groups_modal.editDetails": "Edit Details", @@ -5153,8 +5157,10 @@ "user_groups_modal.myGroups": "My Groups", "user_groups_modal.name": "Name", "user_groups_modal.nameIsEmpty": "Name is a required field.", + "user_groups_modal.restoreGroup": "Restore Group", "user_groups_modal.searchGroups": "Search Groups", "user_groups_modal.showAllGroups": "Show: All Groups", + "user_groups_modal.showArchivedGroups": "Show: Archived Groups", "user_groups_modal.showMyGroups": "Show: My Groups", "user_groups_modal.title": "User Groups", "user_groups_modal.unknownError": "An unknown error has occurred.", diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/groups.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/groups.ts index d20b2e866d..72d9a43e1b 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/groups.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/groups.ts @@ -51,4 +51,6 @@ export default keyMirror({ ARCHIVED_GROUP: null, CREATED_GROUP_TEAMS_AND_CHANNELS: null, + + RESTORED_GROUP: null, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/groups.test.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/groups.test.ts index a6aa279172..8e03e9e277 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/groups.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/groups.test.ts @@ -3,7 +3,7 @@ import nock from 'nock'; -import {SyncableType} from '@mattermost/types/groups'; +import {GetGroupsParams, SyncableType} from '@mattermost/types/groups'; import * as Actions from 'mattermost-redux/actions/groups'; import {Client4} from 'mattermost-redux/client'; @@ -275,7 +275,12 @@ describe('Actions.Groups', () => { get('/groups?filter_allow_reference=true&page=0&per_page=0'). reply(200, response1.groups); - await Actions.getGroups('', true, 0, 0)(store.dispatch, store.getState); + const groupParams: GetGroupsParams = { + filter_allow_reference: true, + page: 0, + per_page: 0, + }; + await Actions.getGroups(groupParams)(store.dispatch, store.getState); const state = store.getState(); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/groups.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/groups.ts index b62bb34c7d..f57fc21c14 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/groups.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/groups.ts @@ -9,7 +9,7 @@ import {General} from 'mattermost-redux/constants'; import {Client4} from 'mattermost-redux/client'; import {ActionFunc, DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; -import {GroupPatch, SyncableType, SyncablePatch, GroupCreateWithUserIds, CustomGroupPatch, GroupSearachParams, GroupSource} from '@mattermost/types/groups'; +import {GroupPatch, SyncableType, SyncablePatch, GroupCreateWithUserIds, CustomGroupPatch, GroupSearchParams, GroupSource, GetGroupsParams, GetGroupsForUserParams} from '@mattermost/types/groups'; import {logError} from './errors'; import {bindClientFunc, forceLogoutIfNecessary} from './helpers'; @@ -156,19 +156,15 @@ export function getGroup(id: string, includeMemberCount = false): ActionFunc { }); } -export function getGroups(q = '', filterAllowReference = false, page = 0, perPage = 10, includeMemberCount = false): ActionFunc { +export function getGroups(opts: GetGroupsParams): ActionFunc { return bindClientFunc({ - clientFunc: async (param1, param2, param3, param4, param5) => { - const result = await Client4.getGroups(param1, param2, param3, param4, param5); + clientFunc: async (opts) => { + const result = await Client4.getGroups(opts); return result; }, onSuccess: [GroupTypes.RECEIVED_GROUPS], params: [ - q, - filterAllowReference, - page, - perPage, - includeMemberCount, + opts, ], }); } @@ -303,19 +299,15 @@ export function getGroupsByUserId(userID: string): ActionFunc { }); } -export function getGroupsByUserIdPaginated(userId: string, filterAllowReference = false, page = 0, perPage: number = General.PAGE_SIZE_DEFAULT, includeMemberCount = false): ActionFunc { +export function getGroupsByUserIdPaginated(opts: GetGroupsForUserParams): ActionFunc { return bindClientFunc({ - clientFunc: async (param1, param2, param3, param4, param5) => { - const result = await Client4.getGroups(param1, param2, param3, param4, param5); + clientFunc: async (opts) => { + const result = await Client4.getGroups(opts); return result; }, onSuccess: [GroupTypes.RECEIVED_MY_GROUPS, GroupTypes.RECEIVED_GROUPS], params: [ - filterAllowReference, - page, - perPage, - includeMemberCount, - userId, + opts, ], }); } @@ -392,7 +384,7 @@ export function removeUsersFromGroup(groupId: string, userIds: string[]): Action }; } -export function searchGroups(params: GroupSearachParams): ActionFunc { +export function searchGroups(params: GroupSearchParams): ActionFunc { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let data; try { @@ -405,7 +397,7 @@ export function searchGroups(params: GroupSearachParams): ActionFunc { const dispatches: AnyAction[] = [{type: GroupTypes.RECEIVED_GROUPS, data}]; - if (params.user_id) { + if (params.filter_has_member) { dispatches.push({type: GroupTypes.RECEIVED_MY_GROUPS, data}); } if (params.include_channel_member_count) { @@ -431,6 +423,29 @@ export function archiveGroup(groupId: string): ActionFunc { { type: GroupTypes.ARCHIVED_GROUP, id: groupId, + data, + }, + ); + + return {data}; + }; +} + +export function restoreGroup(groupId: string): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + let data; + try { + data = await Client4.restoreGroup(groupId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + return {error}; + } + + dispatch( + { + type: GroupTypes.RESTORED_GROUP, + id: groupId, + data, }, ); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts index ad5f023050..7012359721 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts @@ -29,7 +29,7 @@ import {isCombinedUserActivityPost} from 'mattermost-redux/utils/post_list'; import {General, Preferences, Posts} from 'mattermost-redux/constants'; -import {getGroups} from 'mattermost-redux/actions/groups'; +import {searchGroups} from 'mattermost-redux/actions/groups'; import {getProfilesByIds, getProfilesByUsernames, getStatusesByIds} from 'mattermost-redux/actions/users'; import { deletePreferences, @@ -1127,7 +1127,16 @@ export async function getMentionsAndStatusesForPosts(postsArrayOrMap: Post[]|Pos const loadedProfiles = new Set((data || []).map((p) => p.username)); const groupsToCheck = Array.from(usernamesAndGroupsToLoad).filter((name) => !loadedProfiles.has(name)); - groupsToCheck.forEach((name) => promises.push(getGroups(name)(dispatch, getState))); + groupsToCheck.forEach((name) => { + const groupParams = { + q: name, + filter_allow_reference: true, + page: 0, + per_page: 60, + include_member_count: true, + }; + promises.push(searchGroups(groupParams)(dispatch, getState)); + }); } return Promise.all(promises); diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/groups.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/groups.ts index 5fb057ff55..72c660cb14 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/groups.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/groups.ts @@ -164,8 +164,7 @@ function myGroups(state: string[] = [], action: GenericAction) { return nextState; } - case GroupTypes.REMOVE_MY_GROUP: - case GroupTypes.ARCHIVED_GROUP: { + case GroupTypes.REMOVE_MY_GROUP: { const groupId = action.id; const index = state.indexOf(groupId); @@ -203,6 +202,8 @@ function groups(state: Record = {}, action: GenericAction) { switch (action.type) { case GroupTypes.CREATE_GROUP_SUCCESS: case GroupTypes.PATCHED_GROUP: + case GroupTypes.RESTORED_GROUP: + case GroupTypes.ARCHIVED_GROUP: case GroupTypes.RECEIVED_GROUP: { return { ...state, @@ -241,11 +242,6 @@ function groups(state: Record = {}, action: GenericAction) { return nextState; } - case GroupTypes.ARCHIVED_GROUP: { - const nextState = {...state}; - Reflect.deleteProperty(nextState, action.id); - return nextState; - } default: return state; } diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/groups.test.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/groups.test.ts index b1c38a8b86..8331dd15cd 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/groups.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/groups.test.ts @@ -208,13 +208,14 @@ describe('Selectors.Groups', () => { expect(Selectors.getGroupsAssociatedToChannelForReference(testState, channelID)).toEqual(expected); }); - it('getAllAssociatedGroupsForReference', () => { + it('makeGetAllAssociatedGroupsForReference', () => { const expected = [ group1, group4, group5, ]; - expect(Selectors.getAllAssociatedGroupsForReference(testState)).toEqual(expected); + const getAllAssociatedGroupsForReference = Selectors.makeGetAllAssociatedGroupsForReference(); + expect(getAllAssociatedGroupsForReference(testState, false)).toEqual(expected); }); it('getMyGroupMentionKeys', () => { @@ -226,7 +227,7 @@ describe('Selectors.Groups', () => { key: `@${group4.name}`, }, ]; - expect(Selectors.getMyGroupMentionKeys(testState)).toEqual(expected); + expect(Selectors.getMyGroupMentionKeys(testState, false)).toEqual(expected); }); it('getMyGroupMentionKeysForChannel', () => { diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/groups.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/groups.ts index aae984ffa6..7e01819ad5 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/groups.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/groups.ts @@ -130,7 +130,7 @@ export function getAssociatedGroupsForReference(state: GlobalState, teamId: stri } else if (channel && channel.group_constrained) { groupsForReference = getGroupsAssociatedToChannelForReference(state, channelId); } else { - groupsForReference = getAllAssociatedGroupsForReference(state); + groupsForReference = getAllAssociatedGroupsForReference(state, false); } return groupsForReference; } @@ -221,20 +221,29 @@ export const getGroupsAssociatedToChannelForReference: (state: GlobalState, chan }, ); -export const getAllAssociatedGroupsForReference: (state: GlobalState) => Group[] = createSelector( - 'getAllAssociatedGroupsForReference', - getAllGroups, - getCurrentUserLocale, - (allGroups, locale) => { - const groups = Object.entries(allGroups).filter((entry) => (entry[1].allow_reference && entry[1].delete_at === 0)).map((entry) => entry[1]); +export const makeGetAllAssociatedGroupsForReference = () => { + return createSelector( + 'makeGetAllAssociatedGroupsForReference', + (state: GlobalState) => getAllGroups(state), + (state: GlobalState) => getCurrentUserLocale(state), + (_: GlobalState, includeArchived: boolean) => includeArchived, + (allGroups, locale, includeArchived) => { + const groups = Object.entries(allGroups).filter((entry) => { + if (includeArchived) { + return entry[1].allow_reference; + } + return entry[1].allow_reference && entry[1].delete_at === 0; + }).map((entry) => entry[1]); + return sortGroups(groups, locale); + }, + ); +}; - return sortGroups(groups, locale); - }, -); +const getAllAssociatedGroupsForReference = makeGetAllAssociatedGroupsForReference(); export const getAllGroupsForReferenceByName: (state: GlobalState) => Record = createSelector( 'getAllGroupsForReferenceByName', - getAllAssociatedGroupsForReference, + (state: GlobalState) => getAllAssociatedGroupsForReference(state, false), (groups) => { const groupsByName: Record = {}; @@ -249,16 +258,25 @@ export const getAllGroupsForReferenceByName: (state: GlobalState) => Record Group[] = createSelector( - 'getMyAllowReferencedGroups', - getMyGroups, - getCurrentUserLocale, - (myGroups, locale) => { - const groups = myGroups.filter((group) => group.allow_reference && group.delete_at === 0); +export const makeGetMyAllowReferencedGroups = () => { + return createSelector( + 'makeGetMyAllowReferencedGroups', + (state: GlobalState) => getMyGroups(state), + (state: GlobalState) => getCurrentUserLocale(state), + (_: GlobalState, includeArchived: boolean) => includeArchived, + (myGroups, locale, includeArchived) => { + const groups = myGroups.filter((group) => { + if (includeArchived) { + return group.allow_reference; + } - return sortGroups(groups, locale); - }, -); + return group.allow_reference && group.delete_at === 0; + }); + + return sortGroups(groups, locale); + }, + ); +}; export const getMyGroupsAssociatedToChannelForReference: (state: GlobalState, teamId: string, channelId: string) => Group[] = createSelector( 'getMyGroupsAssociatedToChannelForReference', @@ -269,9 +287,11 @@ export const getMyGroupsAssociatedToChannelForReference: (state: GlobalState, te }, ); -export const getMyGroupMentionKeys: (state: GlobalState) => UserMentionKey[] = createSelector( +const getMyAllowReferencedGroups = makeGetMyAllowReferencedGroups(); + +export const getMyGroupMentionKeys: (state: GlobalState, includeArchived: boolean) => UserMentionKey[] = createSelector( 'getMyGroupMentionKeys', - getMyAllowReferencedGroups, + (state: GlobalState, includeArchived: boolean) => getMyAllowReferencedGroups(state, includeArchived), (groups: Group[]) => { const keys: UserMentionKey[] = []; groups.forEach((group) => keys.push({key: `@${group.name}`})); @@ -289,20 +309,24 @@ export const getMyGroupMentionKeysForChannel: (state: GlobalState, teamId: strin }, ); -export const searchAllowReferencedGroups: (state: GlobalState, term: string) => Group[] = createSelector( +const searchGetAllAssociatedGroupsForReference = makeGetAllAssociatedGroupsForReference(); + +export const searchAllowReferencedGroups: (state: GlobalState, term: string, includeArchived: boolean) => Group[] = createSelector( 'searchAllowReferencedGroups', - getAllAssociatedGroupsForReference, (state: GlobalState, term: string) => term, - (groups, term) => { + (state: GlobalState, term: string, includeArchived: boolean) => searchGetAllAssociatedGroupsForReference(state, includeArchived), + (term, groups) => { return filterGroupsMatchingTerm(groups, term); }, ); -export const searchMyAllowReferencedGroups: (state: GlobalState, term: string) => Group[] = createSelector( +const searchGetMyAllowReferencedGroups = makeGetMyAllowReferencedGroups(); + +export const searchMyAllowReferencedGroups: (state: GlobalState, term: string, includeArchived: boolean) => Group[] = createSelector( 'searchMyAllowReferencedGroups', - getMyAllowReferencedGroups, (state: GlobalState, term: string) => term, - (groups, term) => { + (state: GlobalState, term: string, includeArchived: boolean) => searchGetMyAllowReferencedGroups(state, includeArchived), + (term, groups) => { return filterGroupsMatchingTerm(groups, term); }, ); @@ -321,3 +345,24 @@ export const isMyGroup: (state: GlobalState, groupId: string) => boolean = creat return isMyGroup; }, ); + +export const getArchivedGroups: (state: GlobalState) => Group[] = createSelector( + 'getArchivedGroups', + (state: GlobalState) => getAllGroups(state), + (state: GlobalState) => getCurrentUserLocale(state), + (allGroups, locale) => { + const groups = Object.entries(allGroups).filter((entry) => { + return entry[1].allow_reference && entry[1].delete_at > 0; + }).map((entry) => entry[1]); + return sortGroups(groups, locale); + }, +); + +export const searchArchivedGroups: (state: GlobalState, term: string) => Group[] = createSelector( + 'searchArchivedGroups', + getArchivedGroups, + (state: GlobalState, term: string) => term, + (groups, term) => { + return filterGroupsMatchingTerm(groups, term); + }, +); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/roles.test.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/roles.test.ts index 08814f7e8c..8a738cbb74 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/roles.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/roles.test.ts @@ -93,7 +93,7 @@ describe('Selectors.Roles', () => { test_channel_b_role2: {permissions: ['channel_b_role2']}, test_channel_c_role1: {permissions: ['channel_c_role1']}, test_channel_c_role2: {permissions: ['channel_c_role2']}, - test_user_role2: {permissions: ['user_role2', Permissions.EDIT_CUSTOM_GROUP, Permissions.CREATE_CUSTOM_GROUP, Permissions.MANAGE_CUSTOM_GROUP_MEMBERS, Permissions.DELETE_CUSTOM_GROUP]}, + test_user_role2: {permissions: ['user_role2', Permissions.EDIT_CUSTOM_GROUP, Permissions.CREATE_CUSTOM_GROUP, Permissions.MANAGE_CUSTOM_GROUP_MEMBERS, Permissions.DELETE_CUSTOM_GROUP, Permissions.RESTORE_CUSTOM_GROUP]}, custom_group_user: {permissions: ['custom_group_user']}, }; @@ -102,6 +102,8 @@ describe('Selectors.Roles', () => { const group3 = TestHelper.fakeGroup('group3', 'custom'); const group4 = TestHelper.fakeGroup('group4', 'custom'); const group5 = TestHelper.fakeGroup('group5'); + const group6 = TestHelper.fakeGroup('group6', 'custom'); + group6.delete_at = 10000; const groups: Record = {}; groups.group1 = group1; @@ -109,6 +111,7 @@ describe('Selectors.Roles', () => { groups.group3 = group3; groups.group4 = group4; groups.group5 = group5; + groups.group6 = group6; const testState = deepFreezeAndThrowOnMutation({ entities: { @@ -164,7 +167,7 @@ describe('Selectors.Roles', () => { test_channel_b_role2: {permissions: ['channel_b_role2']}, test_channel_c_role1: {permissions: ['channel_c_role1']}, test_channel_c_role2: {permissions: ['channel_c_role2']}, - test_user_role2: {permissions: ['user_role2', Permissions.EDIT_CUSTOM_GROUP, Permissions.CREATE_CUSTOM_GROUP, Permissions.MANAGE_CUSTOM_GROUP_MEMBERS, Permissions.DELETE_CUSTOM_GROUP]}, + test_user_role2: {permissions: ['user_role2', Permissions.EDIT_CUSTOM_GROUP, Permissions.CREATE_CUSTOM_GROUP, Permissions.MANAGE_CUSTOM_GROUP_MEMBERS, Permissions.DELETE_CUSTOM_GROUP, Permissions.RESTORE_CUSTOM_GROUP]}, custom_group_user: {permissions: ['custom_group_user']}, }; expect(getRoles(testState)).toEqual(loadedRoles); @@ -172,7 +175,7 @@ describe('Selectors.Roles', () => { it('should return my system permission on getMySystemPermissions', () => { expect(getMySystemPermissions(testState)).toEqual(new Set([ - 'user_role2', Permissions.EDIT_CUSTOM_GROUP, Permissions.CREATE_CUSTOM_GROUP, Permissions.MANAGE_CUSTOM_GROUP_MEMBERS, Permissions.DELETE_CUSTOM_GROUP, + 'user_role2', Permissions.EDIT_CUSTOM_GROUP, Permissions.CREATE_CUSTOM_GROUP, Permissions.MANAGE_CUSTOM_GROUP_MEMBERS, Permissions.DELETE_CUSTOM_GROUP, Permissions.RESTORE_CUSTOM_GROUP, ])); }); @@ -270,15 +273,17 @@ describe('Selectors.Roles', () => { expect(Selectors.haveIGroupPermission(newState, group2.id, Permissions.CREATE_CUSTOM_GROUP)).toEqual(true); expect(Selectors.haveIGroupPermission(newState, group2.id, Permissions.MANAGE_CUSTOM_GROUP_MEMBERS)).toEqual(true); expect(Selectors.haveIGroupPermission(newState, group2.id, Permissions.DELETE_CUSTOM_GROUP)).toEqual(true); + expect(Selectors.haveIGroupPermission(newState, group2.id, Permissions.RESTORE_CUSTOM_GROUP)).toEqual(false); }); it('should return group set with permissions on getGroupListPermissions', () => { expect(Selectors.getGroupListPermissions(testState)).toEqual({ - [group1.id]: {can_delete: true, can_manage_members: true}, - [group2.id]: {can_delete: true, can_manage_members: true}, - [group3.id]: {can_delete: true, can_manage_members: true}, - [group4.id]: {can_delete: true, can_manage_members: true}, - [group5.id]: {can_delete: false, can_manage_members: false}, + [group1.id]: {can_delete: true, can_manage_members: true, can_restore: false}, + [group2.id]: {can_delete: true, can_manage_members: true, can_restore: false}, + [group3.id]: {can_delete: true, can_manage_members: true, can_restore: false}, + [group4.id]: {can_delete: true, can_manage_members: true, can_restore: false}, + [group5.id]: {can_delete: false, can_manage_members: false, can_restore: false}, + [group6.id]: {can_delete: false, can_manage_members: false, can_restore: true}, }); }); }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/roles.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/roles.ts index e742d25c77..3fcb32b199 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/roles.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/roles.ts @@ -60,7 +60,7 @@ export const getGroupListPermissions: (state: GlobalState) => Record state.entities.groups.groups, (myGroupRoles, roles, systemPermissions, allGroups) => { - const groups = Object.entries(allGroups).filter((entry) => (entry[1].allow_reference && entry[1].delete_at === 0)).map((entry) => entry[1]); + const groups = Object.entries(allGroups).filter((entry) => (entry[1].allow_reference)).map((entry) => entry[1]); const permissions = new Set(); groups.forEach((group) => { @@ -83,8 +83,9 @@ export const getGroupListPermissions: (state: GlobalState) => Record = {}; groups.forEach((g) => { groupPermissionsMap[g.id] = { - can_delete: permissions.has(Permissions.DELETE_CUSTOM_GROUP) && g.source.toLowerCase() !== 'ldap', - can_manage_members: permissions.has(Permissions.MANAGE_CUSTOM_GROUP_MEMBERS) && g.source.toLowerCase() !== 'ldap', + can_delete: permissions.has(Permissions.DELETE_CUSTOM_GROUP) && g.source.toLowerCase() !== 'ldap' && g.delete_at === 0, + can_manage_members: permissions.has(Permissions.MANAGE_CUSTOM_GROUP_MEMBERS) && g.source.toLowerCase() !== 'ldap' && g.delete_at === 0, + can_restore: permissions.has(Permissions.RESTORE_CUSTOM_GROUP) && g.source.toLowerCase() !== 'ldap' && g.delete_at !== 0, }; }); return groupPermissionsMap; @@ -175,12 +176,34 @@ export function haveITeamPermission(state: GlobalState, teamId: string, permissi ); } -export function haveIGroupPermission(state: GlobalState, groupID: string, permission: string): boolean { - return ( - getMySystemPermissions(state).has(permission) || - (getMyPermissionsByGroup(state)[groupID] ? getMyPermissionsByGroup(state)[groupID].has(permission) : false) - ); -} +export const haveIGroupPermission: (state: GlobalState, groupID: string, permission: string) => boolean = createSelector( + 'haveIGroupPermission', + getMySystemPermissions, + getMyPermissionsByGroup, + (state: GlobalState, groupID: string) => state.entities.groups.groups[groupID], + (state: GlobalState, groupID: string, permission: string) => permission, + (systemPermissions, permissionGroups, group, permission) => { + if (permission === Permissions.RESTORE_CUSTOM_GROUP) { + if ((group.source !== 'ldap' && group.delete_at !== 0) && (systemPermissions.has(permission) || (permissionGroups[group.id] && permissionGroups[group.id].has(permission)))) { + return true; + } + return false; + } + + if (group.source === 'ldap' || group.delete_at !== 0) { + return false; + } + + if (systemPermissions.has(permission)) { + return true; + } + + if (permissionGroups[group.id] && permissionGroups[group.id].has(permission)) { + return true; + } + return false; + }, +); export function haveIChannelPermission(state: GlobalState, teamId: string, channelId: string, permission: string): boolean { return ( diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/search.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/search.ts index cf13ef3777..a6faa88405 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/search.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/search.ts @@ -20,7 +20,7 @@ export const getCurrentSearchForCurrentTeam: (state: GlobalState) => string = cr export const getAllUserMentionKeys: (state: GlobalState) => UserMentionKey[] = createSelector( 'getAllUserMentionKeys', getCurrentUserMentionKeys, - getMyGroupMentionKeys, + (state: GlobalState) => getMyGroupMentionKeys(state, false), (userMentionKeys, groupMentionKeys) => { return userMentionKeys.concat(groupMentionKeys); }, diff --git a/webapp/channels/src/packages/mattermost-redux/src/utils/group_utils.ts b/webapp/channels/src/packages/mattermost-redux/src/utils/group_utils.ts index 444493ff7b..98badb787f 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/utils/group_utils.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/utils/group_utils.ts @@ -34,6 +34,16 @@ export function filterGroupsMatchingTerm(groups: Group[], term: string): Group[] export function sortGroups(groups: Group[] = [], locale: string = General.DEFAULT_LOCALE): Group[] { return groups.sort((a, b) => { - return a.display_name.localeCompare(b.display_name, locale, {numeric: true}); + if ((a.delete_at === 0 && b.delete_at === 0) || (a.delete_at > 0 && b.delete_at > 0)) { + return a.display_name.localeCompare(b.display_name, locale, {numeric: true}); + } + if (a.delete_at < b.delete_at) { + return -1; + } + if (a.delete_at > b.delete_at) { + return 1; + } + + return 0; }); } diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index d38c888ee7..5d7f09bcde 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -74,8 +74,10 @@ import { UsersWithGroupsAndCount, GroupsWithCount, GroupCreateWithUserIds, - GroupSearachParams, + GroupSearchParams, CustomGroupPatch, + GetGroupsParams, + GetGroupsForUserParams, } from '@mattermost/types/groups'; import {PostActionResponse} from '@mattermost/types/integration_actions'; import { @@ -3502,20 +3504,9 @@ export default class Client4 { ); }; - getGroups = (q = '', filterAllowReference = false, page = 0, perPage = 10, includeMemberCount = false, hasFilterMember = false) => { - const qs: any = { - q, - filter_allow_reference: filterAllowReference, - page, - per_page: perPage, - include_member_count: includeMemberCount, - }; - - if (hasFilterMember) { - qs.filter_has_member = hasFilterMember; - } + getGroups = (opts: GetGroupsForUserParams | GetGroupsParams) => { return this.doFetch( - `${this.getGroupsRoute()}${buildQueryString(qs)}`, + `${this.getGroupsRoute()}${buildQueryString(opts)}`, {method: 'get'}, ); }; @@ -3573,7 +3564,7 @@ export default class Client4 { ); } - searchGroups = (params: GroupSearachParams) => { + searchGroups = (params: GroupSearchParams) => { return this.doFetch( `${this.getGroupsRoute()}${buildQueryString(params)}`, {method: 'get'}, @@ -3676,6 +3667,13 @@ export default class Client4 { ); } + restoreGroup = (groupId: string) => { + return this.doFetch( + `${this.getGroupRoute(groupId)}/restore`, + {method: 'post'}, + ); + } + createGroupTeamsAndChannels = (userID: string) => { return this.doFetch( `${this.getBaseRoute()}/ldap/users/${userID}/group_sync_memberships`, diff --git a/webapp/platform/types/src/groups.ts b/webapp/platform/types/src/groups.ts index 882ea07d3f..f557620b60 100644 --- a/webapp/platform/types/src/groups.ts +++ b/webapp/platform/types/src/groups.ts @@ -150,13 +150,22 @@ export type GroupCreateWithUserIds = { description?: string; } -export type GroupSearachParams = { +export type GetGroupsParams = { + filter_allow_reference?: boolean; + page?: number; + per_page?: number; + include_member_count?: boolean; + include_archived?: boolean; + filter_archived?: boolean; +} + +export type GetGroupsForUserParams = GetGroupsParams & { + filter_has_member: string; +} + +export type GroupSearchParams = GetGroupsParams & { q: string; - filter_allow_reference: boolean; - page: number; - per_page: number; - include_member_count: boolean; - user_id?: string; + filter_has_member?: string; include_timezones?: string; include_channel_member_count?: string; } @@ -169,4 +178,5 @@ export type GroupMembership = { export type GroupPermissions = { can_delete: boolean; can_manage_members: boolean; + can_restore: boolean; }