From 6004d15f9ee9f3b0bf145d2b003f05bae610c861 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Wed, 2 Jul 2025 16:23:22 -0400 Subject: [PATCH] MM-61595 Use dedicated popover component for post actions menu when empty (#31656) * Link post actions menu button to existing menu That menu isn't fully accessible because it uses the old Menu widget, but the readout will at least be correct when tabbing through the post controls. * MM-61595 Use dedicated popover component for post actions menu when empty * Update unit tests * Split out ActionsMenuEmptyPopover to start of separate Popover component --- .../__snapshots__/actions_menu.test.tsx.snap | 154 +++------------- .../actions_menu_empty.test.tsx.snap | 7 - .../components/actions_menu/actions_menu.scss | 12 ++ .../components/actions_menu/actions_menu.tsx | 167 ++++++++---------- .../actions_menu/actions_menu_button.tsx | 55 ++++++ .../actions_menu/actions_menu_empty.test.tsx | 53 ------ .../actions_menu_empty_popover.tsx | 72 ++++++++ .../actions_menu/actions_menu_icon.tsx | 5 +- .../actions_menu/actions_menu_mobile.test.tsx | 2 + .../components/actions_menu/index.test.tsx | 126 +++++++++++++ .../src/components/actions_menu/popover.tsx | 75 ++++++++ webapp/channels/src/tests/setup_jest.ts | 4 +- 12 files changed, 443 insertions(+), 289 deletions(-) delete mode 100644 webapp/channels/src/components/actions_menu/__snapshots__/actions_menu_empty.test.tsx.snap create mode 100644 webapp/channels/src/components/actions_menu/actions_menu_button.tsx delete mode 100644 webapp/channels/src/components/actions_menu/actions_menu_empty.test.tsx create mode 100644 webapp/channels/src/components/actions_menu/actions_menu_empty_popover.tsx create mode 100644 webapp/channels/src/components/actions_menu/index.test.tsx create mode 100644 webapp/channels/src/components/actions_menu/popover.tsx diff --git a/webapp/channels/src/components/actions_menu/__snapshots__/actions_menu.test.tsx.snap b/webapp/channels/src/components/actions_menu/__snapshots__/actions_menu.test.tsx.snap index ace3a145f9..d04cebb8fa 100644 --- a/webapp/channels/src/components/actions_menu/__snapshots__/actions_menu.test.tsx.snap +++ b/webapp/channels/src/components/actions_menu/__snapshots__/actions_menu.test.tsx.snap @@ -7,31 +7,14 @@ exports[`components/actions_menu/ActionsMenu has actions - marketplace disabled onToggle={[Function]} open={true} > - - } - > - - + @@ -57,31 +40,14 @@ exports[`components/actions_menu/ActionsMenu has actions - marketplace enabled a onToggle={[Function]} open={true} > - - } - > - - + @@ -120,86 +86,18 @@ exports[`components/actions_menu/ActionsMenu has actions - marketplace enabled a exports[`components/actions_menu/ActionsMenu no actions - end user - menu should not be visible to end user 1`] = `""`; exports[`components/actions_menu/ActionsMenu no actions - sysadmin - menu should show visit marketplace 1`] = ` - - - } - > - - - - -
-

- -

-

- -

-
-
- -
-
-
-
+ + + + `; diff --git a/webapp/channels/src/components/actions_menu/__snapshots__/actions_menu_empty.test.tsx.snap b/webapp/channels/src/components/actions_menu/__snapshots__/actions_menu_empty.test.tsx.snap deleted file mode 100644 index 9b2ef70d4c..0000000000 --- a/webapp/channels/src/components/actions_menu/__snapshots__/actions_menu_empty.test.tsx.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/actions_menu/ActionsMenu returning empty ("") should match snapshot, return empty ("") on Center 1`] = ` - - - -`; diff --git a/webapp/channels/src/components/actions_menu/actions_menu.scss b/webapp/channels/src/components/actions_menu/actions_menu.scss index c526fbfe34..6bad51d8b8 100644 --- a/webapp/channels/src/components/actions_menu/actions_menu.scss +++ b/webapp/channels/src/components/actions_menu/actions_menu.scss @@ -17,3 +17,15 @@ text-align: left; } } + +.ActionsMenuEmptyPopover { + & .MuiPaper-root { + min-width: 114px; + max-width: 496px; + max-height: 80vh; + padding: 4px 0; + background-color: var(--center-channel-bg); + box-shadow: var(--elevation-4), 0 0 0 1px rgba(var(--center-channel-color-rgb), 0.12) inset; + margin-block-start: 4px; + } +} diff --git a/webapp/channels/src/components/actions_menu/actions_menu.tsx b/webapp/channels/src/components/actions_menu/actions_menu.tsx index e538587fa2..67e2b16543 100644 --- a/webapp/channels/src/components/actions_menu/actions_menu.tsx +++ b/webapp/channels/src/components/actions_menu/actions_menu.tsx @@ -1,24 +1,20 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import classNames from 'classnames'; import React from 'react'; import type {IntlShape} from 'react-intl'; -import {FormattedMessage, injectIntl} from 'react-intl'; +import {injectIntl} from 'react-intl'; import type {AppBinding} from '@mattermost/types/apps'; import type {Post} from '@mattermost/types/posts'; import {AppCallResponseTypes} from 'mattermost-redux/constants/apps'; -import Permissions from 'mattermost-redux/constants/permissions'; import type {ActionResult} from 'mattermost-redux/types/actions'; -import SystemPermissionGate from 'components/permissions_gates/system_permission_gate'; import type {OpenedFromType} from 'components/plugin_marketplace/marketplace_modal'; import MarketplaceModal from 'components/plugin_marketplace/marketplace_modal'; import Menu from 'components/widgets/menu/menu'; import MenuWrapper from 'components/widgets/menu/menu_wrapper'; -import WithTooltip from 'components/with_tooltip'; import Pluggable from 'plugins/pluggable'; import {createCallContext} from 'utils/apps'; @@ -29,6 +25,8 @@ import type {ModalData} from 'types/actions'; import type {HandleBindingClick, OpenAppsModal, PostEphemeralCallResponseForPost} from 'types/apps'; import type {PostDropdownMenuAction, PostDropdownMenuItemComponent} from 'types/store/plugins'; +import ActionsMenuButton from './actions_menu_button'; +import ActionsMenuEmptyPopover from './actions_menu_empty_popover'; import {ActionsMenuIcon} from './actions_menu_icon'; import './actions_menu.scss'; @@ -39,9 +37,9 @@ export const PLUGGABLE_COMPONENT = 'PostDropdownMenuItem'; export type Props = { appBindings: AppBinding[] | null; appsEnabled: boolean; - handleDropdownOpened?: (open: boolean) => void; + handleDropdownOpened: (open: boolean) => void; intl: IntlShape; - isMenuOpen?: boolean; + isMenuOpen: boolean; isSysAdmin: boolean; location?: 'CENTER' | 'RHS_ROOT' | 'RHS_COMMENT' | 'SEARCH' | string; pluginMenuItems?: PostDropdownMenuAction[]; @@ -95,7 +93,7 @@ export class ActionMenuClass extends React.PureComponent { location: Locations.CENTER, pluginMenuItems: [], }; - private buttonRef: React.RefObject; + private buttonElement: HTMLButtonElement | null = null; constructor(props: Props) { super(props); @@ -103,8 +101,6 @@ export class ActionMenuClass extends React.PureComponent { this.state = { openUp: false, }; - - this.buttonRef = React.createRef(); } componentDidUpdate(prevProps: Props) { @@ -121,6 +117,10 @@ export class ActionMenuClass extends React.PureComponent { return state; } + private buttonRef = (element: HTMLButtonElement | null) => { + this.buttonElement = element; + }; + fetchBindings = () => { if (this.props.appsEnabled && !this.state.appBindings) { this.props.actions.fetchBindings(this.props.post.channel_id, this.props.teamId).then(({data}) => { @@ -137,6 +137,8 @@ export class ActionMenuClass extends React.PureComponent { dialogProps: {openedFrom}, }; this.props.actions.openModal(openMarketplaceData); + + this.closeDropdown(); }; onClickAppBinding = async (binding: AppBinding) => { @@ -189,45 +191,6 @@ export class ActionMenuClass extends React.PureComponent { } }; - visitMarketplaceTip(): React.ReactElement { - return ( - -
-

- -

-

- -

-
-
- -
-
- ); - } - renderDivider = (suffix: string): React.ReactNode => { return (
  • { ); }; + openDropdown = () => { + this.props.handleDropdownOpened(true); + }; + + closeDropdown = () => { + this.props.handleDropdownOpened(false); + }; + handleDropdownOpened = (open: boolean) => { - this.props.handleDropdownOpened?.(open); + this.props.handleDropdownOpened(open); if (!open) { return; } - const buttonRect = this.buttonRef.current?.getBoundingClientRect(); + const buttonRect = this.buttonElement?.getBoundingClientRect(); let y; if (typeof buttonRect?.y === 'undefined') { y = typeof buttonRect?.top == 'undefined' ? 0 : buttonRect?.top; @@ -342,7 +313,6 @@ export class ActionMenuClass extends React.PureComponent { ); } - let menuItems; const hasApps = Boolean(appBindings.length); const hasPluggables = Boolean(this.props.pluginMenuItemComponents?.length); const hasPluginItems = Boolean(pluginItems?.length); @@ -352,65 +322,68 @@ export class ActionMenuClass extends React.PureComponent { return null; } + const buttonId = `${this.props.location}_actions_button_${this.props.post.id}`; + const popupId = `${this.props.location}_actions_dropdown_${this.props.post.id}`; + if (hasPluginMenuItems) { const pluggable = ( ); + /> + ); - menuItems = [ + const menuItems = [ pluginItems, appBindings, pluggable, marketPlace, ]; - } else { - menuItems = [this.visitMarketplaceTip()]; - if (!this.props.isSysAdmin) { - return null; - } + + return ( + + + + {menuItems} + + + ); + } else if (this.props.isSysAdmin) { + return ( + <> + + + + + ); } - return ( - - - } - > - - - - {menuItems} - - - ); + return null; } } diff --git a/webapp/channels/src/components/actions_menu/actions_menu_button.tsx b/webapp/channels/src/components/actions_menu/actions_menu_button.tsx new file mode 100644 index 0000000000..081122c506 --- /dev/null +++ b/webapp/channels/src/components/actions_menu/actions_menu_button.tsx @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import WithTooltip from 'components/with_tooltip'; + +type ActionsMenuButtonProps = { + buttonId: string; + onClick?: React.MouseEventHandler; + isMenuOpen: boolean; + popupId: string; +}; + +const ActionsMenuButton = React.forwardRef(({ + buttonId, + onClick, + isMenuOpen, + popupId, +}, ref) => { + const {formatMessage} = useIntl(); + + return ( + + } + > + + + ); +}); +ActionsMenuButton.displayName = 'ActionsMenuButton'; + +export default ActionsMenuButton; diff --git a/webapp/channels/src/components/actions_menu/actions_menu_empty.test.tsx b/webapp/channels/src/components/actions_menu/actions_menu_empty.test.tsx deleted file mode 100644 index bc309fdce1..0000000000 --- a/webapp/channels/src/components/actions_menu/actions_menu_empty.test.tsx +++ /dev/null @@ -1,53 +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 ActionsMenu from 'components/actions_menu/actions_menu'; -import type {Props} from 'components/actions_menu/actions_menu'; - -import {TestHelper} from 'utils/test_helper'; - -jest.mock('utils/utils', () => { - return { - isMobile: jest.fn(() => false), - localizeMessage: jest.fn().mockReturnValue(''), - }; -}); - -jest.mock('utils/post_utils', () => { - const original = jest.requireActual('utils/post_utils'); - return { - ...original, - isSystemMessage: jest.fn(() => true), - }; -}); - -describe('components/actions_menu/ActionsMenu returning empty ("")', () => { - test('should match snapshot, return empty ("") on Center', () => { - const baseProps: Omit = { - post: TestHelper.getPostMock({id: 'post_id_1'}), - teamId: 'team_id_1', - actions: { - openModal: jest.fn(), - openAppsModal: jest.fn(), - handleBindingClick: jest.fn(), - postEphemeralCallResponseForPost: jest.fn(), - fetchBindings: jest.fn(), - }, - appBindings: [], - pluginMenuItems: [], - appsEnabled: false, - isSysAdmin: true, - canOpenMarketplace: false, - pluginMenuItemComponents: [], - }; - - const wrapper = shallow( - , - ); - - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/webapp/channels/src/components/actions_menu/actions_menu_empty_popover.tsx b/webapp/channels/src/components/actions_menu/actions_menu_empty_popover.tsx new file mode 100644 index 0000000000..9ffbe67fd9 --- /dev/null +++ b/webapp/channels/src/components/actions_menu/actions_menu_empty_popover.tsx @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {Permissions} from 'mattermost-redux/constants'; + +import SystemPermissionGate from 'components/permissions_gates/system_permission_gate'; + +import {ActionsMenuIcon} from './actions_menu_icon'; +import Popover from './popover'; + +type Props = { + anchorElement: Element | null | undefined; + onOpenMarketplace: () => void; + onToggle: (open: boolean) => void; + isOpen: boolean; +} + +export default function ActionsMenuEmptyPopover({ + anchorElement, + onOpenMarketplace, + onToggle, + isOpen, +}: Props) { + return ( + + +
    +

    + +

    +

    + +

    +
    +
    + +
    +
    +
    + ); +} diff --git a/webapp/channels/src/components/actions_menu/actions_menu_icon.tsx b/webapp/channels/src/components/actions_menu/actions_menu_icon.tsx index 66022cef8c..2bfcdbe1f6 100644 --- a/webapp/channels/src/components/actions_menu/actions_menu_icon.tsx +++ b/webapp/channels/src/components/actions_menu/actions_menu_icon.tsx @@ -11,6 +11,9 @@ type Props = { export function ActionsMenuIcon({name, dangerous}: Props) { const colorClass = dangerous ? 'MenuItem__compass-icon-dangerous' : 'MenuItem__compass-icon'; return ( - + ); } diff --git a/webapp/channels/src/components/actions_menu/actions_menu_mobile.test.tsx b/webapp/channels/src/components/actions_menu/actions_menu_mobile.test.tsx index ac67550ee0..12615165a7 100644 --- a/webapp/channels/src/components/actions_menu/actions_menu_mobile.test.tsx +++ b/webapp/channels/src/components/actions_menu/actions_menu_mobile.test.tsx @@ -29,6 +29,8 @@ describe('components/actions_menu/ActionsMenu on mobile view', () => { const baseProps: Omit = { post: TestHelper.getPostMock({id: 'post_id_1'}), teamId: 'team_id_1', + handleDropdownOpened: jest.fn(), + isMenuOpen: true, actions: { openModal: jest.fn(), openAppsModal: jest.fn(), diff --git a/webapp/channels/src/components/actions_menu/index.test.tsx b/webapp/channels/src/components/actions_menu/index.test.tsx new file mode 100644 index 0000000000..63b9d42694 --- /dev/null +++ b/webapp/channels/src/components/actions_menu/index.test.tsx @@ -0,0 +1,126 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; + +import {Permissions} from 'mattermost-redux/constants'; + +import ActionsMenu from 'components/actions_menu'; +import ModalController from 'components/modal_controller'; + +import {act, renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; + +function ActionsMenuTestWrapper(props: Omit, 'isMenuOpen' | 'handleDropdownOpened'>) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + return ( + + ); +} + +describe('ActionsMenu', () => { + test('should match snapshot, return empty ("") on Center', async () => { + const user1 = TestHelper.getUserMock({id: 'user1', roles: 'system_admin system_user'}); + const post1 = TestHelper.getPostMock({id: 'post1', type: ''}); + + const baseProps = { + post: post1, + teamId: 'team_id_1', + }; + + renderWithContext( + <> + + + , + { + entities: { + general: { + config: { + PluginsEnabled: 'true', + EnableMarketplace: 'true', + }, + }, + posts: { + posts: { + [post1.id]: post1, + }, + }, + roles: { + roles: { + system_admin: TestHelper.getRoleMock({ + permissions: [Permissions.MANAGE_SYSTEM, Permissions.SYSCONSOLE_WRITE_PLUGINS], + }), + }, + }, + users: { + currentUserId: user1.id, + profiles: { + [user1.id]: user1, + }, + }, + }, + }, + ); + + expect(screen.getByRole('button')).toHaveAccessibleName('actions'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + // Open the menu + screen.getByRole('button').click(); + + // The dialog should open up + await waitFor(() => { + expect(screen.queryByRole('dialog', {name: 'actions'})).toBeVisible(); + expect(screen.queryByRole('button', {name: 'Visit the Marketplace'})).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Visit the Marketplace'})).toBeVisible(); + }); + + // The focus starts on the dialog itself, so pressing tab should move it to the button + userEvent.tab(); + + expect(screen.queryByRole('button', {name: 'Visit the Marketplace'})).toHaveFocus(); + + // The focus should be trapped, so hitting tab again shouldn't change the focus + userEvent.tab(); + + expect(screen.queryByRole('button', {name: 'Visit the Marketplace'})).toHaveFocus(); + + // Pressing enter should open the marketplace modal and close the menu + userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(screen.queryByRole('dialog', {name: 'actions'})).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog', {name: 'App Marketplace'})).toBeVisible(); + }); + + // Pressing escape should close the marketplace modal + act(() => { + userEvent.keyboard('{Escape}'); + }); + + await waitFor(() => { + expect(screen.queryByRole('dialog', {name: 'App Marketplace'})).not.toBeInTheDocument(); + }); + + // Reopen the menu + screen.getByRole('button').click(); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBeVisible(); + }); + expect(screen.queryByRole('dialog')).toHaveAccessibleName('actions'); + + // Pressing escape should close the dialog + userEvent.keyboard('{Escape}'); + + await waitFor(() => { + expect(screen.queryByRole('dialog', {name: 'actions'})).not.toBeInTheDocument(); + }); + }); +}); diff --git a/webapp/channels/src/components/actions_menu/popover.tsx b/webapp/channels/src/components/actions_menu/popover.tsx new file mode 100644 index 0000000000..56ba7b333e --- /dev/null +++ b/webapp/channels/src/components/actions_menu/popover.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {PopoverOrigin} from '@mui/material/Popover'; +import MuiPopover from '@mui/material/Popover'; +import classNames from 'classnames'; +import React, {useCallback} from 'react'; +import {useSelector} from 'react-redux'; + +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; + +import CompassDesignProvider from 'components/compass_design_provider'; + +import {A11yClassNames} from 'utils/constants'; + +import './actions_menu.scss'; + +export type PopoverProps = { + anchorElement: Element | null | undefined; + children: React.ReactNode; + isOpen: boolean; + onToggle?: (isOpen: boolean) => void; + + anchorOrigin?: PopoverOrigin; + transformOrigin?: PopoverOrigin; +} + +const OPEN_ANIMATION_DURATION = 150; +const CLOSE_ANIMATION_DURATION = 100; + +const defaultAnchorOrigin = {vertical: 'bottom', horizontal: 'left'} as PopoverOrigin; +const defaultTransformOrigin = {vertical: 'top', horizontal: 'left'} as PopoverOrigin; + +export default function Popover({ + anchorElement, + children, + isOpen, + onToggle, + + anchorOrigin = defaultAnchorOrigin, + transformOrigin = defaultTransformOrigin, +}: PopoverProps) { + const theme = useSelector(getTheme); + + const handleClose = useCallback(() => { + onToggle?.(false); + }, [onToggle]); + + return ( + + + {children} + + + ); +} diff --git a/webapp/channels/src/tests/setup_jest.ts b/webapp/channels/src/tests/setup_jest.ts index 432f9939d8..ceb6cefdd7 100644 --- a/webapp/channels/src/tests/setup_jest.ts +++ b/webapp/channels/src/tests/setup_jest.ts @@ -68,9 +68,7 @@ function isDependencyWarning(params: string[]) { paramsHasComponent('Overlay') || paramsHasComponent('Position') || paramsHasComponent('Dropdown') || - - // React-Select - paramsHasComponent('Select') + paramsHasComponent('Tabs') ); }