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,9 +31,10 @@ exports[`component/add_users_to_group_modal should match snapshot 1`] = `
role="none" role="none"
show={true} show={true}
> >
<div>
<ModalHeader <ModalHeader
bsClass="modal-header" bsClass="modal-header"
closeButton={true} closeButton={false}
closeLabel="Close" closeLabel="Close"
> >
<div <div
@@ -65,6 +66,26 @@ exports[`component/add_users_to_group_modal should match snapshot 1`] = `
/> />
</ModalTitle> </ModalTitle>
</div> </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> </ModalHeader>
<ModalBody <ModalBody
bsClass="modal-body" bsClass="modal-body"
@@ -113,5 +134,6 @@ exports[`component/add_users_to_group_modal should match snapshot 1`] = `
</form> </form>
</div> </div>
</ModalBody> </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,7 +101,8 @@ const AddUsersToGroupModal = (props: Props) => {
aria-labelledby='createUserGroupsModalLabel' aria-labelledby='createUserGroupsModalLabel'
id='addUsersToGroupsModal' id='addUsersToGroupsModal'
> >
<Modal.Header closeButton={true}> <div ref={modalRef}>
<Modal.Header>
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
<button <button
type='button' type='button'
@@ -118,6 +123,20 @@ const AddUsersToGroupModal = (props: Props) => {
/> />
</Modal.Title> </Modal.Title>
</div> </div>
<button
type='button'
className='close'
onClick={props.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>
<Modal.Body <Modal.Body
className='overflow--visible' className='overflow--visible'
@@ -154,6 +173,7 @@ const AddUsersToGroupModal = (props: Props) => {
</form> </form>
</div> </div>
</Modal.Body> </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,47 +1,43 @@
// 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}
id="userGroupsModal" headerButton={
keyboard={true} <button
manager={ className="user-groups-create btn btn-secondary btn-sm"
ModalManager { onClick={[Function]}
"add": [Function], >
"containers": Array [], <Memo(MemoizedFormattedMessage)
"data": Array [], defaultMessage="Create Group"
"handleContainerOverflow": true, id="user_groups_modal.createNew"
"hideSiblingNodes": true, />
"isTopModal": [Function], </button>
"modals": Array [],
"remove": [Function],
} }
id="userGroupsModal"
modalHeaderText={
<Memo(MemoizedFormattedMessage)
defaultMessage="User Groups"
id="user_groups_modal.title"
/>
} }
onExited={[MockFunction]} onExited={[MockFunction]}
onHide={[Function]} onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="none"
show={true} show={true}
>
<Connect(Component)
backButtonAction={[MockFunction]}
onExited={[MockFunction]}
/>
<ModalBody
bsClass="modal-body"
componentClass="div"
> >
<div <div
className="user-groups-search" className="user-groups-search"
> >
<div
aria-atomic="true"
aria-live="polite"
className="sr-only"
role="status"
/>
<ForwardRef <ForwardRef
className="user-group-search-input" className="user-group-search-input"
data-testid="searchInput" data-testid="searchInput"
@@ -63,6 +59,7 @@ exports[`component/user_groups_modal should match snapshot with groups 1`] = `
</div> </div>
<Memo(UserGroupsFilter) <Memo(UserGroupsFilter)
getGroups={[Function]} getGroups={[Function]}
onToggle={[Function]}
selectedFilter="all" selectedFilter="all"
/> />
<Connect(Component) <Connect(Component)
@@ -120,54 +117,50 @@ exports[`component/user_groups_modal should match snapshot with groups 1`] = `
loadMoreGroups={[Function]} loadMoreGroups={[Function]}
loading={false} loading={false}
onExited={[MockFunction]} onExited={[MockFunction]}
onToggle={[Function]}
searchTerm="" searchTerm=""
/> />
</ModalBody> </GenericModal>
</Modal>
`; `;
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}
id="userGroupsModal" headerButton={
keyboard={true} <button
manager={ className="user-groups-create btn btn-secondary btn-sm"
ModalManager { onClick={[Function]}
"add": [Function], >
"containers": Array [], <Memo(MemoizedFormattedMessage)
"data": Array [], defaultMessage="Create Group"
"handleContainerOverflow": true, id="user_groups_modal.createNew"
"hideSiblingNodes": true, />
"isTopModal": [Function], </button>
"modals": Array [],
"remove": [Function],
} }
id="userGroupsModal"
modalHeaderText={
<Memo(MemoizedFormattedMessage)
defaultMessage="User Groups"
id="user_groups_modal.title"
/>
} }
onExited={[MockFunction]} onExited={[MockFunction]}
onHide={[Function]} onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="none"
show={true} show={true}
>
<Connect(Component)
backButtonAction={[MockFunction]}
onExited={[MockFunction]}
/>
<ModalBody
bsClass="modal-body"
componentClass="div"
> >
<div <div
className="user-groups-search" className="user-groups-search"
> >
<div
aria-atomic="true"
aria-live="polite"
className="sr-only"
role="status"
/>
<ForwardRef <ForwardRef
className="user-group-search-input" className="user-group-search-input"
data-testid="searchInput" data-testid="searchInput"
@@ -189,12 +182,12 @@ exports[`component/user_groups_modal should match snapshot without groups 1`] =
</div> </div>
<Memo(UserGroupsFilter) <Memo(UserGroupsFilter)
getGroups={[Function]} getGroups={[Function]}
onToggle={[Function]}
selectedFilter="all" selectedFilter="all"
/> />
<NoResultsIndicator <NoResultsIndicator
variant="UserGroups" variant="UserGroups"
/> />
<Memo(ADLDAPUpsellBanner) /> <Memo(ADLDAPUpsellBanner) />
</ModalBody> </GenericModal>
</Modal>
`; `;

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

@@ -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={{
id: 'groupsFilterDropdown',
class: 'groups-filter-btn',
children: (
<>
<span>{filterLabel()}</span> <span>{filterLabel()}</span>
<span className='icon icon-chevron-down'/> <span className='icon icon-chevron-down'/>
</a> </>
<Menu ),
openLeft={false} }}
ariaLabel={intl.formatMessage({id: 'user_groups_modal.filterAriaLabel', defaultMessage: 'Groups Filter Menu'})} menu={{
id: 'groupsFilterDropdownMenu',
onToggle: props.onToggle,
'aria-label': intl.formatMessage({id: 'user_groups_modal.filterAriaLabel', defaultMessage: 'Groups Filter'}),
}}
> >
<Menu.Group> <Menu.Item
<Menu.ItemAction
id='groupsDropdownAll' id='groupsDropdownAll'
buttonClass='groups-filter-btn'
onClick={allGroupsOnClick} onClick={allGroupsOnClick}
text={intl.formatMessage({id: 'user_groups_modal.allGroups', defaultMessage: 'All Groups'})} labels={
rightDecorator={selectedFilter === 'all' && <i className='icon icon-check'/>} <FormattedMessage
id='user_groups_modal.allGroups'
defaultMessage='All Groups'
/> />
<Menu.ItemAction }
trailingElements={selectedFilter === 'all' && <i className='icon icon-check'/>}
/>
<Menu.Item
id='groupsDropdownMy' id='groupsDropdownMy'
buttonClass='groups-filter-btn'
onClick={myGroupsOnClick} onClick={myGroupsOnClick}
text={intl.formatMessage({id: 'user_groups_modal.myGroups', defaultMessage: 'My Groups'})} labels={
rightDecorator={selectedFilter === 'my' && <i className='icon icon-check'/>} <FormattedMessage
id='user_groups_modal.myGroups'
defaultMessage='My Groups'
/> />
</Menu.Group> }
<Menu.Group> trailingElements={selectedFilter === 'my' && <i className='icon icon-check'/>}
<Menu.ItemAction />
<Menu.Separator/>
<Menu.Item
id='groupsDropdownArchived' id='groupsDropdownArchived'
buttonClass='groups-filter-btn'
onClick={archivedGroupsOnClick} onClick={archivedGroupsOnClick}
text={intl.formatMessage({id: 'user_groups_modal.archivedGroups', defaultMessage: 'Archived Groups'})} labels={
rightDecorator={selectedFilter === 'archived' && <i className='icon icon-check'/>} <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>
<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={() => { onClick={() => {
goToViewGroupModal(group); goToViewGroupModal(group);
}} }}
icon={<i className='icon-account-multiple-outline'/>} labels={
text={formatMessage({id: 'user_groups_modal.viewGroup', defaultMessage: 'View Group'})} <FormattedMessage
disabled={false} id='user_groups_modal.viewGroup'
defaultMessage='View Group'
/> />
</Menu.Group> }
<Menu.Group> leadingElement={<i className='icon-account-multiple-outline'/>}
<Menu.ItemAction />
show={groupPermissionsMap[group.id].can_delete} <Menu.Separator/>
{groupPermissionsMap[group.id].can_delete && (
<Menu.Item
id='archive-group'
onClick={() => { onClick={() => {
archiveGroup(group.id); archiveGroup(group.id);
}} }}
icon={<i className='icon-archive-outline'/>} labels={
text={formatMessage({id: 'user_groups_modal.archiveGroup', defaultMessage: 'Archive Group'})} <FormattedMessage
disabled={false} id='user_groups_modal.archiveGroup'
isDangerous={true} defaultMessage='Archive Group'
/> />
<Menu.ItemAction }
show={groupPermissionsMap[group.id].can_restore} leadingElement={<i className='icon-archive-outline'/>}
isDestructive={true}
/>
)}
{groupPermissionsMap[group.id].can_restore && (
<Menu.Item
id='restore-group'
onClick={() => { onClick={() => {
restoreGroup(group.id); restoreGroup(group.id);
}} }}
icon={<i className='icon-restore'/>} labels={
text={formatMessage({id: 'user_groups_modal.restoreGroup', defaultMessage: 'Restore Group'})} <FormattedMessage
disabled={false} id='user_groups_modal.restoreGroup'
defaultMessage='Restore Group'
/> />
</Menu.Group> }
</Menu> leadingElement={<i className='icon-restore'/>}
</MenuWrapper> />
</div> )}
</Menu.Container>
</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 {
line-height: 12px;
span { span {
color: var(--button-bg); color: var(--button-bg);
font-size: 12px; font-size: 12px;
line-height: 12px; line-height: 12px;
} white-space: nowrap;
} }
} }
@@ -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,22 +185,71 @@ 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> }
headerButton={
props.canCreateCustomGroups &&
<button
className='user-groups-create btn btn-secondary btn-sm'
onClick={goToCreateModal}
>
<FormattedMessage
id='user_groups_modal.createNew'
defaultMessage='Create Group'
/>
</button>
}
bodyPadding={false}
enforceFocus={!isMenuOpen}
>
<div className='user-groups-search'> <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 <Input
type='text' type='text'
placeholder={defineMessage({id: 'user_groups_modal.searchGroups', defaultMessage: 'Search Groups'})} placeholder={defineMessage({id: 'user_groups_modal.searchGroups', defaultMessage: 'Search Groups'})}
@@ -209,6 +263,7 @@ const UserGroupsModal = (props: Props) => {
<UserGroupsFilter <UserGroupsFilter
selectedFilter={selectedFilter} selectedFilter={selectedFilter}
getGroups={getGroups} getGroups={getGroups}
onToggle={setIsMenuOpen}
/> />
{(groups.length === 0 && !props.searchTerm) ? <> {(groups.length === 0 && !props.searchTerm) ? <>
<NoResultsIndicator <NoResultsIndicator
@@ -224,11 +279,11 @@ const UserGroupsModal = (props: Props) => {
loadMoreGroups={loadMoreGroups} loadMoreGroups={loadMoreGroups}
onExited={props.onExited} onExited={props.onExited}
backButtonAction={props.backButtonAction} backButtonAction={props.backButtonAction}
onToggle={setIsMenuOpen}
/> />
</> </>
} }
</Modal.Body> </GenericModal>
</Modal>
); );
}; };

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

@@ -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,6 +30,7 @@ exports[`component/view_user_group_modal should match snapshot 1`] = `
role="none" role="none"
show={true} show={true}
> >
<div>
<Connect(Component) <Connect(Component)
backButtonAction={[MockFunction]} backButtonAction={[MockFunction]}
backButtonCallback={[MockFunction]} backButtonCallback={[MockFunction]}
@@ -121,5 +122,6 @@ exports[`component/view_user_group_modal should match snapshot 1`] = `
<LoadingScreen /> <LoadingScreen />
</div> </div>
</ModalBody> </ModalBody>
</div>
</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'
/> />
<Menu.ItemAction }
show={props.permissionToArchiveGroup} isDestructive={true}
/>
)}
{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'
/> />
</Menu> }
</MenuWrapper> isDestructive={true}
/>
)}
</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;
selectedFilter: string;
memberCount: number;
}
export default class ViewUserGroupModal extends React.PureComponent<Props, State> {
private divScrollRef: RefObject<HTMLDivElement>;
private searchTimeoutId: number;
constructor(props: Props) {
super(props);
this.divScrollRef = createRef();
this.searchTimeoutId = 0;
this.state = {
page: 0,
loading: true,
show: true,
selectedFilter: 'all',
memberCount: props.group?.member_count || 0,
};
}
incrementMemberCount = () => {
this.setState({memberCount: this.state.memberCount + 1});
};
decrementMemberCount = () => {
this.setState({memberCount: this.state.memberCount - 1});
};
doHide = () => {
this.setState({show: false});
};
async componentDidMount() {
const {
groupId, groupId,
group,
users,
backButtonCallback,
backButtonAction,
actions, actions,
} = this.props; }) => {
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);
const modalRef = useRef<HTMLDivElement>(null);
useFocusTrap(show, modalRef);
const incrementMemberCount = useCallback(() => {
setMemberCount((prev) => prev + 1);
}, []);
const decrementMemberCount = useCallback(() => {
setMemberCount((prev) => prev - 1);
}, []);
const doHide = useCallback(() => {
setShow(false);
}, []);
const startLoad = useCallback(() => {
setLoading(true);
}, []);
const loadComplete = useCallback(() => {
setLoading(false);
}, []);
const handleSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const term = e.target.value;
actions.setModalSearchTerm(term);
}, [actions]);
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],
);
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 && group) && (users.length !== group.member_count && !loading)) {
getGroupMembers();
}
}, [group, users.length, loading, getGroupMembers]);
useEffect(() => {
const fetchData = async () => {
await Promise.all([ await Promise.all([
actions.getGroup(groupId, true), actions.getGroup(groupId, true),
actions.getUsersInGroup(groupId, 0, USERS_PER_PAGE), actions.getUsersInGroup(groupId, 0, USERS_PER_PAGE),
]); ]);
this.loadComplete(); 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]);
componentWillUnmount() { useEffect(() => {
this.props.actions.setModalSearchTerm('');
}
componentDidUpdate(prevProps: Props) {
if (prevProps.searchTerm !== this.props.searchTerm) {
clearTimeout(this.searchTimeoutId);
const searchTerm = this.props.searchTerm;
if (searchTerm === '') { if (searchTerm === '') {
this.loadComplete(); loadComplete();
this.searchTimeoutId = 0; searchTimeoutIdRef.current = 0;
return; return () => {};
} }
const searchTimeoutId = window.setTimeout( clearTimeout(searchTimeoutIdRef.current);
searchTimeoutIdRef.current = window.setTimeout(
async () => { async () => {
await prevProps.actions.searchProfiles(searchTerm, {in_group_id: this.props.groupId}); await actions.searchProfiles(searchTerm, {in_group_id: groupId});
}, },
Constants.SEARCH_TIMEOUT_MILLISECONDS, Constants.SEARCH_TIMEOUT_MILLISECONDS,
); );
this.searchTimeoutId = searchTimeoutId; return () => {
} clearTimeout(searchTimeoutIdRef.current);
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});
}; };
}, [searchTerm, groupId, actions, loadComplete]);
startLoad = () => { const mentionName = () => {
this.setState({loading: true});
};
loadComplete = () => {
this.setState({loading: false});
};
handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const term = e.target.value;
this.props.actions.setModalSearchTerm(term);
};
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 => {},
);
onScroll = () => {
const scrollHeight = this.divScrollRef.current?.scrollHeight || 0;
const scrollTop = this.divScrollRef.current?.scrollTop || 0;
const clientHeight = this.divScrollRef.current?.clientHeight || 0;
if (((scrollTop + clientHeight + 30) >= scrollHeight && this.props.group) && (this.props.users.length !== this.props.group.member_count && this.state.loading === false)) {
this.getGroupMembers();
}
};
mentionName = () => {
const {group} = this.props;
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() {
const {groupId, group, users, onExited} = this.props;
return ( return (
<Modal <Modal
dialogClassName='a11y__modal view-user-groups-modal' dialogClassName='a11y__modal view-user-groups-modal'
show={this.state.show} show={show}
onHide={this.doHide} onHide={doHide}
onExited={onExited} onExited={onExited}
role='none' role='none'
aria-labelledby='viewUserGroupModalLabel' aria-labelledby='viewUserGroupModalLabel'
enforceFocus={false}
> >
<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={this.decrementMemberCount} decrementMemberCount={decrementMemberCount}
key={user.id} key={user.id}
/> />
); ))}
})}
{ {
this.state.loading && loading &&
<LoadingScreen/> <LoadingScreen/>
} }
</div> </div>
</> </>
)} )}
</Modal.Body> </Modal.Body>
</div>
</Modal> </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;