diff --git a/UNRELEASED.md b/UNRELEASED.md index 926214c7f0d..8dae4c28da3 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -11,6 +11,7 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f - Added consistent spacing to `ActionList` ([#4355](https://github.com/Shopify/polaris-react/pull/4355)) - Added `ariaLabelledBy` props to `Navigation` component to allow a hidden label for accessibility ([#4343](https://github.com/Shopify/polaris-react/pull/4343)) - Add `lastColumnSticky` prop to `IndexTable` to create a sticky last cell and optional sticky last heading on viewports larger than small ([#4150](https://github.com/Shopify/polaris-react/pull/4150)) +- Allow promoted actions to be rendered as a menu on the `BulkAction` component ([#4266](https://github.com/Shopify/polaris-react/pull/4266)) ### Bug fixes diff --git a/src/components/BulkActions/BulkActions.tsx b/src/components/BulkActions/BulkActions.tsx index b4184f2d165..33e187dd160 100644 --- a/src/components/BulkActions/BulkActions.tsx +++ b/src/components/BulkActions/BulkActions.tsx @@ -11,6 +11,7 @@ import type { DisableableAction, Action, ActionListSection, + MenuGroupDescriptor, } from '../../types'; import {ActionList} from '../ActionList'; import {Popover} from '../Popover'; @@ -19,10 +20,10 @@ import {ButtonGroup} from '../ButtonGroup'; import {EventListener} from '../EventListener'; import {CheckableButton} from '../CheckableButton'; -import {BulkActionButton} from './components'; +import {BulkActionButton, BulkActionMenu} from './components'; import styles from './BulkActions.scss'; -type BulkAction = DisableableAction & BadgeAction; +export type BulkAction = DisableableAction & BadgeAction; type BulkActionListSection = ActionListSection; @@ -42,7 +43,7 @@ export interface BulkActionsProps { /** List is in a selectable state */ selectMode?: boolean; /** Actions that will be given more prominence */ - promotedActions?: BulkAction[]; + promotedActions?: (BulkAction | MenuGroupDescriptor)[]; /** List of actions */ actions?: (BulkAction | BulkActionListSection)[]; /** Text to select all across pages */ @@ -178,6 +179,28 @@ class BulkActionsInner extends PureComponent { } } + private rolledInPromotedActions() { + const {promotedActions} = this.props; + const numberOfPromotedActionsToRender = this.numberOfPromotedActionsToRender(); + + if ( + !promotedActions || + promotedActions.length === 0 || + numberOfPromotedActionsToRender >= promotedActions.length + ) { + return []; + } + + const rolledInPromotedActions = promotedActions.map((action) => { + if (instanceOfMenuGroupDescriptor(action)) { + return {items: [...action.actions]}; + } + return {items: [action]}; + }); + + return rolledInPromotedActions.slice(numberOfPromotedActionsToRender); + } + // eslint-disable-next-line @typescript-eslint/member-ordering componentDidMount() { const {actions, promotedActions} = this.props; @@ -297,21 +320,28 @@ class BulkActionsInner extends PureComponent { promotedActions && numberOfPromotedActionsToRender > 0 ? [...promotedActions] .slice(0, numberOfPromotedActionsToRender) - .map((action, index) => ( - - )) + .map((action, index) => { + if (instanceOfMenuGroupDescriptor(action)) { + return ( + + ); + } + return ( + + ); + }) : null; - const rolledInPromotedActions = - promotedActions && - numberOfPromotedActionsToRender < promotedActions.length - ? [...promotedActions].slice(numberOfPromotedActionsToRender) - : []; + const rolledInPromotedActions = this.rolledInPromotedActions(); const activatorLabel = !promotedActions || @@ -326,11 +356,11 @@ class BulkActionsInner extends PureComponent { let combinedActions: ActionListSection[] = []; if (actionSections && rolledInPromotedActions.length > 0) { - combinedActions = [{items: rolledInPromotedActions}, ...actionSections]; + combinedActions = [...rolledInPromotedActions, ...actionSections]; } else if (actionSections) { combinedActions = actionSections; } else if (rolledInPromotedActions.length > 0) { - combinedActions = [{items: rolledInPromotedActions}]; + combinedActions = [...rolledInPromotedActions]; } const actionsPopover = @@ -548,6 +578,12 @@ function instanceOfBulkActionArray( return actions.length === validList.length; } +function instanceOfMenuGroupDescriptor( + action: MenuGroupDescriptor | BulkAction, +): action is MenuGroupDescriptor { + return 'title' in action; +} + export function BulkActions(props: BulkActionsProps) { const i18n = useI18n(); diff --git a/src/components/BulkActions/components/BulkActionMenu/BulkActionMenu.tsx b/src/components/BulkActions/components/BulkActionMenu/BulkActionMenu.tsx new file mode 100644 index 00000000000..46f885252be --- /dev/null +++ b/src/components/BulkActions/components/BulkActionMenu/BulkActionMenu.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import {Popover} from '../../../Popover'; +import {ActionList} from '../../../ActionList'; +import {BulkActionButton} from '../BulkActionButton'; +import {useToggle} from '../../../../utilities/use-toggle'; +import type {MenuGroupDescriptor} from '../../../../types'; + +export interface BulkActionsMenuProps extends MenuGroupDescriptor { + isNewBadgeInBadgeActions: boolean; +} + +export function BulkActionMenu({ + title, + actions, + isNewBadgeInBadgeActions, +}: BulkActionsMenuProps) { + const {value: isVisible, toggle: toggleMenuVisibility} = useToggle(false); + + return ( + <> + + } + onClose={toggleMenuVisibility} + preferInputActivator + > + + + + ); +} diff --git a/src/components/BulkActions/components/BulkActionMenu/index.tsx b/src/components/BulkActions/components/BulkActionMenu/index.tsx new file mode 100644 index 00000000000..b25f155e3e5 --- /dev/null +++ b/src/components/BulkActions/components/BulkActionMenu/index.tsx @@ -0,0 +1 @@ +export * from './BulkActionMenu'; diff --git a/src/components/BulkActions/components/BulkActionMenu/tests/BulkActionMenu.test.tsx b/src/components/BulkActions/components/BulkActionMenu/tests/BulkActionMenu.test.tsx new file mode 100644 index 00000000000..823f87f774a --- /dev/null +++ b/src/components/BulkActions/components/BulkActionMenu/tests/BulkActionMenu.test.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import {Popover, ActionList} from 'components'; +import {mountWithApp} from 'test-utilities'; + +import {BulkActionMenu, BulkActionsMenuProps, BulkActionButton} from '../..'; + +const defaultProps: BulkActionsMenuProps = { + title: 'New promoted action', + actions: [{content: 'Action1', onAction: jest.fn}], + isNewBadgeInBadgeActions: false, +}; + +describe('BulkActionMenu', () => { + describe('initial render', () => { + it('renders an inactive Popover', () => { + const bulkActionMenu = mountWithApp(); + + expect(bulkActionMenu).toContainReactComponent(Popover, { + active: false, + }); + }); + + it('does not render an ActionList', () => { + const bulkActionMenu = mountWithApp(); + + expect(bulkActionMenu).not.toContainReactComponent(ActionList); + }); + + it('renders a BulkActionButton as the Popover activator with the right props', () => { + const bulkActionMenu = mountWithApp(); + + expect(bulkActionMenu).toContainReactComponent(BulkActionButton, { + indicator: defaultProps.isNewBadgeInBadgeActions, + disclosure: true, + content: defaultProps.title, + }); + }); + }); + + describe('upon click', () => { + it('renders an active Popover', () => { + const bulkActionMenu = mountWithApp(); + const bulkActionButton = bulkActionMenu.find(BulkActionButton); + bulkActionButton!.trigger('onAction'); + + expect(bulkActionMenu).toContainReactComponent(Popover, { + active: true, + }); + }); + + it('renders an ActionList with the supplied actions', () => { + const bulkActionMenu = mountWithApp(); + const bulkActionButton = bulkActionMenu.find(BulkActionButton); + bulkActionButton!.trigger('onAction'); + + expect(bulkActionMenu).toContainReactComponent(ActionList, { + items: defaultProps.actions, + }); + }); + + it('closes the Popover when an action is clicked', () => { + const bulkActionMenu = mountWithApp(); + const bulkActionButton = bulkActionMenu.find(BulkActionButton); + bulkActionButton!.trigger('onAction'); + const actionList = bulkActionMenu.find(ActionList); + actionList!.trigger('onActionAnyItem'); + + expect(bulkActionMenu).toContainReactComponent(Popover, { + active: false, + }); + }); + }); +}); diff --git a/src/components/BulkActions/components/index.ts b/src/components/BulkActions/components/index.ts index ae84a5487cc..ea2d0c2e60c 100644 --- a/src/components/BulkActions/components/index.ts +++ b/src/components/BulkActions/components/index.ts @@ -1 +1,2 @@ export * from './BulkActionButton'; +export * from './BulkActionMenu'; diff --git a/src/components/BulkActions/tests/BulkActions.test.tsx b/src/components/BulkActions/tests/BulkActions.test.tsx index cdbd30e587c..083de60d5c4 100644 --- a/src/components/BulkActions/tests/BulkActions.test.tsx +++ b/src/components/BulkActions/tests/BulkActions.test.tsx @@ -1,12 +1,16 @@ import React from 'react'; import {Transition, CSSTransition} from 'react-transition-group'; import {mountWithApp} from 'test-utilities'; -import {Popover} from 'components'; +import {Popover, ActionList} from 'components'; import {CheckableButton} from '../../CheckableButton'; import {Button} from '../../Button'; -import {BulkActionButton, BulkActionButtonProps} from '../components'; -import {BulkActions, BulkActionsProps} from '../BulkActions'; +import { + BulkActionButton, + BulkActionMenu, + BulkActionButtonProps, +} from '../components'; +import {BulkAction, BulkActions, BulkActionsProps} from '../BulkActions'; import styles from '../BulkActions.scss'; interface Props { @@ -80,8 +84,8 @@ describe('', () => { const bulkActionsCount = bulkActions.findAllWhere( (node: any) => - node.props.content === promotedActions[0].content || - node.props.content === promotedActions[1].content, + node.props.content === (promotedActions[0] as BulkAction).content || + node.props.content === (promotedActions[1] as BulkAction).content, ).length; expect(bulkActionsCount).toBe(promotedActions.length); @@ -240,14 +244,30 @@ describe('', () => { }); describe('promotedActions', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + it('renders a BulkActionButton for each item in promotedActions', () => { - const warnSpy = jest.spyOn(console, 'warn'); - warnSpy.mockImplementation(() => {}); const bulkActionProps: Props = { bulkActions: [], promotedActions: [ { - content: 'button 1', + title: 'button1', + actions: [ + { + content: 'action1', + }, + { + content: 'action2', + }, + ], }, { content: 'button 2', @@ -265,7 +285,88 @@ describe('', () => { const bulkActions = mountWithApp(); expect(bulkActions).toContainReactComponentTimes(BulkActionButton, 3); - warnSpy.mockRestore(); + }); + + it('renders a BulkActionMenu when promotedActions are menus', () => { + const bulkActionProps: Props = { + bulkActions: [], + promotedActions: [ + { + title: 'button1', + actions: [ + { + content: 'action1', + }, + { + content: 'action2', + }, + ], + }, + { + title: 'button2', + actions: [ + { + content: 'action1', + }, + { + content: 'action2', + }, + ], + }, + { + content: 'button 2', + }, + { + content: 'button 3', + }, + ], + paginatedSelectAllText: 'paginated select all text string', + selected: false, + accessibilityLabel: 'test-aria-label', + label: 'Test-Label', + disabled: false, + }; + const bulkActions = mountWithApp(); + const bulkActionButtons = bulkActions.findAll(BulkActionButton); + expect(bulkActionButtons).toHaveLength(4); + const bulkActionMenus = bulkActions.findAll(BulkActionMenu); + expect(bulkActionMenus).toHaveLength(2); + }); + + it('opens a popover menu when clicking on a promoted action that is a menu', () => { + const promotedActionToBeClicked = { + title: 'button1', + actions: [ + { + content: 'action1', + }, + { + content: 'action2', + }, + ], + }; + const bulkActionProps: Props = { + bulkActions: [], + promotedActions: [ + {...promotedActionToBeClicked}, + { + content: 'button 2', + }, + ], + paginatedSelectAllText: 'paginated select all text string', + selected: false, + accessibilityLabel: 'test-aria-label', + label: 'Test-Label', + disabled: false, + }; + const bulkActions = mountWithApp(); + + bulkActions.find(BulkActionButton)?.trigger('onAction'); + + const actionList = bulkActions.find(ActionList); + expect(actionList!.prop('items')).toBe( + promotedActionToBeClicked.actions, + ); }); });