MM-64030/MM-64025/MM-63985/MM-63986/MM-63987/MM-63976/MM-64018/MM-64017/MM-64034/MM-64029 - Various accessibility fixes around User Groups modals (#31147)

* Convert user groups main modal to GenericModal, convert menu to Menu component, fixes

* Added useFocusTrap on other modals, fix DOM ordering

* [MM-64018] Add proper aria-labels to the menu button

* [MM-64025] Convert user group button to an actual button

* [MM-64017] Fixed aria-label on 3-dot menu in view user group modal

* [MM-64030] Show aria-live region when search results are rendered

* Fix tests

* PR feedback

* PR feedback
Этот коммит содержится в:
Devin Binnie
2025-05-23 16:09:06 -04:00
коммит произвёл GitHub
родитель 583eb3f62c
Коммит 2358699d91
23 изменённых файлов: 887 добавлений и 927 удалений

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

@@ -31,87 +31,109 @@ exports[`component/add_users_to_group_modal should match snapshot 1`] = `
role="none"
show={true}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<div
className="d-flex align-items-center"
<div>
<ModalHeader
bsClass="modal-header"
closeButton={false}
closeLabel="Close"
>
<button
aria-label="Back"
className="modal-header-back-button btn btn-icon"
onClick={[Function]}
type="button"
<div
className="d-flex align-items-center"
>
<i
className="icon icon-arrow-left"
/>
</button>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="addUsersToGroupsModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="Add people to {group}"
id="user_groups_modal.addPeopleTitle"
values={
Object {
"group": "Group Name",
}
}
/>
</ModalTitle>
</div>
</ModalHeader>
<ModalBody
bsClass="modal-body"
className="overflow--visible"
componentClass="div"
>
<div
className="user-groups-modal__content"
>
<form
role="form"
>
<div
className="group-add-user"
<button
aria-label="Back"
className="modal-header-back-button btn btn-icon"
onClick={[Function]}
type="button"
>
<Connect(injectIntl(AddUserToGroupMultiSelect))
addUserCallback={[Function]}
backButtonClass="multiselect-back"
backButtonClick={[Function]}
buttonSubmitLoadingText={
<i
className="icon icon-arrow-left"
/>
</button>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="addUsersToGroupsModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="Add people to {group}"
id="user_groups_modal.addPeopleTitle"
values={
Object {
"defaultMessage": "Adding...",
"id": "multiselect.adding",
}
}
buttonSubmitText={
Object {
"defaultMessage": "Add People",
"id": "multiselect.addPeopleToGroup",
}
}
deleteUserCallback={[Function]}
focusOnLoad={false}
groupId="groupid123"
multilSelectKey="addUsersToGroupKey"
onSubmitCallback={[Function]}
saving={false}
savingEnabled={false}
searchOptions={
Object {
"not_in_group_id": "groupid123",
"group": "Group Name",
}
}
/>
</div>
</form>
</div>
</ModalBody>
</ModalTitle>
</div>
<button
aria-label="Close"
className="close"
onClick={[MockFunction]}
type="button"
>
<span
aria-hidden="true"
>
×
</span>
<span
className="sr-only"
>
<MemoizedFormattedMessage
defaultMessage="Close"
id="generic.close"
/>
</span>
</button>
</ModalHeader>
<ModalBody
bsClass="modal-body"
className="overflow--visible"
componentClass="div"
>
<div
className="user-groups-modal__content"
>
<form
role="form"
>
<div
className="group-add-user"
>
<Connect(injectIntl(AddUserToGroupMultiSelect))
addUserCallback={[Function]}
backButtonClass="multiselect-back"
backButtonClick={[Function]}
buttonSubmitLoadingText={
Object {
"defaultMessage": "Adding...",
"id": "multiselect.adding",
}
}
buttonSubmitText={
Object {
"defaultMessage": "Add People",
"id": "multiselect.addPeopleToGroup",
}
}
deleteUserCallback={[Function]}
focusOnLoad={false}
groupId="groupid123"
multilSelectKey="addUsersToGroupKey"
onSubmitCallback={[Function]}
saving={false}
savingEnabled={false}
searchOptions={
Object {
"not_in_group_id": "groupid123",
}
}
/>
</div>
</form>
</div>
</ModalBody>
</div>
</Modal>
`;

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

@@ -1,10 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useCallback, useMemo} from 'react';
import React, {useState, useCallback, useMemo, useRef} from 'react';
import {Modal} from 'react-bootstrap';
import {defineMessage, FormattedMessage, useIntl} from 'react-intl';
import {useFocusTrap} from '@mattermost/components/src/hooks/useFocusTrap';
import type {Group} from '@mattermost/types/groups';
import type {UserProfile} from '@mattermost/types/users';
@@ -34,6 +35,9 @@ const AddUsersToGroupModal = (props: Props) => {
const [showUnknownError, setShowUnknownError] = useState(false);
const {formatMessage} = useIntl();
const modalRef = useRef<HTMLDivElement>(null);
useFocusTrap(show, modalRef);
const doHide = useCallback(() => {
setShow(false);
}, []);
@@ -97,63 +101,79 @@ const AddUsersToGroupModal = (props: Props) => {
aria-labelledby='createUserGroupsModalLabel'
id='addUsersToGroupsModal'
>
<Modal.Header closeButton={true}>
<div className='d-flex align-items-center'>
<div ref={modalRef}>
<Modal.Header>
<div className='d-flex align-items-center'>
<button
type='button'
className='modal-header-back-button btn btn-icon'
aria-label={formatMessage({id: 'user_groups_modal.goBackLabel', defaultMessage: 'Back'})}
onClick={goBack}
>
<i className='icon icon-arrow-left'/>
</button>
<Modal.Title
componentClass='h1'
id='addUsersToGroupsModalLabel'
>
<FormattedMessage
id='user_groups_modal.addPeopleTitle'
defaultMessage='Add people to {group}'
values={titleValue}
/>
</Modal.Title>
</div>
<button
type='button'
className='modal-header-back-button btn btn-icon'
aria-label={formatMessage({id: 'user_groups_modal.goBackLabel', defaultMessage: 'Back'})}
onClick={goBack}
className='close'
onClick={props.onExited}
aria-label={formatMessage({id: 'generic.close', defaultMessage: 'Close'})}
>
<i className='icon icon-arrow-left'/>
</button>
<Modal.Title
componentClass='h1'
id='addUsersToGroupsModalLabel'
>
<FormattedMessage
id='user_groups_modal.addPeopleTitle'
defaultMessage='Add people to {group}'
values={titleValue}
/>
</Modal.Title>
</div>
</Modal.Header>
<Modal.Body
className='overflow--visible'
>
<div className='user-groups-modal__content'>
<form role='form'>
<div className='group-add-user'>
<AddUserToGroupMultiSelect
multilSelectKey={'addUsersToGroupKey'}
onSubmitCallback={addUsersToGroup}
focusOnLoad={false}
savingEnabled={isSaveEnabled()}
addUserCallback={addUserCallback}
deleteUserCallback={deleteUserCallback}
groupId={props.groupId}
searchOptions={searchOptions}
buttonSubmitText={defineMessage({id: 'multiselect.addPeopleToGroup', defaultMessage: 'Add People'})}
buttonSubmitLoadingText={defineMessage({id: 'multiselect.adding', defaultMessage: 'Adding...'})}
backButtonClick={goBack}
backButtonClass={'multiselect-back'}
saving={saving}
<span aria-hidden='true'>{'×'}</span>
<span className='sr-only'>
<FormattedMessage
id='generic.close'
defaultMessage='Close'
/>
</div>
{
showUnknownError &&
<div className='Input___error group-error'>
<i className='icon icon-alert-outline'/>
<FormattedMessage
id='user_groups_modal.unknownError'
defaultMessage='An unknown error has occurred.'
</span>
</button>
</Modal.Header>
<Modal.Body
className='overflow--visible'
>
<div className='user-groups-modal__content'>
<form role='form'>
<div className='group-add-user'>
<AddUserToGroupMultiSelect
multilSelectKey={'addUsersToGroupKey'}
onSubmitCallback={addUsersToGroup}
focusOnLoad={false}
savingEnabled={isSaveEnabled()}
addUserCallback={addUserCallback}
deleteUserCallback={deleteUserCallback}
groupId={props.groupId}
searchOptions={searchOptions}
buttonSubmitText={defineMessage({id: 'multiselect.addPeopleToGroup', defaultMessage: 'Add People'})}
buttonSubmitLoadingText={defineMessage({id: 'multiselect.adding', defaultMessage: 'Adding...'})}
backButtonClick={goBack}
backButtonClass={'multiselect-back'}
saving={saving}
/>
</div>
}
</form>
</div>
</Modal.Body>
{
showUnknownError &&
<div className='Input___error group-error'>
<i className='icon icon-alert-outline'/>
<FormattedMessage
id='user_groups_modal.unknownError'
defaultMessage='An unknown error has occurred.'
/>
</div>
}
</form>
</div>
</Modal.Body>
</div>
</Modal>
);
};

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

