Restore previously archived groups (#22597)

* add ability to restore groups from the user group modal

* factory selector for groups to reduce number of renders across the app

* react window and infinite scroll for user groups

* adding archive groups to dropdown

* restore user group from the view modal

* component cleanup

* lint

* adding websocket for archiveGroup

* updating tests

* adding some tests and fixing types

* lint

* fixing broken test

* fixing snapshot

* fixing infinitescroll

* lint

* increasing max-height and updating snapshots

* fixing PR comments

* fixing case for button

* snapshot and translation

* fixing PR comments

* tiding up repition and creating new hook

* fixing tests

* add additional parammeter for call to getGroups()

* make sure popup is visible for all rows

* update text for admin console

* update css for lint

* fix edge cases found in review

* revert package-lock.json

* revert adding query to GetGroupsParam

* fixing lint

* change include_archived to false in team_controller

---------

Co-authored-by: Benjamin Cooke <benjamincooke@Benjamins-MacBook-Pro.local>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Ben Cooke 2023-08-31 10:07:51 -04:00 committed by GitHub
parent 32512d35fb
commit 2c6179a0a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1031 additions and 1019 deletions

View File

@ -986,14 +986,19 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) {
includeTimezones := r.URL.Query().Get("include_timezones") == "true" includeTimezones := r.URL.Query().Get("include_timezones") == "true"
// Include archived groups
includeArchived := r.URL.Query().Get("include_archived") == "true"
opts := model.GroupSearchOpts{ opts := model.GroupSearchOpts{
Q: c.Params.Q, Q: c.Params.Q,
IncludeMemberCount: c.Params.IncludeMemberCount, IncludeMemberCount: c.Params.IncludeMemberCount,
FilterAllowReference: c.Params.FilterAllowReference, FilterAllowReference: c.Params.FilterAllowReference,
FilterArchived: c.Params.FilterArchived,
FilterParentTeamPermitted: c.Params.FilterParentTeamPermitted, FilterParentTeamPermitted: c.Params.FilterParentTeamPermitted,
Source: source, Source: source,
FilterHasMember: c.Params.FilterHasMember, FilterHasMember: c.Params.FilterHasMember,
IncludeTimezones: includeTimezones, IncludeTimezones: includeTimezones,
IncludeArchived: includeArchived,
} }
if teamID != "" { if teamID != "" {
@ -1145,15 +1150,19 @@ func deleteGroup(c *Context, w http.ResponseWriter, r *http.Request) {
defer c.LogAuditRec(auditRec) defer c.LogAuditRec(auditRec)
audit.AddEventParameter(auditRec, "group_id", c.Params.GroupId) 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 { if err != nil {
c.Err = err c.Err = err
return 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() auditRec.Success()
w.Write(b)
ReturnStatusOK(w)
} }
func restoreGroup(c *Context, w http.ResponseWriter, r *http.Request) { 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) defer c.LogAuditRec(auditRec)
audit.AddEventParameter(auditRec, "group_id", c.Params.GroupId) 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 { if err != nil {
c.Err = err c.Err = err
return 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) { 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 { 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 return
} }
appErr = licensedAndConfiguredForGroupBySource(c.App, model.GroupSourceCustom) appErr = licensedAndConfiguredForGroupBySource(c.App, model.GroupSourceCustom)
if appErr != nil { if appErr != nil {
appErr.Where = "Api4.deleteGroup" appErr.Where = "Api4.addGroupMembers"
c.Err = appErr c.Err = appErr
return return
} }
@ -1282,13 +1296,13 @@ func deleteGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) {
} }
if group.Source != model.GroupSourceCustom { 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 return
} }
appErr = licensedAndConfiguredForGroupBySource(c.App, model.GroupSourceCustom) appErr = licensedAndConfiguredForGroupBySource(c.App, model.GroupSourceCustom)
if appErr != nil { if appErr != nil {
appErr.Where = "Api4.deleteGroup" appErr.Where = "Api4.deleteGroupMembers"
c.Err = appErr c.Err = appErr
return return
} }

View File

@ -1291,6 +1291,21 @@ func TestGetGroups(t *testing.T) {
// make sure it returned th.Group,not group // make sure it returned th.Group,not group
assert.Equal(t, groups[0].Id, th.Group.Id) 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 opts.Source = model.GroupSourceCustom
groups, _, err = th.Client.GetGroups(context.Background(), opts) groups, _, err = th.Client.GetGroups(context.Background(), opts)
assert.NoError(t, err) assert.NoError(t, err)

View File

@ -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 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 return restoredGroup, nil
} }

View File

@ -384,9 +384,11 @@ func (s *SqlGroupStore) Delete(groupID string) (*model.Group, error) {
} }
time := model.GetMillis() time := model.GetMillis()
group.DeleteAt = time
group.UpdateAt = time
if _, err := s.GetMasterX().Exec(`UPDATE UserGroups if _, err := s.GetMasterX().Exec(`UPDATE UserGroups
SET DeleteAt=?, UpdateAt=? 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) 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) 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 if _, err := s.GetMasterX().Exec(`UPDATE UserGroups
SET DeleteAt=0, UpdateAt=? 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) 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. groupsQuery = groupsQuery.
From("UserGroups g"). From("UserGroups g")
OrderBy("g.DisplayName")
if opts.Since > 0 { if opts.Since > 0 {
groupsQuery = groupsQuery.Where(sq.Gt{ groupsQuery = groupsQuery.Where(sq.Gt{
"g.UpdateAt": opts.Since, "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") 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 { if perPage != 0 {
groupsQuery = groupsQuery. groupsQuery = groupsQuery.
Limit(uint64(perPage)). Limit(uint64(perPage)).

View File

@ -3962,6 +3962,26 @@ func testGetGroups(t *testing.T, ss store.Store) {
}, },
Restrictions: nil, 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 { for _, tc := range testCases {

View File

@ -83,6 +83,7 @@ type Params struct {
IncludeTotalCount bool IncludeTotalCount bool
IncludeDeleted bool IncludeDeleted bool
FilterAllowReference bool FilterAllowReference bool
FilterArchived bool
FilterParentTeamPermitted bool FilterParentTeamPermitted bool
CategoryId string CategoryId string
WarnMetricId string WarnMetricId string
@ -208,6 +209,7 @@ func ParamsFromRequest(r *http.Request) *Params {
params.NotAssociatedToTeam = query.Get("not_associated_to_team") params.NotAssociatedToTeam = query.Get("not_associated_to_team")
params.NotAssociatedToChannel = query.Get("not_associated_to_channel") params.NotAssociatedToChannel = query.Get("not_associated_to_channel")
params.FilterAllowReference, _ = strconv.ParseBool(query.Get("filter_allow_reference")) 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.FilterParentTeamPermitted, _ = strconv.ParseBool(query.Get("filter_parent_team_permitted"))
params.IncludeChannelMemberCount = query.Get("include_channel_member_count") params.IncludeChannelMemberCount = query.Get("include_channel_member_count")

View File

@ -5510,7 +5510,7 @@ func (c *Client4) GetGroupsAssociatedToChannelsByTeam(ctx context.Context, teamI
// GetGroups retrieves Mattermost Groups // GetGroups retrieves Mattermost Groups
func (c *Client4) GetGroups(ctx context.Context, opts GroupSearchOpts) ([]*Group, *Response, error) { func (c *Client4) GetGroups(ctx context.Context, opts GroupSearchOpts) ([]*Group, *Response, error) {
path := fmt.Sprintf( path := fmt.Sprintf(
"%s?include_member_count=%v&not_associated_to_team=%v&not_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&not_associated_to_team=%v&not_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(), c.groupsRoute(),
opts.IncludeMemberCount, opts.IncludeMemberCount,
opts.NotAssociatedToTeam, opts.NotAssociatedToTeam,
@ -5521,6 +5521,8 @@ func (c *Client4) GetGroups(ctx context.Context, opts GroupSearchOpts) ([]*Group
opts.Source, opts.Source,
opts.IncludeChannelMemberCount, opts.IncludeChannelMemberCount,
opts.IncludeTimezones, opts.IncludeTimezones,
opts.IncludeArchived,
opts.FilterArchived,
) )
if opts.Since > 0 { if opts.Since > 0 {
path = fmt.Sprintf("%s&since=%v", path, opts.Since) path = fmt.Sprintf("%s&since=%v", path, opts.Since)

View File

@ -133,6 +133,12 @@ type GroupSearchOpts struct {
IncludeChannelMemberCount string IncludeChannelMemberCount string
IncludeTimezones bool IncludeTimezones bool
// Include archived groups
IncludeArchived bool
// Only return archived groups
FilterArchived bool
} }
type GetGroupOpts struct { type GetGroupOpts struct {

View File

@ -117,10 +117,13 @@ exports[`components/global/product_switcher_menu should match snapshot with id 1
} }
dialogType={ dialogType={
Object { Object {
"$$typeof": Symbol(react.forward_ref),
"WrappedComponent": Object {
"$$typeof": Symbol(react.memo), "$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null, "compare": null,
"type": [Function], "type": [Function],
},
"render": [Function],
} }
} }
disabled={false} disabled={false}
@ -312,10 +315,13 @@ exports[`components/global/product_switcher_menu should match snapshot with most
} }
dialogType={ dialogType={
Object { Object {
"$$typeof": Symbol(react.forward_ref),
"WrappedComponent": Object {
"$$typeof": Symbol(react.memo), "$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null, "compare": null,
"type": [Function], "type": [Function],
},
"render": [Function],
} }
} }
disabled={false} disabled={false}
@ -399,10 +405,13 @@ exports[`components/global/product_switcher_menu should match userGroups snapsho
} }
dialogType={ dialogType={
Object { Object {
"$$typeof": Symbol(react.forward_ref),
"WrappedComponent": Object {
"$$typeof": Symbol(react.memo), "$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null, "compare": null,
"type": [Function], "type": [Function],
},
"render": [Function],
} }
} }
disabled={false} disabled={false}
@ -428,10 +437,13 @@ exports[`components/global/product_switcher_menu should match userGroups snapsho
} }
dialogType={ dialogType={
Object { Object {
"$$typeof": Symbol(react.forward_ref),
"WrappedComponent": Object {
"$$typeof": Symbol(react.memo), "$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null, "compare": null,
"type": [Function], "type": [Function],
},
"render": [Function],
} }
} }
disabled={true} disabled={true}
@ -470,10 +482,13 @@ exports[`components/global/product_switcher_menu should match userGroups snapsho
} }
dialogType={ dialogType={
Object { Object {
"$$typeof": Symbol(react.forward_ref),
"WrappedComponent": Object {
"$$typeof": Symbol(react.memo), "$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null, "compare": null,
"type": [Function], "type": [Function],
},
"render": [Function],
} }
} }
disabled={false} disabled={false}
@ -624,10 +639,13 @@ exports[`components/global/product_switcher_menu should show integrations should
} }
dialogType={ dialogType={
Object { Object {
"$$typeof": Symbol(react.forward_ref),
"WrappedComponent": Object {
"$$typeof": Symbol(react.memo), "$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null, "compare": null,
"type": [Function], "type": [Function],
},
"render": [Function],
} }
} }
disabled={false} disabled={false}

View File

@ -37,6 +37,7 @@ const iconMap: {[key in NoResultsVariant]: React.ReactNode } = {
[NoResultsVariant.ChannelFilesFiltered]: <i className='icon icon-file-text-outline no-results__icon'/>, [NoResultsVariant.ChannelFilesFiltered]: <i className='icon icon-file-text-outline no-results__icon'/>,
[NoResultsVariant.UserGroups]: <i className='icon icon-account-multiple-outline no-results__icon'/>, [NoResultsVariant.UserGroups]: <i className='icon icon-account-multiple-outline no-results__icon'/>,
[NoResultsVariant.UserGroupMembers]: <i className='icon icon-account-outline no-results__icon'/>, [NoResultsVariant.UserGroupMembers]: <i className='icon icon-account-outline no-results__icon'/>,
[NoResultsVariant.UserGroupsArchived]: <i className='icon icon-account-multiple-outline no-results__icon'/>,
}; };
const titleMap: {[key in NoResultsVariant]: MessageDescriptor} = { const titleMap: {[key in NoResultsVariant]: MessageDescriptor} = {
@ -64,6 +65,9 @@ const titleMap: {[key in NoResultsVariant]: MessageDescriptor} = {
[NoResultsVariant.UserGroupMembers]: { [NoResultsVariant.UserGroupMembers]: {
id: t('no_results.user_group_members.title'), id: t('no_results.user_group_members.title'),
}, },
[NoResultsVariant.UserGroupsArchived]: {
id: t('no_results.user_groups.archived.title'),
},
}; };
const subtitleMap: {[key in NoResultsVariant]: MessageDescriptor} = { const subtitleMap: {[key in NoResultsVariant]: MessageDescriptor} = {
@ -91,6 +95,9 @@ const subtitleMap: {[key in NoResultsVariant]: MessageDescriptor} = {
[NoResultsVariant.UserGroupMembers]: { [NoResultsVariant.UserGroupMembers]: {
id: t('no_results.user_group_members.subtitle'), id: t('no_results.user_group_members.subtitle'),
}, },
[NoResultsVariant.UserGroupsArchived]: {
id: t('no_results.user_groups.archived.subtitle'),
},
}; };
import './no_results_indicator.scss'; import './no_results_indicator.scss';

View File

@ -9,6 +9,7 @@ export enum NoResultsVariant {
ChannelFiles = 'ChannelFiles', ChannelFiles = 'ChannelFiles',
ChannelFilesFiltered = 'ChannelFilesFiltered', ChannelFilesFiltered = 'ChannelFilesFiltered',
UserGroups = 'UserGroups', UserGroups = 'UserGroups',
UserGroupsArchived = 'UserGroupsArchived',
UserGroupMembers = 'UserGroupMembers', UserGroupMembers = 'UserGroupMembers',
} }

View File

@ -37,7 +37,7 @@ export function makeGetMentionKeysForPost(): (
getCurrentUserMentionKeys, getCurrentUserMentionKeys,
(state: GlobalState, post?: Post) => post, (state: GlobalState, post?: Post) => post,
(state: GlobalState, post?: Post, channel?: Channel) => (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) => { (mentionKeysWithoutGroups, post, groupMentionKeys) => {
let mentionKeys = mentionKeysWithoutGroups; let mentionKeys = mentionKeysWithoutGroups;
if (!post?.props?.disable_group_highlight) { if (!post?.props?.disable_group_highlight) {

View File

@ -21,6 +21,7 @@ import LocalStorageStore from 'stores/local_storage_store';
import {Team} from '@mattermost/types/teams'; import {Team} from '@mattermost/types/teams';
import {ServerError} from '@mattermost/types/errors'; import {ServerError} from '@mattermost/types/errors';
import {GetGroupsForUserParams, GetGroupsParams} from '@mattermost/types/groups';
export function initializeTeam(team: Team): ActionFunc<Team, ServerError> { export function initializeTeam(team: Team): ActionFunc<Team, ServerError> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
@ -50,8 +51,20 @@ export function initializeTeam(team: Team): ActionFunc<Team, ServerError> {
if (license && if (license &&
license.IsLicensed === 'true' && license.IsLicensed === 'true' &&
(license.LDAPGroups === 'true' || customGroupEnabled)) { (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) { if (currentUser) {
dispatch(getGroupsByUserIdPaginated(currentUser.id, false, 0, 60, true)); dispatch(getGroupsByUserIdPaginated(myGroupsParams));
} }
if (license.LDAPGroups === 'true') { if (license.LDAPGroups === 'true') {
@ -61,7 +74,7 @@ export function initializeTeam(team: Team): ActionFunc<Team, ServerError> {
if (team.group_constrained && license.LDAPGroups === 'true') { if (team.group_constrained && license.LDAPGroups === 'true') {
dispatch(getAllGroupsAssociatedToTeam(team.id, true)); dispatch(getAllGroupsAssociatedToTeam(team.id, true));
} else { } else {
dispatch(getGroups('', false, 0, 60, true)); dispatch(getGroups(groupsParams));
} }
} }

View File

@ -56,49 +56,10 @@ exports[`component/user_groups_modal should match snapshot with groups 1`] = `
value="" value=""
/> />
</div> </div>
<div <Memo(UserGroupsFilter)
className="more-modal__dropdown" getGroups={[Function]}
> selectedFilter="all"
<MenuWrapper
animationComponent={[Function]}
className=""
id="groupsFilterDropdown"
>
<a>
<span>
Show: All Groups
</span>
<span
className="icon icon-chevron-down"
/> />
</a>
<Menu
ariaLabel="Groups Filter Menu"
openLeft={false}
>
<MenuItemAction
buttonClass="groups-filter-btn"
id="groupsDropdownAll"
onClick={[Function]}
rightDecorator={
<i
className="icon icon-check"
/>
}
show={true}
text="All Groups"
/>
<MenuItemAction
buttonClass="groups-filter-btn"
id="groupsDropdownMy"
onClick={[Function]}
rightDecorator={false}
show={true}
text="My Groups"
/>
</Menu>
</MenuWrapper>
</div>
<Connect(Component) <Connect(Component)
backButtonAction={[MockFunction]} backButtonAction={[MockFunction]}
groups={ groups={
@ -150,16 +111,17 @@ exports[`component/user_groups_modal should match snapshot with groups 1`] = `
}, },
] ]
} }
loading={true} hasNextPage={true}
loadMoreGroups={[Function]}
loading={false}
onExited={[MockFunction]} onExited={[MockFunction]}
onScroll={[Function]}
searchTerm="" searchTerm=""
/> />
</ModalBody> </ModalBody>
</Modal> </Modal>
`; `;
exports[`component/user_groups_modal should match snapshot with groups, myGroups selected 1`] = ` exports[`component/user_groups_modal should match snapshot without groups 1`] = `
<Modal <Modal
animation={true} animation={true}
aria-labelledby="userGroupsModalLabel" aria-labelledby="userGroupsModalLabel"
@ -215,122 +177,14 @@ exports[`component/user_groups_modal should match snapshot with groups, myGroups
value="" value=""
/> />
</div> </div>
<div <Memo(UserGroupsFilter)
className="more-modal__dropdown" getGroups={[Function]}
> selectedFilter="all"
<MenuWrapper
animationComponent={[Function]}
className=""
id="groupsFilterDropdown"
>
<a>
<span>
Show: My Groups
</span>
<span
className="icon icon-chevron-down"
/> />
</a>
<Menu
ariaLabel="Groups Filter Menu"
openLeft={false}
>
<MenuItemAction
buttonClass="groups-filter-btn"
id="groupsDropdownAll"
onClick={[Function]}
rightDecorator={false}
show={true}
text="All Groups"
/>
<MenuItemAction
buttonClass="groups-filter-btn"
id="groupsDropdownMy"
onClick={[Function]}
rightDecorator={
<i
className="icon icon-check"
/>
}
show={true}
text="My Groups"
/>
</Menu>
</MenuWrapper>
</div>
<Connect(Component)
backButtonAction={[MockFunction]}
groups={
Array [
Object {
"allow_reference": true,
"create_at": 1637349374137,
"delete_at": 0,
"description": "Group 0 description",
"display_name": "Group 0",
"has_syncables": false,
"id": "group0",
"member_count": 1,
"name": "group0",
"remote_id": null,
"scheme_admin": false,
"source": "custom",
"update_at": 1637349374137,
},
]
}
loading={true}
onExited={[MockFunction]}
onScroll={[Function]}
searchTerm=""
/>
</ModalBody>
</Modal>
`;
exports[`component/user_groups_modal should match snapshot without groups 1`] = `
<Modal
animation={true}
aria-labelledby="userGroupsModalLabel"
autoFocus={true}
backdrop={true}
bsClass="modal"
dialogClassName="a11y__modal user-groups-modal"
dialogComponentClass={[Function]}
enforceFocus={true}
id="userGroupsModal"
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="dialog"
show={true}
>
<Connect(Component)
backButtonAction={[MockFunction]}
onExited={[MockFunction]}
/>
<ModalBody
bsClass="modal-body"
componentClass="div"
>
<NoResultsIndicator <NoResultsIndicator
variant="UserGroups" variant="UserGroups"
/> />
<ADLDAPUpsellBanner /> <Memo(ADLDAPUpsellBanner) />
</ModalBody> </ModalBody>
</Modal> </Modal>
`; `;

View File

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // 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 {useDispatch, useSelector} from 'react-redux';
import moment from 'moment'; import moment from 'moment';
import {useIntl} from 'react-intl'; import {useIntl} from 'react-intl';
@ -146,4 +146,4 @@ function ADLDAPUpsellBanner() {
); );
} }
export default ADLDAPUpsellBanner; export default memo(ADLDAPUpsellBanner);

