From 9f4aeccbf4de9fc41cff7f81a2e647aab4df397c Mon Sep 17 00:00:00 2001 From: Jason Pan Date: Mon, 18 Aug 2025 18:34:01 -0400 Subject: [PATCH 1/6] feat(metadata-view): bulk custom actions --- i18n/en-US.properties | 2 + .../common/sub-header/BulkItemActionMenu.tsx | 45 +++++ src/elements/common/sub-header/SubHeader.tsx | 4 + .../common/sub-header/SubHeaderRight.scss | 4 + .../common/sub-header/SubHeaderRight.tsx | 20 ++- .../__tests__/SubHeaderRight.test.tsx | 1 + src/elements/common/sub-header/messages.ts | 5 + .../content-explorer/ContentExplorer.tsx | 5 + .../tests/MetadataView-visual.stories.tsx | 156 ++++++++++++++---- 9 files changed, 205 insertions(+), 37 deletions(-) create mode 100644 src/elements/common/sub-header/BulkItemActionMenu.tsx 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..cba8f8238c --- /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/index'; +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 ( + + + + <> + {(selectedItemIds === 'all' || (selectedItemIds instanceof Set && selectedItemIds.size > 0)) && + 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..988c68d486 100644 --- a/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx +++ b/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx @@ -19,6 +19,7 @@ describe('elements/common/sub-header/SubHeaderRight', () => { onViewModeChange: jest.fn(), view: VIEW_FOLDER, viewMode: VIEW_MODE_GRID, + selectedItemIds: new Set([]), }; const renderComponent = (props: Partial = {}) => 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..762d915422 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -78,6 +78,7 @@ import type { ViewMode } from '../common/flowTypes'; import type { ItemAction } from '../common/item'; import type { Theme } from '../common/theming'; import type { MetadataQuery, FieldsToShow } from '../../common/types/metadataQueries'; + import type { MetadataFieldValue, MetadataTemplate } from '../../common/types/metadata'; import type { View, @@ -91,6 +92,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 +109,7 @@ export interface ContentExplorerProps { apiHost?: string; appHost?: string; autoFocus?: boolean; + bulkItemActions?: BulkItemAction[]; canCreateNewFolder?: boolean; canDelete?: boolean; canDownload?: boolean; @@ -1699,6 +1702,7 @@ class ContentExplorer extends Component { const { apiHost, appHost, + bulkItemActions, canCreateNewFolder, canDelete, canDownload, @@ -1791,6 +1795,7 @@ class ContentExplorer extends Component { )} ; - 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,79 @@ export const sidePanelOpenWithSingleItemSelected: Story = { }, }; +export const metadataViewV2WithBulkItemActionMenuShowsEllipsis: Story = { + args: metadataViewV2WithBulkItemActions, + play: async ({ canvas }) => { + await waitFor(() => { + expect(canvas.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); + }); + + const firstRow = canvas.getByRole('row', { name: /Child 2/i }); + const checkbox = within(firstRow).getByRole('checkbox'); + userEvent.click(checkbox); + + await waitFor(() => { + expect(canvas.getByRole('button', { name: 'Bulk actions' })).toBeInTheDocument(); + }); + }, +}; + +export const metadataViewV2WithBulkItemActionMenuShowsItemActionMenu: Story = { + args: metadataViewV2WithBulkItemActions, + play: async ({ canvas }) => { + await waitFor(() => { + expect(canvas.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); + }); + + const firstRow = canvas.getByRole('row', { name: /Child 2/i }); + const checkbox = within(firstRow).getByRole('checkbox'); + userEvent.click(checkbox); + + await waitFor(() => { + expect(canvas.getByRole('button', { name: 'Bulk actions' })).toBeInTheDocument(); + }); + + const ellipsisButton = canvas.getByRole('button', { name: 'Bulk actions' }); + + userEvent.click(ellipsisButton); + + await waitFor(() => { + expect(screen.getByRole('menuitem', { name: 'Download' })).toBeInTheDocument(); + }); + }, +}; + +export const metadataViewV2WithBulkItemActionMenuCallsOnClick: Story = { + args: metadataViewV2WithBulkItemActions, + play: async ({ canvas, args }) => { + await waitFor(() => { + expect(canvas.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); + }); + + const firstRow = canvas.getByRole('row', { name: /Child 2/i }); + const checkbox = within(firstRow).getByRole('checkbox'); + userEvent.click(checkbox); + + await waitFor(() => { + expect(canvas.getByRole('button', { name: 'Bulk actions' })).toBeInTheDocument(); + }); + + const ellipsisButton = canvas.getByRole('button', { name: 'Bulk actions' }); + + userEvent.click(ellipsisButton); + + await waitFor(() => { + expect(screen.getByRole('menuitem', { name: 'Download' })).toBeInTheDocument(); + }); + const downloadAction = screen.getByRole('menuitem', { name: 'Download' }); + userEvent.click(downloadAction); + + await waitFor(() => { + expect(args.bulkItemActions[0].onClick).toHaveBeenCalled(); + }); + }, +}; + const meta: Meta = { title: 'Elements/ContentExplorer/tests/MetadataView/visual', component: ContentExplorer, @@ -229,4 +317,6 @@ const meta: Meta = { }, }; +type Story = StoryObj; + export default meta; From 9943576e2c2ded6d5fa07634e7defcfaaeb1713e Mon Sep 17 00:00:00 2001 From: Jason Pan Date: Mon, 18 Aug 2025 23:08:52 -0400 Subject: [PATCH 2/6] feat(metadata-view): bulk custom actions --- src/elements/common/sub-header/BulkItemActionMenu.tsx | 4 ++-- src/elements/common/sub-header/SubHeaderRight.scss | 2 +- src/elements/common/sub-header/SubHeaderRight.tsx | 6 +++--- .../common/sub-header/__tests__/SubHeaderRight.test.tsx | 1 - src/elements/content-explorer/ContentExplorer.tsx | 1 - 5 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/elements/common/sub-header/BulkItemActionMenu.tsx b/src/elements/common/sub-header/BulkItemActionMenu.tsx index cba8f8238c..0887416ecb 100644 --- a/src/elements/common/sub-header/BulkItemActionMenu.tsx +++ b/src/elements/common/sub-header/BulkItemActionMenu.tsx @@ -2,7 +2,7 @@ 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/index'; +import { Ellipsis } from '@box/blueprint-web-assets/icons/Fill'; import type { Selection } from 'react-aria-components'; import messages from '../../common/sub-header/messages'; @@ -22,7 +22,7 @@ export const BulkItemActionMenu = ({ actions, selectedItemIds }: BulkItemActionM return ( - + diff --git a/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx b/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx index 988c68d486..6663549a42 100644 --- a/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx +++ b/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx @@ -19,7 +19,6 @@ describe('elements/common/sub-header/SubHeaderRight', () => { onViewModeChange: jest.fn(), view: VIEW_FOLDER, viewMode: VIEW_MODE_GRID, - selectedItemIds: new Set([]), }; const renderComponent = (props: Partial = {}) => diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index 762d915422..b9cac8d468 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -78,7 +78,6 @@ import type { ViewMode } from '../common/flowTypes'; import type { ItemAction } from '../common/item'; import type { Theme } from '../common/theming'; import type { MetadataQuery, FieldsToShow } from '../../common/types/metadataQueries'; - import type { MetadataFieldValue, MetadataTemplate } from '../../common/types/metadata'; import type { View, From 0cb42744890b26f176306cdd34f82292328148f9 Mon Sep 17 00:00:00 2001 From: Jason Pan Date: Wed, 20 Aug 2025 10:45:50 -0400 Subject: [PATCH 3/6] feat(metadata-view): bulk custom actions Refactor to use RTL --- .../common/sub-header/SubHeaderRight.tsx | 1 + .../__tests__/SubHeaderRight.test.tsx | 104 +++++++++++++++++- .../__tests__/ContentExplorer.test.tsx | 52 +++++++++ .../tests/MetadataView-visual.stories.tsx | 48 -------- 4 files changed, 153 insertions(+), 52 deletions(-) diff --git a/src/elements/common/sub-header/SubHeaderRight.tsx b/src/elements/common/sub-header/SubHeaderRight.tsx index ea0210dae9..7c99debba1 100644 --- a/src/elements/common/sub-header/SubHeaderRight.tsx +++ b/src/elements/common/sub-header/SubHeaderRight.tsx @@ -71,6 +71,7 @@ const SubHeaderRight = ({ const showAdd: boolean = (!!canUpload || !!canCreateNewFolder) && isFolder; const isMetadataView: boolean = view === VIEW_METADATA; const hasSelectedItems: boolean = !!(selectedItemIds && (selectedItemIds === 'all' || selectedItemIds.size > 0)); + return (
{!isMetadataView && ( diff --git a/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx b/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx index 6663549a42..55d3475d7b 100644 --- a/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx +++ b/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx @@ -1,7 +1,8 @@ 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'; +import { FeatureProvider } from '../../feature-checking'; describe('elements/common/sub-header/SubHeaderRight', () => { const defaultProps = { @@ -21,8 +22,12 @@ describe('elements/common/sub-header/SubHeaderRight', () => { viewMode: VIEW_MODE_GRID, }; - const renderComponent = (props: Partial = {}) => - render(); + const renderComponent = (props: Partial = {}, features = {}) => + render( + + + , + ); test('should render GridViewSlider when there are items and viewMode is grid', () => { renderComponent({ @@ -82,4 +87,95 @@ describe('elements/common/sub-header/SubHeaderRight', () => { renderComponent(defaultProps); expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument(); }); + + describe('metadataViewV2', () => { + const metadataViewV2Props = { + ...defaultProps, + selectedItemIds: 'all' as const, + bulkItemActions: [ + { + label: 'Download', + onClick: jest.fn(), + }, + ], + view: VIEW_METADATA, + onMetadataSidePanelToggle: jest.fn(), + }; + + test.each([ + { + selectedItemIds: 'all' as const, + }, + { + selectedItemIds: 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 = screen.getByRole('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 button 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/content-explorer/__tests__/ContentExplorer.test.tsx b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx index ee62cf2848..43242d6d41 100644 --- a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx +++ b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx @@ -505,6 +505,58 @@ describe('elements/content-explorer/ContentExplorer', () => { expect(screen.getByRole('button', { name: 'Metadata' })).toBeInTheDocument(); }); + + test('should call onClick when bulk item action is clicked', async () => { + const mockOnClick = jest.fn(); + const metadataViewV2WithBulkItemActions = { + ...metadataViewV2ElementProps, + bulkItemActions: [ + { + label: 'Download', + onClick: mockOnClick, + }, + ], + }; + + renderComponent(metadataViewV2WithBulkItemActions); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); + }); + + const firstRow = screen.getByRole('row', { name: /Child 2/i }); + const checkbox = within(firstRow).getByRole('checkbox'); + await userEvent.click(checkbox); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Bulk actions' })).toBeInTheDocument(); + }); + + const ellipsisButton = screen.getByRole('button', { name: 'Bulk actions' }); + await userEvent.click(ellipsisButton); + + await waitFor(() => { + expect(screen.getByRole('menuitem', { name: 'Download' })).toBeInTheDocument(); + }); + + const downloadAction = screen.getByRole('menuitem', { name: 'Download' }); + await userEvent.click(downloadAction); + + const expectedOnClickArgument = new Set(['1188890835']); + await waitFor(() => { + expect(mockOnClick).toHaveBeenCalled(); + + // Array conversion from sets to avoid set comparison issues in Jest + const argsForFirstMockCall = mockOnClick.mock.calls[0]; + const firstArgToMockOnClick = argsForFirstMockCall[0]; + + expect(Array.from(firstArgToMockOnClick)).toEqual(Array.from(expectedOnClickArgument)); + }); + }); }); }); 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 3ee302bdff..228759660d 100644 --- a/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx +++ b/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx @@ -219,23 +219,6 @@ export const sidePanelOpenWithSingleItemSelected: Story = { }, }; -export const metadataViewV2WithBulkItemActionMenuShowsEllipsis: Story = { - args: metadataViewV2WithBulkItemActions, - play: async ({ canvas }) => { - await waitFor(() => { - expect(canvas.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); - }); - - const firstRow = canvas.getByRole('row', { name: /Child 2/i }); - const checkbox = within(firstRow).getByRole('checkbox'); - userEvent.click(checkbox); - - await waitFor(() => { - expect(canvas.getByRole('button', { name: 'Bulk actions' })).toBeInTheDocument(); - }); - }, -}; - export const metadataViewV2WithBulkItemActionMenuShowsItemActionMenu: Story = { args: metadataViewV2WithBulkItemActions, play: async ({ canvas }) => { @@ -261,37 +244,6 @@ export const metadataViewV2WithBulkItemActionMenuShowsItemActionMenu: Story = { }, }; -export const metadataViewV2WithBulkItemActionMenuCallsOnClick: Story = { - args: metadataViewV2WithBulkItemActions, - play: async ({ canvas, args }) => { - await waitFor(() => { - expect(canvas.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); - }); - - const firstRow = canvas.getByRole('row', { name: /Child 2/i }); - const checkbox = within(firstRow).getByRole('checkbox'); - userEvent.click(checkbox); - - await waitFor(() => { - expect(canvas.getByRole('button', { name: 'Bulk actions' })).toBeInTheDocument(); - }); - - const ellipsisButton = canvas.getByRole('button', { name: 'Bulk actions' }); - - userEvent.click(ellipsisButton); - - await waitFor(() => { - expect(screen.getByRole('menuitem', { name: 'Download' })).toBeInTheDocument(); - }); - const downloadAction = screen.getByRole('menuitem', { name: 'Download' }); - userEvent.click(downloadAction); - - await waitFor(() => { - expect(args.bulkItemActions[0].onClick).toHaveBeenCalled(); - }); - }, -}; - const meta: Meta = { title: 'Elements/ContentExplorer/tests/MetadataView/visual', component: ContentExplorer, From dff893454ad463282165d46fcca806835be21c74 Mon Sep 17 00:00:00 2001 From: Jason Pan Date: Wed, 20 Aug 2025 14:18:35 -0400 Subject: [PATCH 4/6] feat(metadata-view): bulk custom actions Refactor tests --- .../__tests__/SubHeaderRight.test.tsx | 59 +++++++------------ .../__tests__/ContentExplorer.test.tsx | 33 ++++------- 2 files changed, 32 insertions(+), 60 deletions(-) diff --git a/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx b/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx index 55d3475d7b..4084437430 100644 --- a/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx +++ b/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { render, screen, userEvent } from '../../../../test-utils/testing-library'; import SubHeaderRight, { SubHeaderRightProps } from '../SubHeaderRight'; import { VIEW_FOLDER, VIEW_METADATA, VIEW_MODE_GRID } from '../../../../constants'; -import { FeatureProvider } from '../../feature-checking'; describe('elements/common/sub-header/SubHeaderRight', () => { const defaultProps = { @@ -23,11 +22,7 @@ describe('elements/common/sub-header/SubHeaderRight', () => { }; const renderComponent = (props: Partial = {}, features = {}) => - render( - - - , - ); + render(, { wrapperProps: { features } }); test('should render GridViewSlider when there are items and viewMode is grid', () => { renderComponent({ @@ -90,7 +85,6 @@ describe('elements/common/sub-header/SubHeaderRight', () => { describe('metadataViewV2', () => { const metadataViewV2Props = { - ...defaultProps, selectedItemIds: 'all' as const, bulkItemActions: [ { @@ -102,30 +96,26 @@ describe('elements/common/sub-header/SubHeaderRight', () => { onMetadataSidePanelToggle: jest.fn(), }; - test.each([ - { - selectedItemIds: 'all' as const, - }, - { - selectedItemIds: new Set(['1', '2']), + 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(); }, - ])('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(); @@ -160,19 +150,14 @@ describe('elements/common/sub-header/SubHeaderRight', () => { expect(mockOnClick).toHaveBeenCalledWith(expectedOnClickArgument); }); - test('should not render metadata button when metadataViewV2 feature is disabled', async () => { + 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, - ); + 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/content-explorer/__tests__/ContentExplorer.test.tsx b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx index 43242d6d41..dc9609df5d 100644 --- a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx +++ b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx @@ -507,7 +507,10 @@ describe('elements/content-explorer/ContentExplorer', () => { }); test('should call onClick when bulk item action is clicked', async () => { - const mockOnClick = jest.fn(); + let mockOnClickArg; + const mockOnClick = jest.fn(arg => { + mockOnClickArg = arg; + }); const metadataViewV2WithBulkItemActions = { ...metadataViewV2ElementProps, bulkItemActions: [ @@ -520,42 +523,26 @@ describe('elements/content-explorer/ContentExplorer', () => { renderComponent(metadataViewV2WithBulkItemActions); - await waitFor(() => { - expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); - }); + await screen.findByTestId('content-explorer'); - await waitFor(() => { - expect(screen.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); - }); + await screen.findByRole('row', { name: /Child 2/i }); const firstRow = screen.getByRole('row', { name: /Child 2/i }); const checkbox = within(firstRow).getByRole('checkbox'); await userEvent.click(checkbox); - await waitFor(() => { - expect(screen.getByRole('button', { name: 'Bulk actions' })).toBeInTheDocument(); - }); + await screen.findByRole('button', { name: 'Bulk actions' }); const ellipsisButton = screen.getByRole('button', { name: 'Bulk actions' }); await userEvent.click(ellipsisButton); - await waitFor(() => { - expect(screen.getByRole('menuitem', { name: 'Download' })).toBeInTheDocument(); - }); + await screen.findByRole('menuitem', { name: 'Download' }); const downloadAction = screen.getByRole('menuitem', { name: 'Download' }); await userEvent.click(downloadAction); - const expectedOnClickArgument = new Set(['1188890835']); - await waitFor(() => { - expect(mockOnClick).toHaveBeenCalled(); - - // Array conversion from sets to avoid set comparison issues in Jest - const argsForFirstMockCall = mockOnClick.mock.calls[0]; - const firstArgToMockOnClick = argsForFirstMockCall[0]; - - expect(Array.from(firstArgToMockOnClick)).toEqual(Array.from(expectedOnClickArgument)); - }); + expect(mockOnClick).toHaveBeenCalled(); + expect(Array.from(mockOnClickArg)).toEqual(['1188890835']); }); }); }); From dff6a419679606862e27007e7f9d9b825812ad9f Mon Sep 17 00:00:00 2001 From: Jason Pan Date: Wed, 20 Aug 2025 18:11:06 -0400 Subject: [PATCH 5/6] feat(metadata-view): bulk custom actions Remove unnecessary awaits --- .../__tests__/SubHeaderRight.test.tsx | 7 +++---- .../__tests__/ContentExplorer.test.tsx | 20 ++++++++----------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx b/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx index 4084437430..3e1ff0c9b7 100644 --- a/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx +++ b/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx @@ -139,11 +139,10 @@ describe('elements/common/sub-header/SubHeaderRight', () => { features, ); - const ellipsisButton = screen.getByRole('button', { name: 'Bulk actions' }); + const ellipsisButton = await screen.findByRole('button', { name: 'Bulk actions' }); + user.click(ellipsisButton); - await user.click(ellipsisButton); - - const downloadAction = screen.getByRole('menuitem', { name: 'Download' }); + const downloadAction = await screen.findByRole('menuitem', { name: 'Download' }); await user.click(downloadAction); const expectedOnClickArgument = 'all'; diff --git a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx index dc9609df5d..e79762f44a 100644 --- a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx +++ b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx @@ -523,22 +523,18 @@ describe('elements/content-explorer/ContentExplorer', () => { renderComponent(metadataViewV2WithBulkItemActions); - await screen.findByTestId('content-explorer'); + const firstRow = await screen.findByRole('row', { name: /Child 2/i }); + expect(firstRow).toBeInTheDocument(); - await screen.findByRole('row', { name: /Child 2/i }); - - const firstRow = screen.getByRole('row', { name: /Child 2/i }); const checkbox = within(firstRow).getByRole('checkbox'); - await userEvent.click(checkbox); - - await screen.findByRole('button', { name: 'Bulk actions' }); - - const ellipsisButton = screen.getByRole('button', { name: 'Bulk actions' }); - await userEvent.click(ellipsisButton); + userEvent.click(checkbox); - await screen.findByRole('menuitem', { name: 'Download' }); + const ellipsisButton = await screen.findByRole('button', { name: 'Bulk actions' }); + expect(ellipsisButton).toBeInTheDocument(); + userEvent.click(ellipsisButton); - const downloadAction = screen.getByRole('menuitem', { name: 'Download' }); + const downloadAction = await screen.findByRole('menuitem', { name: 'Download' }); + expect(downloadAction).toBeInTheDocument(); await userEvent.click(downloadAction); expect(mockOnClick).toHaveBeenCalled(); From 9b5ada0b926b6d4df7ae5782dee40b0158092006 Mon Sep 17 00:00:00 2001 From: Jason Pan Date: Wed, 20 Aug 2025 22:50:54 -0400 Subject: [PATCH 6/6] feat(metadata-view): bulk custom actions Switch await pattern --- .../common/sub-header/SubHeaderRight.tsx | 8 +++---- .../__tests__/SubHeaderRight.test.tsx | 4 ++-- .../__tests__/ContentExplorer.test.tsx | 11 +++++----- .../tests/MetadataView-visual.stories.tsx | 21 +++++++------------ 4 files changed, 17 insertions(+), 27 deletions(-) diff --git a/src/elements/common/sub-header/SubHeaderRight.tsx b/src/elements/common/sub-header/SubHeaderRight.tsx index 7c99debba1..e9d736d1cc 100644 --- a/src/elements/common/sub-header/SubHeaderRight.tsx +++ b/src/elements/common/sub-header/SubHeaderRight.tsx @@ -104,11 +104,9 @@ const SubHeaderRight = ({ {isMetadataView && isMetadataViewV2Feature && hasSelectedItems && ( <> - {(selectedItemIds === 'all' || (selectedItemIds instanceof Set && selectedItemIds.size > 0)) && - bulkItemActions && - bulkItemActions.length > 0 && ( - - )} + {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 3e1ff0c9b7..8471957c78 100644 --- a/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx +++ b/src/elements/common/sub-header/__tests__/SubHeaderRight.test.tsx @@ -140,9 +140,9 @@ describe('elements/common/sub-header/SubHeaderRight', () => { ); const ellipsisButton = await screen.findByRole('button', { name: 'Bulk actions' }); - user.click(ellipsisButton); + await user.click(ellipsisButton); - const downloadAction = await screen.findByRole('menuitem', { name: 'Download' }); + const downloadAction = screen.getByRole('menuitem', { name: 'Download' }); await user.click(downloadAction); const expectedOnClickArgument = 'all'; diff --git a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx index e79762f44a..bea8fb77ba 100644 --- a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx +++ b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx @@ -526,14 +526,13 @@ describe('elements/content-explorer/ContentExplorer', () => { const firstRow = await screen.findByRole('row', { name: /Child 2/i }); expect(firstRow).toBeInTheDocument(); - const checkbox = within(firstRow).getByRole('checkbox'); - userEvent.click(checkbox); + await userEvent.click(within(firstRow).getByRole('checkbox')); - const ellipsisButton = await screen.findByRole('button', { name: 'Bulk actions' }); - expect(ellipsisButton).toBeInTheDocument(); - userEvent.click(ellipsisButton); + const bulkActionsButton = screen.getByRole('button', { name: 'Bulk actions' }); + expect(bulkActionsButton).toBeInTheDocument(); + await userEvent.click(bulkActionsButton); - const downloadAction = await screen.findByRole('menuitem', { name: 'Download' }); + const downloadAction = screen.getByRole('menuitem', { name: 'Download' }); expect(downloadAction).toBeInTheDocument(); await userEvent.click(downloadAction); 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 228759660d..e2aab4c225 100644 --- a/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx +++ b/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx @@ -222,25 +222,18 @@ export const sidePanelOpenWithSingleItemSelected: Story = { export const metadataViewV2WithBulkItemActionMenuShowsItemActionMenu: Story = { args: metadataViewV2WithBulkItemActions, play: async ({ canvas }) => { - await waitFor(() => { - expect(canvas.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); - }); + const firstRow = await canvas.findByRole('row', { name: /Child 2/i }); + expect(firstRow).toBeInTheDocument(); - const firstRow = canvas.getByRole('row', { name: /Child 2/i }); const checkbox = within(firstRow).getByRole('checkbox'); - userEvent.click(checkbox); - - await waitFor(() => { - expect(canvas.getByRole('button', { name: 'Bulk actions' })).toBeInTheDocument(); - }); + await userEvent.click(checkbox); const ellipsisButton = canvas.getByRole('button', { name: 'Bulk actions' }); + expect(ellipsisButton).toBeInTheDocument(); + await userEvent.click(ellipsisButton); - userEvent.click(ellipsisButton); - - await waitFor(() => { - expect(screen.getByRole('menuitem', { name: 'Download' })).toBeInTheDocument(); - }); + const downloadAction = screen.getByRole('menuitem', { name: 'Download' }); + expect(downloadAction).toBeInTheDocument(); }, };