@@ -73,7 +73,7 @@ exports[`component/user_group_popover should match snapshot 1`] = `
className="overflow--ellipsis text-nowrap"
>
<span
className="Title-gqsoLG eUZcLS overflow--ellipsis text-nowrap"
className="Title-kjfqTJ hEcOkX overflow--ellipsis text-nowrap"
>
group_display_name
</span>

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

@@ -1,200 +1,193 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`component/user_groups_modal should match snapshot with groups 1`] = `
<Modal
animation={true}
aria-labelledby="userGroupsModalLabel"
autoFocus={true}
backdrop={true}
bsClass="modal"
dialogClassName="a11y__modal user-groups-modal"
dialogComponentClass={[Function]}
<GenericModal
ariaLabel="userGroupsModalLabel"
bodyPadding={false}
className="a11y__modal user-groups-modal"
compassDesign={true}
enforceFocus={true}
headerButton={
<button
className="user-groups-create btn btn-secondary btn-sm"
onClick={[Function]}
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Create Group"
id="user_groups_modal.createNew"
/>
</button>
}
id="userGroupsModal"
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
modalHeaderText={
<Memo(MemoizedFormattedMessage)
defaultMessage="User Groups"
id="user_groups_modal.title"
/>
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="none"
show={true}
>
<Connect(Component)
backButtonAction={[MockFunction]}
onExited={[MockFunction]}
/>
<ModalBody
bsClass="modal-body"
componentClass="div"
<div
className="user-groups-search"
>
<div
className="user-groups-search"
>
<ForwardRef
className="user-group-search-input"
data-testid="searchInput"
inputPrefix={
<i
className="icon icon-magnify"
/>
}
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "Search Groups",
"id": "user_groups_modal.searchGroups",
}
}
type="text"
value=""
/>
</div>
<Memo(UserGroupsFilter)
getGroups={[Function]}
selectedFilter="all"
aria-atomic="true"
aria-live="polite"
className="sr-only"
role="status"
/>
<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,
},
Object {
"allow_reference": true,
"create_at": 1637349374137,
"delete_at": 0,
"description": "Group 1 description",
"display_name": "Group 1",
"has_syncables": false,
"id": "group1",
"member_count": 2,
"name": "group1",
"remote_id": null,
"scheme_admin": false,
"source": "custom",
"update_at": 1637349374137,
},
Object {
"allow_reference": true,
"create_at": 1637349374137,
"delete_at": 0,
"description": "Group 2 description",
"display_name": "Group 2",
"has_syncables": false,
"id": "group2",
"member_count": 3,
"name": "group2",
"remote_id": null,
"scheme_admin": false,
"source": "custom",
"update_at": 1637349374137,
},
]
<ForwardRef
className="user-group-search-input"
data-testid="searchInput"
inputPrefix={
<i
className="icon icon-magnify"
/>
}
hasNextPage={true}
loadMoreGroups={[Function]}
loading={false}
onExited={[MockFunction]}
searchTerm=""
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "Search Groups",
"id": "user_groups_modal.searchGroups",
}
}
type="text"
value=""
/>
</ModalBody>
</Modal>
</div>
<Memo(UserGroupsFilter)
getGroups={[Function]}
onToggle={[Function]}
selectedFilter="all"
/>
<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,
},
Object {
"allow_reference": true,
"create_at": 1637349374137,
"delete_at": 0,
"description": "Group 1 description",
"display_name": "Group 1",
"has_syncables": false,
"id": "group1",
"member_count": 2,
"name": "group1",
"remote_id": null,
"scheme_admin": false,
"source": "custom",
"update_at": 1637349374137,
},
Object {
"allow_reference": true,
"create_at": 1637349374137,
"delete_at": 0,
"description": "Group 2 description",
"display_name": "Group 2",
"has_syncables": false,
"id": "group2",
"member_count": 3,
"name": "group2",
"remote_id": null,
"scheme_admin": false,
"source": "custom",
"update_at": 1637349374137,
},
]
}
hasNextPage={true}
loadMoreGroups={[Function]}
loading={false}
onExited={[MockFunction]}
onToggle={[Function]}
searchTerm=""
/>
</GenericModal>
`;
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]}
<GenericModal
ariaLabel="userGroupsModalLabel"
bodyPadding={false}
className="a11y__modal user-groups-modal"
compassDesign={true}
enforceFocus={true}
headerButton={
<button
className="user-groups-create btn btn-secondary btn-sm"
onClick={[Function]}
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Create Group"
id="user_groups_modal.createNew"
/>
</button>
}
id="userGroupsModal"
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
modalHeaderText={
<Memo(MemoizedFormattedMessage)
defaultMessage="User Groups"
id="user_groups_modal.title"
/>
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="none"
show={true}
>
<Connect(Component)
backButtonAction={[MockFunction]}
onExited={[MockFunction]}
/>
<ModalBody
bsClass="modal-body"
componentClass="div"
<div
className="user-groups-search"
>
<div
className="user-groups-search"
>
<ForwardRef
className="user-group-search-input"
data-testid="searchInput"
inputPrefix={
<i
className="icon icon-magnify"
/>
}
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "Search Groups",
"id": "user_groups_modal.searchGroups",
}
}
type="text"
value=""
/>
</div>
<Memo(UserGroupsFilter)
getGroups={[Function]}
selectedFilter="all"
aria-atomic="true"
aria-live="polite"
className="sr-only"
role="status"
/>
<NoResultsIndicator
variant="UserGroups"
<ForwardRef
className="user-group-search-input"
data-testid="searchInput"
inputPrefix={
<i
className="icon icon-magnify"
/>
}
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "Search Groups",
"id": "user_groups_modal.searchGroups",
}
}
type="text"
value=""
/>
<Memo(ADLDAPUpsellBanner) />
</ModalBody>
</Modal>
</div>
<Memo(UserGroupsFilter)
getGroups={[Function]}
onToggle={[Function]}
selectedFilter="all"
/>
<NoResultsIndicator
variant="UserGroups"
/>
<Memo(ADLDAPUpsellBanner) />
</GenericModal>
`;

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