View File

@ -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,
];
}

View File

@ -9,9 +9,9 @@ import {ActionFunc, GenericAction} from 'mattermost-redux/types/actions';
import {GlobalState} from 'types/store'; import {GlobalState} from 'types/store';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; 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 {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 {ModalIdentifiers} from 'utils/constants';
import {isModalOpen} from 'selectors/views/modals'; import {isModalOpen} from 'selectors/views/modals';
import {setModalSearchTerm} from 'actions/views/search'; import {setModalSearchTerm} from 'actions/views/search';
@ -20,35 +20,35 @@ import UserGroupsModal from './user_groups_modal';
type Actions = { type Actions = {
getGroups: ( getGroups: (
filterAllowReference?: boolean, groupsParams: GetGroupsParams,
page?: number,
perPage?: number,
includeMemberCount?: boolean
) => Promise<{data: Group[]}>; ) => Promise<{data: Group[]}>;
setModalSearchTerm: (term: string) => void; setModalSearchTerm: (term: string) => void;
getGroupsByUserIdPaginated: ( getGroupsByUserIdPaginated: (
userId: string, opts: GetGroupsForUserParams,
filterAllowReference?: boolean,
page?: number,
perPage?: number,
includeMemberCount?: boolean
) => Promise<{data: Group[]}>; ) => Promise<{data: Group[]}>;
searchGroups: ( searchGroups: (
params: GroupSearachParams, params: GroupSearchParams,
) => Promise<{data: Group[]}>; ) => Promise<{data: Group[]}>;
}; };
function mapStateToProps(state: GlobalState) { function makeMapStateToProps() {
const getAllAssociatedGroupsForReference = makeGetAllAssociatedGroupsForReference();
const getMyAllowReferencedGroups = makeGetMyAllowReferencedGroups();
return function mapStateToProps(state: GlobalState) {
const searchTerm = state.views.search.modalSearch; const searchTerm = state.views.search.modalSearch;
let groups: Group[] = []; let groups: Group[] = [];
let myGroups: Group[] = []; let myGroups: Group[] = [];
let archivedGroups: Group[] = [];
if (searchTerm) { if (searchTerm) {
groups = searchAllowReferencedGroups(state, searchTerm); groups = searchAllowReferencedGroups(state, searchTerm, true);
myGroups = searchMyAllowReferencedGroups(state, searchTerm); myGroups = searchMyAllowReferencedGroups(state, searchTerm, true);
archivedGroups = searchArchivedGroups(state, searchTerm);
} else { } else {
groups = getAllAssociatedGroupsForReference(state); groups = getAllAssociatedGroupsForReference(state, true);
myGroups = getMyAllowReferencedGroups(state); myGroups = getMyAllowReferencedGroups(state, true);
archivedGroups = getArchivedGroups(state);
} }
return { return {
@ -56,8 +56,10 @@ function mapStateToProps(state: GlobalState) {
groups, groups,
searchTerm, searchTerm,
myGroups, myGroups,
archivedGroups,
currentUserId: getCurrentUserId(state), currentUserId: getCurrentUserId(state),
}; };
};
} }
function mapDispatchToProps(dispatch: Dispatch) { function mapDispatchToProps(dispatch: Dispatch) {
@ -71,4 +73,4 @@ function mapDispatchToProps(dispatch: Dispatch) {
}; };
} }
export default connect(mapStateToProps, mapDispatchToProps)(UserGroupsModal); export default connect(makeMapStateToProps, mapDispatchToProps, null, {forwardRef: true})(UserGroupsModal);

View File

@ -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 (
<div className='more-modal__dropdown'>
<MenuWrapper id='groupsFilterDropdown'>
<a>
<span>{filterLabel()}</span>
<span className='icon icon-chevron-down'/>
</a>
<Menu
openLeft={false}
ariaLabel={intl.formatMessage({id: 'user_groups_modal.filterAriaLabel', defaultMessage: 'Groups Filter Menu'})}
>
<Menu.Group>
<Menu.ItemAction
id='groupsDropdownAll'
buttonClass='groups-filter-btn'
onClick={allGroupsOnClick}
text={intl.formatMessage({id: 'user_groups_modal.allGroups', defaultMessage: 'All Groups'})}
rightDecorator={selectedFilter === 'all' && <i className='icon icon-check'/>}
/>
<Menu.ItemAction
id='groupsDropdownMy'
buttonClass='groups-filter-btn'
onClick={myGroupsOnClick}
text={intl.formatMessage({id: 'user_groups_modal.myGroups', defaultMessage: 'My Groups'})}
rightDecorator={selectedFilter === 'my' && <i className='icon icon-check'/>}
/>
</Menu.Group>
<Menu.Group>
<Menu.ItemAction
id='groupsDropdownArchived'
buttonClass='groups-filter-btn'
onClick={archivedGroupsOnClick}
text={intl.formatMessage({id: 'user_groups_modal.archivedGroups', defaultMessage: 'Archived Groups'})}
rightDecorator={selectedFilter === 'archived' && <i className='icon icon-check'/>}
/>
</Menu.Group>
</Menu>
</MenuWrapper>
</div>
);
};
export default React.memo(UserGroupsFilter);

