Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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 @@ -602,6 +602,8 @@ be.noActivity = No activity to show
be.noActivityAnnotationPrompt = Hover over the preview and use the controls at the bottom to annotate the file.
# Message shown in
be.noActivityCommentPrompt = Comment and @mention people to notify them.
# Text shown to indicate the number of files selected
be.numFilesSelected = {numSelected, plural, =0 {0 files selected} one {1 file selected} other {# files selected} }
# Label for open action.
be.open = Open
# Next page button tooltip
Expand Down
6 changes: 4 additions & 2 deletions src/api/APIFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -470,8 +470,10 @@ class APIFactory {
*
* @return {FolderAPI} FolderAPI instance
*/
getFolderAPI(): FolderAPI {
this.destroy();
getFolderAPI(shouldDestroy: boolean = true): FolderAPI {
if (shouldDestroy) {
this.destroy();
}
this.folderAPI = new FolderAPI(this.options);
return this.folderAPI;
}
Expand Down
11 changes: 11 additions & 0 deletions src/elements/common/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -1089,6 +1089,17 @@ const messages = defineMessages({
description: 'Icon title for a Box item of type folder that is private and has no collaborators',
defaultMessage: 'Personal Folder',
},
numFilesSelected: {
id: 'be.numFilesSelected',
description: 'Text shown to indicate the number of files selected',
defaultMessage: `
{numSelected, plural,
=0 {0 files selected}
one {1 file selected}
other {# files selected}
}
`,
},
});

export default messages;
25 changes: 24 additions & 1 deletion src/elements/common/sub-header/SubHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import * as React from 'react';
import noop from 'lodash/noop';
import classNames from 'classnames';
import { PageHeader } from '@box/blueprint-web';
import type { Selection } from 'react-aria-components';

import SubHeaderLeft from './SubHeaderLeft';
import SubHeaderLeftV2 from './SubHeaderLeftV2';
import SubHeaderRight from './SubHeaderRight';
import type { ViewMode } from '../flowTypes';
import type { View, Collection } from '../../../common/types/core';
Expand All @@ -19,6 +23,7 @@ export interface SubHeaderProps {
gridMinColumns?: number;
isSmall: boolean;
maxGridColumnCountForWidth?: number;
onClearSelectedItemIds: () => void;
onCreate: () => void;
onGridViewSliderChange?: (newSliderValue: number) => void;
onItemClick: (id: string | null, triggerNavigationEvent: boolean | null) => void;
Expand All @@ -28,6 +33,8 @@ export interface SubHeaderProps {
portalElement?: HTMLElement;
rootId: string;
rootName?: string;
selectedItemIds: Selection;
title?: string;
view: View;
viewMode?: ViewMode;
}
Expand All @@ -42,6 +49,7 @@ const SubHeader = ({
maxGridColumnCountForWidth = 0,
onGridViewSliderChange = noop,
isSmall,
onClearSelectedItemIds,
onCreate,
onItemClick,
onSortChange,
Expand All @@ -50,6 +58,8 @@ const SubHeader = ({
portalElement,
rootId,
rootName,
selectedItemIds,
title,
view,
viewMode = VIEW_MODE_LIST,
}: SubHeaderProps) => {
Expand All @@ -60,7 +70,11 @@ const SubHeader = ({
}

return (
<PageHeader.Root className="be-sub-header" data-testid="be-sub-header" variant="inline">
<PageHeader.Root
className={classNames({ 'be-sub-header': !isMetadataViewV2Feature })}
data-testid="be-sub-header"
variant="inline"
>
<PageHeader.StartElements>
{view !== VIEW_METADATA && !isMetadataViewV2Feature && (
<SubHeaderLeft
Expand All @@ -73,6 +87,15 @@ const SubHeader = ({
view={view}
/>
)}
{isMetadataViewV2Feature && (
<SubHeaderLeftV2
currentCollection={currentCollection}
onClearSelectedItemIds={onClearSelectedItemIds}
rootName={rootName}
selectedItemIds={selectedItemIds}
title={title}
/>
)}
</PageHeader.StartElements>
<PageHeader.EndElements>
<SubHeaderRight
Expand Down
3 changes: 3 additions & 0 deletions src/elements/common/sub-header/SubHeaderLeftV2.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.be-sub-header-left-v2-selected {
gap: 12px;
}
75 changes: 75 additions & 0 deletions src/elements/common/sub-header/SubHeaderLeftV2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, { useMemo } from 'react';
import { useIntl } from 'react-intl';
import { XMark } from '@box/blueprint-web-assets/icons/Fill/index';
import { IconButton, PageHeader, Text } from '@box/blueprint-web';
import type { Selection } from 'react-aria-components';
import type { Collection } from '../../../common/types/core';
import messages from '../messages';

import './SubHeaderLeftV2.scss';

export interface SubHeaderLeftV2Props {
currentCollection: Collection;
onClearSelectedItemIds?: () => void;
rootName?: string;
selectedItemIds: Selection;
title?: string;
}

const SubHeaderLeftV2 = (props: SubHeaderLeftV2Props) => {
const { currentCollection, onClearSelectedItemIds, rootName, selectedItemIds, title } = props;
const { formatMessage } = useIntl();

// Generate selected item text based on selected keys
const selectedItemText = useMemo(() => {
const selectedCount = selectedItemIds === 'all' ? currentCollection.items.length : selectedItemIds.size;

if (typeof selectedCount !== 'number' || selectedCount === 0) {
return '';
}

// Case 1: Single selected item - show item name
if (selectedCount === 1) {
const selectedKey =
selectedItemIds === 'all' ? currentCollection.items[0].id : selectedItemIds.values().next().value;
const selectedItem = currentCollection.items.find(item => item.id === selectedKey);
if (typeof selectedItem?.name === 'string') {
return selectedItem.name as string;
}
}
// Case 2: Multiple selected items - show count
if (selectedCount > 1) {
return formatMessage(messages.numFilesSelected, { numSelected: selectedCount });
}
return '';
}, [currentCollection.items, formatMessage, selectedItemIds]);

// Case 1 and 2: selected item text with X button
if (selectedItemText) {
return (
<PageHeader.Root className="be-sub-header-left-v2-selected" variant="default">
<PageHeader.Corner>
<IconButton
aria-label="Clear selection"
icon={XMark}
onClick={onClearSelectedItemIds}
variant="small-utility"
/>
</PageHeader.Corner>

<PageHeader.StartElements>
<Text as="p">{selectedItemText}</Text>
</PageHeader.StartElements>
</PageHeader.Root>
);
}

// Case 3: No selected items - show title if provided, otherwise show root name
return (
<Text as="h1" variant="titleXLarge">
{title || rootName}
</Text>
);
};

export default SubHeaderLeftV2;
109 changes: 109 additions & 0 deletions src/elements/common/sub-header/__tests__/SubHeaderLeftV2.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import * as React from 'react';
import { render, screen } from '../../../../test-utils/testing-library';
import SubHeaderLeftV2 from '../SubHeaderLeftV2';
import type { Collection } from '../../../../common/types/core';
import type { SubHeaderLeftV2Props } from '../SubHeaderLeftV2';

const mockCollection: Collection = {
items: [
{ id: '1', name: 'file1.txt' },
{ id: '2', name: 'file2.txt' },
{ id: '3', name: 'file3.txt' },
],
};

const defaultProps: SubHeaderLeftV2Props = {
currentCollection: mockCollection,
selectedItemIds: new Set(),
};

const renderComponent = (props: Partial<SubHeaderLeftV2Props> = {}) =>
render(<SubHeaderLeftV2 {...defaultProps} {...props} />);

describe('elements/common/sub-header/SubHeaderLeftV2', () => {
describe('when no items are selected', () => {
test('should render title if provided', () => {
renderComponent({
rootName: 'Custom Folder',
title: 'Custom Title',
selectedItemIds: new Set(),
});

expect(screen.getByText('Custom Title')).toBeInTheDocument();
});

test('should render root name if no title is provided', () => {
renderComponent({
rootName: 'Custom Folder',
title: undefined,
selectedItemIds: new Set(),
});

expect(screen.getByText('Custom Folder')).toBeInTheDocument();
});
});

describe('when items are selected', () => {
test('should render single selected item name', () => {
renderComponent({
selectedItemIds: new Set(['1']),
});

expect(screen.getByText('file1.txt')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument(); // Close button
});

test('should render multiple selected items count', () => {
renderComponent({
selectedItemIds: new Set(['1', '2']),
});

expect(screen.getByText('2 files selected')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument(); // Close button
});

test('should render all items selected count', () => {
renderComponent({
selectedItemIds: 'all',
});

expect(screen.getByText('3 files selected')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument(); // Close button
});

test('should call onClearSelectedItemIds when close button is clicked', () => {
const mockOnClearSelectedItemIds = jest.fn();

renderComponent({
selectedItemIds: new Set(['1']),
onClearSelectedItemIds: mockOnClearSelectedItemIds,
});

const closeButton = screen.getByRole('button');
closeButton.click();

expect(mockOnClearSelectedItemIds).toHaveBeenCalledTimes(1);
});

test('should handle selected item not found in collection', () => {
renderComponent({
selectedItemIds: new Set(['999']), // Non-existent ID
});

// Should not crash and should not render any selected item text
expect(screen.queryByText('file1.txt')).not.toBeInTheDocument();
expect(screen.queryByText('file2.txt')).not.toBeInTheDocument();
expect(screen.queryByText('file3.txt')).not.toBeInTheDocument();
});

test('should handle empty collection with selected items', () => {
renderComponent({
currentCollection: { items: [] },
selectedItemIds: new Set(['1']),
});

// Should not crash and should not render any selected item text
expect(screen.queryByText('file1.txt')).not.toBeInTheDocument();
});
});
});
Loading