Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions i18n/en-US.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions src/elements/common/sub-header/BulkItemActionMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DropdownMenu.Root>
<DropdownMenu.Trigger className="be-BulkItemActionMenu-trigger">
<Button
role="button"
aria-label={formatMessage(messages.bulkItemActionMenuAriaLabel)}
icon={Ellipsis}
size="large"
variant="secondary"
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{actions.map(({ label, onClick }) => {
return (
<DropdownMenu.Item key={label} onSelect={() => onClick(selectedItemIds)}>
{label}
</DropdownMenu.Item>
);
})}
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};
4 changes: 4 additions & 0 deletions src/elements/common/sub-header/SubHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import classNames from 'classnames';
import { PageHeader } from '@box/blueprint-web';
import type { Selection } from 'react-aria-components';

import type { BulkItemAction } from './BulkItemActionMenu';
import SubHeaderLeft from './SubHeaderLeft';
import SubHeaderLeftV2 from './SubHeaderLeftV2';
import SubHeaderRight from './SubHeaderRight';
Expand All @@ -15,6 +16,7 @@ import { useFeatureEnabled } from '../feature-checking';
import './SubHeader.scss';

export interface SubHeaderProps {
bulkItemActions?: BulkItemAction[];
canCreateNewFolder: boolean;
canUpload: boolean;
currentCollection: Collection;
Expand All @@ -41,6 +43,7 @@ export interface SubHeaderProps {
}

const SubHeader = ({
bulkItemActions,
canCreateNewFolder,
canUpload,
currentCollection,
Expand Down Expand Up @@ -101,6 +104,7 @@ const SubHeader = ({
</PageHeader.StartElements>
<PageHeader.EndElements>
<SubHeaderRight
bulkItemActions={bulkItemActions}
canCreateNewFolder={canCreateNewFolder}
canUpload={canUpload}
currentCollection={currentCollection}
Expand Down
4 changes: 4 additions & 0 deletions src/elements/common/sub-header/SubHeaderRight.scss
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,8 @@
.bdl-ViewModeChangeButton {
margin-left: 7px;
}

.be-BulkItemActionMenu-trigger {
margin-right: var(--space-2);
}
}
19 changes: 15 additions & 4 deletions src/elements/common/sub-header/SubHeaderRight.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import * as React from 'react';
import { useIntl } from 'react-intl';

import { Button } from '@box/blueprint-web';
import { Pencil } from '@box/blueprint-web-assets/icons/Fill';
import { useIntl } from 'react-intl';
import type { Selection } from 'react-aria-components';

import { type BulkItemAction, BulkItemActionMenu } from './BulkItemActionMenu';
import Sort from './Sort';
import Add from './Add';
import GridViewSlider from '../../../components/grid-view/GridViewSlider';
Expand All @@ -18,6 +21,7 @@ import messages from './messages';
import './SubHeaderRight.scss';

export interface SubHeaderRightProps {
bulkItemActions?: BulkItemAction[];
canCreateNewFolder: boolean;
canUpload: boolean;
currentCollection: Collection;
Expand All @@ -38,6 +42,7 @@ export interface SubHeaderRightProps {
}

const SubHeaderRight = ({
bulkItemActions,
canCreateNewFolder,
canUpload,
currentCollection,
Expand Down Expand Up @@ -66,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 (
<div className="be-sub-header-right">
{!isMetadataView && (
Expand Down Expand Up @@ -97,9 +103,14 @@ const SubHeaderRight = ({
)}

{isMetadataView && isMetadataViewV2Feature && hasSelectedItems && (
<Button icon={Pencil} size="large" variant="primary" onClick={onMetadataSidePanelToggle}>
{formatMessage(messages.metadata)}
</Button>
<>
{bulkItemActions && bulkItemActions.length > 0 && (
<BulkItemActionMenu actions={bulkItemActions} selectedItemIds={selectedItemIds} />
)}
<Button icon={Pencil} size="large" variant="primary" onClick={onMetadataSidePanelToggle}>
{formatMessage(messages.metadata)}
</Button>
</>
)}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -21,8 +21,8 @@ describe('elements/common/sub-header/SubHeaderRight', () => {
viewMode: VIEW_MODE_GRID,
};

const renderComponent = (props: Partial<SubHeaderRightProps> = {}) =>
render(<SubHeaderRight {...defaultProps} {...props} />);
const renderComponent = (props: Partial<SubHeaderRightProps> = {}, features = {}) =>
render(<SubHeaderRight {...defaultProps} {...props} />, { wrapperProps: { features } });

test('should render GridViewSlider when there are items and viewMode is grid', () => {
renderComponent({
Expand Down Expand Up @@ -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();
});
});
});
5 changes: 5 additions & 0 deletions src/elements/common/sub-header/messages.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
4 changes: 4 additions & 0 deletions src/elements/content-explorer/ContentExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -107,6 +108,7 @@ export interface ContentExplorerProps {
apiHost?: string;
appHost?: string;
autoFocus?: boolean;
bulkItemActions?: BulkItemAction[];
canCreateNewFolder?: boolean;
canDelete?: boolean;
canDownload?: boolean;
Expand Down Expand Up @@ -1699,6 +1701,7 @@ class ContentExplorer extends Component<ContentExplorerProps, State> {
const {
apiHost,
appHost,
bulkItemActions,
canCreateNewFolder,
canDelete,
canDownload,
Expand Down Expand Up @@ -1791,6 +1794,7 @@ class ContentExplorer extends Component<ContentExplorerProps, State> {
)}

<SubHeader
bulkItemActions={bulkItemActions}
view={view}
viewMode={viewMode}
rootId={rootFolderId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,40 @@ describe('elements/content-explorer/ContentExplorer', () => {

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']);
});
});
});

Expand Down
Loading