View File

@ -3,276 +3,39 @@
exports[`component/user_groups_modal should match snapshot with groups 1`] = ` exports[`component/user_groups_modal should match snapshot with groups 1`] = `
<div <div
className="user-groups-modal__content user-groups-list" className="user-groups-modal__content user-groups-list"
onScroll={[MockFunction]}
style={ style={
Object { Object {
"overflow": "overlay", "overflow": "overlay",
} }
} }
> >
<div <InfiniteLoader
className="group-row" isItemLoaded={[Function]}
key="group0" itemCount={100000}
onClick={[Function]} loadMoreItems={[MockFunction]}
> >
<span <Component />
className="group-display-name" </InfiniteLoader>
> <Memo(ADLDAPUpsellBanner) />
Group 0
</span>
<span
className="group-name"
>
@
group0
</span>
<div
className="group-member-count"
>
<MemoizedFormattedMessage
defaultMessage="{member_count} {member_count, plural, one {member} other {members}}"
id="user_groups_modal.memberCount"
values={
Object {
"member_count": 1,
}
}
/>
</div>
<div
className="group-action"
>
<MenuWrapper
animationComponent={[Function]}
className=""
id="customWrapper-group0"
isDisabled={false}
stopPropagationOnToggle={true}
>
<button
className="action-wrapper"
>
<i
className="icon icon-dots-vertical"
/>
</button>
<Menu
ariaLabel="User Actions Menu"
className="group-actions-menu"
openLeft={true}
openUp={false}
>
<MenuGroup>
<MenuItemAction
disabled={false}
icon={
<i
className="icon-account-multiple-outline"
/>
}
onClick={[Function]}
show={true}
text="View Group"
/>
</MenuGroup>
<MenuGroup>
<MenuItemAction
disabled={false}
icon={
<i
className="icon-archive-outline"
/>
}
isDangerous={true}
onClick={[Function]}
show={true}
text="Archive Group"
/>
</MenuGroup>
</Menu>
</MenuWrapper>
</div>
</div>
<div
className="group-row"
key="group1"
onClick={[Function]}
>
<span
className="group-display-name"
>
Group 1
</span>
<span
className="group-name"
>
@
group1
</span>
<div
className="group-member-count"
>
<MemoizedFormattedMessage
defaultMessage="{member_count} {member_count, plural, one {member} other {members}}"
id="user_groups_modal.memberCount"
values={
Object {
"member_count": 2,
}
}
/>
</div>
<div
className="group-action"
>
<MenuWrapper
animationComponent={[Function]}
className=""
id="customWrapper-group1"
isDisabled={false}
stopPropagationOnToggle={true}
>
<button
className="action-wrapper"
>
<i
className="icon icon-dots-vertical"
/>
</button>
<Menu
ariaLabel="User Actions Menu"
className="group-actions-menu"
openLeft={true}
openUp={true}
>
<MenuGroup>
<MenuItemAction
disabled={false}
icon={
<i
className="icon-account-multiple-outline"
/>
}
onClick={[Function]}
show={true}
text="View Group"
/>
</MenuGroup>
<MenuGroup>
<MenuItemAction
disabled={false}
icon={
<i
className="icon-archive-outline"
/>
}
isDangerous={true}
onClick={[Function]}
show={true}
text="Archive Group"
/>
</MenuGroup>
</Menu>
</MenuWrapper>
</div>
</div>
<div
className="group-row"
key="group2"
onClick={[Function]}
>
<span
className="group-display-name"
>
Group 2
</span>
<span
className="group-name"
>
@
group2
</span>
<div
className="group-member-count"
>
<MemoizedFormattedMessage
defaultMessage="{member_count} {member_count, plural, one {member} other {members}}"
id="user_groups_modal.memberCount"
values={
Object {
"member_count": 3,
}
}
/>
</div>
<div
className="group-action"
>
<MenuWrapper
animationComponent={[Function]}
className=""
id="customWrapper-group2"
isDisabled={false}
stopPropagationOnToggle={true}
>
<button
className="action-wrapper"
>
<i
className="icon icon-dots-vertical"
/>
</button>
<Menu
ariaLabel="User Actions Menu"
className="group-actions-menu"
openLeft={true}
openUp={true}
>
<MenuGroup>
<MenuItemAction
disabled={false}
icon={
<i
className="icon-account-multiple-outline"
/>
}
onClick={[Function]}
show={true}
text="View Group"
/>
</MenuGroup>
<MenuGroup>
<MenuItemAction
disabled={false}
icon={
<i
className="icon-archive-outline"
/>
}
isDangerous={true}
onClick={[Function]}
show={true}
text="Archive Group"
/>
</MenuGroup>
</Menu>
</MenuWrapper>
</div>
</div>
<ADLDAPUpsellBanner />
</div> </div>
`; `;
exports[`component/user_groups_modal should match snapshot without groups 1`] = ` exports[`component/user_groups_modal should match snapshot without groups 1`] = `
<div <div
className="user-groups-modal__content user-groups-list" className="user-groups-modal__content user-groups-list"
onScroll={[MockFunction]}
style={ style={
Object { Object {
"overflow": "overlay", "overflow": "overlay",
} }
} }
> >
<ADLDAPUpsellBanner /> <InfiniteLoader
isItemLoaded={[Function]}
itemCount={100000}
loadMoreItems={[MockFunction]}
>
<Component />
</InfiniteLoader>
<Memo(ADLDAPUpsellBanner) />
</div> </div>
`; `;

View File

@ -8,7 +8,7 @@ import {ActionFunc, ActionResult, GenericAction} from 'mattermost-redux/types/ac
import {GlobalState} from 'types/store'; 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 {ModalData} from 'types/actions';
import {openModal} from 'actions/views/modals'; import {openModal} from 'actions/views/modals';
import {getGroupListPermissions} from 'mattermost-redux/selectors/entities/roles'; import {getGroupListPermissions} from 'mattermost-redux/selectors/entities/roles';
@ -18,6 +18,7 @@ import UserGroupsList from './user_groups_list';
type Actions = { type Actions = {
openModal: <P>(modalData: ModalData<P>) => void; openModal: <P>(modalData: ModalData<P>) => void;
archiveGroup: (groupId: string) => Promise<ActionResult>; archiveGroup: (groupId: string) => Promise<ActionResult>;
restoreGroup: (groupId: string) => Promise<ActionResult>;
}; };
function mapStateToProps(state: GlobalState) { function mapStateToProps(state: GlobalState) {
@ -32,6 +33,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc | GenericAction>, Actions>({ actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc | GenericAction>, Actions>({
openModal, openModal,
archiveGroup, archiveGroup,
restoreGroup,
}, dispatch), }, dispatch),
}; };
} }

