From 2358699d91994b2af09fac38342e97141028b010 Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Fri, 23 May 2025 16:09:06 -0400 Subject: [PATCH] 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 --- .../add_users_to_group_modal.test.tsx.snap | 172 +++++---- .../add_users_to_group_modal.tsx | 124 ++++--- .../user_group_popover.test.tsx.snap | 2 +- .../user_groups_modal.test.tsx.snap | 333 +++++++++--------- .../src/components/user_groups_modal/index.ts | 5 + .../user_groups_filter/user_groups_filter.tsx | 88 +++-- .../user_groups_list.test.tsx | 1 + .../user_groups_list/user_groups_list.tsx | 120 ++++--- .../user_groups_modal/user_groups_modal.scss | 29 +- .../user_groups_modal.test.tsx | 2 + .../user_groups_modal/user_groups_modal.tsx | 149 +++++--- .../user_groups_modal_header.test.tsx.snap | 48 --- .../user_groups_modal_header/index.ts | 33 -- .../user_groups_modal_header.test.tsx | 37 -- .../user_groups_modal_header.tsx | 62 ---- .../view_user_group_modal.test.tsx.snap | 170 ++++----- .../view_user_group_header_sub_menu.tsx | 96 +++-- .../view_user_group_modal.scss | 6 +- .../view_user_group_modal.test.tsx | 19 +- .../view_user_group_modal.tsx | 293 ++++++++------- .../view_user_group_modal_header.tsx | 16 +- webapp/channels/src/i18n/en.json | 7 +- .../components/src/hooks/useFocusTrap.ts | 2 +- 23 files changed, 887 insertions(+), 927 deletions(-) delete mode 100644 webapp/channels/src/components/user_groups_modal/user_groups_modal_header/__snapshots__/user_groups_modal_header.test.tsx.snap delete mode 100644 webapp/channels/src/components/user_groups_modal/user_groups_modal_header/index.ts delete mode 100644 webapp/channels/src/components/user_groups_modal/user_groups_modal_header/user_groups_modal_header.test.tsx delete mode 100644 webapp/channels/src/components/user_groups_modal/user_groups_modal_header/user_groups_modal_header.tsx diff --git a/webapp/channels/src/components/add_users_to_group_modal/__snapshots__/add_users_to_group_modal.test.tsx.snap b/webapp/channels/src/components/add_users_to_group_modal/__snapshots__/add_users_to_group_modal.test.tsx.snap index 4a505886b1..6ab5054795 100644 --- a/webapp/channels/src/components/add_users_to_group_modal/__snapshots__/add_users_to_group_modal.test.tsx.snap +++ b/webapp/channels/src/components/add_users_to_group_modal/__snapshots__/add_users_to_group_modal.test.tsx.snap @@ -31,87 +31,109 @@ exports[`component/add_users_to_group_modal should match snapshot 1`] = ` role="none" show={true} > - -
+ - - - - -
-
- -
-
-
- + + + -
-
-
-
+ + + + + +
+
+
+ +
+
+
+
+ `; diff --git a/webapp/channels/src/components/add_users_to_group_modal/add_users_to_group_modal.tsx b/webapp/channels/src/components/add_users_to_group_modal/add_users_to_group_modal.tsx index 1935a53add..942ad2c6e7 100644 --- a/webapp/channels/src/components/add_users_to_group_modal/add_users_to_group_modal.tsx +++ b/webapp/channels/src/components/add_users_to_group_modal/add_users_to_group_modal.tsx @@ -1,10 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useState, useCallback, useMemo} from 'react'; +import React, {useState, useCallback, useMemo, useRef} from 'react'; import {Modal} from 'react-bootstrap'; import {defineMessage, FormattedMessage, useIntl} from 'react-intl'; +import {useFocusTrap} from '@mattermost/components/src/hooks/useFocusTrap'; import type {Group} from '@mattermost/types/groups'; import type {UserProfile} from '@mattermost/types/users'; @@ -34,6 +35,9 @@ const AddUsersToGroupModal = (props: Props) => { const [showUnknownError, setShowUnknownError] = useState(false); const {formatMessage} = useIntl(); + const modalRef = useRef(null); + useFocusTrap(show, modalRef); + const doHide = useCallback(() => { setShow(false); }, []); @@ -97,63 +101,79 @@ const AddUsersToGroupModal = (props: Props) => { aria-labelledby='createUserGroupsModalLabel' id='addUsersToGroupsModal' > - -
+
+ +
+ + + + +
- - - -
- - -
-
-
-
- { - showUnknownError && -
- - + + + +
+ +
+
- } - -
-
+ { + showUnknownError && +
+ + +
+ } + +
+ +
); }; diff --git a/webapp/channels/src/components/user_group_popover/__snapshots__/user_group_popover.test.tsx.snap b/webapp/channels/src/components/user_group_popover/__snapshots__/user_group_popover.test.tsx.snap index a267334634..0034c19c83 100644 --- a/webapp/channels/src/components/user_group_popover/__snapshots__/user_group_popover.test.tsx.snap +++ b/webapp/channels/src/components/user_group_popover/__snapshots__/user_group_popover.test.tsx.snap @@ -73,7 +73,7 @@ exports[`component/user_group_popover should match snapshot 1`] = ` className="overflow--ellipsis text-nowrap" > group_display_name diff --git a/webapp/channels/src/components/user_groups_modal/__snapshots__/user_groups_modal.test.tsx.snap b/webapp/channels/src/components/user_groups_modal/__snapshots__/user_groups_modal.test.tsx.snap index 695d666449..21f115076f 100644 --- a/webapp/channels/src/components/user_groups_modal/__snapshots__/user_groups_modal.test.tsx.snap +++ b/webapp/channels/src/components/user_groups_modal/__snapshots__/user_groups_modal.test.tsx.snap @@ -1,200 +1,193 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`component/user_groups_modal should match snapshot with groups 1`] = ` - + + + } id="userGroupsModal" - keyboard={true} - manager={ - ModalManager { - "add": [Function], - "containers": Array [], - "data": Array [], - "handleContainerOverflow": true, - "hideSiblingNodes": true, - "isTopModal": [Function], - "modals": Array [], - "remove": [Function], - } + modalHeaderText={ + } onExited={[MockFunction]} onHide={[Function]} - renderBackdrop={[Function]} - restoreFocus={true} - role="none" show={true} > - -
- - } - onChange={[Function]} - placeholder={ - Object { - "defaultMessage": "Search Groups", - "id": "user_groups_modal.searchGroups", - } - } - type="text" - value="" - /> -
- - } - hasNextPage={true} - loadMoreGroups={[Function]} - loading={false} - onExited={[MockFunction]} - searchTerm="" + onChange={[Function]} + placeholder={ + Object { + "defaultMessage": "Search Groups", + "id": "user_groups_modal.searchGroups", + } + } + type="text" + value="" /> -
-
+
+ + + `; exports[`component/user_groups_modal should match snapshot without groups 1`] = ` - + + + } id="userGroupsModal" - keyboard={true} - manager={ - ModalManager { - "add": [Function], - "containers": Array [], - "data": Array [], - "handleContainerOverflow": true, - "hideSiblingNodes": true, - "isTopModal": [Function], - "modals": Array [], - "remove": [Function], - } + modalHeaderText={ + } onExited={[MockFunction]} onHide={[Function]} - renderBackdrop={[Function]} - restoreFocus={true} - role="none" show={true} > - -
- - } - onChange={[Function]} - placeholder={ - Object { - "defaultMessage": "Search Groups", - "id": "user_groups_modal.searchGroups", - } - } - type="text" - value="" - /> -
- - + } + onChange={[Function]} + placeholder={ + Object { + "defaultMessage": "Search Groups", + "id": "user_groups_modal.searchGroups", + } + } + type="text" + value="" /> - -
-
+ + + + + `; diff --git a/webapp/channels/src/components/user_groups_modal/index.ts b/webapp/channels/src/components/user_groups_modal/index.ts index 6934ea0d22..7ae424dcde 100644 --- a/webapp/channels/src/components/user_groups_modal/index.ts +++ b/webapp/channels/src/components/user_groups_modal/index.ts @@ -8,9 +8,12 @@ import type {Dispatch} from 'redux'; import type {Group} from '@mattermost/types/groups'; import {getGroups, getGroupsByUserIdPaginated, searchGroups} from 'mattermost-redux/actions/groups'; +import {Permissions} from 'mattermost-redux/constants'; import {makeGetAllAssociatedGroupsForReference, makeGetMyAllowReferencedGroups, searchAllowReferencedGroups, searchMyAllowReferencedGroups, searchArchivedGroups, getArchivedGroups} from 'mattermost-redux/selectors/entities/groups'; +import {haveISystemPermission} from 'mattermost-redux/selectors/entities/roles'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; +import {openModal} from 'actions/views/modals'; import {setModalSearchTerm} from 'actions/views/search'; import {isModalOpen} from 'selectors/views/modals'; @@ -47,6 +50,7 @@ function makeMapStateToProps() { myGroups, archivedGroups, currentUserId: getCurrentUserId(state), + canCreateCustomGroups: haveISystemPermission(state, {permission: Permissions.CREATE_CUSTOM_GROUP}), }; }; } @@ -58,6 +62,7 @@ function mapDispatchToProps(dispatch: Dispatch) { setModalSearchTerm, getGroupsByUserIdPaginated, searchGroups, + openModal, }, dispatch), }; } diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_filter/user_groups_filter.tsx b/webapp/channels/src/components/user_groups_modal/user_groups_filter/user_groups_filter.tsx index ac59e37e5c..9a03764b2f 100644 --- a/webapp/channels/src/components/user_groups_modal/user_groups_filter/user_groups_filter.tsx +++ b/webapp/channels/src/components/user_groups_modal/user_groups_filter/user_groups_filter.tsx @@ -2,14 +2,14 @@ // See LICENSE.txt for license information. import React, {useCallback} from 'react'; -import {useIntl} from 'react-intl'; +import {FormattedMessage, useIntl} from 'react-intl'; -import Menu from 'components/widgets/menu/menu'; -import MenuWrapper from 'components/widgets/menu/menu_wrapper'; +import * as Menu from 'components/menu'; type Props = { selectedFilter: string; getGroups: (page: number, groupType: string) => void; + onToggle: (isOpen: boolean) => void; } const UserGroupsFilter = (props: Props) => { @@ -45,42 +45,58 @@ const UserGroupsFilter = (props: Props) => { return (
- - - {filterLabel()} - - - - - } + + {filterLabel()} + + + ), + }} + menu={{ + id: 'groupsFilterDropdownMenu', + onToggle: props.onToggle, + 'aria-label': intl.formatMessage({id: 'user_groups_modal.filterAriaLabel', defaultMessage: 'Groups Filter'}), + }} + > + - } + } + trailingElements={selectedFilter === 'all' && } + /> + - - - } + } + trailingElements={selectedFilter === 'my' && } + /> + + - - - + } + trailingElements={selectedFilter === 'archived' && } + /> +
); }; diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_list/user_groups_list.test.tsx b/webapp/channels/src/components/user_groups_modal/user_groups_list/user_groups_list.test.tsx index 8c6f8b7d6a..029d30341e 100644 --- a/webapp/channels/src/components/user_groups_modal/user_groups_list/user_groups_list.test.tsx +++ b/webapp/channels/src/components/user_groups_modal/user_groups_list/user_groups_list.test.tsx @@ -12,6 +12,7 @@ describe('component/user_groups_modal', () => { const baseProps = { onExited: jest.fn(), onScroll: jest.fn(), + onToggle: jest.fn(), groups: [], searchTerm: '', backButtonAction: jest.fn(), diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_list/user_groups_list.tsx b/webapp/channels/src/components/user_groups_modal/user_groups_list/user_groups_list.tsx index fa087a45a9..9fafefb28a 100644 --- a/webapp/channels/src/components/user_groups_modal/user_groups_list/user_groups_list.tsx +++ b/webapp/channels/src/components/user_groups_modal/user_groups_list/user_groups_list.tsx @@ -12,11 +12,10 @@ import type {Group, GroupPermissions} from '@mattermost/types/groups'; import type {ActionResult} from 'mattermost-redux/types/actions'; import LoadingScreen from 'components/loading_screen'; +import * as Menu from 'components/menu'; import NoResultsIndicator from 'components/no_results_indicator'; import {NoResultsVariant} from 'components/no_results_indicator/types'; import ViewUserGroupModal from 'components/view_user_group_modal'; -import Menu from 'components/widgets/menu/menu'; -import MenuWrapper from 'components/widgets/menu/menu_wrapper'; import {ModalIdentifiers} from 'utils/constants'; @@ -33,6 +32,7 @@ export type Props = { onExited: () => void; backButtonAction: () => void; hasNextPage: boolean; + onToggle: (isOpen: boolean) => void; actions: { archiveGroup: (groupId: string) => Promise; restoreGroup: (groupId: string) => Promise; @@ -51,6 +51,7 @@ const UserGroupsList = (props: Props) => { backButtonAction, onExited, actions, + onToggle, } = props; const infiniteLoaderRef = useRef(null); @@ -108,13 +109,6 @@ const UserGroupsList = (props: Props) => { onExited(); }, [actions.openModal, onExited, backButtonAction]); - const groupListOpenUp = (groupListItemIndex: number): boolean => { - if (groupListItemIndex === 0) { - return false; - } - return true; - }; - const Item = ({index, style}: ListChildComponentProps) => { if (groups.length === 0 && searchTerm) { return ( @@ -131,13 +125,14 @@ const UserGroupsList = (props: Props) => { } return ( -
{ goToViewGroupModal(group); }} + aria-label={formatMessage({id: 'user_groups_list.groupAriaLabel', defaultMessage: '{group_name} group'}, {group_name: group.display_name})} > { @@ -159,55 +154,68 @@ const UserGroupsList = (props: Props) => { />
- , + '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', + }} > - - - - { - goToViewGroupModal(group); - }} - icon={} - text={formatMessage({id: 'user_groups_modal.viewGroup', defaultMessage: 'View Group'})} - disabled={false} + { + goToViewGroupModal(group); + }} + labels={ + - - - { - archiveGroup(group.id); - }} - icon={} - text={formatMessage({id: 'user_groups_modal.archiveGroup', defaultMessage: 'Archive Group'})} - disabled={false} - isDangerous={true} - /> - { - restoreGroup(group.id); - }} - icon={} - text={formatMessage({id: 'user_groups_modal.restoreGroup', defaultMessage: 'Restore Group'})} - disabled={false} - /> - - - + } + leadingElement={} + /> + + {groupPermissionsMap[group.id].can_delete && ( + { + archiveGroup(group.id); + }} + labels={ + + } + leadingElement={} + isDestructive={true} + /> + )} + {groupPermissionsMap[group.id].can_restore && ( + { + restoreGroup(group.id); + }} + labels={ + + } + leadingElement={} + /> + )} +
- + ); } if (loading) { diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_modal.scss b/webapp/channels/src/components/user_groups_modal/user_groups_modal.scss index 1683d9d48f..7d6ee5fd0e 100644 --- a/webapp/channels/src/components/user_groups_modal/user_groups_modal.scss +++ b/webapp/channels/src/components/user_groups_modal/user_groups_modal.scss @@ -127,12 +127,8 @@ } } - .modal-header { - h1#createGroupsModalTitleWithBack, - h1#updateGroupsModalTitle, - h1#addUsersToGroupsModalLabel { - margin-left: 16px; - } + .GenericModal__header { + display: flex; } } @@ -155,7 +151,9 @@ line-height: 38px; } - .Input { + .Input.form-control { + height: 34px; + border: none; font-size: 14px; line-height: 38px; } @@ -177,8 +175,11 @@ border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08); #groupsFilterDropdown { + display: inline-flex; padding: 6px; + border: none; border-radius: 4px; + background: none; line-height: 12px; &:hover { @@ -189,14 +190,11 @@ background: rgba(var(--button-bg-rgb), 0.08); } - a { + span { + color: var(--button-bg); + font-size: 12px; line-height: 12px; - - span { - color: var(--button-bg); - font-size: 12px; - line-height: 12px; - } + white-space: nowrap; } } @@ -242,6 +240,9 @@ display: flex; align-items: center; padding: 14px 32px; + border: none; + background: none; + line-height: 38px; &:hover { background: rgba(var(--center-channel-color-rgb), 0.08); diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_modal.test.tsx b/webapp/channels/src/components/user_groups_modal/user_groups_modal.test.tsx index 3972a67c6d..f5c0023e9c 100644 --- a/webapp/channels/src/components/user_groups_modal/user_groups_modal.test.tsx +++ b/webapp/channels/src/components/user_groups_modal/user_groups_modal.test.tsx @@ -22,7 +22,9 @@ describe('component/user_groups_modal', () => { setModalSearchTerm: jest.fn(), getGroupsByUserIdPaginated: jest.fn(), searchGroups: jest.fn(), + openModal: jest.fn(), }, + canCreateCustomGroups: true, }; function getGroups(numberOfGroups: number) { diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_modal.tsx b/webapp/channels/src/components/user_groups_modal/user_groups_modal.tsx index b2482e9b7a..9ade4ad7fd 100644 --- a/webapp/channels/src/components/user_groups_modal/user_groups_modal.tsx +++ b/webapp/channels/src/components/user_groups_modal/user_groups_modal.tsx @@ -2,25 +2,27 @@ // See LICENSE.txt for license information. import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {Modal} from 'react-bootstrap'; -import {defineMessage} from 'react-intl'; +import {defineMessage, FormattedMessage} from 'react-intl'; +import {GenericModal} from '@mattermost/components'; import type {GetGroupsForUserParams, GetGroupsParams, Group, GroupSearchParams} from '@mattermost/types/groups'; import './user_groups_modal.scss'; import type {ActionResult} from 'mattermost-redux/types/actions'; +import CreateUserGroupsModal from 'components/create_user_groups_modal'; import NoResultsIndicator from 'components/no_results_indicator'; import {NoResultsVariant} from 'components/no_results_indicator/types'; import Input from 'components/widgets/inputs/input/input'; -import Constants from 'utils/constants'; +import Constants, {ModalIdentifiers} from 'utils/constants'; + +import type {ModalData} from 'types/actions'; import ADLDAPUpsellBanner from './ad_ldap_upsell_banner'; import {usePagingMeta} from './hooks'; import UserGroupsFilter from './user_groups_filter/user_groups_filter'; import UserGroupsList from './user_groups_list'; -import UserGroupsModalHeader from './user_groups_modal_header'; const GROUPS_PER_PAGE = 60; @@ -32,6 +34,7 @@ export type Props = { searchTerm: string; currentUserId: string; backButtonAction: () => void; + canCreateCustomGroups: boolean; actions: { getGroups: ( opts: GetGroupsParams, @@ -43,6 +46,7 @@ export type Props = { searchGroups: ( params: GroupSearchParams, ) => Promise; + openModal:

(modalData: ModalData

) => void; }; } @@ -53,6 +57,7 @@ const UserGroupsModal = (props: Props) => { const [selectedFilter, setSelectedFilter] = useState('all'); const [groupsFull, setGroupsFull] = useState(false); const [groups, setGroups] = useState(props.groups); + const [isMenuOpen, setIsMenuOpen] = useState(false); const [page, setPage] = usePagingMeta(selectedFilter); @@ -180,55 +185,105 @@ const UserGroupsModal = (props: Props) => { return NoResultsVariant.UserGroups; }, [selectedFilter]); + const goToCreateModal = useCallback(() => { + props.actions.openModal({ + modalId: ModalIdentifiers.USER_GROUPS_CREATE, + dialogType: CreateUserGroupsModal, + dialogProps: { + backButtonCallback: props.backButtonAction, + }, + }); + props.onExited(); + }, [props.actions.openModal, props.backButtonAction, props.onExited]); + return ( - - - -

- -
- - {(groups.length === 0 && !props.searchTerm) ? <> - + - - : <> - - - } - - + + } + bodyPadding={false} + enforceFocus={!isMenuOpen} + > +
+
+ {props.searchTerm && ( + groups.length > 0 ? ( + + ) : ( + + ) + )} +
+ +
+ + {(groups.length === 0 && !props.searchTerm) ? <> + + + : <> + + + } + ); }; diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_modal_header/__snapshots__/user_groups_modal_header.test.tsx.snap b/webapp/channels/src/components/user_groups_modal/user_groups_modal_header/__snapshots__/user_groups_modal_header.test.tsx.snap deleted file mode 100644 index d3ed76d819..0000000000 --- a/webapp/channels/src/components/user_groups_modal/user_groups_modal_header/__snapshots__/user_groups_modal_header.test.tsx.snap +++ /dev/null @@ -1,48 +0,0 @@ -// 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 without groups 1`] = ` - - - - - - -`; diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_modal_header/index.ts b/webapp/channels/src/components/user_groups_modal/user_groups_modal_header/index.ts deleted file mode 100644 index 5dae4f429e..0000000000 --- a/webapp/channels/src/components/user_groups_modal/user_groups_modal_header/index.ts +++ /dev/null @@ -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); diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_modal_header/user_groups_modal_header.test.tsx b/webapp/channels/src/components/user_groups_modal/user_groups_modal_header/user_groups_modal_header.test.tsx deleted file mode 100644 index 8ac4f04bd8..0000000000 --- a/webapp/channels/src/components/user_groups_modal/user_groups_modal_header/user_groups_modal_header.test.tsx +++ /dev/null @@ -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( - , - ); - expect(wrapper).toMatchSnapshot(); - }); - - test('should match snapshot with groups', () => { - const wrapper = shallow( - , - ); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/webapp/channels/src/components/user_groups_modal/user_groups_modal_header/user_groups_modal_header.tsx b/webapp/channels/src/components/user_groups_modal/user_groups_modal_header/user_groups_modal_header.tsx deleted file mode 100644 index 782b554035..0000000000 --- a/webapp/channels/src/components/user_groups_modal/user_groups_modal_header/user_groups_modal_header.tsx +++ /dev/null @@ -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:

(modalData: ModalData

) => 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 ( - - - - - { - props.canCreateCustomGroups && - - } - - ); -}; - -export default React.memo(UserGroupsModalHeader); diff --git a/webapp/channels/src/components/view_user_group_modal/__snapshots__/view_user_group_modal.test.tsx.snap b/webapp/channels/src/components/view_user_group_modal/__snapshots__/view_user_group_modal.test.tsx.snap index 30e93b0dd6..bbf72dce10 100644 --- a/webapp/channels/src/components/view_user_group_modal/__snapshots__/view_user_group_modal.test.tsx.snap +++ b/webapp/channels/src/components/view_user_group_modal/__snapshots__/view_user_group_modal.test.tsx.snap @@ -9,7 +9,7 @@ exports[`component/view_user_group_modal should match snapshot 1`] = ` bsClass="modal" dialogClassName="a11y__modal view-user-groups-modal" dialogComponentClass={[Function]} - enforceFocus={true} + enforceFocus={false} keyboard={true} manager={ ModalManager { @@ -30,96 +30,98 @@ exports[`component/view_user_group_modal should match snapshot 1`] = ` role="none" show={true} > - - -

+ + - - @group - -
-
- - } - onChange={[Function]} - placeholder={ - Object { - "defaultMessage": "Search group members", - "id": "search_bar.searchGroupMembers", + + @group + +
+
+ } - } - type="text" - value="" - /> -
-
-