@@ -8,9 +8,12 @@ import type {Dispatch} from 'redux';
import type {Group} from '@mattermost/types/groups';
import {getGroups, getGroupsByUserIdPaginated, searchGroups} from 'mattermost-redux/actions/groups';
import {Permissions} from 'mattermost-redux/constants';
import {makeGetAllAssociatedGroupsForReference, makeGetMyAllowReferencedGroups, searchAllowReferencedGroups, searchMyAllowReferencedGroups, searchArchivedGroups, getArchivedGroups} from 'mattermost-redux/selectors/entities/groups';
import {haveISystemPermission} from 'mattermost-redux/selectors/entities/roles';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {openModal} from 'actions/views/modals';
import {setModalSearchTerm} from 'actions/views/search';
import {isModalOpen} from 'selectors/views/modals';
@@ -47,6 +50,7 @@ function makeMapStateToProps() {
myGroups,
archivedGroups,
currentUserId: getCurrentUserId(state),
canCreateCustomGroups: haveISystemPermission(state, {permission: Permissions.CREATE_CUSTOM_GROUP}),
};
};
}
@@ -58,6 +62,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
setModalSearchTerm,
getGroupsByUserIdPaginated,
searchGroups,
openModal,
}, dispatch),
};
}

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

@@ -2,14 +2,14 @@
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {FormattedMessage, useIntl} from 'react-intl';
import Menu from 'components/widgets/menu/menu';
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
import * as Menu from 'components/menu';
type Props = {
selectedFilter: string;
getGroups: (page: number, groupType: string) => void;
onToggle: (isOpen: boolean) => void;
}
const UserGroupsFilter = (props: Props) => {
@@ -45,42 +45,58 @@ const UserGroupsFilter = (props: Props) => {
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.Container
menuButton={{
id: 'groupsFilterDropdown',
class: 'groups-filter-btn',
children: (
<>
<span>{filterLabel()}</span>
<span className='icon icon-chevron-down'/>
</>
),
}}
menu={{
id: 'groupsFilterDropdownMenu',
onToggle: props.onToggle,
'aria-label': intl.formatMessage({id: 'user_groups_modal.filterAriaLabel', defaultMessage: 'Groups Filter'}),
}}
>
<Menu.Item
id='groupsDropdownAll'
onClick={allGroupsOnClick}
labels={
<FormattedMessage
id='user_groups_modal.allGroups'
defaultMessage='All Groups'
/>
<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'/>}
}
trailingElements={selectedFilter === 'all' && <i className='icon icon-check'/>}
/>
<Menu.Item
id='groupsDropdownMy'
onClick={myGroupsOnClick}
labels={
<FormattedMessage
id='user_groups_modal.myGroups'
defaultMessage='My Groups'
/>
</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'/>}
}
trailingElements={selectedFilter === 'my' && <i className='icon icon-check'/>}
/>
<Menu.Separator/>
<Menu.Item
id='groupsDropdownArchived'
onClick={archivedGroupsOnClick}
labels={
<FormattedMessage
id='user_groups_modal.archivedGroups'
defaultMessage='Archived Groups'
/>
</Menu.Group>
</Menu>
</MenuWrapper>
}
trailingElements={selectedFilter === 'archived' && <i className='icon icon-check'/>}
/>
</Menu.Container>
</div>
);
};

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