View File

@ -18,9 +18,12 @@ describe('component/user_groups_modal', () => {
backButtonAction: jest.fn(), backButtonAction: jest.fn(),
groupPermissionsMap: {}, groupPermissionsMap: {},
loading: false, loading: false,
loadMoreGroups: jest.fn(),
hasNextPage: false,
actions: { actions: {
openModal: jest.fn(), openModal: jest.fn(),
archiveGroup: jest.fn(), archiveGroup: jest.fn(),
restoreGroup: jest.fn(),
}, },
}; };
@ -53,6 +56,7 @@ describe('component/user_groups_modal', () => {
groupPermissionsMap[g.id] = { groupPermissionsMap[g.id] = {
can_delete: true, can_delete: true,
can_manage_members: true, can_manage_members: true,
can_restore: true,
}; };
}); });

View File

@ -1,9 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // 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 {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 NoResultsIndicator from 'components/no_results_indicator';
import {NoResultsVariant} from 'components/no_results_indicator/types'; import {NoResultsVariant} from 'components/no_results_indicator/types';
@ -25,27 +27,33 @@ export type Props = {
searchTerm: string; searchTerm: string;
loading: boolean; loading: boolean;
groupPermissionsMap: Record<string, GroupPermissions>; groupPermissionsMap: Record<string, GroupPermissions>;
onScroll: () => void; loadMoreGroups: () => void;
onExited: () => void; onExited: () => void;
backButtonAction: () => void; backButtonAction: () => void;
hasNextPage: boolean;
actions: { actions: {
archiveGroup: (groupId: string) => Promise<ActionResult>; archiveGroup: (groupId: string) => Promise<ActionResult>;
restoreGroup: (groupId: string) => Promise<ActionResult>;
openModal: <P>(modalData: ModalData<P>) => void; openModal: <P>(modalData: ModalData<P>) => void;
}; };
} }
const UserGroupsList = React.forwardRef((props: Props, ref?: React.Ref<HTMLDivElement>) => { const UserGroupsList = (props: Props) => {
const { const {
groups, groups,
searchTerm, searchTerm,
loading, loading,
groupPermissionsMap, groupPermissionsMap,
onScroll, hasNextPage,
loadMoreGroups,
backButtonAction, backButtonAction,
onExited, onExited,
actions, actions,
} = props; } = props;
const infiniteLoaderRef = useRef<InfiniteLoader | null>(null);
const variableSizeListRef = useRef<VariableSizeList | null>(null);
const [hasMounted, setHasMounted] = useState(false);
const [overflowState, setOverflowState] = useState('overlay'); const [overflowState, setOverflowState] = useState('overlay');
useEffect(() => { useEffect(() => {
@ -54,10 +62,34 @@ const UserGroupsList = React.forwardRef((props: Props, ref?: React.Ref<HTMLDivEl
} }
}, [groups]); }, [groups]);
useEffect(() => {
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) => { const archiveGroup = useCallback(async (groupId: string) => {
await actions.archiveGroup(groupId); await actions.archiveGroup(groupId);
}, [actions.archiveGroup]); }, [actions.archiveGroup]);
const restoreGroup = useCallback(async (groupId: string) => {
await actions.restoreGroup(groupId);
}, [actions.restoreGroup]);
const goToViewGroupModal = useCallback((group: Group) => { const goToViewGroupModal = useCallback((group: Group) => {
actions.openModal({ actions.openModal({
modalId: ModalIdentifiers.VIEW_USER_GROUP, modalId: ModalIdentifiers.VIEW_USER_GROUP,
@ -74,36 +106,41 @@ const UserGroupsList = React.forwardRef((props: Props, ref?: React.Ref<HTMLDivEl
}, [actions.openModal, onExited, backButtonAction]); }, [actions.openModal, onExited, backButtonAction]);
const groupListOpenUp = (groupListItemIndex: number): boolean => { const groupListOpenUp = (groupListItemIndex: number): boolean => {
if (groups.length > 1 && groupListItemIndex === 0) { if (groupListItemIndex === 0) {
return false; return false;
} }
return true; return true;
}; };
const Item = ({index, style}: ListChildComponentProps) => {
if (groups.length === 0 && searchTerm) {
return ( return (
<div
className='user-groups-modal__content user-groups-list'
onScroll={onScroll}
ref={ref}
style={{overflow: overflowState}}
>
{(groups.length === 0 && searchTerm) &&
<NoResultsIndicator <NoResultsIndicator
variant={NoResultsVariant.ChannelSearch} variant={NoResultsVariant.ChannelSearch}
titleValues={{channelName: `"${searchTerm}"`}} titleValues={{channelName: `"${searchTerm}"`}}
/> />
);
} }
{groups.map((group, i) => { if (isItemLoaded(index)) {
const group = groups[index] as Group;
if (!group) {
return null;
}
return ( return (
<div <div
className='group-row' className='group-row'
style={style}
key={group.id} key={group.id}
onClick={() => { onClick={() => {
goToViewGroupModal(group); goToViewGroupModal(group);
}} }}
> >
<span className='group-display-name'> <span className='group-display-name'>
{
group.delete_at > 0 &&
<i className='icon icon-archive-outline'/>
}
{group.display_name} {group.display_name}
</span> </span>
<span className='group-name'> <span className='group-name'>
@ -129,7 +166,7 @@ const UserGroupsList = React.forwardRef((props: Props, ref?: React.Ref<HTMLDivEl
</button> </button>
<Menu <Menu
openLeft={true} openLeft={true}
openUp={groupListOpenUp(i)} openUp={groupListOpenUp(index)}
className={'group-actions-menu'} className={'group-actions-menu'}
ariaLabel={Utils.localizeMessage('admin.user_item.menuAriaLabel', 'User Actions Menu')} ariaLabel={Utils.localizeMessage('admin.user_item.menuAriaLabel', 'User Actions Menu')}
> >
@ -154,20 +191,54 @@ const UserGroupsList = React.forwardRef((props: Props, ref?: React.Ref<HTMLDivEl
disabled={false} disabled={false}
isDangerous={true} isDangerous={true}
/> />
<Menu.ItemAction
show={groupPermissionsMap[group.id].can_restore}
onClick={() => {
restoreGroup(group.id);
}}
icon={<i className='icon-restore'/>}
text={Utils.localizeMessage('user_groups_modal.restoreGroup', 'Restore Group')}
disabled={false}
/>
</Menu.Group> </Menu.Group>
</Menu> </Menu>
</MenuWrapper> </MenuWrapper>
</div> </div>
</div> </div>
); );
})}
{
(loading) &&
<LoadingScreen/>
} }
if (loading) {
return <LoadingScreen/>;
}
return null;
};
return (
<div
className='user-groups-modal__content user-groups-list'
style={{overflow: overflowState}}
>
<InfiniteLoader
ref={infiniteLoaderRef}
isItemLoaded={isItemLoaded}
itemCount={100000}
loadMoreItems={loadMoreItems}
>
{({onItemsRendered, ref}) => (
<VariableSizeList
itemCount={itemCount}
onItemsRendered={onItemsRendered}
ref={ref}
itemSize={() => 52}
height={groups.length >= 8 ? 416 : Math.max(groups.length, 3) * 52}
width={'100%'}
>
{Item}
</VariableSizeList>)}
</InfiniteLoader>
<ADLDAPUpsellBanner/> <ADLDAPUpsellBanner/>
</div> </div>
); );
}); };
export default React.memo(UserGroupsList); export default React.memo(UserGroupsList);

View File

@ -162,11 +162,11 @@
} }
.modal-body { .modal-body {
overflow: hidden;
max-height: 100%; max-height: 100%;
padding: 0; padding: 0;
.no-results__wrapper { .no-results__wrapper {
max-width: 350px;
padding-bottom: 60px; padding-bottom: 60px;
} }
@ -242,7 +242,7 @@
&.user-groups-list { &.user-groups-list {
position: relative; position: relative;
max-height: 450px; max-height: 460px;
padding-top: 24px; padding-top: 24px;
padding-bottom: 16px; padding-bottom: 16px;
overflow-y: scroll; // for Firefox and browsers that doesn't support overflow-y:overlay property overflow-y: scroll; // for Firefox and browsers that doesn't support overflow-y:overlay property
@ -284,6 +284,11 @@
max-width: 200px; max-width: 200px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
i {
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 16px;
}
} }
.group-name { .group-name {
@ -305,6 +310,13 @@
.MenuWrapper { .MenuWrapper {
width: 24px; width: 24px;
margin-left: auto; 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 { .group-actions-menu {

View File

@ -2,7 +2,6 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react'; import React from 'react';
import {shallow} from 'enzyme'; import {shallow} from 'enzyme';
import {Group} from '@mattermost/types/groups'; import {Group} from '@mattermost/types/groups';
@ -14,6 +13,7 @@ describe('component/user_groups_modal', () => {
onExited: jest.fn(), onExited: jest.fn(),
groups: [], groups: [],
myGroups: [], myGroups: [],
archivedGroups: [],
searchTerm: '', searchTerm: '',
currentUserId: '', currentUserId: '',
backButtonAction: jest.fn(), backButtonAction: jest.fn(),
@ -70,52 +70,4 @@ describe('component/user_groups_modal', () => {
); );
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
test('should match snapshot with groups, myGroups selected', () => {
const groups = getGroups(3);
const myGroups = getGroups(1);
const wrapper = shallow(
<UserGroupsModal
{...baseProps}
groups={groups}
myGroups={myGroups}
/>,
);
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(
<UserGroupsModal
{...baseProps}
groups={groups}
myGroups={myGroups}
searchTerm='group1'
/>,
);
const instance = wrapper.instance() as UserGroupsModal;
const e = {
target: {
value: '',
},
};
instance.handleSearch(e as React.ChangeEvent<HTMLInputElement>);
expect(baseProps.actions.setModalSearchTerm).toHaveBeenCalledTimes(1);
expect(baseProps.actions.setModalSearchTerm).toBeCalledWith('');
e.target.value = 'group1';
instance.handleSearch(e as React.ChangeEvent<HTMLInputElement>);
expect(wrapper.state('loading')).toEqual(true);
expect(baseProps.actions.setModalSearchTerm).toHaveBeenCalledTimes(2);
expect(baseProps.actions.setModalSearchTerm).toBeCalledWith(e.target.value);
});
}); });

View File

@ -1,26 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // 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 {Modal} from 'react-bootstrap';
import Constants from 'utils/constants'; import Constants from 'utils/constants';
import * as Utils from 'utils/utils'; 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 './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 Input from 'components/widgets/inputs/input/input';
import NoResultsIndicator from 'components/no_results_indicator'; import NoResultsIndicator from 'components/no_results_indicator';
import {NoResultsVariant} from 'components/no_results_indicator/types'; import {NoResultsVariant} from 'components/no_results_indicator/types';
import UserGroupsList from './user_groups_list'; import UserGroupsList from './user_groups_list';
import UserGroupsFilter from './user_groups_filter/user_groups_filter';
import UserGroupsModalHeader from './user_groups_modal_header'; import UserGroupsModalHeader from './user_groups_modal_header';
import ADLDAPUpsellBanner from './ad_ldap_upsell_banner'; import ADLDAPUpsellBanner from './ad_ldap_upsell_banner';
import {usePagingMeta} from './hooks';
const GROUPS_PER_PAGE = 60; const GROUPS_PER_PAGE = 60;
@ -28,269 +27,208 @@ export type Props = {
onExited: () => void; onExited: () => void;
groups: Group[]; groups: Group[];
myGroups: Group[]; myGroups: Group[];
archivedGroups: Group[];
searchTerm: string; searchTerm: string;
currentUserId: string; currentUserId: string;
backButtonAction: () => void; backButtonAction: () => void;
actions: { actions: {
getGroups: ( getGroups: (
filterAllowReference?: boolean, opts: GetGroupsParams,
page?: number,
perPage?: number,
includeMemberCount?: boolean
) => Promise<{data: Group[]}>; ) => Promise<{data: Group[]}>;
setModalSearchTerm: (term: string) => void; setModalSearchTerm: (term: string) => void;
getGroupsByUserIdPaginated: ( getGroupsByUserIdPaginated: (
userId: string, opts: GetGroupsForUserParams,
filterAllowReference?: boolean,
page?: number,
perPage?: number,
includeMemberCount?: boolean
) => Promise<{data: Group[]}>; ) => Promise<{data: Group[]}>;
searchGroups: ( searchGroups: (
params: GroupSearachParams, params: GroupSearchParams,
) => Promise<{data: Group[]}>; ) => Promise<{data: Group[]}>;
}; };
} }
type State = { const UserGroupsModal = (props: Props) => {
page: number; const [searchTimeoutId, setSearchTimeoutId] = useState(0);
myGroupsPage: number; const [loading, setLoading] = useState(false);
loading: boolean; const [show, setShow] = useState(true);
show: boolean; const [selectedFilter, setSelectedFilter] = useState('all');
selectedFilter: string; const [groupsFull, setGroupsFull] = useState(false);
allGroupsFull: boolean; const [groups, setGroups] = useState(props.groups);
myGroupsFull: boolean;
const [page, setPage] = usePagingMeta(selectedFilter);
useEffect(() => {
if (selectedFilter === 'all') {
setGroups(props.groups);
} }
if (selectedFilter === 'my') {
export default class UserGroupsModal extends React.PureComponent<Props, State> { setGroups(props.myGroups);
divScrollRef: RefObject<HTMLDivElement>;
private searchTimeoutId: number;
constructor(props: Props) {
super(props);
this.divScrollRef = createRef();
this.searchTimeoutId = 0;
this.state = {
page: 0,
myGroupsPage: 0,
loading: true,
show: true,
selectedFilter: 'all',
allGroupsFull: false,
myGroupsFull: false,
};
} }
if (selectedFilter === 'archived') {
setGroups(props.archivedGroups);
}
}, [selectedFilter, props.groups, props.myGroups]);
doHide = () => { const doHide = () => {
this.setState({show: false}); setShow(false);
}; };
async componentDidMount() { const getGroups = useCallback(async (page: number, groupType: string) => {
const { const {actions, currentUserId} = props;
actions, setLoading(true);
} = this.props; const groupsParams: GetGroupsParams = {
await Promise.all([ filter_allow_reference: false,
actions.getGroups(false, this.state.page, GROUPS_PER_PAGE, true), page,
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, per_page: GROUPS_PER_PAGE,
include_member_count: true, include_member_count: true,
}; };
if (this.state.selectedFilter === 'all') { let data: {data: Group[]} = {data: []};
await prevProps.actions.searchGroups(params);
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);
}
if (data && data.data.length === 0) {
setGroupsFull(true);
} else { } else {
params.user_id = this.props.currentUserId; setGroupsFull(false);
await prevProps.actions.searchGroups(params); }
setLoading(false);
setSelectedFilter(groupType);
}, [props.actions.getGroups, props.actions.getGroupsByUserIdPaginated, props.currentUserId]);
useEffect(() => {
getGroups(0, 'all');
return () => {
props.actions.setModalSearchTerm('');
};
}, []);
useEffect(() => {
clearTimeout(searchTimeoutId);
const searchTerm = props.searchTerm;
if (searchTerm === '') {
setLoading(false);
setSearchTimeoutId(0);
return;
}
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, Constants.SEARCH_TIMEOUT_MILLISECONDS,
); );
this.searchTimeoutId = searchTimeoutId; setSearchTimeoutId(timeoutId);
} }, [props.searchTerm, setSearchTimeoutId]);
}
startLoad = () => { const handleSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({loading: true});
};
loadComplete = () => {
this.setState({loading: false});
};
handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const term = e.target.value; const term = e.target.value;
this.props.actions.setModalSearchTerm(term); props.actions.setModalSearchTerm(term);
}; }, [props.actions.setModalSearchTerm]);
scrollGetGroups = debounce( const loadMoreGroups = useCallback(() => {
async () => {
const {page} = this.state;
const newPage = page + 1; const newPage = page + 1;
setPage(newPage);
this.setState({page: newPage}); if (selectedFilter === 'all' && !loading) {
this.getGroups(newPage); getGroups(newPage, 'all');
},
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) { if (selectedFilter === 'my' && !loading) {
this.scrollGetMyGroups(); getGroups(newPage, 'my');
} }
if (selectedFilter === 'archived' && !loading) {
getGroups(newPage, 'archived');
} }
}; }, [selectedFilter, page, getGroups, loading]);
getMyGroups = async (page: number) => { const inputPrefix = useMemo(() => {
const {actions} = this.props; return <i className={'icon icon-magnify'}/>;
}, []);
this.startLoad(); const noResultsType = useMemo(() => {
const data = await actions.getGroupsByUserIdPaginated(this.props.currentUserId, false, page, GROUPS_PER_PAGE, true); if (selectedFilter === 'archived') {
if (data.data.length === 0) { return NoResultsVariant.UserGroupsArchived;
this.setState({myGroupsFull: true});
} }
this.loadComplete(); return NoResultsVariant.UserGroups;
this.setState({selectedFilter: 'my'}); }, [selectedFilter]);
};
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 ( return (
<Modal <Modal
dialogClassName='a11y__modal user-groups-modal' dialogClassName='a11y__modal user-groups-modal'
show={this.state.show} show={show}
onHide={this.doHide} onHide={doHide}
onExited={this.props.onExited} onExited={props.onExited}
role='dialog' role='dialog'
aria-labelledby='userGroupsModalLabel' aria-labelledby='userGroupsModalLabel'
id='userGroupsModal' id='userGroupsModal'
> >
<UserGroupsModalHeader <UserGroupsModalHeader
onExited={this.props.onExited} onExited={props.onExited}
backButtonAction={this.props.backButtonAction} backButtonAction={props.backButtonAction}
/> />
<Modal.Body> <Modal.Body>
{(groups.length === 0 && !this.props.searchTerm) ? <>
<NoResultsIndicator
variant={NoResultsVariant.UserGroups}
/>
<ADLDAPUpsellBanner/>
</> : <>
<div className='user-groups-search'> <div className='user-groups-search'>
<Input <Input
type='text' type='text'
placeholder={Utils.localizeMessage('user_groups_modal.searchGroups', 'Search Groups')} placeholder={Utils.localizeMessage('user_groups_modal.searchGroups', 'Search Groups')}
onChange={this.handleSearch} onChange={handleSearch}
value={this.props.searchTerm} value={props.searchTerm}
data-testid='searchInput' data-testid='searchInput'
className={'user-group-search-input'} className={'user-group-search-input'}
inputPrefix={<i className={'icon icon-magnify'}/>} inputPrefix={inputPrefix}
/> />
</div> </div>
<div className='more-modal__dropdown'> <UserGroupsFilter
<MenuWrapper id='groupsFilterDropdown'> selectedFilter={selectedFilter}
<a> getGroups={getGroups}
<span>{this.state.selectedFilter === 'all' ? Utils.localizeMessage('user_groups_modal.showAllGroups', 'Show: All Groups') : Utils.localizeMessage('user_groups_modal.showMyGroups', 'Show: My Groups')}</span>
<span className='icon icon-chevron-down'/>
</a>
<Menu
openLeft={false}
ariaLabel={Utils.localizeMessage('user_groups_modal.filterAriaLabel', 'Groups Filter Menu')}
>
<Menu.ItemAction
id='groupsDropdownAll'
buttonClass='groups-filter-btn'
onClick={() => {
this.getGroups(0);
}}
text={Utils.localizeMessage('user_groups_modal.allGroups', 'All Groups')}
rightDecorator={this.state.selectedFilter === 'all' && <i className='icon icon-check'/>}
/> />
<Menu.ItemAction {(groups.length === 0 && !props.searchTerm) ? <>
id='groupsDropdownMy' <NoResultsIndicator
buttonClass='groups-filter-btn' variant={noResultsType}
onClick={() => {
this.getMyGroups(0);
}}
text={Utils.localizeMessage('user_groups_modal.myGroups', 'My Groups')}
rightDecorator={this.state.selectedFilter !== 'all' && <i className='icon icon-check'/>}
/> />
</Menu> <ADLDAPUpsellBanner/>
</MenuWrapper> </> : <>
</div>
<UserGroupsList <UserGroupsList
groups={groups} groups={groups}
searchTerm={this.props.searchTerm} searchTerm={props.searchTerm}
loading={this.state.loading} loading={loading}
onScroll={this.onScroll} hasNextPage={!groupsFull}
ref={this.divScrollRef} loadMoreGroups={loadMoreGroups}
onExited={this.props.onExited} onExited={props.onExited}
backButtonAction={this.props.backButtonAction} backButtonAction={props.backButtonAction}
/> />
</> </>
} }
</Modal.Body> </Modal.Body>
</Modal> </Modal>
); );
} };
}
export default React.memo(UserGroupsModal);

View File

@ -5,6 +5,11 @@
.modal-title { .modal-title {
margin-left: 46px; margin-left: 46px;
i {
margin-left: 5px;
color: rgba(var(--center-channel-color-rgb), 0.56);
}
} }
button.user-groups-create { button.user-groups-create {

View File

@ -10,9 +10,8 @@ import {GlobalState} from 'types/store';
import {ModalData} from 'types/actions'; import {ModalData} from 'types/actions';
import {openModal} from 'actions/views/modals'; import {openModal} from 'actions/views/modals';
import {getGroup as getGroupById, isMyGroup} from 'mattermost-redux/selectors/entities/groups'; 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 {haveIGroupPermission} from 'mattermost-redux/selectors/entities/roles';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {Permissions} from 'mattermost-redux/constants'; import {Permissions} from 'mattermost-redux/constants';
import ViewUserGroupModalHeader from './view_user_group_modal_header'; import ViewUserGroupModalHeader from './view_user_group_modal_header';
@ -22,6 +21,7 @@ type Actions = {
removeUsersFromGroup: (groupId: string, userIds: string[]) => Promise<ActionResult>; removeUsersFromGroup: (groupId: string, userIds: string[]) => Promise<ActionResult>;
addUsersToGroup: (groupId: string, userIds: string[]) => Promise<ActionResult>; addUsersToGroup: (groupId: string, userIds: string[]) => Promise<ActionResult>;
archiveGroup: (groupId: string) => Promise<ActionResult>; archiveGroup: (groupId: string) => Promise<ActionResult>;
restoreGroup: (groupId: string) => Promise<ActionResult>;
}; };
type OwnProps = { type OwnProps = {
@ -36,15 +36,16 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const permissionToJoinGroup = haveIGroupPermission(state, ownProps.groupId, Permissions.MANAGE_CUSTOM_GROUP_MEMBERS); const permissionToJoinGroup = haveIGroupPermission(state, ownProps.groupId, Permissions.MANAGE_CUSTOM_GROUP_MEMBERS);
const permissionToLeaveGroup = 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 permissionToArchiveGroup = haveIGroupPermission(state, ownProps.groupId, Permissions.DELETE_CUSTOM_GROUP);
const permissionToRestoreGroup = haveIGroupPermission(state, ownProps.groupId, Permissions.RESTORE_CUSTOM_GROUP);
return { return {
permissionToEditGroup, permissionToEditGroup,
permissionToJoinGroup, permissionToJoinGroup,
permissionToLeaveGroup, permissionToLeaveGroup,
permissionToArchiveGroup, permissionToArchiveGroup,
permissionToRestoreGroup,
isGroupMember, isGroupMember,
group, group,
currentUserId: getCurrentUserId(state),
}; };
} }
@ -55,6 +56,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
removeUsersFromGroup, removeUsersFromGroup,
addUsersToGroup, addUsersToGroup,
archiveGroup, archiveGroup,
restoreGroup,
}, dispatch), }, dispatch),
}; };
} }

