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>
Этот коммит содержится в:
Ben Cooke
2023-08-31 10:07:51 -04:00
коммит произвёл GitHub
родитель 32512d35fb
Коммит 2c6179a0a6
44 изменённых файлов: 1031 добавлений и 1019 удалений

Просмотреть файл

@@ -56,49 +56,10 @@ exports[`component/user_groups_modal should match snapshot with groups 1`] = `
value=""
/>
</div>
<div
className="more-modal__dropdown"
>
<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>
<Memo(UserGroupsFilter)
getGroups={[Function]}
selectedFilter="all"
/>
<Connect(Component)
backButtonAction={[MockFunction]}
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]}
onScroll={[Function]}
searchTerm=""
/>
</ModalBody>
</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
animation={true}
aria-labelledby="userGroupsModalLabel"
@@ -215,122 +177,14 @@ exports[`component/user_groups_modal should match snapshot with groups, myGroups
value=""
/>
</div>
<div
className="more-modal__dropdown"
>
<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=""
<Memo(UserGroupsFilter)
getGroups={[Function]}
selectedFilter="all"
/>
</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
variant="UserGroups"
/>
<ADLDAPUpsellBanner />
<Memo(ADLDAPUpsellBanner) />
</ModalBody>
</Modal>
`;

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

@@ -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);

Просмотреть файл

@@ -3,276 +3,39 @@
exports[`component/user_groups_modal should match snapshot with groups 1`] = `
<div
className="user-groups-modal__content user-groups-list"
onScroll={[MockFunction]}
style={
Object {
"overflow": "overlay",
}
}
>
<div
className="group-row"
key="group0"
onClick={[Function]}
<InfiniteLoader
isItemLoaded={[Function]}
itemCount={100000}
loadMoreItems={[MockFunction]}
>
<span
className="group-display-name"
>
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 />
<Component />
</InfiniteLoader>
<Memo(ADLDAPUpsellBanner) />
</div>
`;
exports[`component/user_groups_modal should match snapshot without groups 1`] = `
<div
className="user-groups-modal__content user-groups-list"
onScroll={[MockFunction]}
style={
Object {
"overflow": "overlay",
}
}
>
<ADLDAPUpsellBanner />
<InfiniteLoader
isItemLoaded={[Function]}
itemCount={100000}
loadMoreItems={[MockFunction]}
>
<Component />
</InfiniteLoader>
<Memo(ADLDAPUpsellBanner) />
</div>
`;

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

@@ -1,9 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {VariableSizeList, ListChildComponentProps} from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import NoResultsIndicator from 'components/no_results_indicator';
import {NoResultsVariant} from 'components/no_results_indicator/types';
@@ -25,27 +27,33 @@ export type Props = {
searchTerm: string;
loading: boolean;
groupPermissionsMap: Record<string, GroupPermissions>;
onScroll: () => void;
loadMoreGroups: () => void;
onExited: () => void;
backButtonAction: () => void;
hasNextPage: boolean;
actions: {
archiveGroup: (groupId: string) => Promise<ActionResult>;
restoreGroup: (groupId: string) => Promise<ActionResult>;
openModal: <P>(modalData: ModalData<P>) => void;
};
}
const UserGroupsList = React.forwardRef((props: Props, ref?: React.Ref<HTMLDivElement>) => {
const UserGroupsList = (props: Props) => {
const {
groups,
searchTerm,
loading,
groupPermissionsMap,
onScroll,
hasNextPage,
loadMoreGroups,
backButtonAction,
onExited,
actions,
} = props;
const infiniteLoaderRef = useRef<InfiniteLoader | null>(null);
const variableSizeListRef = useRef<VariableSizeList | null>(null);
const [hasMounted, setHasMounted] = useState(false);
const [overflowState, setOverflowState] = useState('overlay');
useEffect(() => {
@@ -54,10 +62,34 @@ const UserGroupsList = React.forwardRef((props: Props, ref?: React.Ref<HTMLDivEl
}
}, [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) => {
await actions.archiveGroup(groupId);
}, [actions.archiveGroup]);
const restoreGroup = useCallback(async (groupId: string) => {
await actions.restoreGroup(groupId);
}, [actions.restoreGroup]);
const goToViewGroupModal = useCallback((group: Group) => {
actions.openModal({
modalId: ModalIdentifiers.VIEW_USER_GROUP,
@@ -74,100 +106,139 @@ const UserGroupsList = React.forwardRef((props: Props, ref?: React.Ref<HTMLDivEl
}, [actions.openModal, onExited, backButtonAction]);
const groupListOpenUp = (groupListItemIndex: number): boolean => {
if (groups.length > 1 && groupListItemIndex === 0) {
if (groupListItemIndex === 0) {
return false;
}
return true;
};
const Item = ({index, style}: ListChildComponentProps) => {
if (groups.length === 0 && searchTerm) {
return (
<NoResultsIndicator
variant={NoResultsVariant.ChannelSearch}
titleValues={{channelName: `"${searchTerm}"`}}
/>
);
}
if (isItemLoaded(index)) {
const group = groups[index] as Group;
if (!group) {
return null;
}
return (
<div
className='group-row'
style={style}
key={group.id}
onClick={() => {
goToViewGroupModal(group);
}}
>
<span className='group-display-name'>
{
group.delete_at > 0 &&
<i className='icon icon-archive-outline'/>
}
{group.display_name}
</span>
<span className='group-name'>
{'@'}{group.name}
</span>
<div className='group-member-count'>
<FormattedMessage
id='user_groups_modal.memberCount'
defaultMessage='{member_count} {member_count, plural, one {member} other {members}}'
values={{
member_count: group.member_count,
}}
/>
</div>
<div className='group-action'>
<MenuWrapper
isDisabled={false}
stopPropagationOnToggle={true}
id={`customWrapper-${group.id}`}
>
<button className='action-wrapper'>
<i className='icon icon-dots-vertical'/>
</button>
<Menu
openLeft={true}
openUp={groupListOpenUp(index)}
className={'group-actions-menu'}
ariaLabel={Utils.localizeMessage('admin.user_item.menuAriaLabel', 'User Actions Menu')}
>
<Menu.Group>
<Menu.ItemAction
onClick={() => {
goToViewGroupModal(group);
}}
icon={<i className='icon-account-multiple-outline'/>}
text={Utils.localizeMessage('user_groups_modal.viewGroup', 'View Group')}
disabled={false}
/>
</Menu.Group>
<Menu.Group>
<Menu.ItemAction
show={groupPermissionsMap[group.id].can_delete}
onClick={() => {
archiveGroup(group.id);
}}
icon={<i className='icon-archive-outline'/>}
text={Utils.localizeMessage('user_groups_modal.archiveGroup', 'Archive Group')}
disabled={false}
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>
</MenuWrapper>
</div>
</div>
);
}
if (loading) {
return <LoadingScreen/>;
}
return null;
};
return (
<div
className='user-groups-modal__content user-groups-list'
onScroll={onScroll}
ref={ref}
style={{overflow: overflowState}}
>
{(groups.length === 0 && searchTerm) &&
<NoResultsIndicator
variant={NoResultsVariant.ChannelSearch}
titleValues={{channelName: `"${searchTerm}"`}}
/>
}
{groups.map((group, i) => {
return (
<div
className='group-row'
key={group.id}
onClick={() => {
goToViewGroupModal(group);
}}
<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%'}
>
<span className='group-display-name'>
{group.display_name}
</span>
<span className='group-name'>
{'@'}{group.name}
</span>
<div className='group-member-count'>
<FormattedMessage
id='user_groups_modal.memberCount'
defaultMessage='{member_count} {member_count, plural, one {member} other {members}}'
values={{
member_count: group.member_count,
}}
/>
</div>
<div className='group-action'>
<MenuWrapper
isDisabled={false}
stopPropagationOnToggle={true}
id={`customWrapper-${group.id}`}
>
<button className='action-wrapper'>
<i className='icon icon-dots-vertical'/>
</button>
<Menu
openLeft={true}
openUp={groupListOpenUp(i)}
className={'group-actions-menu'}
ariaLabel={Utils.localizeMessage('admin.user_item.menuAriaLabel', 'User Actions Menu')}
>
<Menu.Group>
<Menu.ItemAction
onClick={() => {
goToViewGroupModal(group);
}}
icon={<i className='icon-account-multiple-outline'/>}
text={Utils.localizeMessage('user_groups_modal.viewGroup', 'View Group')}
disabled={false}
/>
</Menu.Group>
<Menu.Group>
<Menu.ItemAction
show={groupPermissionsMap[group.id].can_delete}
onClick={() => {
archiveGroup(group.id);
}}
icon={<i className='icon-archive-outline'/>}
text={Utils.localizeMessage('user_groups_modal.archiveGroup', 'Archive Group')}
disabled={false}
isDangerous={true}
/>
</Menu.Group>
</Menu>
</MenuWrapper>
</div>
</div>
);
})}
{
(loading) &&
<LoadingScreen/>
}
{Item}
</VariableSizeList>)}
</InfiniteLoader>
<ADLDAPUpsellBanner/>
</div>
);
});
};
export default React.memo(UserGroupsList);

Просмотреть файл

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

Просмотреть файл

@@ -2,7 +2,6 @@
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {Group} from '@mattermost/types/groups';
@@ -14,6 +13,7 @@ describe('component/user_groups_modal', () => {
onExited: jest.fn(),
groups: [],
myGroups: [],
archivedGroups: [],
searchTerm: '',
currentUserId: '',
backButtonAction: jest.fn(),
@@ -70,52 +70,4 @@ describe('component/user_groups_modal', () => {
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with groups, myGroups selected', () => {
const groups = getGroups(3);
const myGroups = getGroups(1);
const wrapper = shallow(
<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);
});
});

Просмотреть файл

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