diff --git a/i18n/en-US.properties b/i18n/en-US.properties index 8dcd61481d..c99167f15e 100644 --- a/i18n/en-US.properties +++ b/i18n/en-US.properties @@ -1824,6 +1824,8 @@ boxui.shareMenu.shortcutOnly = Shortcut Only boxui.shareMenu.viewAndDownload = View and Download # Description of permissions granted to users who have access to the shared link boxui.shareMenu.viewOnly = View Only +# Aria-label for the dropdown menu that shows actions for selected items +boxui.subHeader.bulkItemActionMenuAriaLabel = Bulk actions # Text for metadata button that will open the metadata side panel boxui.subHeader.metadata = Metadata # Error message for empty time formats. "HH:MM A" should be localized. diff --git a/src/elements/common/sub-header/BulkItemActionMenu.tsx b/src/elements/common/sub-header/BulkItemActionMenu.tsx new file mode 100644 index 0000000000..0887416ecb --- /dev/null +++ b/src/elements/common/sub-header/BulkItemActionMenu.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { useIntl } from 'react-intl'; + +import { Button, DropdownMenu } from '@box/blueprint-web'; +import { Ellipsis } from '@box/blueprint-web-assets/icons/Fill'; +import type { Selection } from 'react-aria-components'; + +import messages from '../../common/sub-header/messages'; + +export interface BulkItemAction { + label: string; + onClick: (selectedItemIds: Selection) => void; +} + +export interface BulkItemActionMenuProps { + actions: BulkItemAction[]; + selectedItemIds: Selection; +} + +export const BulkItemActionMenu = ({ actions, selectedItemIds }: BulkItemActionMenuProps) => { + const { formatMessage } = useIntl(); + + return ( + + + + <> + {bulkItemActions && bulkItemActions.length > 0 && ( + + )} + + )} ); diff --git a/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx b/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx index 6663549a42..8471957c78 100644 --- a/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx +++ b/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; -import { render, screen } from '../../../../test-utils/testing-library'; +import { render, screen, userEvent } from '../../../../test-utils/testing-library'; import SubHeaderRight, { SubHeaderRightProps } from '../SubHeaderRight'; -import { VIEW_FOLDER, VIEW_MODE_GRID } from '../../../../constants'; +import { VIEW_FOLDER, VIEW_METADATA, VIEW_MODE_GRID } from '../../../../constants'; describe('elements/common/sub-header/SubHeaderRight', () => { const defaultProps = { @@ -21,8 +21,8 @@ describe('elements/common/sub-header/SubHeaderRight', () => { viewMode: VIEW_MODE_GRID, }; - const renderComponent = (props: Partial = {}) => - render(); + const renderComponent = (props: Partial = {}, features = {}) => + render(, { wrapperProps: { features } }); test('should render GridViewSlider when there are items and viewMode is grid', () => { renderComponent({ @@ -82,4 +82,84 @@ describe('elements/common/sub-header/SubHeaderRight', () => { renderComponent(defaultProps); expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument(); }); + + describe('metadataViewV2', () => { + const metadataViewV2Props = { + selectedItemIds: 'all' as const, + bulkItemActions: [ + { + label: 'Download', + onClick: jest.fn(), + }, + ], + view: VIEW_METADATA, + onMetadataSidePanelToggle: jest.fn(), + }; + + test.each(['all' as const, new Set(['1', '2'])])( + 'should render bulkItemActionMenu when selectedItemIds is $selectedItemIds', + async selectedItemIds => { + const features = { + contentExplorer: { + metadataViewV2: true, // enable the feature flag + }, + }; + + renderComponent( + { + ...metadataViewV2Props, + selectedItemIds, + }, + features, + ); + + expect(screen.getByRole('button', { name: 'Bulk actions' })).toBeInTheDocument(); + }, + ); + + test('should call onClick when a bulk item action is clicked', async () => { + const mockOnClick = jest.fn(); + const user = userEvent(); + const features = { + contentExplorer: { + metadataViewV2: true, // enable the feature flag + }, + }; + + renderComponent( + { + ...metadataViewV2Props, + bulkItemActions: [ + { + label: 'Download', + onClick: mockOnClick, + }, + ], + }, + features, + ); + + const ellipsisButton = await screen.findByRole('button', { name: 'Bulk actions' }); + await user.click(ellipsisButton); + + const downloadAction = screen.getByRole('menuitem', { name: 'Download' }); + await user.click(downloadAction); + + const expectedOnClickArgument = 'all'; + expect(mockOnClick).toHaveBeenCalledWith(expectedOnClickArgument); + }); + + test('should not render metadata v2 features when metadataViewV2 feature is disabled', async () => { + const features = { + contentExplorer: { + metadataViewV2: false, // Disable the feature flag + }, + }; + + renderComponent(metadataViewV2Props, features); + + expect(screen.queryByRole('button', { name: 'Bulk actions' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Metadata' })).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/elements/common/sub-header/messages.ts b/src/elements/common/sub-header/messages.ts index c0daa2d6d5..243285f807 100644 --- a/src/elements/common/sub-header/messages.ts +++ b/src/elements/common/sub-header/messages.ts @@ -1,6 +1,11 @@ import { defineMessages } from 'react-intl'; const messages = defineMessages({ + bulkItemActionMenuAriaLabel: { + defaultMessage: 'Bulk actions', + description: 'Aria-label for the dropdown menu that shows actions for selected items', + id: 'boxui.subHeader.bulkItemActionMenuAriaLabel', + }, metadata: { defaultMessage: 'Metadata', description: 'Text for metadata button that will open the metadata side panel', diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index c883922719..b9cac8d468 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -91,6 +91,7 @@ import type { BoxItemPermission, BoxItem, } from '../../common/types/core'; +import type { BulkItemAction } from '../common/sub-header/BulkItemActionMenu'; import type { ContentPreviewProps } from '../content-preview'; import type { ContentUploaderProps } from '../content-uploader'; import type { MetadataViewContainerProps } from './MetadataViewContainer'; @@ -107,6 +108,7 @@ export interface ContentExplorerProps { apiHost?: string; appHost?: string; autoFocus?: boolean; + bulkItemActions?: BulkItemAction[]; canCreateNewFolder?: boolean; canDelete?: boolean; canDownload?: boolean; @@ -1699,6 +1701,7 @@ class ContentExplorer extends Component { const { apiHost, appHost, + bulkItemActions, canCreateNewFolder, canDelete, canDownload, @@ -1791,6 +1794,7 @@ class ContentExplorer extends Component { )} { expect(screen.getByRole('button', { name: 'Metadata' })).toBeInTheDocument(); }); + + test('should call onClick when bulk item action is clicked', async () => { + let mockOnClickArg; + const mockOnClick = jest.fn(arg => { + mockOnClickArg = arg; + }); + const metadataViewV2WithBulkItemActions = { + ...metadataViewV2ElementProps, + bulkItemActions: [ + { + label: 'Download', + onClick: mockOnClick, + }, + ], + }; + + renderComponent(metadataViewV2WithBulkItemActions); + + const firstRow = await screen.findByRole('row', { name: /Child 2/i }); + expect(firstRow).toBeInTheDocument(); + + await userEvent.click(within(firstRow).getByRole('checkbox')); + + const bulkActionsButton = screen.getByRole('button', { name: 'Bulk actions' }); + expect(bulkActionsButton).toBeInTheDocument(); + await userEvent.click(bulkActionsButton); + + const downloadAction = screen.getByRole('menuitem', { name: 'Download' }); + expect(downloadAction).toBeInTheDocument(); + await userEvent.click(downloadAction); + + expect(mockOnClick).toHaveBeenCalled(); + expect(Array.from(mockOnClickArg)).toEqual(['1188890835']); + }); }); }); diff --git a/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx b/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx index 03c7609675..e2aab4c225 100644 --- a/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx +++ b/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx @@ -1,8 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import { http, HttpResponse } from 'msw'; -import { expect, userEvent, waitFor, within } from 'storybook/test'; import { Download, SignMeOthers } from '@box/blueprint-web-assets/icons/Fill/index'; import { Sign } from '@box/blueprint-web-assets/icons/Line'; +import { expect, fn, userEvent, waitFor, within, screen } from 'storybook/test'; import noop from 'lodash/noop'; import ContentExplorer from '../../ContentExplorer'; @@ -66,8 +66,6 @@ const columns = [ // Switches ContentExplorer to use Metadata View over standard, folder-based view. const defaultView = 'metadata'; -type Story = StoryObj; - export const metadataView: Story = { args: { metadataQuery, @@ -90,6 +88,52 @@ const metadataViewV2ElementProps = { }, }; +const metadataViewV2WithInlineCustomActionsElementProps = { + ...metadataViewV2ElementProps, + metadataViewProps: { + columns, + tableProps: { + isSelectAllEnabled: true, + }, + itemActionMenuProps: { + actions: [ + { + label: 'Download', + onClick: noop, + icon: Download, + }, + ], + subMenuTrigger: { + label: 'Sign', + icon: Sign, + }, + subMenuActions: [ + { + label: 'Request Signature', + onClick: noop, + icon: SignMeOthers, + }, + ], + }, + }, +}; + +const metadataViewV2WithBulkItemActions = { + ...metadataViewV2ElementProps, + bulkItemActions: [ + { + label: 'Download', + onClick: fn(), + }, + ], + metadataViewProps: { + columns, + tableProps: { + isSelectAllEnabled: true, + }, + }, +}; + export const metadataViewV2: Story = { args: metadataViewV2ElementProps, }; @@ -109,35 +153,7 @@ export const metadataViewV2SortsFromHeader: Story = { }; export const metadataViewV2WithCustomActions: Story = { - args: { - ...metadataViewV2ElementProps, - metadataViewProps: { - columns, - tableProps: { - isSelectAllEnabled: true, - }, - itemActionMenuProps: { - actions: [ - { - label: 'Download', - onClick: noop, - icon: Download, - }, - ], - subMenuTrigger: { - label: 'Sign', - icon: Sign, - }, - subMenuActions: [ - { - label: 'Request Signature', - onClick: noop, - icon: SignMeOthers, - }, - ], - }, - }, - }, + args: metadataViewV2WithInlineCustomActionsElementProps, play: async ({ canvas }) => { await waitFor(() => { expect(canvas.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); @@ -188,7 +204,6 @@ export const sidePanelOpenWithSingleItemSelected: Story = { }, }, }, - play: async ({ canvas }) => { await waitFor(() => { expect(canvas.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); @@ -204,6 +219,24 @@ export const sidePanelOpenWithSingleItemSelected: Story = { }, }; +export const metadataViewV2WithBulkItemActionMenuShowsItemActionMenu: Story = { + args: metadataViewV2WithBulkItemActions, + play: async ({ canvas }) => { + const firstRow = await canvas.findByRole('row', { name: /Child 2/i }); + expect(firstRow).toBeInTheDocument(); + + const checkbox = within(firstRow).getByRole('checkbox'); + await userEvent.click(checkbox); + + const ellipsisButton = canvas.getByRole('button', { name: 'Bulk actions' }); + expect(ellipsisButton).toBeInTheDocument(); + await userEvent.click(ellipsisButton); + + const downloadAction = screen.getByRole('menuitem', { name: 'Download' }); + expect(downloadAction).toBeInTheDocument(); + }, +}; + const meta: Meta = { title: 'Elements/ContentExplorer/tests/MetadataView/visual', component: ContentExplorer, @@ -229,4 +262,6 @@ const meta: Meta = { }, }; +type Story = StoryObj; + export default meta;