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" role="none"
show={true} show={true}
> >
<ModalHeader <div>
bsClass="modal-header" <ModalHeader
closeButton={true} bsClass="modal-header"
closeLabel="Close" closeButton={false}
> closeLabel="Close"
<div
className="d-flex align-items-center"
> >
<button <div
aria-label="Back" className="d-flex align-items-center"
className="modal-header-back-button btn btn-icon"
onClick={[Function]}
type="button"
> >
<i <button
className="icon icon-arrow-left" aria-label="Back"
/> className="modal-header-back-button btn btn-icon"
</button> onClick={[Function]}
<ModalTitle type="button"
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"
> >
<Connect(injectIntl(AddUserToGroupMultiSelect)) <i
addUserCallback={[Function]} className="icon icon-arrow-left"
backButtonClass="multiselect-back" />
backButtonClick={[Function]} </button>
buttonSubmitLoadingText={ <ModalTitle
bsClass="modal-title"
componentClass="h1"
id="addUsersToGroupsModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="Add people to {group}"
id="user_groups_modal.addPeopleTitle"
values={
Object { Object {
"defaultMessage": "Adding...", "group": "Group Name",
"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> </ModalTitle>
</form> </div>
</div> <button
</ModalBody> 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> </Modal>
`; `;

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

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

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

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

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

@@ -1,200 +1,193 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`component/user_groups_modal should match snapshot with groups 1`] = ` exports[`component/user_groups_modal should match snapshot with groups 1`] = `
<Modal <GenericModal
animation={true} ariaLabel="userGroupsModalLabel"
aria-labelledby="userGroupsModalLabel" bodyPadding={false}
autoFocus={true} className="a11y__modal user-groups-modal"
backdrop={true} compassDesign={true}
bsClass="modal"
dialogClassName="a11y__modal user-groups-modal"
dialogComponentClass={[Function]}
enforceFocus={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" id="userGroupsModal"
keyboard={true} modalHeaderText={
manager={ <Memo(MemoizedFormattedMessage)
ModalManager { defaultMessage="User Groups"
"add": [Function], id="user_groups_modal.title"
"containers": Array [], />
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
} }
onExited={[MockFunction]} onExited={[MockFunction]}
onHide={[Function]} onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="none"
show={true} show={true}
> >
<Connect(Component) <div
backButtonAction={[MockFunction]} className="user-groups-search"
onExited={[MockFunction]}
/>
<ModalBody
bsClass="modal-body"
componentClass="div"
> >
<div <div
className="user-groups-search" aria-atomic="true"
> aria-live="polite"
<ForwardRef className="sr-only"
className="user-group-search-input" role="status"
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"
/> />
<Connect(Component) <ForwardRef
backButtonAction={[MockFunction]} className="user-group-search-input"
groups={ data-testid="searchInput"
Array [ inputPrefix={
Object { <i
"allow_reference": true, className="icon icon-magnify"
"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} onChange={[Function]}
loadMoreGroups={[Function]} placeholder={
loading={false} Object {
onExited={[MockFunction]} "defaultMessage": "Search Groups",
searchTerm="" "id": "user_groups_modal.searchGroups",
}
}
type="text"
value=""
/> />
</ModalBody> </div>
</Modal> <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`] = ` exports[`component/user_groups_modal should match snapshot without groups 1`] = `
<Modal <GenericModal
animation={true} ariaLabel="userGroupsModalLabel"
aria-labelledby="userGroupsModalLabel" bodyPadding={false}
autoFocus={true} className="a11y__modal user-groups-modal"
backdrop={true} compassDesign={true}
bsClass="modal"
dialogClassName="a11y__modal user-groups-modal"
dialogComponentClass={[Function]}
enforceFocus={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" id="userGroupsModal"
keyboard={true} modalHeaderText={
manager={ <Memo(MemoizedFormattedMessage)
ModalManager { defaultMessage="User Groups"
"add": [Function], id="user_groups_modal.title"
"containers": Array [], />
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
} }
onExited={[MockFunction]} onExited={[MockFunction]}
onHide={[Function]} onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="none"
show={true} show={true}
> >
<Connect(Component) <div
backButtonAction={[MockFunction]} className="user-groups-search"
onExited={[MockFunction]}
/>
<ModalBody
bsClass="modal-body"
componentClass="div"
> >
<div <div
className="user-groups-search" aria-atomic="true"
> aria-live="polite"
<ForwardRef className="sr-only"
className="user-group-search-input" role="status"
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"
/> />
<NoResultsIndicator <ForwardRef
variant="UserGroups" 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) /> </div>
</ModalBody> <Memo(UserGroupsFilter)
</Modal> 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 type {Group} from '@mattermost/types/groups';
import {getGroups, getGroupsByUserIdPaginated, searchGroups} from 'mattermost-redux/actions/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 {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 {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {openModal} from 'actions/views/modals';
import {setModalSearchTerm} from 'actions/views/search'; import {setModalSearchTerm} from 'actions/views/search';
import {isModalOpen} from 'selectors/views/modals'; import {isModalOpen} from 'selectors/views/modals';
@@ -47,6 +50,7 @@ function makeMapStateToProps() {
myGroups, myGroups,
archivedGroups, archivedGroups,
currentUserId: getCurrentUserId(state), currentUserId: getCurrentUserId(state),
canCreateCustomGroups: haveISystemPermission(state, {permission: Permissions.CREATE_CUSTOM_GROUP}),
}; };
}; };
} }
@@ -58,6 +62,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
setModalSearchTerm, setModalSearchTerm,
getGroupsByUserIdPaginated, getGroupsByUserIdPaginated,
searchGroups, searchGroups,
openModal,
}, dispatch), }, dispatch),
}; };
} }

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

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

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

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

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

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

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

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

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

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

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

@@ -2,25 +2,27 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useState} from 'react'; import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Modal} from 'react-bootstrap'; import {defineMessage, FormattedMessage} from 'react-intl';
import {defineMessage} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import type {GetGroupsForUserParams, GetGroupsParams, Group, GroupSearchParams} from '@mattermost/types/groups'; import type {GetGroupsForUserParams, GetGroupsParams, Group, GroupSearchParams} from '@mattermost/types/groups';
import './user_groups_modal.scss'; import './user_groups_modal.scss';
import type {ActionResult} from 'mattermost-redux/types/actions'; import type {ActionResult} from 'mattermost-redux/types/actions';
import CreateUserGroupsModal from 'components/create_user_groups_modal';
import NoResultsIndicator from 'components/no_results_indicator'; import NoResultsIndicator from 'components/no_results_indicator';
import {NoResultsVariant} from 'components/no_results_indicator/types'; import {NoResultsVariant} from 'components/no_results_indicator/types';
import Input from 'components/widgets/inputs/input/input'; 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 ADLDAPUpsellBanner from './ad_ldap_upsell_banner';
import {usePagingMeta} from './hooks'; import {usePagingMeta} from './hooks';
import UserGroupsFilter from './user_groups_filter/user_groups_filter'; import UserGroupsFilter from './user_groups_filter/user_groups_filter';
import UserGroupsList from './user_groups_list'; import UserGroupsList from './user_groups_list';
import UserGroupsModalHeader from './user_groups_modal_header';
const GROUPS_PER_PAGE = 60; const GROUPS_PER_PAGE = 60;
@@ -32,6 +34,7 @@ export type Props = {
searchTerm: string; searchTerm: string;
currentUserId: string; currentUserId: string;
backButtonAction: () => void; backButtonAction: () => void;
canCreateCustomGroups: boolean;
actions: { actions: {
getGroups: ( getGroups: (
opts: GetGroupsParams, opts: GetGroupsParams,
@@ -43,6 +46,7 @@ export type Props = {
searchGroups: ( searchGroups: (
params: GroupSearchParams, params: GroupSearchParams,
) => Promise<ActionResult>; ) => Promise<ActionResult>;
openModal: <P>(modalData: ModalData<P>) => void;
}; };
} }
@@ -53,6 +57,7 @@ const UserGroupsModal = (props: Props) => {
const [selectedFilter, setSelectedFilter] = useState('all'); const [selectedFilter, setSelectedFilter] = useState('all');
const [groupsFull, setGroupsFull] = useState(false); const [groupsFull, setGroupsFull] = useState(false);
const [groups, setGroups] = useState(props.groups); const [groups, setGroups] = useState(props.groups);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [page, setPage] = usePagingMeta(selectedFilter); const [page, setPage] = usePagingMeta(selectedFilter);
@@ -180,55 +185,105 @@ const UserGroupsModal = (props: Props) => {
return NoResultsVariant.UserGroups; return NoResultsVariant.UserGroups;
}, [selectedFilter]); }, [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 ( return (
<Modal <GenericModal
dialogClassName='a11y__modal user-groups-modal' id='userGroupsModal'
ariaLabel='userGroupsModalLabel'
className='a11y__modal user-groups-modal'
show={show} show={show}
onHide={doHide} onHide={doHide}
onExited={props.onExited} onExited={props.onExited}
role='none' compassDesign={true}
aria-labelledby='userGroupsModalLabel' modalHeaderText={
id='userGroupsModal' <FormattedMessage
> id='user_groups_modal.title'
<UserGroupsModalHeader defaultMessage='User Groups'
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}
/> />
{(groups.length === 0 && !props.searchTerm) ? <> }
<NoResultsIndicator headerButton={
variant={noResultsType} props.canCreateCustomGroups &&
<button
className='user-groups-create btn btn-secondary btn-sm'
onClick={goToCreateModal}
>
<FormattedMessage
id='user_groups_modal.createNew'
defaultMessage='Create Group'
/> />
<ADLDAPUpsellBanner/> </button>
</> : <> }
<UserGroupsList bodyPadding={false}
groups={groups} enforceFocus={!isMenuOpen}
searchTerm={props.searchTerm} >
loading={loading} <div className='user-groups-search'>
hasNextPage={!groupsFull} <div
loadMoreGroups={loadMoreGroups} className='sr-only'
onExited={props.onExited} role='status'
backButtonAction={props.backButtonAction} aria-live='polite'
/> aria-atomic='true'
</> >
} {props.searchTerm && (
</Modal.Body> groups.length > 0 ? (
</Modal> <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" bsClass="modal"
dialogClassName="a11y__modal view-user-groups-modal" dialogClassName="a11y__modal view-user-groups-modal"
dialogComponentClass={[Function]} dialogComponentClass={[Function]}
enforceFocus={true} enforceFocus={false}
keyboard={true} keyboard={true}
manager={ manager={
ModalManager { ModalManager {
@@ -30,96 +30,98 @@ exports[`component/view_user_group_modal should match snapshot 1`] = `
role="none" role="none"
show={true} show={true}
> >
<Connect(Component) <div>
backButtonAction={[MockFunction]} <Connect(Component)
backButtonCallback={[MockFunction]} backButtonAction={[MockFunction]}
decrementMemberCount={[Function]} backButtonCallback={[MockFunction]}
groupId="groupid123" decrementMemberCount={[Function]}
incrementMemberCount={[Function]} groupId="groupid123"
onExited={[MockFunction]} incrementMemberCount={[Function]}
/> onExited={[MockFunction]}
<ModalBody />
bsClass="modal-body" <ModalBody
componentClass="div" bsClass="modal-body"
> componentClass="div"
<div
className="group-mention-name"
> >
<span <div
className="group-name" className="group-mention-name"
> >
@group <span
</span> className="group-name"
</div> >
<div @group
className="user-groups-search" </span>
> </div>
<ForwardRef <div
className="user-group-search-input" className="user-groups-search"
data-testid="searchInput" >
inputPrefix={ <ForwardRef
<i className="user-group-search-input"
className="icon icon-magnify" data-testid="searchInput"
/> inputPrefix={
} <i
onChange={[Function]} className="icon icon-magnify"
placeholder={ />
Object {
"defaultMessage": "Search group members",
"id": "search_bar.searchGroupMembers",
} }
} onChange={[Function]}
type="text" placeholder={
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 { 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)
<Connect(Component) decrementMemberCount={[Function]}
decrementMemberCount={[Function]} groupId="groupid123"
groupId="groupid123" key="user-2"
key="user-1" user={
user={ Object {
Object { "delete_at": 0,
"delete_at": 0, "first_name": "user",
"first_name": "user", "id": "user-2",
"id": "user-1", "last_name": "otwo",
"last_name": "one", "username": "user2",
"username": "user1", }
} }
} />
/> <LoadingScreen />
<Connect(Component) </div>
decrementMemberCount={[Function]} </ModalBody>
groupId="groupid123" </div>
key="user-2"
user={
Object {
"delete_at": 0,
"first_name": "user",
"id": "user-2",
"last_name": "otwo",
"username": "user2",
}
}
/>
<LoadingScreen />
</div>
</ModalBody>
</Modal> </Modal>
`; `;

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

@@ -2,18 +2,16 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useCallback} from 'react'; 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 {Group} from '@mattermost/types/groups';
import type {ActionResult} from 'mattermost-redux/types/actions'; import type {ActionResult} from 'mattermost-redux/types/actions';
import * as Menu from 'components/menu';
import UpdateUserGroupModal from 'components/update_user_group_modal'; 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 {ModalIdentifiers} from 'utils/constants';
import * as Utils from 'utils/utils';
import type {ModalData} from 'types/actions'; import type {ModalData} from 'types/actions';
@@ -86,50 +84,70 @@ const ViewUserGroupHeaderSubMenu = (props: Props) => {
return ( return (
<div className='details-action'> <div className='details-action'>
<MenuWrapper <Menu.Container
isDisabled={false} menuButton={{
stopPropagationOnToggle={false} id: `detailsCustomWrapper-${group.id}`,
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'> {props.permissionToEditGroup && (
<i <Menu.Item
className='icon icon-dots-vertical' id='edit-details'
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}
onClick={goToEditGroupModal} onClick={goToEditGroupModal}
text={Utils.localizeMessage({id: 'user_groups_modal.editDetails', defaultMessage: 'Edit Details'})} labels={
disabled={false} <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} onClick={joinGroup}
text={Utils.localizeMessage({id: 'user_groups_modal.joinGroup', defaultMessage: 'Join Group'})} labels={
disabled={false} <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} onClick={leaveGroup}
text={Utils.localizeMessage({id: 'user_groups_modal.leaveGroup', defaultMessage: 'Leave Group'})} labels={
disabled={false} <FormattedMessage
isDangerous={true} id='user_groups_modal.leaveGroup'
defaultMessage='Leave Group'
/>
}
isDestructive={true}
/> />
<Menu.ItemAction )}
show={props.permissionToArchiveGroup} {props.permissionToArchiveGroup && (
<Menu.Item
id='archive-group'
onClick={archiveGroup} onClick={archiveGroup}
text={Utils.localizeMessage({id: 'user_groups_modal.archiveGroup', defaultMessage: 'Archive Group'})} labels={
disabled={false} <FormattedMessage
isDangerous={true} id='user_groups_modal.archiveGroup'
defaultMessage='Archive Group'
/>
}
isDestructive={true}
/> />
</Menu> )}
</MenuWrapper> </Menu.Container>
</div> </div>
); );
}; };

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

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

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

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

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

@@ -160,7 +160,7 @@ const ViewUserGroupModalHeader = ({
}, [backButtonCallback, onExited]); }, [backButtonCallback, onExited]);
return ( return (
<Modal.Header closeButton={true}> <Modal.Header>
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
<button <button
type='button' type='button'
@@ -179,6 +179,20 @@ const ViewUserGroupModalHeader = ({
{restoreGroupButton()} {restoreGroupButton()}
{subMenuButton()} {subMenuButton()}
</div> </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> </Modal.Header>
); );
}; };

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

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

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

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