View File

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react'; import React, {useCallback} from 'react';
import {Modal} from 'react-bootstrap'; import {Modal} from 'react-bootstrap';
import {FormattedMessage} from 'react-intl'; import {FormattedMessage} from 'react-intl';
@ -24,8 +24,8 @@ export type Props = {
permissionToJoinGroup: boolean; permissionToJoinGroup: boolean;
permissionToLeaveGroup: boolean; permissionToLeaveGroup: boolean;
permissionToArchiveGroup: boolean; permissionToArchiveGroup: boolean;
permissionToRestoreGroup: boolean;
isGroupMember: boolean; isGroupMember: boolean;
currentUserId: string;
incrementMemberCount: () => void; incrementMemberCount: () => void;
decrementMemberCount: () => void; decrementMemberCount: () => void;
actions: { actions: {
@ -33,39 +33,50 @@ export type Props = {
removeUsersFromGroup: (groupId: string, userIds: string[]) => Promise<ActionResult>; removeUsersFromGroup: (groupId: string, userIds: string[]) => Promise<ActionResult>;
addUsersToGroup: (groupId: string, userIds: string[]) => Promise<ActionResult>; addUsersToGroup: (groupId: string, userIds: string[]) => Promise<ActionResult>;
archiveGroup: (groupId: string) => Promise<ActionResult>; archiveGroup: (groupId: string) => Promise<ActionResult>;
restoreGroup: (groupId: string) => Promise<ActionResult>;
}; };
} }
const ViewUserGroupModalHeader = (props: Props) => { const ViewUserGroupModalHeader = ({
const goToAddPeopleModal = () => { groupId,
const {actions, groupId} = props; group,
onExited,
backButtonCallback,
backButtonAction,
permissionToEditGroup,
permissionToJoinGroup,
permissionToLeaveGroup,
permissionToArchiveGroup,
permissionToRestoreGroup,
isGroupMember,
incrementMemberCount,
decrementMemberCount,
actions,
}: Props) => {
const goToAddPeopleModal = useCallback(() => {
actions.openModal({ actions.openModal({
modalId: ModalIdentifiers.ADD_USERS_TO_GROUP, modalId: ModalIdentifiers.ADD_USERS_TO_GROUP,
dialogType: AddUsersToGroupModal, dialogType: AddUsersToGroupModal,
dialogProps: { dialogProps: {
groupId, groupId,
backButtonCallback: props.backButtonAction, backButtonCallback: backButtonAction,
}, },
}); });
props.onExited(); onExited();
}; }, [actions.openModal, groupId, onExited, backButtonAction]);
const showSubMenu = (source: string) => { const restoreGroup = useCallback(async () => {
const {permissionToEditGroup, permissionToJoinGroup, permissionToLeaveGroup, permissionToArchiveGroup} = props; await actions.restoreGroup(groupId);
}, [actions.restoreGroup, groupId]);
return source.toLowerCase() !== 'ldap' && const showSubMenu = useCallback(() => {
( return permissionToEditGroup ||
permissionToEditGroup ||
permissionToJoinGroup || permissionToJoinGroup ||
permissionToLeaveGroup || permissionToLeaveGroup ||
permissionToArchiveGroup permissionToArchiveGroup;
); }, [permissionToEditGroup, permissionToJoinGroup, permissionToLeaveGroup, permissionToArchiveGroup]);
};
const modalTitle = () => {
const {group} = props;
const modalTitle = useCallback(() => {
if (group) { if (group) {
return ( return (
<Modal.Title <Modal.Title
@ -73,16 +84,18 @@ const ViewUserGroupModalHeader = (props: Props) => {
id='userGroupsModalLabel' id='userGroupsModalLabel'
> >
{group.display_name} {group.display_name}
{
group.delete_at > 0 &&
<i className='icon icon-archive-outline'/>
}
</Modal.Title> </Modal.Title>
); );
} }
return (<></>); return (<></>);
}; }, [group]);
const addPeopleButton = () => { const addPeopleButton = useCallback(() => {
const {group, permissionToJoinGroup} = props; if (permissionToJoinGroup) {
if (group?.source.toLowerCase() !== 'ldap' && permissionToJoinGroup) {
return ( return (
<button <button
className='user-groups-create btn btn-md btn-primary' className='user-groups-create btn btn-md btn-primary'
@ -90,47 +103,64 @@ const ViewUserGroupModalHeader = (props: Props) => {
> >
<FormattedMessage <FormattedMessage
id='user_groups_modal.addPeople' id='user_groups_modal.addPeople'
defaultMessage='Add People' defaultMessage='Add people'
/> />
</button> </button>
); );
} }
return (<></>); return (<></>);
}; }, [permissionToJoinGroup, goToAddPeopleModal]);
const restoreGroupButton = useCallback(() => {
if (permissionToRestoreGroup) {
return (
<button
className='user-groups-create btn btn-md btn-primary'
onClick={restoreGroup}
>
<FormattedMessage
id='user_groups_modal.button.restoreGroup'
defaultMessage='Restore Group'
/>
</button>
);
}
return (<></>);
}, [permissionToRestoreGroup, restoreGroup]);
const subMenuButton = () => { const subMenuButton = () => {
const {group} = props; if (group && showSubMenu()) {
if (group && showSubMenu(group?.source)) {
return ( return (
<ViewUserGroupHeaderSubMenu <ViewUserGroupHeaderSubMenu
group={group} group={group}
isGroupMember={props.isGroupMember} isGroupMember={isGroupMember}
decrementMemberCount={props.decrementMemberCount} decrementMemberCount={decrementMemberCount}
incrementMemberCount={props.incrementMemberCount} incrementMemberCount={incrementMemberCount}
backButtonCallback={props.backButtonCallback} backButtonCallback={backButtonCallback}
backButtonAction={props.backButtonAction} backButtonAction={backButtonAction}
onExited={props.onExited} onExited={onExited}
permissionToEditGroup={props.permissionToEditGroup} permissionToEditGroup={permissionToEditGroup}
permissionToJoinGroup={props.permissionToJoinGroup} permissionToJoinGroup={permissionToJoinGroup}
permissionToLeaveGroup={props.permissionToLeaveGroup} permissionToLeaveGroup={permissionToLeaveGroup}
permissionToArchiveGroup={props.permissionToArchiveGroup} permissionToArchiveGroup={permissionToArchiveGroup}
/> />
); );
} }
return null; return null;
}; };
const goBack = useCallback(() => {
backButtonCallback();
onExited();
}, [backButtonCallback, onExited]);
return ( return (
<Modal.Header closeButton={true}> <Modal.Header closeButton={true}>
<button <button
type='button' type='button'
className='modal-header-back-button btn-icon' className='modal-header-back-button btn-icon'
aria-label='Close' aria-label='Close'
onClick={() => { onClick={goBack}
props.backButtonCallback();
props.onExited();
}}
> >
<LocalizedIcon <LocalizedIcon
className='icon icon-arrow-left' className='icon icon-arrow-left'
@ -139,6 +169,7 @@ const ViewUserGroupModalHeader = (props: Props) => {
</button> </button>
{modalTitle()} {modalTitle()}
{addPeopleButton()} {addPeopleButton()}
{restoreGroupButton()}
{subMenuButton()} {subMenuButton()}
</Modal.Header> </Modal.Header>
); );

View File

@ -1665,7 +1665,7 @@
"admin.permissions.permission.read_user_access_token.name": "Read user access token", "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.description": "Remove user from team",
"admin.permissions.permission.remove_user_from_team.name": "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.restore_custom_group.name": "Restore",
"admin.permissions.permission.revoke_user_access_token.description": "Revoke user access token", "admin.permissions.permission.revoke_user_access_token.description": "Revoke user access token",
"admin.permissions.permission.revoke_user_access_token.name": "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.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.subtitle": "There are currently no members in this group, please add one.",
"no_results.user_group_members.title": "No members yet", "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.subtitle": "Groups are a custom collection of users that can be used for mentions and invites.",
"no_results.user_groups.title": "No groups yet", "no_results.user_groups.title": "No groups yet",
"notification.crt": "Reply in {title}", "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.memberCount": "{member_count} {member_count, plural, one {Member} other {Members}}",
"user_group_popover.openGroupModal": "View full group info", "user_group_popover.openGroupModal": "View full group info",
"user_group_popover.searchGroupMembers": "Search members", "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.addPeopleTitle": "Add people to {group}",
"user_groups_modal.allGroups": "All Groups", "user_groups_modal.allGroups": "All Groups",
"user_groups_modal.archivedGroups": "Archived Groups",
"user_groups_modal.archiveGroup": "Archive Group", "user_groups_modal.archiveGroup": "Archive Group",
"user_groups_modal.button.restoreGroup": "Restore Group",
"user_groups_modal.createNew": "Create Group", "user_groups_modal.createNew": "Create Group",
"user_groups_modal.createTitle": "Create Group", "user_groups_modal.createTitle": "Create Group",
"user_groups_modal.editDetails": "Edit Details", "user_groups_modal.editDetails": "Edit Details",
@ -5153,8 +5157,10 @@
"user_groups_modal.myGroups": "My Groups", "user_groups_modal.myGroups": "My Groups",
"user_groups_modal.name": "Name", "user_groups_modal.name": "Name",
"user_groups_modal.nameIsEmpty": "Name is a required field.", "user_groups_modal.nameIsEmpty": "Name is a required field.",
"user_groups_modal.restoreGroup": "Restore Group",
"user_groups_modal.searchGroups": "Search Groups", "user_groups_modal.searchGroups": "Search Groups",
"user_groups_modal.showAllGroups": "Show: All 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.showMyGroups": "Show: My Groups",
"user_groups_modal.title": "User Groups", "user_groups_modal.title": "User Groups",
"user_groups_modal.unknownError": "An unknown error has occurred.", "user_groups_modal.unknownError": "An unknown error has occurred.",

View File

@ -51,4 +51,6 @@ export default keyMirror({
ARCHIVED_GROUP: null, ARCHIVED_GROUP: null,
CREATED_GROUP_TEAMS_AND_CHANNELS: null, CREATED_GROUP_TEAMS_AND_CHANNELS: null,
RESTORED_GROUP: null,
}); });

View File

@ -3,7 +3,7 @@
import nock from 'nock'; 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 * as Actions from 'mattermost-redux/actions/groups';
import {Client4} from 'mattermost-redux/client'; import {Client4} from 'mattermost-redux/client';
@ -275,7 +275,12 @@ describe('Actions.Groups', () => {
get('/groups?filter_allow_reference=true&page=0&per_page=0'). get('/groups?filter_allow_reference=true&page=0&per_page=0').
reply(200, response1.groups); 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(); const state = store.getState();

View File

@ -9,7 +9,7 @@ import {General} from 'mattermost-redux/constants';
import {Client4} from 'mattermost-redux/client'; import {Client4} from 'mattermost-redux/client';
import {ActionFunc, DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; 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 {logError} from './errors';
import {bindClientFunc, forceLogoutIfNecessary} from './helpers'; 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({ return bindClientFunc({
clientFunc: async (param1, param2, param3, param4, param5) => { clientFunc: async (opts) => {
const result = await Client4.getGroups(param1, param2, param3, param4, param5); const result = await Client4.getGroups(opts);
return result; return result;
}, },
onSuccess: [GroupTypes.RECEIVED_GROUPS], onSuccess: [GroupTypes.RECEIVED_GROUPS],
params: [ params: [
q, opts,
filterAllowReference,
page,
perPage,
includeMemberCount,
], ],
}); });
} }
@ -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({ return bindClientFunc({
clientFunc: async (param1, param2, param3, param4, param5) => { clientFunc: async (opts) => {
const result = await Client4.getGroups(param1, param2, param3, param4, param5); const result = await Client4.getGroups(opts);
return result; return result;
}, },
onSuccess: [GroupTypes.RECEIVED_MY_GROUPS, GroupTypes.RECEIVED_GROUPS], onSuccess: [GroupTypes.RECEIVED_MY_GROUPS, GroupTypes.RECEIVED_GROUPS],
params: [ params: [
filterAllowReference, opts,
page,
perPage,
includeMemberCount,
userId,
], ],
}); });
} }
@ -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) => { return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
let data; let data;
try { try {
@ -405,7 +397,7 @@ export function searchGroups(params: GroupSearachParams): ActionFunc {
const dispatches: AnyAction[] = [{type: GroupTypes.RECEIVED_GROUPS, data}]; 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}); dispatches.push({type: GroupTypes.RECEIVED_MY_GROUPS, data});
} }
if (params.include_channel_member_count) { if (params.include_channel_member_count) {
@ -431,6 +423,29 @@ export function archiveGroup(groupId: string): ActionFunc {
{ {
type: GroupTypes.ARCHIVED_GROUP, type: GroupTypes.ARCHIVED_GROUP,
id: groupId, 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,
}, },
); );

View File

@ -29,7 +29,7 @@ import {isCombinedUserActivityPost} from 'mattermost-redux/utils/post_list';
import {General, Preferences, Posts} from 'mattermost-redux/constants'; 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 {getProfilesByIds, getProfilesByUsernames, getStatusesByIds} from 'mattermost-redux/actions/users';
import { import {
deletePreferences, deletePreferences,
@ -1127,7 +1127,16 @@ export async function getMentionsAndStatusesForPosts(postsArrayOrMap: Post[]|Pos
const loadedProfiles = new Set<string>((data || []).map((p) => p.username)); const loadedProfiles = new Set<string>((data || []).map((p) => p.username));
const groupsToCheck = Array.from(usernamesAndGroupsToLoad).filter((name) => !loadedProfiles.has(name)); 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); return Promise.all(promises);

View File

@ -164,8 +164,7 @@ function myGroups(state: string[] = [], action: GenericAction) {
return nextState; return nextState;
} }
case GroupTypes.REMOVE_MY_GROUP: case GroupTypes.REMOVE_MY_GROUP: {
case GroupTypes.ARCHIVED_GROUP: {
const groupId = action.id; const groupId = action.id;
const index = state.indexOf(groupId); const index = state.indexOf(groupId);
@ -203,6 +202,8 @@ function groups(state: Record<string, Group> = {}, action: GenericAction) {
switch (action.type) { switch (action.type) {
case GroupTypes.CREATE_GROUP_SUCCESS: case GroupTypes.CREATE_GROUP_SUCCESS:
case GroupTypes.PATCHED_GROUP: case GroupTypes.PATCHED_GROUP:
case GroupTypes.RESTORED_GROUP:
case GroupTypes.ARCHIVED_GROUP:
case GroupTypes.RECEIVED_GROUP: { case GroupTypes.RECEIVED_GROUP: {
return { return {
...state, ...state,
@ -241,11 +242,6 @@ function groups(state: Record<string, Group> = {}, action: GenericAction) {
return nextState; return nextState;
} }
case GroupTypes.ARCHIVED_GROUP: {
const nextState = {...state};
Reflect.deleteProperty(nextState, action.id);
return nextState;
}
default: default:
return state; return state;
} }

View File

@ -208,13 +208,14 @@ describe('Selectors.Groups', () => {
expect(Selectors.getGroupsAssociatedToChannelForReference(testState, channelID)).toEqual(expected); expect(Selectors.getGroupsAssociatedToChannelForReference(testState, channelID)).toEqual(expected);
}); });
it('getAllAssociatedGroupsForReference', () => { it('makeGetAllAssociatedGroupsForReference', () => {
const expected = [ const expected = [
group1, group1,
group4, group4,
group5, group5,
]; ];
expect(Selectors.getAllAssociatedGroupsForReference(testState)).toEqual(expected); const getAllAssociatedGroupsForReference = Selectors.makeGetAllAssociatedGroupsForReference();
expect(getAllAssociatedGroupsForReference(testState, false)).toEqual(expected);
}); });
it('getMyGroupMentionKeys', () => { it('getMyGroupMentionKeys', () => {
@ -226,7 +227,7 @@ describe('Selectors.Groups', () => {
key: `@${group4.name}`, key: `@${group4.name}`,
}, },
]; ];
expect(Selectors.getMyGroupMentionKeys(testState)).toEqual(expected); expect(Selectors.getMyGroupMentionKeys(testState, false)).toEqual(expected);
}); });
it('getMyGroupMentionKeysForChannel', () => { it('getMyGroupMentionKeysForChannel', () => {

View File

@ -130,7 +130,7 @@ export function getAssociatedGroupsForReference(state: GlobalState, teamId: stri
} else if (channel && channel.group_constrained) { } else if (channel && channel.group_constrained) {
groupsForReference = getGroupsAssociatedToChannelForReference(state, channelId); groupsForReference = getGroupsAssociatedToChannelForReference(state, channelId);
} else { } else {
groupsForReference = getAllAssociatedGroupsForReference(state); groupsForReference = getAllAssociatedGroupsForReference(state, false);
} }
return groupsForReference; return groupsForReference;
} }
@ -221,20 +221,29 @@ export const getGroupsAssociatedToChannelForReference: (state: GlobalState, chan
}, },
); );
export const getAllAssociatedGroupsForReference: (state: GlobalState) => Group[] = createSelector( export const makeGetAllAssociatedGroupsForReference = () => {
'getAllAssociatedGroupsForReference', return createSelector(
getAllGroups, 'makeGetAllAssociatedGroupsForReference',
getCurrentUserLocale, (state: GlobalState) => getAllGroups(state),
(allGroups, locale) => { (state: GlobalState) => getCurrentUserLocale(state),
const groups = Object.entries(allGroups).filter((entry) => (entry[1].allow_reference && entry[1].delete_at === 0)).map((entry) => entry[1]); (_: 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<string, Group> = createSelector( export const getAllGroupsForReferenceByName: (state: GlobalState) => Record<string, Group> = createSelector(
'getAllGroupsForReferenceByName', 'getAllGroupsForReferenceByName',
getAllAssociatedGroupsForReference, (state: GlobalState) => getAllAssociatedGroupsForReference(state, false),
(groups) => { (groups) => {
const groupsByName: Record<string, Group> = {}; const groupsByName: Record<string, Group> = {};
@ -249,16 +258,25 @@ export const getAllGroupsForReferenceByName: (state: GlobalState) => Record<stri
}, },
); );
export const getMyAllowReferencedGroups: (state: GlobalState) => Group[] = createSelector( export const makeGetMyAllowReferencedGroups = () => {
'getMyAllowReferencedGroups', return createSelector(
getMyGroups, 'makeGetMyAllowReferencedGroups',
getCurrentUserLocale, (state: GlobalState) => getMyGroups(state),
(myGroups, locale) => { (state: GlobalState) => getCurrentUserLocale(state),
const groups = myGroups.filter((group) => group.allow_reference && group.delete_at === 0); (_: GlobalState, includeArchived: boolean) => includeArchived,
(myGroups, locale, includeArchived) => {
const groups = myGroups.filter((group) => {
if (includeArchived) {
return group.allow_reference;
}
return group.allow_reference && group.delete_at === 0;
});
return sortGroups(groups, locale); return sortGroups(groups, locale);
}, },
); );
};
export const getMyGroupsAssociatedToChannelForReference: (state: GlobalState, teamId: string, channelId: string) => Group[] = createSelector( export const getMyGroupsAssociatedToChannelForReference: (state: GlobalState, teamId: string, channelId: string) => Group[] = createSelector(
'getMyGroupsAssociatedToChannelForReference', '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', 'getMyGroupMentionKeys',
getMyAllowReferencedGroups, (state: GlobalState, includeArchived: boolean) => getMyAllowReferencedGroups(state, includeArchived),
(groups: Group[]) => { (groups: Group[]) => {
const keys: UserMentionKey[] = []; const keys: UserMentionKey[] = [];
groups.forEach((group) => keys.push({key: `@${group.name}`})); 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', 'searchAllowReferencedGroups',
getAllAssociatedGroupsForReference,
(state: GlobalState, term: string) => term, (state: GlobalState, term: string) => term,
(groups, term) => { (state: GlobalState, term: string, includeArchived: boolean) => searchGetAllAssociatedGroupsForReference(state, includeArchived),
(term, groups) => {
return filterGroupsMatchingTerm(groups, term); 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', 'searchMyAllowReferencedGroups',
getMyAllowReferencedGroups,
(state: GlobalState, term: string) => term, (state: GlobalState, term: string) => term,
(groups, term) => { (state: GlobalState, term: string, includeArchived: boolean) => searchGetMyAllowReferencedGroups(state, includeArchived),
(term, groups) => {
return filterGroupsMatchingTerm(groups, term); return filterGroupsMatchingTerm(groups, term);
}, },
); );
@ -321,3 +345,24 @@ export const isMyGroup: (state: GlobalState, groupId: string) => boolean = creat
return isMyGroup; 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);
},
);

View File

@ -93,7 +93,7 @@ describe('Selectors.Roles', () => {
test_channel_b_role2: {permissions: ['channel_b_role2']}, test_channel_b_role2: {permissions: ['channel_b_role2']},
test_channel_c_role1: {permissions: ['channel_c_role1']}, test_channel_c_role1: {permissions: ['channel_c_role1']},
test_channel_c_role2: {permissions: ['channel_c_role2']}, 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']}, custom_group_user: {permissions: ['custom_group_user']},
}; };
@ -102,6 +102,8 @@ describe('Selectors.Roles', () => {
const group3 = TestHelper.fakeGroup('group3', 'custom'); const group3 = TestHelper.fakeGroup('group3', 'custom');
const group4 = TestHelper.fakeGroup('group4', 'custom'); const group4 = TestHelper.fakeGroup('group4', 'custom');
const group5 = TestHelper.fakeGroup('group5'); const group5 = TestHelper.fakeGroup('group5');
const group6 = TestHelper.fakeGroup('group6', 'custom');
group6.delete_at = 10000;
const groups: Record<string, Group> = {}; const groups: Record<string, Group> = {};
groups.group1 = group1; groups.group1 = group1;
@ -109,6 +111,7 @@ describe('Selectors.Roles', () => {
groups.group3 = group3; groups.group3 = group3;
groups.group4 = group4; groups.group4 = group4;
groups.group5 = group5; groups.group5 = group5;
groups.group6 = group6;
const testState = deepFreezeAndThrowOnMutation({ const testState = deepFreezeAndThrowOnMutation({
entities: { entities: {
@ -164,7 +167,7 @@ describe('Selectors.Roles', () => {
test_channel_b_role2: {permissions: ['channel_b_role2']}, test_channel_b_role2: {permissions: ['channel_b_role2']},
test_channel_c_role1: {permissions: ['channel_c_role1']}, test_channel_c_role1: {permissions: ['channel_c_role1']},
test_channel_c_role2: {permissions: ['channel_c_role2']}, 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']}, custom_group_user: {permissions: ['custom_group_user']},
}; };
expect(getRoles(testState)).toEqual(loadedRoles); expect(getRoles(testState)).toEqual(loadedRoles);
@ -172,7 +175,7 @@ describe('Selectors.Roles', () => {
it('should return my system permission on getMySystemPermissions', () => { it('should return my system permission on getMySystemPermissions', () => {
expect(getMySystemPermissions(testState)).toEqual(new Set([ 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.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.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.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', () => { it('should return group set with permissions on getGroupListPermissions', () => {
expect(Selectors.getGroupListPermissions(testState)).toEqual({ expect(Selectors.getGroupListPermissions(testState)).toEqual({
[group1.id]: {can_delete: true, can_manage_members: true}, [group1.id]: {can_delete: true, can_manage_members: true, can_restore: false},
[group2.id]: {can_delete: true, can_manage_members: true}, [group2.id]: {can_delete: true, can_manage_members: true, can_restore: false},
[group3.id]: {can_delete: true, can_manage_members: true}, [group3.id]: {can_delete: true, can_manage_members: true, can_restore: false},
[group4.id]: {can_delete: true, can_manage_members: true}, [group4.id]: {can_delete: true, can_manage_members: true, can_restore: false},
[group5.id]: {can_delete: false, can_manage_members: 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},
}); });
}); });
}); });

View File

@ -60,7 +60,7 @@ export const getGroupListPermissions: (state: GlobalState) => Record<string, Gro
getMySystemPermissions, getMySystemPermissions,
(state) => state.entities.groups.groups, (state) => state.entities.groups.groups,
(myGroupRoles, roles, systemPermissions, allGroups) => { (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<string>(); const permissions = new Set<string>();
groups.forEach((group) => { groups.forEach((group) => {
@ -83,8 +83,9 @@ export const getGroupListPermissions: (state: GlobalState) => Record<string, Gro
const groupPermissionsMap: Record<string, GroupPermissions> = {}; const groupPermissionsMap: Record<string, GroupPermissions> = {};
groups.forEach((g) => { groups.forEach((g) => {
groupPermissionsMap[g.id] = { groupPermissionsMap[g.id] = {
can_delete: permissions.has(Permissions.DELETE_CUSTOM_GROUP) && 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', 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; return groupPermissionsMap;
@ -175,12 +176,34 @@ export function haveITeamPermission(state: GlobalState, teamId: string, permissi
); );
} }
export function haveIGroupPermission(state: GlobalState, groupID: string, permission: string): boolean { export const haveIGroupPermission: (state: GlobalState, groupID: string, permission: string) => boolean = createSelector(
return ( 'haveIGroupPermission',
getMySystemPermissions(state).has(permission) || getMySystemPermissions,
(getMyPermissionsByGroup(state)[groupID] ? getMyPermissionsByGroup(state)[groupID].has(permission) : false) 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 { export function haveIChannelPermission(state: GlobalState, teamId: string, channelId: string, permission: string): boolean {
return ( return (

View File

@ -20,7 +20,7 @@ export const getCurrentSearchForCurrentTeam: (state: GlobalState) => string = cr
export const getAllUserMentionKeys: (state: GlobalState) => UserMentionKey[] = createSelector( export const getAllUserMentionKeys: (state: GlobalState) => UserMentionKey[] = createSelector(
'getAllUserMentionKeys', 'getAllUserMentionKeys',
getCurrentUserMentionKeys, getCurrentUserMentionKeys,
getMyGroupMentionKeys, (state: GlobalState) => getMyGroupMentionKeys(state, false),
(userMentionKeys, groupMentionKeys) => { (userMentionKeys, groupMentionKeys) => {
return userMentionKeys.concat(groupMentionKeys); return userMentionKeys.concat(groupMentionKeys);
}, },

View File

@ -34,6 +34,16 @@ export function filterGroupsMatchingTerm(groups: Group[], term: string): Group[]
export function sortGroups(groups: Group[] = [], locale: string = General.DEFAULT_LOCALE): Group[] { export function sortGroups(groups: Group[] = [], locale: string = General.DEFAULT_LOCALE): Group[] {
return groups.sort((a, b) => { return groups.sort((a, b) => {
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}); 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;
}); });
} }

View File

@ -74,8 +74,10 @@ import {
UsersWithGroupsAndCount, UsersWithGroupsAndCount,
GroupsWithCount, GroupsWithCount,
GroupCreateWithUserIds, GroupCreateWithUserIds,
GroupSearachParams, GroupSearchParams,
CustomGroupPatch, CustomGroupPatch,
GetGroupsParams,
GetGroupsForUserParams,
} from '@mattermost/types/groups'; } from '@mattermost/types/groups';
import {PostActionResponse} from '@mattermost/types/integration_actions'; import {PostActionResponse} from '@mattermost/types/integration_actions';
import { import {
@ -3502,20 +3504,9 @@ export default class Client4 {
); );
}; };
getGroups = (q = '', filterAllowReference = false, page = 0, perPage = 10, includeMemberCount = false, hasFilterMember = false) => { getGroups = (opts: GetGroupsForUserParams | GetGroupsParams) => {
const qs: any = {
q,
filter_allow_reference: filterAllowReference,
page,
per_page: perPage,
include_member_count: includeMemberCount,
};
if (hasFilterMember) {
qs.filter_has_member = hasFilterMember;
}
return this.doFetch<Group[]>( return this.doFetch<Group[]>(
`${this.getGroupsRoute()}${buildQueryString(qs)}`, `${this.getGroupsRoute()}${buildQueryString(opts)}`,
{method: 'get'}, {method: 'get'},
); );
}; };
@ -3573,7 +3564,7 @@ export default class Client4 {
); );
} }
searchGroups = (params: GroupSearachParams) => { searchGroups = (params: GroupSearchParams) => {
return this.doFetch<Group[]>( return this.doFetch<Group[]>(
`${this.getGroupsRoute()}${buildQueryString(params)}`, `${this.getGroupsRoute()}${buildQueryString(params)}`,
{method: 'get'}, {method: 'get'},
@ -3676,6 +3667,13 @@ export default class Client4 {
); );
} }
restoreGroup = (groupId: string) => {
return this.doFetch<Group>(
`${this.getGroupRoute(groupId)}/restore`,
{method: 'post'},
);
}
createGroupTeamsAndChannels = (userID: string) => { createGroupTeamsAndChannels = (userID: string) => {
return this.doFetch<Group>( return this.doFetch<Group>(
`${this.getBaseRoute()}/ldap/users/${userID}/group_sync_memberships`, `${this.getBaseRoute()}/ldap/users/${userID}/group_sync_memberships`,

View File

@ -150,13 +150,22 @@ export type GroupCreateWithUserIds = {
description?: string; 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; q: string;
filter_allow_reference: boolean; filter_has_member?: string;
page: number;
per_page: number;
include_member_count: boolean;
user_id?: string;
include_timezones?: string; include_timezones?: string;
include_channel_member_count?: string; include_channel_member_count?: string;
} }
@ -169,4 +178,5 @@ export type GroupMembership = {
export type GroupPermissions = { export type GroupPermissions = {
can_delete: boolean; can_delete: boolean;
can_manage_members: boolean; can_manage_members: boolean;
can_restore: boolean;
} }