0));
+
return (
{!isMetadataView && (
@@ -97,9 +103,14 @@ const SubHeaderRight = ({
)}
{isMetadataView && isMetadataViewV2Feature && hasSelectedItems && (
-
+ <>
+ {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;