@@ -12,6 +12,7 @@ describe('component/user_groups_modal', () => {
const baseProps = {
onExited: jest.fn(),
onScroll: jest.fn(),
onToggle: jest.fn(),
groups: [],
searchTerm: '',
backButtonAction: jest.fn(),

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

@@ -12,11 +12,10 @@ import type {Group, GroupPermissions} from '@mattermost/types/groups';
import type {ActionResult} from 'mattermost-redux/types/actions';
import LoadingScreen from 'components/loading_screen';
import * as Menu from 'components/menu';
import NoResultsIndicator from 'components/no_results_indicator';
import {NoResultsVariant} from 'components/no_results_indicator/types';
import ViewUserGroupModal from 'components/view_user_group_modal';
import Menu from 'components/widgets/menu/menu';
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
import {ModalIdentifiers} from 'utils/constants';
@@ -33,6 +32,7 @@ export type Props = {
onExited: () => void;
backButtonAction: () => void;
hasNextPage: boolean;
onToggle: (isOpen: boolean) => void;
actions: {
archiveGroup: (groupId: string) => Promise<ActionResult>;
restoreGroup: (groupId: string) => Promise<ActionResult>;
@@ -51,6 +51,7 @@ const UserGroupsList = (props: Props) => {
backButtonAction,
onExited,
actions,
onToggle,
} = props;
const infiniteLoaderRef = useRef<InfiniteLoader | null>(null);
@@ -108,13 +109,6 @@ const UserGroupsList = (props: Props) => {
onExited();
}, [actions.openModal, onExited, backButtonAction]);
const groupListOpenUp = (groupListItemIndex: number): boolean => {
if (groupListItemIndex === 0) {
return false;
}
return true;
};
const Item = ({index, style}: ListChildComponentProps) => {
if (groups.length === 0 && searchTerm) {
return (
@@ -131,13 +125,14 @@ const UserGroupsList = (props: Props) => {
}
return (
<div
<button
className='group-row'
style={style}
key={group.id}
onClick={() => {
goToViewGroupModal(group);
}}
aria-label={formatMessage({id: 'user_groups_list.groupAriaLabel', defaultMessage: '{group_name} group'}, {group_name: group.display_name})}
>
<span className='group-display-name'>
{
@@ -159,55 +154,68 @@ const UserGroupsList = (props: Props) => {
/>
</div>
<div className='group-action'>
<MenuWrapper
isDisabled={false}
stopPropagationOnToggle={true}
id={`customWrapper-${group.id}`}
<Menu.Container
menuButton={{
id: `customWrapper-${group.id}`,
class: 'btn btn-icon btn-xs',
children: <i className='icon icon-dots-vertical'/>,
'aria-label': formatMessage({id: 'user_groups_list.menuAriaLabel', defaultMessage: '{group_name} actions'}, {group_name: group.display_name}),
}}
menu={{
id: 'group-actions-menu',
onToggle,
'aria-label': formatMessage({id: 'user_groups_list.menuAriaLabel', defaultMessage: '{group_name} actions'}, {group_name: group.display_name}),
className: 'group-actions-menu',
}}
>
<button className='btn btn-icon btn-xs'>
<i className='icon icon-dots-vertical'/>
</button>
<Menu
openLeft={true}
openUp={groupListOpenUp(index)}
className={'group-actions-menu'}
ariaLabel={formatMessage({id: 'admin.user_item.menuAriaLabel', defaultMessage: 'User Actions Menu'})}
>
<Menu.Group>
<Menu.ItemAction
onClick={() => {
goToViewGroupModal(group);
}}
icon={<i className='icon-account-multiple-outline'/>}
text={formatMessage({id: 'user_groups_modal.viewGroup', defaultMessage: 'View Group'})}
disabled={false}
<Menu.Item
id='view-group'
onClick={() => {
goToViewGroupModal(group);
}}
labels={
<FormattedMessage
id='user_groups_modal.viewGroup'
defaultMessage='View Group'
/>
</Menu.Group>
<Menu.Group>
<Menu.ItemAction
show={groupPermissionsMap[group.id].can_delete}
onClick={() => {
archiveGroup(group.id);
}}
icon={<i className='icon-archive-outline'/>}
text={formatMessage({id: 'user_groups_modal.archiveGroup', defaultMessage: 'Archive Group'})}
disabled={false}
isDangerous={true}
/>
<Menu.ItemAction
show={groupPermissionsMap[group.id].can_restore}
onClick={() => {
restoreGroup(group.id);
}}
icon={<i className='icon-restore'/>}
text={formatMessage({id: 'user_groups_modal.restoreGroup', defaultMessage: 'Restore Group'})}
disabled={false}
/>
</Menu.Group>
</Menu>
</MenuWrapper>
}
leadingElement={<i className='icon-account-multiple-outline'/>}
/>
<Menu.Separator/>
{groupPermissionsMap[group.id].can_delete && (
<Menu.Item
id='archive-group'
onClick={() => {
archiveGroup(group.id);
}}
labels={
<FormattedMessage
id='user_groups_modal.archiveGroup'
defaultMessage='Archive Group'
/>
}
leadingElement={<i className='icon-archive-outline'/>}
isDestructive={true}
/>
)}
{groupPermissionsMap[group.id].can_restore && (
<Menu.Item
id='restore-group'
onClick={() => {
restoreGroup(group.id);
}}
labels={
<FormattedMessage
id='user_groups_modal.restoreGroup'
defaultMessage='Restore Group'
/>
}
leadingElement={<i className='icon-restore'/>}
/>
)}
</Menu.Container>
</div>
</div>
</button>
);
}
if (loading) {

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

@@ -127,12 +127,8 @@
}
}
.modal-header {
h1#createGroupsModalTitleWithBack,
h1#updateGroupsModalTitle,
h1#addUsersToGroupsModalLabel {
margin-left: 16px;
}
.GenericModal__header {
display: flex;
}
}
@@ -155,7 +151,9 @@
line-height: 38px;
}
.Input {
.Input.form-control {
height: 34px;
border: none;
font-size: 14px;
line-height: 38px;
}
@@ -177,8 +175,11 @@
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
#groupsFilterDropdown {
display: inline-flex;
padding: 6px;
border: none;
border-radius: 4px;
background: none;
line-height: 12px;
&:hover {
@@ -189,14 +190,11 @@
background: rgba(var(--button-bg-rgb), 0.08);
}
a {
span {
color: var(--button-bg);
font-size: 12px;
line-height: 12px;
span {
color: var(--button-bg);
font-size: 12px;
line-height: 12px;
}
white-space: nowrap;
}
}
@@ -242,6 +240,9 @@
display: flex;
align-items: center;
padding: 14px 32px;
border: none;
background: none;
line-height: 38px;
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.08);

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

@@ -22,7 +22,9 @@ describe('component/user_groups_modal', () => {
setModalSearchTerm: jest.fn(),
getGroupsByUserIdPaginated: jest.fn(),
searchGroups: jest.fn(),
openModal: jest.fn(),
},
canCreateCustomGroups: true,
};
function getGroups(numberOfGroups: number) {

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

@@ -2,25 +2,27 @@
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Modal} from 'react-bootstrap';
import {defineMessage} from 'react-intl';
import {defineMessage, FormattedMessage} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import type {GetGroupsForUserParams, GetGroupsParams, Group, GroupSearchParams} from '@mattermost/types/groups';
import './user_groups_modal.scss';
import type {ActionResult} from 'mattermost-redux/types/actions';
import CreateUserGroupsModal from 'components/create_user_groups_modal';
import NoResultsIndicator from 'components/no_results_indicator';
import {NoResultsVariant} from 'components/no_results_indicator/types';
import Input from 'components/widgets/inputs/input/input';
import Constants from 'utils/constants';
import Constants, {ModalIdentifiers} from 'utils/constants';
import type {ModalData} from 'types/actions';
import ADLDAPUpsellBanner from './ad_ldap_upsell_banner';
import {usePagingMeta} from './hooks';
import UserGroupsFilter from './user_groups_filter/user_groups_filter';
import UserGroupsList from './user_groups_list';
import UserGroupsModalHeader from './user_groups_modal_header';
const GROUPS_PER_PAGE = 60;
@@ -32,6 +34,7 @@ export type Props = {
searchTerm: string;
currentUserId: string;
backButtonAction: () => void;
canCreateCustomGroups: boolean;
actions: {
getGroups: (
opts: GetGroupsParams,
@@ -43,6 +46,7 @@ export type Props = {
searchGroups: (
params: GroupSearchParams,
) => Promise<ActionResult>;
openModal: <P>(modalData: ModalData<P>) => void;
};
}
@@ -53,6 +57,7 @@ const UserGroupsModal = (props: Props) => {
const [selectedFilter, setSelectedFilter] = useState('all');
const [groupsFull, setGroupsFull] = useState(false);
const [groups, setGroups] = useState(props.groups);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [page, setPage] = usePagingMeta(selectedFilter);
@@ -180,55 +185,105 @@ const UserGroupsModal = (props: Props) => {
return NoResultsVariant.UserGroups;
}, [selectedFilter]);
const goToCreateModal = useCallback(() => {
props.actions.openModal({
modalId: ModalIdentifiers.USER_GROUPS_CREATE,
dialogType: CreateUserGroupsModal,
dialogProps: {
backButtonCallback: props.backButtonAction,
},
});
props.onExited();
}, [props.actions.openModal, props.backButtonAction, props.onExited]);
return (
<Modal
dialogClassName='a11y__modal user-groups-modal'
<GenericModal
id='userGroupsModal'
ariaLabel='userGroupsModalLabel'
className='a11y__modal user-groups-modal'
show={show}
onHide={doHide}
onExited={props.onExited}
role='none'
aria-labelledby='userGroupsModalLabel'
id='userGroupsModal'
>
<UserGroupsModalHeader
onExited={props.onExited}
backButtonAction={props.backButtonAction}
/>
<Modal.Body>
<div className='user-groups-search'>
<Input
type='text'
placeholder={defineMessage({id: 'user_groups_modal.searchGroups', defaultMessage: 'Search Groups'})}
onChange={handleSearch}
value={props.searchTerm}
data-testid='searchInput'
className={'user-group-search-input'}
inputPrefix={inputPrefix}
/>
</div>
<UserGroupsFilter
selectedFilter={selectedFilter}
getGroups={getGroups}
compassDesign={true}
modalHeaderText={
<FormattedMessage
id='user_groups_modal.title'
defaultMessage='User Groups'
/>
{(groups.length === 0 && !props.searchTerm) ? <>
<NoResultsIndicator
variant={noResultsType}
}
headerButton={
props.canCreateCustomGroups &&
<button
className='user-groups-create btn btn-secondary btn-sm'
onClick={goToCreateModal}
>
<FormattedMessage
id='user_groups_modal.createNew'
defaultMessage='Create Group'
/>
<ADLDAPUpsellBanner/>
</> : <>
<UserGroupsList
groups={groups}
searchTerm={props.searchTerm}
loading={loading}
hasNextPage={!groupsFull}
loadMoreGroups={loadMoreGroups}
onExited={props.onExited}
backButtonAction={props.backButtonAction}
/>
</>
}
</Modal.Body>
</Modal>
</button>
}
bodyPadding={false}
enforceFocus={!isMenuOpen}
>
<div className='user-groups-search'>
<div
className='sr-only'
role='status'
aria-live='polite'
aria-atomic='true'
>
{props.searchTerm && (
groups.length > 0 ? (
<FormattedMessage
id='user_groups_modal.searchResults'
defaultMessage='{count} groups found'
values={{
count: groups.length,
}}
/>
) : (
<FormattedMessage
id='user_groups_modal.noSearchResults'
defaultMessage='No groups found'
/>
)
)}
</div>
<Input
type='text'
placeholder={defineMessage({id: 'user_groups_modal.searchGroups', defaultMessage: 'Search Groups'})}
onChange={handleSearch}
value={props.searchTerm}
data-testid='searchInput'
className={'user-group-search-input'}
inputPrefix={inputPrefix}
/>
</div>
<UserGroupsFilter
selectedFilter={selectedFilter}
getGroups={getGroups}
onToggle={setIsMenuOpen}
/>
{(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}
onToggle={setIsMenuOpen}
/>
</>
}
</GenericModal>
);
};

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

@@ -1,48 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`component/user_groups_modal should match snapshot with groups 1`] = `
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="userGroupsModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="User Groups"
id="user_groups_modal.title"
/>
</ModalTitle>
</ModalHeader>
`;
exports[`component/user_groups_modal should match snapshot without groups 1`] = `
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="userGroupsModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="User Groups"
id="user_groups_modal.title"
/>
</ModalTitle>
<button
className="user-groups-create btn btn-secondary btn-sm"
onClick={[Function]}
>
<MemoizedFormattedMessage
defaultMessage="Create Group"
id="user_groups_modal.createNew"
/>
</button>
</ModalHeader>
`;

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

@@ -1,33 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {Permissions} from 'mattermost-redux/constants';
import {haveISystemPermission} from 'mattermost-redux/selectors/entities/roles';
import {openModal} from 'actions/views/modals';
import type {GlobalState} from 'types/store';
import UserGroupsModalHeader from './user_groups_modal_header';
function mapStateToProps(state: GlobalState) {
const canCreateCustomGroups = haveISystemPermission(state, {permission: Permissions.CREATE_CUSTOM_GROUP});
return {
canCreateCustomGroups,
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
openModal,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(UserGroupsModalHeader);

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

@@ -1,37 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import UserGroupsModalHeader from './user_groups_modal_header';
describe('component/user_groups_modal', () => {
const baseProps = {
onExited: jest.fn(),
backButtonAction: jest.fn(),
canCreateCustomGroups: true,
actions: {
openModal: jest.fn(),
},
};
test('should match snapshot without groups', () => {
const wrapper = shallow(
<UserGroupsModalHeader
{...baseProps}
/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with groups', () => {
const wrapper = shallow(
<UserGroupsModalHeader
{...baseProps}
canCreateCustomGroups={false}
/>,
);
expect(wrapper).toMatchSnapshot();
});
});

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

@@ -1,62 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {Modal} from 'react-bootstrap';
import {FormattedMessage} from 'react-intl';
import CreateUserGroupsModal from 'components/create_user_groups_modal';
import {ModalIdentifiers} from 'utils/constants';
import type {ModalData} from 'types/actions';
export type Props = {
canCreateCustomGroups: boolean;
onExited: () => void;
backButtonAction: () => void;
actions: {
openModal: <P>(modalData: ModalData<P>) => void;
};
}
const UserGroupsModalHeader = (props: Props) => {
const goToCreateModal = useCallback(() => {
props.actions.openModal({
modalId: ModalIdentifiers.USER_GROUPS_CREATE,
dialogType: CreateUserGroupsModal,
dialogProps: {
backButtonCallback: props.backButtonAction,
},
});
props.onExited();
}, [props.actions.openModal, props.backButtonAction, props.onExited]);
return (
<Modal.Header closeButton={true}>
<Modal.Title
componentClass='h1'
id='userGroupsModalLabel'
>
<FormattedMessage
id='user_groups_modal.title'
defaultMessage='User Groups'
/>
</Modal.Title>
{
props.canCreateCustomGroups &&
<button
className='user-groups-create btn btn-secondary btn-sm'
onClick={goToCreateModal}
>
<FormattedMessage
id='user_groups_modal.createNew'
defaultMessage='Create Group'
/>
</button>
}
</Modal.Header>
);
};
export default React.memo(UserGroupsModalHeader);

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

@@ -9,7 +9,7 @@ exports[`component/view_user_group_modal should match snapshot 1`] = `
bsClass="modal"
dialogClassName="a11y__modal view-user-groups-modal"
dialogComponentClass={[Function]}
enforceFocus={true}
enforceFocus={false}
keyboard={true}
manager={
ModalManager {
@@ -30,96 +30,98 @@ exports[`component/view_user_group_modal should match snapshot 1`] = `
role="none"
show={true}
>
<Connect(Component)
backButtonAction={[MockFunction]}
backButtonCallback={[MockFunction]}
decrementMemberCount={[Function]}
groupId="groupid123"
incrementMemberCount={[Function]}
onExited={[MockFunction]}
/>
<ModalBody
bsClass="modal-body"
componentClass="div"
>
<div
className="group-mention-name"
<div>
<Connect(Component)
backButtonAction={[MockFunction]}
backButtonCallback={[MockFunction]}
decrementMemberCount={[Function]}
groupId="groupid123"
incrementMemberCount={[Function]}
onExited={[MockFunction]}
/>
<ModalBody
bsClass="modal-body"
componentClass="div"
>
<span
className="group-name"
<div
className="group-mention-name"
>
@group
</span>
</div>
<div
className="user-groups-search"
>
<ForwardRef
className="user-group-search-input"
data-testid="searchInput"
inputPrefix={
<i
className="icon icon-magnify"
/>
}
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "Search group members",
"id": "search_bar.searchGroupMembers",
<span
className="group-name"
>
@group
</span>
</div>
<div
className="user-groups-search"
>
<ForwardRef
className="user-group-search-input"
data-testid="searchInput"
inputPrefix={
<i
className="icon icon-magnify"
/>
}
}
type="text"
value=""
/>
</div>
<div
className="user-groups-modal__content group-member-list"
onScroll={[Function]}
>
<h2
className="group-member-count"
>
<MemoizedFormattedMessage
defaultMessage="{member_count} {member_count, plural, one {Member} other {Members}}"
id="view_user_group_modal.memberCount"
values={
onChange={[Function]}
placeholder={
Object {
"member_count": 6,
"defaultMessage": "Search group members",
"id": "search_bar.searchGroupMembers",
}
}
type="text"
value=""
/>
</div>
<div
className="user-groups-modal__content group-member-list"
onScroll={[Function]}
>
<h2
className="group-member-count"
>
<MemoizedFormattedMessage
defaultMessage="{member_count} {member_count, plural, one {Member} other {Members}}"
id="view_user_group_modal.memberCount"
values={
Object {
"member_count": 6,
}
}
/>
</h2>
<Connect(Component)
decrementMemberCount={[Function]}
groupId="groupid123"
key="user-1"
user={
Object {
"delete_at": 0,
"first_name": "user",
"id": "user-1",
"last_name": "one",
"username": "user1",
}
}
/>
</h2>
<Connect(Component)
decrementMemberCount={[Function]}
groupId="groupid123"
key="user-1"
user={
Object {
"delete_at": 0,
"first_name": "user",
"id": "user-1",
"last_name": "one",
"username": "user1",
<Connect(Component)
decrementMemberCount={[Function]}
groupId="groupid123"
key="user-2"
user={
Object {
"delete_at": 0,
"first_name": "user",
"id": "user-2",
"last_name": "otwo",
"username": "user2",
}
}
}
/>
<Connect(Component)
decrementMemberCount={[Function]}
groupId="groupid123"
key="user-2"
user={
Object {
"delete_at": 0,
"first_name": "user",
"id": "user-2",
"last_name": "otwo",
"username": "user2",
}
}
/>
<LoadingScreen />
</div>
</ModalBody>
/>
<LoadingScreen />
</div>
</ModalBody>
</div>
</Modal>
`;

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

@@ -2,18 +2,16 @@
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {useIntl, FormattedMessage} from 'react-intl';
import type {Group} from '@mattermost/types/groups';
import type {ActionResult} from 'mattermost-redux/types/actions';
import * as Menu from 'components/menu';
import UpdateUserGroupModal from 'components/update_user_group_modal';
import Menu from 'components/widgets/menu/menu';
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
import {ModalIdentifiers} from 'utils/constants';
import * as Utils from 'utils/utils';
import type {ModalData} from 'types/actions';
@@ -86,50 +84,70 @@ const ViewUserGroupHeaderSubMenu = (props: Props) => {
return (
<div className='details-action'>
<MenuWrapper
isDisabled={false}
stopPropagationOnToggle={false}
id={`detailsCustomWrapper-${group.id}`}
<Menu.Container
menuButton={{
id: `detailsCustomWrapper-${group.id}`,
class: 'btn btn-icon',
children: (<i className='icon icon-dots-vertical'/>),
'aria-label': formatMessage({id: 'view_user_group_header_sub_menu.menuAriaLabel', defaultMessage: 'User group actions'}),
}}
menu={{
id: 'details-group-actions-menu',
'aria-labelledby': `detailsCustomWrapper-${group.id}`,
className: 'group-actions-menu',
}}
>
<button className='btn btn-icon'>
<i
className='icon icon-dots-vertical'
aria-label={formatMessage({id: 'user_groups_modal.goBackLabel', defaultMessage: 'Back'})}
/>
</button>
<Menu
openLeft={false}
openUp={false}
ariaLabel={Utils.localizeMessage({id: 'admin.user_item.menuAriaLabel', defaultMessage: 'User Actions Menu'})}
>
<Menu.ItemAction
show={props.permissionToEditGroup}
{props.permissionToEditGroup && (
<Menu.Item
id='edit-details'
onClick={goToEditGroupModal}
text={Utils.localizeMessage({id: 'user_groups_modal.editDetails', defaultMessage: 'Edit Details'})}
disabled={false}
labels={
<FormattedMessage
id='user_groups_modal.editDetails'
defaultMessage='Edit Details'
/>
}
/>
<Menu.ItemAction
show={props.permissionToJoinGroup && !isGroupMember}
)}
{props.permissionToJoinGroup && !isGroupMember && (
<Menu.Item
id='join-group'
onClick={joinGroup}
text={Utils.localizeMessage({id: 'user_groups_modal.joinGroup', defaultMessage: 'Join Group'})}
disabled={false}
labels={
<FormattedMessage
id='user_groups_modal.joinGroup'
defaultMessage='Join Group'
/>
}
/>
<Menu.ItemAction
show={props.permissionToLeaveGroup && isGroupMember}
)}
{props.permissionToLeaveGroup && isGroupMember && (
<Menu.Item
id='leave-group'
onClick={leaveGroup}
text={Utils.localizeMessage({id: 'user_groups_modal.leaveGroup', defaultMessage: 'Leave Group'})}
disabled={false}
isDangerous={true}
labels={
<FormattedMessage
id='user_groups_modal.leaveGroup'
defaultMessage='Leave Group'
/>
}
isDestructive={true}
/>
<Menu.ItemAction
show={props.permissionToArchiveGroup}
)}
{props.permissionToArchiveGroup && (
<Menu.Item
id='archive-group'
onClick={archiveGroup}
text={Utils.localizeMessage({id: 'user_groups_modal.archiveGroup', defaultMessage: 'Archive Group'})}
disabled={false}
isDangerous={true}
labels={
<FormattedMessage
id='user_groups_modal.archiveGroup'
defaultMessage='Archive Group'
/>
}
isDestructive={true}
/>
</Menu>
</MenuWrapper>
)}
</Menu.Container>
</div>
);
};

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

@@ -98,16 +98,16 @@
}
button.remove-group-member {
display: none;
margin-right: 0;
margin-left: auto;
opacity: 0;
}
&:hover {
&:hover, &:focus-within {
background: rgba(var(--center-channel-color-rgb), 0.08);
button.remove-group-member {
display: block;
opacity: 1;
}
}
}

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

@@ -75,7 +75,7 @@ describe('component/view_user_group_modal', () => {
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, search user1', () => {
test('should match snapshot, search user1', async () => {
const wrapper = shallow(
<ViewUserGroupModal
{...baseProps}
@@ -83,21 +83,14 @@ describe('component/view_user_group_modal', () => {
/>,
);
const instance = wrapper.instance() as ViewUserGroupModal;
const e = {
target: {
value: '',
},
};
instance.handleSearch(e as React.ChangeEvent<HTMLInputElement>);
// Simulate search input change
const searchInput = wrapper.find('ForwardRef[data-testid="searchInput"]');
searchInput.simulate('change', {target: {value: ''}});
expect(baseProps.actions.setModalSearchTerm).toHaveBeenCalledTimes(1);
expect(baseProps.actions.setModalSearchTerm).toBeCalledWith('');
e.target.value = 'user1';
instance.handleSearch(e as React.ChangeEvent<HTMLInputElement>);
expect(wrapper.state('loading')).toEqual(true);
searchInput.simulate('change', {target: {value: 'user1'}});
expect(baseProps.actions.setModalSearchTerm).toHaveBeenCalledTimes(2);
expect(baseProps.actions.setModalSearchTerm).toBeCalledWith(e.target.value);
expect(baseProps.actions.setModalSearchTerm).toBeCalledWith('user1');
});
});

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

@@ -1,11 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {createRef} from 'react';
import type {RefObject} from 'react';
import React, {useRef, useState, useEffect, useCallback} from 'react';
import {Modal} from 'react-bootstrap';
import {defineMessage, FormattedMessage} from 'react-intl';
import {useFocusTrap} from '@mattermost/components/src/hooks/useFocusTrap';
import type {Group} from '@mattermost/types/groups';
import {GroupSource, PluginGroupSourcePrefix} from '@mattermost/types/groups';
import type {UserProfile} from '@mattermost/types/users';
@@ -43,133 +43,119 @@ export type Props = {
};
}
type State = {
page: number;
loading: boolean;
show: boolean;
selectedFilter: string;
memberCount: number;
}
const ViewUserGroupModal: React.FC<Props> = ({
onExited,
searchTerm,
groupId,
group,
users,
backButtonCallback,
backButtonAction,
actions,
}) => {
const divScrollRef = useRef<HTMLDivElement>(null);
const searchTimeoutIdRef = useRef<number>(0);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(true);
const [show, setShow] = useState(true);
const [memberCount, setMemberCount] = useState(group?.member_count || 0);
export default class ViewUserGroupModal extends React.PureComponent<Props, State> {
private divScrollRef: RefObject<HTMLDivElement>;
private searchTimeoutId: number;
const modalRef = useRef<HTMLDivElement>(null);
useFocusTrap(show, modalRef);
constructor(props: Props) {
super(props);
const incrementMemberCount = useCallback(() => {
setMemberCount((prev) => prev + 1);
}, []);
this.divScrollRef = createRef();
this.searchTimeoutId = 0;
this.state = {
page: 0,
loading: true,
show: true,
selectedFilter: 'all',
memberCount: props.group?.member_count || 0,
};
}
const decrementMemberCount = useCallback(() => {
setMemberCount((prev) => prev - 1);
}, []);
incrementMemberCount = () => {
this.setState({memberCount: this.state.memberCount + 1});
};
const doHide = useCallback(() => {
setShow(false);
}, []);
decrementMemberCount = () => {
this.setState({memberCount: this.state.memberCount - 1});
};
const startLoad = useCallback(() => {
setLoading(true);
}, []);
doHide = () => {
this.setState({show: false});
};
const loadComplete = useCallback(() => {
setLoading(false);
}, []);
async componentDidMount() {
const {
groupId,
actions,
} = this.props;
await Promise.all([
actions.getGroup(groupId, true),
actions.getUsersInGroup(groupId, 0, USERS_PER_PAGE),
]);
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 () => {
await prevProps.actions.searchProfiles(searchTerm, {in_group_id: this.props.groupId});
},
Constants.SEARCH_TIMEOUT_MILLISECONDS,
);
this.searchTimeoutId = searchTimeoutId;
}
if (prevProps.group?.member_count !== this.props.group?.member_count) {
this.setMemberCount(this.props.group?.member_count || 0);
}
}
setMemberCount = (count: number) => {
this.setState({memberCount: count});
};
startLoad = () => {
this.setState({loading: true});
};
loadComplete = () => {
this.setState({loading: false});
};
handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const term = e.target.value;
this.props.actions.setModalSearchTerm(term);
};
actions.setModalSearchTerm(term);
}, [actions]);
getGroupMembers = debounce(
async () => {
const {actions, groupId} = this.props;
const {page} = this.state;
const newPage = page + 1;
this.setState({page: newPage});
this.startLoad();
await actions.getUsersInGroup(groupId, newPage, USERS_PER_PAGE);
this.loadComplete();
},
200,
false,
(): void => {},
const getGroupMembers = useCallback(
debounce(
async () => {
const newPage = page + 1;
setPage(newPage);
startLoad();
await actions.getUsersInGroup(groupId, newPage, USERS_PER_PAGE);
loadComplete();
},
200,
false,
() => {},
),
[page, groupId, actions, startLoad, loadComplete],
);
onScroll = () => {
const scrollHeight = this.divScrollRef.current?.scrollHeight || 0;
const scrollTop = this.divScrollRef.current?.scrollTop || 0;
const clientHeight = this.divScrollRef.current?.clientHeight || 0;
const onScroll = useCallback(() => {
const scrollHeight = divScrollRef.current?.scrollHeight || 0;
const scrollTop = divScrollRef.current?.scrollTop || 0;
const clientHeight = divScrollRef.current?.clientHeight || 0;
if (((scrollTop + clientHeight + 30) >= scrollHeight && this.props.group) && (this.props.users.length !== this.props.group.member_count && this.state.loading === false)) {
this.getGroupMembers();
if (((scrollTop + clientHeight + 30) >= scrollHeight && group) && (users.length !== group.member_count && !loading)) {
getGroupMembers();
}
};
}, [group, users.length, loading, getGroupMembers]);
mentionName = () => {
const {group} = this.props;
useEffect(() => {
const fetchData = async () => {
await Promise.all([
actions.getGroup(groupId, true),
actions.getUsersInGroup(groupId, 0, USERS_PER_PAGE),
]);
loadComplete();
};
fetchData();
return () => {
actions.setModalSearchTerm('');
};
}, [groupId, actions, loadComplete]);
useEffect(() => {
if (group?.member_count !== undefined && group.member_count !== memberCount) {
setMemberCount(group.member_count);
}
}, [group?.member_count]);
useEffect(() => {
if (searchTerm === '') {
loadComplete();
searchTimeoutIdRef.current = 0;
return () => {};
}
clearTimeout(searchTimeoutIdRef.current);
searchTimeoutIdRef.current = window.setTimeout(
async () => {
await actions.searchProfiles(searchTerm, {in_group_id: groupId});
},
Constants.SEARCH_TIMEOUT_MILLISECONDS,
);
return () => {
clearTimeout(searchTimeoutIdRef.current);
};
}, [searchTerm, groupId, actions, loadComplete]);
const mentionName = () => {
if (group) {
return (
<div className='group-mention-name'>
@@ -198,29 +184,28 @@ export default class ViewUserGroupModal extends React.PureComponent<Props, State
return (<></>);
};
render() {
const {groupId, group, users, onExited} = this.props;
return (
<Modal
dialogClassName='a11y__modal view-user-groups-modal'
show={this.state.show}
onHide={this.doHide}
onExited={onExited}
role='none'
aria-labelledby='viewUserGroupModalLabel'
>
return (
<Modal
dialogClassName='a11y__modal view-user-groups-modal'
show={show}
onHide={doHide}
onExited={onExited}
role='none'
aria-labelledby='viewUserGroupModalLabel'
enforceFocus={false}
>
<div ref={modalRef}>
<ViewUserGroupModalHeader
onExited={onExited}
groupId={groupId}
backButtonCallback={this.props.backButtonCallback}
backButtonAction={this.props.backButtonAction}
incrementMemberCount={this.incrementMemberCount}
decrementMemberCount={this.decrementMemberCount}
backButtonCallback={backButtonCallback}
backButtonAction={backButtonAction}
incrementMemberCount={incrementMemberCount}
decrementMemberCount={decrementMemberCount}
/>
<Modal.Body>
{this.mentionName()}
{((users.length === 0 && !this.props.searchTerm && !this.state.loading) || !group) ? (
{mentionName()}
{((users.length === 0 && !searchTerm && !loading) || !group) ? (
<NoResultsIndicator
variant={NoResultsVariant.UserGroupMembers}
/>
@@ -230,8 +215,8 @@ export default class ViewUserGroupModal extends React.PureComponent<Props, State
<Input
type='text'
placeholder={defineMessage({id: 'search_bar.searchGroupMembers', defaultMessage: 'Search group members'})}
onChange={this.handleSearch}
value={this.props.searchTerm}
onChange={handleSearch}
value={searchTerm}
data-testid='searchInput'
className={'user-group-search-input'}
inputPrefix={<i className={'icon icon-magnify'}/>}
@@ -239,8 +224,8 @@ export default class ViewUserGroupModal extends React.PureComponent<Props, State
</div>
<div
className='user-groups-modal__content group-member-list'
onScroll={this.onScroll}
ref={this.divScrollRef}
onScroll={onScroll}
ref={divScrollRef}
>
{(users.length !== 0) &&
<h2 className='group-member-count'>
@@ -248,36 +233,36 @@ export default class ViewUserGroupModal extends React.PureComponent<Props, State
id='view_user_group_modal.memberCount'
defaultMessage='{member_count} {member_count, plural, one {Member} other {Members}}'
values={{
member_count: this.state.memberCount,
member_count: memberCount,
}}
/>
</h2>
}
{(users.length === 0 && this.props.searchTerm) &&
{(users.length === 0 && searchTerm) &&
<NoResultsIndicator
variant={NoResultsVariant.Search}
titleValues={{channelName: `${this.props.searchTerm}`}}
titleValues={{channelName: `${searchTerm}`}}
/>
}
{users.map((user) => {
return (
<ViewUserGroupListItem
groupId={groupId}
user={user}
decrementMemberCount={this.decrementMemberCount}
key={user.id}
/>
);
})}
{users.map((user) => (
<ViewUserGroupListItem
groupId={groupId}
user={user}
decrementMemberCount={decrementMemberCount}
key={user.id}
/>
))}
{
this.state.loading &&
loading &&
<LoadingScreen/>
}
</div>
</>
)}
</Modal.Body>
</Modal>
);
}
}
</div>
</Modal>
);
};
export default ViewUserGroupModal;

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

@@ -160,7 +160,7 @@ const ViewUserGroupModalHeader = ({
}, [backButtonCallback, onExited]);
return (
<Modal.Header closeButton={true}>
<Modal.Header>
<div className='d-flex align-items-center'>
<button
type='button'
@@ -179,6 +179,20 @@ const ViewUserGroupModalHeader = ({
{restoreGroupButton()}
{subMenuButton()}
</div>
<button
type='button'
className='close'
onClick={onExited}
aria-label={formatMessage({id: 'generic.close', defaultMessage: 'Close'})}
>
<span aria-hidden='true'>{'×'}</span>
<span className='sr-only'>
<FormattedMessage
id='generic.close'
defaultMessage='Close'
/>
</span>
</button>
</Modal.Header>
);
};

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

@@ -5716,6 +5716,8 @@
"user_group_popover.memberCount": "{member_count} {member_count, plural, one {Member} other {Members}}",
"user_group_popover.openGroupModal": "View full group info",
"user_group_popover.searchGroupMembers": "Search members",
"user_groups_list.groupAriaLabel": "{group_name} group",
"user_groups_list.menuAriaLabel": "{group_name} actions",
"user_groups_modal.addPeople": "Add people",
"user_groups_modal.addPeopleTitle": "Add people to {group}",
"user_groups_modal.allGroups": "All Groups",
@@ -5726,7 +5728,7 @@
"user_groups_modal.createTitle": "Create Group",
"user_groups_modal.editDetails": "Edit Details",
"user_groups_modal.editGroupTitle": "Edit Group Details",
"user_groups_modal.filterAriaLabel": "Groups Filter Menu",
"user_groups_modal.filterAriaLabel": "Groups Filter",
"user_groups_modal.goBackLabel": "Back",
"user_groups_modal.joinGroup": "Join Group",
"user_groups_modal.leaveGroup": "Leave Group",
@@ -5740,8 +5742,10 @@
"user_groups_modal.myGroups": "My Groups",
"user_groups_modal.name": "Name",
"user_groups_modal.nameIsEmpty": "Name is a required field.",
"user_groups_modal.noSearchResults": "No groups found",
"user_groups_modal.restoreGroup": "Restore Group",
"user_groups_modal.searchGroups": "Search Groups",
"user_groups_modal.searchResults": "{count} groups found",
"user_groups_modal.showAllGroups": "Show: All Groups",
"user_groups_modal.showArchivedGroups": "Show: Archived Groups",
"user_groups_modal.showMyGroups": "Show: My Groups",
@@ -6237,6 +6241,7 @@
"view_image.zoom_in": "Zoom In",
"view_image.zoom_out": "Zoom Out",
"view_image.zoom_reset": "Reset Zoom",
"view_user_group_header_sub_menu.menuAriaLabel": "User group actions",
"view_user_group_modal.ldapSynced": "AD/LDAP SYNCED",
"view_user_group_modal.memberCount": "{member_count} {member_count, plural, one {Member} other {Members}}",
"view_user_group_modal.pluginSynced": "Plugin SYNCED",

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

@@ -198,7 +198,7 @@ function isElementVisible(element: HTMLElement): boolean {
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0' ||
style.pointerEvents === 'none' ||
currentElement.hasAttribute('hidden')
) {
return false;