Skip to content

Commit ab12626

Browse files
feat(metadata-view): Add Subheader for Metadata View v2 (#4202)
* feat(metadata-view): Add MetadataView V2 * feat: Add new subheader to ContentExplorer for MetadataViewV2 * fix: use Blueprint Text and i18n, remove logs * fix: Show ancestor folder name if no title * fix: Add shouldDestroy to getFolderAPI() * fix: add tests for SubHeaderLeftMetadataViewV2 * fix: Move i18n from features to elements/common * refactor: Move ancestor folder name to ContentExplorer * chore: rename classnames to match SUIT CSS * chore: Add missed files * refactor: Rename SubHeaderLeftMetadataViewV2 to SubHeaderLeftV2 * chore: Remove unused api prop * feat(metadata-view): Add Subheader for Metadata View v2 * feat(metadata-view): Add Subheader for Metadata View v2 --------- Co-authored-by: Greg Wong <gregorywong@box.com>
1 parent 95b3cc6 commit ab12626

File tree

8 files changed

+290
-5
lines changed

8 files changed

+290
-5
lines changed

i18n/en-US.properties

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ be.breadcrumb.breadcrumbLabel = Breadcrumb
9292
be.cancel = Cancel
9393
# Label for choose action.
9494
be.choose = Choose
95+
# Aria label for the clear selection button.
96+
be.clearSelection = Clear selection
9597
# Label for close action.
9698
be.close = Close
9799
# Icon title for a Box item of type folder that has collaborators
@@ -602,6 +604,8 @@ be.noActivity = No activity to show
602604
be.noActivityAnnotationPrompt = Hover over the preview and use the controls at the bottom to annotate the file.
603605
# Message shown in
604606
be.noActivityCommentPrompt = Comment and @mention people to notify them.
607+
# Text shown to indicate the number of files selected
608+
be.numFilesSelected = {numSelected, plural, =0 {0 files selected} one {1 file selected} other {# files selected} }
605609
# Label for open action.
606610
be.open = Open
607611
# Next page button tooltip

src/api/APIFactory.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -470,8 +470,10 @@ class APIFactory {
470470
*
471471
* @return {FolderAPI} FolderAPI instance
472472
*/
473-
getFolderAPI(): FolderAPI {
474-
this.destroy();
473+
getFolderAPI(shouldDestroy: boolean = true): FolderAPI {
474+
if (shouldDestroy) {
475+
this.destroy();
476+
}
475477
this.folderAPI = new FolderAPI(this.options);
476478
return this.folderAPI;
477479
}

src/elements/common/messages.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,11 @@ const messages = defineMessages({
387387
description: 'Aria label for the clear button in the search box.',
388388
defaultMessage: 'Clear search',
389389
},
390+
clearSelection: {
391+
id: 'be.clearSelection',
392+
description: 'Aria label for the clear selection button.',
393+
defaultMessage: 'Clear selection',
394+
},
390395
searchPlaceholder: {
391396
id: 'be.searchPlaceholder',
392397
description: 'Shown as a placeholder in the search box.',
@@ -1089,6 +1094,17 @@ const messages = defineMessages({
10891094
description: 'Icon title for a Box item of type folder that is private and has no collaborators',
10901095
defaultMessage: 'Personal Folder',
10911096
},
1097+
numFilesSelected: {
1098+
id: 'be.numFilesSelected',
1099+
description: 'Text shown to indicate the number of files selected',
1100+
defaultMessage: `
1101+
{numSelected, plural,
1102+
=0 {0 files selected}
1103+
one {1 file selected}
1104+
other {# files selected}
1105+
}
1106+
`,
1107+
},
10921108
});
10931109

10941110
export default messages;

src/elements/common/sub-header/SubHeader.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import * as React from 'react';
22
import noop from 'lodash/noop';
3+
import classNames from 'classnames';
34
import { PageHeader } from '@box/blueprint-web';
5+
import type { Selection } from 'react-aria-components';
6+
47
import SubHeaderLeft from './SubHeaderLeft';
8+
import SubHeaderLeftV2 from './SubHeaderLeftV2';
59
import SubHeaderRight from './SubHeaderRight';
610
import type { ViewMode } from '../flowTypes';
711
import type { View, Collection } from '../../../common/types/core';
@@ -19,6 +23,7 @@ export interface SubHeaderProps {
1923
gridMinColumns?: number;
2024
isSmall: boolean;
2125
maxGridColumnCountForWidth?: number;
26+
onClearSelectedItemIds: () => void;
2227
onCreate: () => void;
2328
onGridViewSliderChange?: (newSliderValue: number) => void;
2429
onItemClick: (id: string | null, triggerNavigationEvent: boolean | null) => void;
@@ -28,6 +33,8 @@ export interface SubHeaderProps {
2833
portalElement?: HTMLElement;
2934
rootId: string;
3035
rootName?: string;
36+
selectedItemIds: Selection;
37+
title?: string;
3138
view: View;
3239
viewMode?: ViewMode;
3340
}
@@ -42,6 +49,7 @@ const SubHeader = ({
4249
maxGridColumnCountForWidth = 0,
4350
onGridViewSliderChange = noop,
4451
isSmall,
52+
onClearSelectedItemIds,
4553
onCreate,
4654
onItemClick,
4755
onSortChange,
@@ -50,6 +58,8 @@ const SubHeader = ({
5058
portalElement,
5159
rootId,
5260
rootName,
61+
selectedItemIds,
62+
title,
5363
view,
5464
viewMode = VIEW_MODE_LIST,
5565
}: SubHeaderProps) => {
@@ -60,7 +70,11 @@ const SubHeader = ({
6070
}
6171

6272
return (
63-
<PageHeader.Root className="be-sub-header" data-testid="be-sub-header" variant="inline">
73+
<PageHeader.Root
74+
className={classNames({ 'be-sub-header': !isMetadataViewV2Feature })}
75+
data-testid="be-sub-header"
76+
variant="inline"
77+
>
6478
<PageHeader.StartElements>
6579
{view !== VIEW_METADATA && !isMetadataViewV2Feature && (
6680
<SubHeaderLeft
@@ -73,6 +87,15 @@ const SubHeader = ({
7387
view={view}
7488
/>
7589
)}
90+
{isMetadataViewV2Feature && (
91+
<SubHeaderLeftV2
92+
currentCollection={currentCollection}
93+
onClearSelectedItemIds={onClearSelectedItemIds}
94+
rootName={rootName}
95+
selectedItemIds={selectedItemIds}
96+
title={title}
97+
/>
98+
)}
7699
</PageHeader.StartElements>
77100
<PageHeader.EndElements>
78101
<SubHeaderRight
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.be-SubHeaderLeftV2--selection {
2+
gap: var(--space-3);
3+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import React, { useMemo } from 'react';
2+
import { useIntl } from 'react-intl';
3+
import { XMark } from '@box/blueprint-web-assets/icons/Fill/index';
4+
import { IconButton, PageHeader, Text } from '@box/blueprint-web';
5+
import type { Selection } from 'react-aria-components';
6+
import type { Collection } from '../../../common/types/core';
7+
import messages from '../messages';
8+
9+
import './SubHeaderLeftV2.scss';
10+
11+
export interface SubHeaderLeftV2Props {
12+
currentCollection: Collection;
13+
onClearSelectedItemIds?: () => void;
14+
rootName?: string;
15+
selectedItemIds: Selection;
16+
title?: string;
17+
}
18+
19+
const SubHeaderLeftV2 = (props: SubHeaderLeftV2Props) => {
20+
const { currentCollection, onClearSelectedItemIds, rootName, selectedItemIds, title } = props;
21+
const { formatMessage } = useIntl();
22+
23+
// Generate selected item text based on selected keys
24+
const selectedItemText: string = useMemo(() => {
25+
const selectedCount = selectedItemIds === 'all' ? currentCollection.items.length : selectedItemIds.size;
26+
27+
if (selectedCount === 0) {
28+
return '';
29+
}
30+
31+
// Case 1: Single selected item - show item name
32+
if (selectedCount === 1) {
33+
const selectedKey =
34+
selectedItemIds === 'all' ? currentCollection.items[0].id : selectedItemIds.values().next().value;
35+
const selectedItem = currentCollection.items.find(item => item.id === selectedKey);
36+
return selectedItem?.name ?? '';
37+
}
38+
// Case 2: Multiple selected items - show count
39+
if (selectedCount > 1) {
40+
return formatMessage(messages.numFilesSelected, { numSelected: selectedCount });
41+
}
42+
return '';
43+
}, [currentCollection.items, formatMessage, selectedItemIds]);
44+
45+
// Case 1 and 2: selected item text with X button
46+
if (selectedItemText) {
47+
return (
48+
<PageHeader.Root className="be-SubHeaderLeftV2--selection" variant="default">
49+
<PageHeader.Corner>
50+
<IconButton
51+
aria-label={formatMessage(messages.clearSelection)}
52+
icon={XMark}
53+
onClick={onClearSelectedItemIds}
54+
variant="small-utility"
55+
/>
56+
</PageHeader.Corner>
57+
58+
<PageHeader.StartElements>
59+
<Text as="span">{selectedItemText}</Text>
60+
</PageHeader.StartElements>
61+
</PageHeader.Root>
62+
);
63+
}
64+
65+
// Case 3: No selected items - show title if provided, otherwise show root name
66+
return (
67+
<Text as="h1" variant="titleXLarge">
68+
{title ?? rootName}
69+
</Text>
70+
);
71+
};
72+
73+
export default SubHeaderLeftV2;
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import * as React from 'react';
2+
import { render, screen } from '../../../../test-utils/testing-library';
3+
import SubHeaderLeftV2 from '../SubHeaderLeftV2';
4+
import type { Collection } from '../../../../common/types/core';
5+
import type { SubHeaderLeftV2Props } from '../SubHeaderLeftV2';
6+
7+
const mockCollection: Collection = {
8+
items: [
9+
{ id: '1', name: 'file1.txt' },
10+
{ id: '2', name: 'file2.txt' },
11+
{ id: '3', name: 'file3.txt' },
12+
],
13+
};
14+
15+
const defaultProps: SubHeaderLeftV2Props = {
16+
currentCollection: mockCollection,
17+
selectedItemIds: new Set(),
18+
};
19+
20+
const renderComponent = (props: Partial<SubHeaderLeftV2Props> = {}) =>
21+
render(<SubHeaderLeftV2 {...defaultProps} {...props} />);
22+
23+
describe('elements/common/sub-header/SubHeaderLeftV2', () => {
24+
describe('when no items are selected', () => {
25+
test('should render title if provided', () => {
26+
renderComponent({
27+
rootName: 'Custom Folder',
28+
title: 'Custom Title',
29+
selectedItemIds: new Set(),
30+
});
31+
32+
expect(screen.getByText('Custom Title')).toBeInTheDocument();
33+
});
34+
35+
test('should render root name if no title is provided', () => {
36+
renderComponent({
37+
rootName: 'Custom Folder',
38+
title: undefined,
39+
selectedItemIds: new Set(),
40+
});
41+
42+
expect(screen.getByText('Custom Folder')).toBeInTheDocument();
43+
});
44+
});
45+
46+
describe('when items are selected', () => {
47+
test('should render single selected item name', () => {
48+
renderComponent({
49+
selectedItemIds: new Set(['1']),
50+
});
51+
52+
expect(screen.getByText('file1.txt')).toBeInTheDocument();
53+
expect(screen.getByRole('button')).toBeInTheDocument(); // Close button
54+
});
55+
56+
test('should render multiple selected items count', () => {
57+
renderComponent({
58+
selectedItemIds: new Set(['1', '2']),
59+
});
60+
61+
expect(screen.getByText('2 files selected')).toBeInTheDocument();
62+
expect(screen.getByRole('button')).toBeInTheDocument(); // Close button
63+
});
64+
65+
test('should render all items selected count', () => {
66+
renderComponent({
67+
selectedItemIds: 'all',
68+
});
69+
70+
expect(screen.getByText('3 files selected')).toBeInTheDocument();
71+
expect(screen.getByRole('button')).toBeInTheDocument(); // Close button
72+
});
73+
74+
test('should call onClearSelectedItemIds when close button is clicked', () => {
75+
const mockOnClearSelectedItemIds = jest.fn();
76+
77+
renderComponent({
78+
selectedItemIds: new Set(['1']),
79+
onClearSelectedItemIds: mockOnClearSelectedItemIds,
80+
});
81+
82+
const closeButton = screen.getByRole('button');
83+
closeButton.click();
84+
85+
expect(mockOnClearSelectedItemIds).toHaveBeenCalledTimes(1);
86+
});
87+
88+
test('should handle selected item not found in collection', () => {
89+
renderComponent({
90+
selectedItemIds: new Set(['999']), // Non-existent ID
91+
});
92+
93+
// Should not crash and should not render any selected item text
94+
expect(screen.queryByText('file1.txt')).not.toBeInTheDocument();
95+
expect(screen.queryByText('file2.txt')).not.toBeInTheDocument();
96+
expect(screen.queryByText('file3.txt')).not.toBeInTheDocument();
97+
});
98+
99+
test('should handle empty collection with selected items', () => {
100+
renderComponent({
101+
currentCollection: { items: [] },
102+
selectedItemIds: new Set(['1']),
103+
});
104+
105+
// Should not crash and should not render any selected item text
106+
expect(screen.queryByText('file1.txt')).not.toBeInTheDocument();
107+
});
108+
});
109+
});

0 commit comments

Comments
 (0)