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
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
9ad0f1e070
Коммит
6004d15f9e
@@ -7,31 +7,14 @@ exports[`components/actions_menu/ActionsMenu has actions - marketplace disabled
|
||||
onToggle={[Function]}
|
||||
open={true}
|
||||
>
|
||||
<WithTooltip
|
||||
title={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Message actions"
|
||||
id="post_info.tooltip.actions"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-label="actions"
|
||||
className="post-menu__item post-menu__item--active"
|
||||
id="center_actions_button_post_id_1"
|
||||
key="more-actions-button"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
className="icon icon-apps"
|
||||
/>
|
||||
</button>
|
||||
</WithTooltip>
|
||||
<ActionsMenuButton
|
||||
buttonId="center_actions_button_post_id_1"
|
||||
isMenuOpen={true}
|
||||
popupId="center_actions_dropdown_post_id_1"
|
||||
/>
|
||||
<Menu
|
||||
ariaLabel="Post extra options"
|
||||
id="center_actions_dropdown_post_id_1"
|
||||
key="center_actions_dropdown_post_id_1"
|
||||
listId="center_actions_dropdown_post_id_1"
|
||||
openLeft={true}
|
||||
openUp={false}
|
||||
>
|
||||
@@ -57,31 +40,14 @@ exports[`components/actions_menu/ActionsMenu has actions - marketplace enabled a
|
||||
onToggle={[Function]}
|
||||
open={true}
|
||||
>
|
||||
<WithTooltip
|
||||
title={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Message actions"
|
||||
id="post_info.tooltip.actions"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-label="actions"
|
||||
className="post-menu__item post-menu__item--active"
|
||||
id="center_actions_button_post_id_1"
|
||||
key="more-actions-button"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
className="icon icon-apps"
|
||||
/>
|
||||
</button>
|
||||
</WithTooltip>
|
||||
<ActionsMenuButton
|
||||
buttonId="center_actions_button_post_id_1"
|
||||
isMenuOpen={true}
|
||||
popupId="center_actions_dropdown_post_id_1"
|
||||
/>
|
||||
<Menu
|
||||
ariaLabel="Post extra options"
|
||||
id="center_actions_dropdown_post_id_1"
|
||||
key="center_actions_dropdown_post_id_1"
|
||||
listId="center_actions_dropdown_post_id_1"
|
||||
openLeft={true}
|
||||
openUp={false}
|
||||
>
|
||||
@@ -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`] = `
|
||||
<MenuWrapper
|
||||
animationComponent={[Function]}
|
||||
className=""
|
||||
onToggle={[Function]}
|
||||
open={true}
|
||||
>
|
||||
<WithTooltip
|
||||
title={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Message actions"
|
||||
id="post_info.tooltip.actions"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-label="actions"
|
||||
className="post-menu__item post-menu__item--active"
|
||||
id="center_actions_button_post_id_1"
|
||||
key="more-actions-button"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
className="icon icon-apps"
|
||||
/>
|
||||
</button>
|
||||
</WithTooltip>
|
||||
<Menu
|
||||
ariaLabel="Post extra options"
|
||||
id="center_actions_dropdown_post_id_1"
|
||||
key="center_actions_dropdown_post_id_1"
|
||||
openLeft={true}
|
||||
openUp={false}
|
||||
>
|
||||
<Memo(SystemPermissionGate)
|
||||
key="visit-marketplace-permissions"
|
||||
permissions={
|
||||
Array [
|
||||
"manage_system",
|
||||
]
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="visit-marketplace-text"
|
||||
>
|
||||
<p>
|
||||
<MemoizedFormattedMessage
|
||||
defaultMessage="No Actions currently"
|
||||
id="post_info.actions.noActions.first_line"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<MemoizedFormattedMessage
|
||||
defaultMessage="configured for this server"
|
||||
id="post_info.actions.noActions.second_line"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="visit-marketplace"
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary btn-sm visit-marketplace-button"
|
||||
id="marketPlaceButton"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<ActionsMenuIcon
|
||||
name="icon-view-grid-plus-outline"
|
||||
/>
|
||||
<span
|
||||
className="visit-marketplace-button-text"
|
||||
>
|
||||
<MemoizedFormattedMessage
|
||||
defaultMessage="Visit the Marketplace"
|
||||
id="post_info.actions.visitMarketplace"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Memo(SystemPermissionGate)>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
<Fragment>
|
||||
<ActionsMenuButton
|
||||
buttonId="center_actions_button_post_id_1"
|
||||
isMenuOpen={true}
|
||||
onClick={[Function]}
|
||||
popupId="center_actions_dropdown_post_id_1"
|
||||
/>
|
||||
<ActionsMenuEmptyPopover
|
||||
anchorElement={null}
|
||||
isOpen={true}
|
||||
onOpenMarketplace={[Function]}
|
||||
onToggle={[MockFunction]}
|
||||
/>
|
||||
</Fragment>
|
||||
`;
|
||||
|
||||
@@ -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`] = `
|
||||
<ContextConsumer>
|
||||
<Component />
|
||||
</ContextConsumer>
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Props, State> {
|
||||
location: Locations.CENTER,
|
||||
pluginMenuItems: [],
|
||||
};
|
||||
private buttonRef: React.RefObject<HTMLButtonElement>;
|
||||
private buttonElement: HTMLButtonElement | null = null;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
@@ -103,8 +101,6 @@ export class ActionMenuClass extends React.PureComponent<Props, State> {
|
||||
this.state = {
|
||||
openUp: false,
|
||||
};
|
||||
|
||||
this.buttonRef = React.createRef<HTMLButtonElement>();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
@@ -121,6 +117,10 @@ export class ActionMenuClass extends React.PureComponent<Props, State> {
|
||||
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<Props, State> {
|
||||
dialogProps: {openedFrom},
|
||||
};
|
||||
this.props.actions.openModal(openMarketplaceData);
|
||||
|
||||
this.closeDropdown();
|
||||
};
|
||||
|
||||
onClickAppBinding = async (binding: AppBinding) => {
|
||||
@@ -189,45 +191,6 @@ export class ActionMenuClass extends React.PureComponent<Props, State> {
|
||||
}
|
||||
};
|
||||
|
||||
visitMarketplaceTip(): React.ReactElement {
|
||||
return (
|
||||
<SystemPermissionGate
|
||||
permissions={[Permissions.MANAGE_SYSTEM]}
|
||||
key='visit-marketplace-permissions'
|
||||
>
|
||||
<div className='visit-marketplace-text' >
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='post_info.actions.noActions.first_line'
|
||||
defaultMessage='No Actions currently'
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='post_info.actions.noActions.second_line'
|
||||
defaultMessage='configured for this server'
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className='visit-marketplace' >
|
||||
<button
|
||||
id='marketPlaceButton'
|
||||
className='btn btn-primary btn-sm visit-marketplace-button'
|
||||
onClick={this.handleOpenMarketplace}
|
||||
>
|
||||
<ActionsMenuIcon name='icon-view-grid-plus-outline'/>
|
||||
<span className='visit-marketplace-button-text'>
|
||||
<FormattedMessage
|
||||
id='post_info.actions.visitMarketplace'
|
||||
defaultMessage='Visit the Marketplace'
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</SystemPermissionGate>
|
||||
);
|
||||
}
|
||||
|
||||
renderDivider = (suffix: string): React.ReactNode => {
|
||||
return (
|
||||
<li
|
||||
@@ -238,14 +201,22 @@ export class ActionMenuClass extends React.PureComponent<Props, State> {
|
||||
);
|
||||
};
|
||||
|
||||
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<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
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<Props, State> {
|
||||
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 = (
|
||||
<Pluggable
|
||||
postId={this.props.post.id}
|
||||
pluggableName={PLUGGABLE_COMPONENT}
|
||||
key={this.props.post.id + 'pluggable'}
|
||||
/>);
|
||||
/>
|
||||
);
|
||||
|
||||
menuItems = [
|
||||
const menuItems = [
|
||||
pluginItems,
|
||||
appBindings,
|
||||
pluggable,
|
||||
marketPlace,
|
||||
];
|
||||
} else {
|
||||
menuItems = [this.visitMarketplaceTip()];
|
||||
if (!this.props.isSysAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuWrapper
|
||||
open={this.props.isMenuOpen}
|
||||
onToggle={this.handleDropdownOpened}
|
||||
>
|
||||
<ActionsMenuButton
|
||||
ref={this.buttonRef}
|
||||
buttonId={buttonId}
|
||||
popupId={popupId}
|
||||
isMenuOpen={this.props.isMenuOpen}
|
||||
/>
|
||||
<Menu
|
||||
listId={popupId}
|
||||
openLeft={true}
|
||||
openUp={this.state.openUp}
|
||||
ariaLabel={formatMessage({id: 'post_info.menuAriaLabel', defaultMessage: 'Post extra options'})}
|
||||
>
|
||||
{menuItems}
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
);
|
||||
} else if (this.props.isSysAdmin) {
|
||||
return (
|
||||
<>
|
||||
|
||||
<ActionsMenuButton
|
||||
ref={this.buttonRef}
|
||||
buttonId={buttonId}
|
||||
onClick={this.openDropdown}
|
||||
popupId={popupId}
|
||||
isMenuOpen={this.props.isMenuOpen}
|
||||
/>
|
||||
<ActionsMenuEmptyPopover
|
||||
anchorElement={this.buttonElement}
|
||||
onOpenMarketplace={this.handleOpenMarketplace}
|
||||
onToggle={this.props.handleDropdownOpened}
|
||||
isOpen={this.props.isMenuOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuWrapper
|
||||
open={this.props.isMenuOpen}
|
||||
onToggle={this.handleDropdownOpened}
|
||||
>
|
||||
<WithTooltip
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='post_info.tooltip.actions'
|
||||
defaultMessage='Message actions'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<button
|
||||
key='more-actions-button'
|
||||
ref={this.buttonRef}
|
||||
id={`${this.props.location}_actions_button_${this.props.post.id}`}
|
||||
aria-label={formatMessage({id: 'post_info.actions.tooltip.actions', defaultMessage: 'Actions'}).toLowerCase()}
|
||||
className={classNames('post-menu__item', {
|
||||
'post-menu__item--active': this.props.isMenuOpen,
|
||||
})}
|
||||
type='button'
|
||||
aria-expanded='false'
|
||||
>
|
||||
<i className={'icon icon-apps'}/>
|
||||
</button>
|
||||
</WithTooltip>
|
||||
<Menu
|
||||
id={`${this.props.location}_actions_dropdown_${this.props.post.id}`}
|
||||
openLeft={true}
|
||||
openUp={this.state.openUp}
|
||||
ariaLabel={formatMessage({id: 'post_info.menuAriaLabel', defaultMessage: 'Post extra options'})}
|
||||
key={`${this.props.location}_actions_dropdown_${this.props.post.id}`}
|
||||
>
|
||||
{menuItems}
|
||||
</Menu>
|
||||
</MenuWrapper >
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HTMLButtonElement>;
|
||||
isMenuOpen: boolean;
|
||||
popupId: string;
|
||||
};
|
||||
|
||||
const ActionsMenuButton = React.forwardRef<HTMLButtonElement, ActionsMenuButtonProps>(({
|
||||
buttonId,
|
||||
onClick,
|
||||
isMenuOpen,
|
||||
popupId,
|
||||
}, ref) => {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
return (
|
||||
<WithTooltip
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='post_info.tooltip.actions'
|
||||
defaultMessage='Message actions'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<button
|
||||
key='more-actions-button'
|
||||
ref={ref}
|
||||
id={buttonId}
|
||||
aria-label={formatMessage({id: 'post_info.actions.tooltip.actions', defaultMessage: 'Actions'}).toLowerCase()}
|
||||
className={classNames('post-menu__item', {
|
||||
'post-menu__item--active': isMenuOpen,
|
||||
})}
|
||||
type='button'
|
||||
aria-controls={popupId}
|
||||
aria-expanded={isMenuOpen}
|
||||
aria-haspopup={true}
|
||||
onClick={onClick}
|
||||
>
|
||||
<i className={'icon icon-apps'}/>
|
||||
</button>
|
||||
</WithTooltip>
|
||||
);
|
||||
});
|
||||
ActionsMenuButton.displayName = 'ActionsMenuButton';
|
||||
|
||||
export default ActionsMenuButton;
|
||||
@@ -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<Props, 'intl'> = {
|
||||
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(
|
||||
<ActionsMenu {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Popover
|
||||
anchorElement={anchorElement}
|
||||
isOpen={isOpen}
|
||||
onToggle={onToggle}
|
||||
|
||||
anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}
|
||||
transformOrigin={{vertical: 'top', horizontal: 'right'}}
|
||||
>
|
||||
<SystemPermissionGate
|
||||
permissions={[Permissions.MANAGE_SYSTEM]}
|
||||
key='visit-marketplace-permissions'
|
||||
>
|
||||
<div className='visit-marketplace-text'>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='post_info.actions.noActions.first_line'
|
||||
defaultMessage='No Actions currently'
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='post_info.actions.noActions.second_line'
|
||||
defaultMessage='configured for this server'
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className='visit-marketplace'>
|
||||
<button
|
||||
id='marketPlaceButton'
|
||||
className='btn btn-primary btn-sm visit-marketplace-button'
|
||||
onClick={onOpenMarketplace}
|
||||
>
|
||||
<ActionsMenuIcon name='icon-view-grid-plus-outline'/>
|
||||
<span className='visit-marketplace-button-text'>
|
||||
<FormattedMessage
|
||||
id='post_info.actions.visitMarketplace'
|
||||
defaultMessage='Visit the Marketplace'
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</SystemPermissionGate>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,9 @@ type Props = {
|
||||
export function ActionsMenuIcon({name, dangerous}: Props) {
|
||||
const colorClass = dangerous ? 'MenuItem__compass-icon-dangerous' : 'MenuItem__compass-icon';
|
||||
return (
|
||||
<span className={`${name} ${colorClass}`}/>
|
||||
<span
|
||||
className={`${name} ${colorClass}`}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ describe('components/actions_menu/ActionsMenu on mobile view', () => {
|
||||
const baseProps: Omit<Props, 'intl'> = {
|
||||
post: TestHelper.getPostMock({id: 'post_id_1'}),
|
||||
teamId: 'team_id_1',
|
||||
handleDropdownOpened: jest.fn(),
|
||||
isMenuOpen: true,
|
||||
actions: {
|
||||
openModal: jest.fn(),
|
||||
openAppsModal: jest.fn(),
|
||||
|
||||
126
webapp/channels/src/components/actions_menu/index.test.tsx
Обычный файл
126
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<React.ComponentProps<typeof ActionsMenu>, 'isMenuOpen' | 'handleDropdownOpened'>) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ActionsMenu
|
||||
{...props}
|
||||
handleDropdownOpened={setIsMenuOpen}
|
||||
isMenuOpen={isMenuOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<>
|
||||
<ActionsMenuTestWrapper {...baseProps}/>
|
||||
<ModalController/>
|
||||
</>,
|
||||
{
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
75
webapp/channels/src/components/actions_menu/popover.tsx
Обычный файл
75
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 (
|
||||
<CompassDesignProvider theme={theme}>
|
||||
<MuiPopover
|
||||
anchorEl={anchorElement}
|
||||
open={isOpen}
|
||||
onClose={handleClose}
|
||||
className={classNames(A11yClassNames.POPUP, 'ActionsMenuEmptyPopover')}
|
||||
anchorOrigin={anchorOrigin}
|
||||
transformOrigin={transformOrigin}
|
||||
marginThreshold={0}
|
||||
TransitionProps={{
|
||||
mountOnEnter: true,
|
||||
unmountOnExit: true,
|
||||
timeout: {
|
||||
enter: OPEN_ANIMATION_DURATION,
|
||||
exit: CLOSE_ANIMATION_DURATION,
|
||||
},
|
||||
}}
|
||||
role='dialog'
|
||||
aria-modal={true}
|
||||
aria-labelledby={anchorElement?.id}
|
||||
>
|
||||
{children}
|
||||
</MuiPopover>
|
||||
</CompassDesignProvider>
|
||||
);
|
||||
}
|
||||
@@ -68,9 +68,7 @@ function isDependencyWarning(params: string[]) {
|
||||
paramsHasComponent('Overlay') ||
|
||||
paramsHasComponent('Position') ||
|
||||
paramsHasComponent('Dropdown') ||
|
||||
|
||||
// React-Select
|
||||
paramsHasComponent('Select')
|
||||
paramsHasComponent('Tabs')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Ссылка в новой задаче
Block a user