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') ); }