- +

+
+

+ +

+ - - - - -
- + /> + + + + `; diff --git a/webapp/channels/src/components/view_user_group_modal/view_user_group_header_sub_menu/view_user_group_header_sub_menu.tsx b/webapp/channels/src/components/view_user_group_modal/view_user_group_header_sub_menu/view_user_group_header_sub_menu.tsx index 851ee394c0..fa1657cab5 100644 --- a/webapp/channels/src/components/view_user_group_modal/view_user_group_header_sub_menu/view_user_group_header_sub_menu.tsx +++ b/webapp/channels/src/components/view_user_group_modal/view_user_group_header_sub_menu/view_user_group_header_sub_menu.tsx @@ -2,18 +2,16 @@ // See LICENSE.txt for license information. import React, {useCallback} from 'react'; -import {useIntl} from 'react-intl'; +import {useIntl, FormattedMessage} from 'react-intl'; import type {Group} from '@mattermost/types/groups'; import type {ActionResult} from 'mattermost-redux/types/actions'; +import * as Menu from 'components/menu'; import UpdateUserGroupModal from 'components/update_user_group_modal'; -import Menu from 'components/widgets/menu/menu'; -import MenuWrapper from 'components/widgets/menu/menu_wrapper'; import {ModalIdentifiers} from 'utils/constants'; -import * as Utils from 'utils/utils'; import type {ModalData} from 'types/actions'; @@ -86,50 +84,70 @@ const ViewUserGroupHeaderSubMenu = (props: Props) => { return (
- ), + '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', + }} > - - - + } /> - + } /> - + } + isDestructive={true} /> - + } + isDestructive={true} /> - - + )} +
); }; diff --git a/webapp/channels/src/components/view_user_group_modal/view_user_group_modal.scss b/webapp/channels/src/components/view_user_group_modal/view_user_group_modal.scss index 7060aa36a0..ba55bb8987 100644 --- a/webapp/channels/src/components/view_user_group_modal/view_user_group_modal.scss +++ b/webapp/channels/src/components/view_user_group_modal/view_user_group_modal.scss @@ -98,16 +98,16 @@ } button.remove-group-member { - display: none; margin-right: 0; margin-left: auto; + opacity: 0; } - &:hover { + &:hover, &:focus-within { background: rgba(var(--center-channel-color-rgb), 0.08); button.remove-group-member { - display: block; + opacity: 1; } } } diff --git a/webapp/channels/src/components/view_user_group_modal/view_user_group_modal.test.tsx b/webapp/channels/src/components/view_user_group_modal/view_user_group_modal.test.tsx index 73fa7aaa5a..cf5efb5abf 100644 --- a/webapp/channels/src/components/view_user_group_modal/view_user_group_modal.test.tsx +++ b/webapp/channels/src/components/view_user_group_modal/view_user_group_modal.test.tsx @@ -75,7 +75,7 @@ describe('component/view_user_group_modal', () => { expect(wrapper).toMatchSnapshot(); }); - test('should match snapshot, search user1', () => { + test('should match snapshot, search user1', async () => { const wrapper = shallow( { />, ); - const instance = wrapper.instance() as ViewUserGroupModal; - - const e = { - target: { - value: '', - }, - }; - instance.handleSearch(e as React.ChangeEvent); + // Simulate search input change + const searchInput = wrapper.find('ForwardRef[data-testid="searchInput"]'); + searchInput.simulate('change', {target: {value: ''}}); expect(baseProps.actions.setModalSearchTerm).toHaveBeenCalledTimes(1); expect(baseProps.actions.setModalSearchTerm).toBeCalledWith(''); - e.target.value = 'user1'; - instance.handleSearch(e as React.ChangeEvent); - expect(wrapper.state('loading')).toEqual(true); + searchInput.simulate('change', {target: {value: 'user1'}}); expect(baseProps.actions.setModalSearchTerm).toHaveBeenCalledTimes(2); - expect(baseProps.actions.setModalSearchTerm).toBeCalledWith(e.target.value); + expect(baseProps.actions.setModalSearchTerm).toBeCalledWith('user1'); }); }); diff --git a/webapp/channels/src/components/view_user_group_modal/view_user_group_modal.tsx b/webapp/channels/src/components/view_user_group_modal/view_user_group_modal.tsx index cdd2f9b0d6..b8d09215a0 100644 --- a/webapp/channels/src/components/view_user_group_modal/view_user_group_modal.tsx +++ b/webapp/channels/src/components/view_user_group_modal/view_user_group_modal.tsx @@ -1,11 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {createRef} from 'react'; -import type {RefObject} from 'react'; +import React, {useRef, useState, useEffect, useCallback} from 'react'; import {Modal} from 'react-bootstrap'; import {defineMessage, FormattedMessage} from 'react-intl'; +import {useFocusTrap} from '@mattermost/components/src/hooks/useFocusTrap'; import type {Group} from '@mattermost/types/groups'; import {GroupSource, PluginGroupSourcePrefix} from '@mattermost/types/groups'; import type {UserProfile} from '@mattermost/types/users'; @@ -43,133 +43,119 @@ export type Props = { }; } -type State = { - page: number; - loading: boolean; - show: boolean; - selectedFilter: string; - memberCount: number; -} +const ViewUserGroupModal: React.FC = ({ + onExited, + searchTerm, + groupId, + group, + users, + backButtonCallback, + backButtonAction, + actions, +}) => { + const divScrollRef = useRef(null); + const searchTimeoutIdRef = useRef(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 { - private divScrollRef: RefObject; - private searchTimeoutId: number; + const modalRef = useRef(null); + useFocusTrap(show, modalRef); - constructor(props: Props) { - super(props); + const incrementMemberCount = useCallback(() => { + setMemberCount((prev) => prev + 1); + }, []); - this.divScrollRef = createRef(); - this.searchTimeoutId = 0; - this.state = { - page: 0, - loading: true, - show: true, - selectedFilter: 'all', - memberCount: props.group?.member_count || 0, - }; - } + const decrementMemberCount = useCallback(() => { + setMemberCount((prev) => prev - 1); + }, []); - incrementMemberCount = () => { - this.setState({memberCount: this.state.memberCount + 1}); - }; + const doHide = useCallback(() => { + setShow(false); + }, []); - decrementMemberCount = () => { - this.setState({memberCount: this.state.memberCount - 1}); - }; + const startLoad = useCallback(() => { + setLoading(true); + }, []); - doHide = () => { - this.setState({show: false}); - }; + const loadComplete = useCallback(() => { + setLoading(false); + }, []); - async componentDidMount() { - const { - groupId, - actions, - } = this.props; - - await Promise.all([ - actions.getGroup(groupId, true), - actions.getUsersInGroup(groupId, 0, USERS_PER_PAGE), - ]); - this.loadComplete(); - } - - componentWillUnmount() { - this.props.actions.setModalSearchTerm(''); - } - - componentDidUpdate(prevProps: Props) { - if (prevProps.searchTerm !== this.props.searchTerm) { - clearTimeout(this.searchTimeoutId); - const searchTerm = this.props.searchTerm; - - if (searchTerm === '') { - this.loadComplete(); - this.searchTimeoutId = 0; - return; - } - - const searchTimeoutId = window.setTimeout( - async () => { - await prevProps.actions.searchProfiles(searchTerm, {in_group_id: this.props.groupId}); - }, - Constants.SEARCH_TIMEOUT_MILLISECONDS, - ); - - this.searchTimeoutId = searchTimeoutId; - } - if (prevProps.group?.member_count !== this.props.group?.member_count) { - this.setMemberCount(this.props.group?.member_count || 0); - } - } - - setMemberCount = (count: number) => { - this.setState({memberCount: count}); - }; - - startLoad = () => { - this.setState({loading: true}); - }; - - loadComplete = () => { - this.setState({loading: false}); - }; - - handleSearch = (e: React.ChangeEvent) => { + const handleSearch = useCallback((e: React.ChangeEvent) => { const term = e.target.value; - this.props.actions.setModalSearchTerm(term); - }; + actions.setModalSearchTerm(term); + }, [actions]); - getGroupMembers = debounce( - async () => { - const {actions, groupId} = this.props; - const {page} = this.state; - const newPage = page + 1; - - this.setState({page: newPage}); - - this.startLoad(); - await actions.getUsersInGroup(groupId, newPage, USERS_PER_PAGE); - this.loadComplete(); - }, - 200, - false, - (): void => {}, + const getGroupMembers = useCallback( + debounce( + async () => { + const newPage = page + 1; + setPage(newPage); + startLoad(); + await actions.getUsersInGroup(groupId, newPage, USERS_PER_PAGE); + loadComplete(); + }, + 200, + false, + () => {}, + ), + [page, groupId, actions, startLoad, loadComplete], ); - onScroll = () => { - const scrollHeight = this.divScrollRef.current?.scrollHeight || 0; - const scrollTop = this.divScrollRef.current?.scrollTop || 0; - const clientHeight = this.divScrollRef.current?.clientHeight || 0; + const onScroll = useCallback(() => { + const scrollHeight = divScrollRef.current?.scrollHeight || 0; + const scrollTop = divScrollRef.current?.scrollTop || 0; + const clientHeight = divScrollRef.current?.clientHeight || 0; - if (((scrollTop + clientHeight + 30) >= scrollHeight && this.props.group) && (this.props.users.length !== this.props.group.member_count && this.state.loading === false)) { - this.getGroupMembers(); + if (((scrollTop + clientHeight + 30) >= scrollHeight && group) && (users.length !== group.member_count && !loading)) { + getGroupMembers(); } - }; + }, [group, users.length, loading, getGroupMembers]); - mentionName = () => { - const {group} = this.props; + useEffect(() => { + const fetchData = async () => { + await Promise.all([ + actions.getGroup(groupId, true), + actions.getUsersInGroup(groupId, 0, USERS_PER_PAGE), + ]); + loadComplete(); + }; + fetchData(); + return () => { + actions.setModalSearchTerm(''); + }; + }, [groupId, actions, loadComplete]); + + useEffect(() => { + if (group?.member_count !== undefined && group.member_count !== memberCount) { + setMemberCount(group.member_count); + } + }, [group?.member_count]); + + useEffect(() => { + if (searchTerm === '') { + loadComplete(); + searchTimeoutIdRef.current = 0; + return () => {}; + } + + clearTimeout(searchTimeoutIdRef.current); + searchTimeoutIdRef.current = window.setTimeout( + async () => { + await actions.searchProfiles(searchTerm, {in_group_id: groupId}); + }, + Constants.SEARCH_TIMEOUT_MILLISECONDS, + ); + + return () => { + clearTimeout(searchTimeoutIdRef.current); + }; + }, [searchTerm, groupId, actions, loadComplete]); + + const mentionName = () => { if (group) { return (
@@ -198,29 +184,28 @@ export default class ViewUserGroupModal extends React.PureComponent); }; - render() { - const {groupId, group, users, onExited} = this.props; - - return ( - + return ( + +
- {this.mentionName()} - {((users.length === 0 && !this.props.searchTerm && !this.state.loading) || !group) ? ( + {mentionName()} + {((users.length === 0 && !searchTerm && !loading) || !group) ? ( @@ -230,8 +215,8 @@ export default class ViewUserGroupModal extends React.PureComponent} @@ -239,8 +224,8 @@ export default class ViewUserGroupModal extends React.PureComponent
{(users.length !== 0) &&

@@ -248,36 +233,36 @@ export default class ViewUserGroupModal extends React.PureComponent

} - {(users.length === 0 && this.props.searchTerm) && + {(users.length === 0 && searchTerm) && } - {users.map((user) => { - return ( - - ); - })} + {users.map((user) => ( + + ))} { - this.state.loading && + loading && }
)}
- - ); - } -} +
+
+ ); +}; + +export default ViewUserGroupModal; diff --git a/webapp/channels/src/components/view_user_group_modal/view_user_group_modal_header/view_user_group_modal_header.tsx b/webapp/channels/src/components/view_user_group_modal/view_user_group_modal_header/view_user_group_modal_header.tsx index 73b6e382fc..ff9548ec74 100644 --- a/webapp/channels/src/components/view_user_group_modal/view_user_group_modal_header/view_user_group_modal_header.tsx +++ b/webapp/channels/src/components/view_user_group_modal/view_user_group_modal_header/view_user_group_modal_header.tsx @@ -160,7 +160,7 @@ const ViewUserGroupModalHeader = ({ }, [backButtonCallback, onExited]); return ( - +
); }; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index df51d4125f..9e43e56939 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -5716,6 +5716,8 @@ "user_group_popover.memberCount": "{member_count} {member_count, plural, one {Member} other {Members}}", "user_group_popover.openGroupModal": "View full group info", "user_group_popover.searchGroupMembers": "Search members", + "user_groups_list.groupAriaLabel": "{group_name} group", + "user_groups_list.menuAriaLabel": "{group_name} actions", "user_groups_modal.addPeople": "Add people", "user_groups_modal.addPeopleTitle": "Add people to {group}", "user_groups_modal.allGroups": "All Groups", @@ -5726,7 +5728,7 @@ "user_groups_modal.createTitle": "Create Group", "user_groups_modal.editDetails": "Edit Details", "user_groups_modal.editGroupTitle": "Edit Group Details", - "user_groups_modal.filterAriaLabel": "Groups Filter Menu", + "user_groups_modal.filterAriaLabel": "Groups Filter", "user_groups_modal.goBackLabel": "Back", "user_groups_modal.joinGroup": "Join Group", "user_groups_modal.leaveGroup": "Leave Group", @@ -5740,8 +5742,10 @@ "user_groups_modal.myGroups": "My Groups", "user_groups_modal.name": "Name", "user_groups_modal.nameIsEmpty": "Name is a required field.", + "user_groups_modal.noSearchResults": "No groups found", "user_groups_modal.restoreGroup": "Restore Group", "user_groups_modal.searchGroups": "Search Groups", + "user_groups_modal.searchResults": "{count} groups found", "user_groups_modal.showAllGroups": "Show: All Groups", "user_groups_modal.showArchivedGroups": "Show: Archived Groups", "user_groups_modal.showMyGroups": "Show: My Groups", @@ -6237,6 +6241,7 @@ "view_image.zoom_in": "Zoom In", "view_image.zoom_out": "Zoom Out", "view_image.zoom_reset": "Reset Zoom", + "view_user_group_header_sub_menu.menuAriaLabel": "User group actions", "view_user_group_modal.ldapSynced": "AD/LDAP SYNCED", "view_user_group_modal.memberCount": "{member_count} {member_count, plural, one {Member} other {Members}}", "view_user_group_modal.pluginSynced": "Plugin SYNCED", diff --git a/webapp/platform/components/src/hooks/useFocusTrap.ts b/webapp/platform/components/src/hooks/useFocusTrap.ts index 9752a94a98..e1f1df7c33 100644 --- a/webapp/platform/components/src/hooks/useFocusTrap.ts +++ b/webapp/platform/components/src/hooks/useFocusTrap.ts @@ -198,7 +198,7 @@ function isElementVisible(element: HTMLElement): boolean { if ( style.display === 'none' || style.visibility === 'hidden' || - style.opacity === '0' || + style.pointerEvents === 'none' || currentElement.hasAttribute('hidden') ) { return false;