diff --git a/i18n/en-US.properties b/i18n/en-US.properties index ebe51b3059..8dcd61481d 100644 --- a/i18n/en-US.properties +++ b/i18n/en-US.properties @@ -92,6 +92,8 @@ be.breadcrumb.breadcrumbLabel = Breadcrumb be.cancel = Cancel # Label for choose action. be.choose = Choose +# Aria label for the clear selection button. +be.clearSelection = Clear selection # Label for close action. be.close = Close # Icon title for a Box item of type folder that has collaborators @@ -602,6 +604,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 diff --git a/src/api/APIFactory.js b/src/api/APIFactory.js index d472714f9d..e2a3897494 100644 --- a/src/api/APIFactory.js +++ b/src/api/APIFactory.js @@ -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; } diff --git a/src/elements/common/messages.js b/src/elements/common/messages.js index 19338fc3fe..2557ab6241 100644 --- a/src/elements/common/messages.js +++ b/src/elements/common/messages.js @@ -387,6 +387,11 @@ const messages = defineMessages({ description: 'Aria label for the clear button in the search box.', defaultMessage: 'Clear search', }, + clearSelection: { + id: 'be.clearSelection', + description: 'Aria label for the clear selection button.', + defaultMessage: 'Clear selection', + }, searchPlaceholder: { id: 'be.searchPlaceholder', description: 'Shown as a placeholder in the search box.', @@ -1089,6 +1094,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; diff --git a/src/elements/common/sub-header/SubHeader.tsx b/src/elements/common/sub-header/SubHeader.tsx index 06dca1dfc7..7f3afbca73 100644 --- a/src/elements/common/sub-header/SubHeader.tsx +++ b/src/elements/common/sub-header/SubHeader.tsx @@ -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'; @@ -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; @@ -28,6 +33,8 @@ export interface SubHeaderProps { portalElement?: HTMLElement; rootId: string; rootName?: string; + selectedItemIds: Selection; + title?: string; view: View; viewMode?: ViewMode; } @@ -42,6 +49,7 @@ const SubHeader = ({ maxGridColumnCountForWidth = 0, onGridViewSliderChange = noop, isSmall, + onClearSelectedItemIds, onCreate, onItemClick, onSortChange, @@ -50,6 +58,8 @@ const SubHeader = ({ portalElement, rootId, rootName, + selectedItemIds, + title, view, viewMode = VIEW_MODE_LIST, }: SubHeaderProps) => { @@ -60,7 +70,11 @@ const SubHeader = ({ } return ( - + {view !== VIEW_METADATA && !isMetadataViewV2Feature && ( )} + {isMetadataViewV2Feature && ( + + )} 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: string = useMemo(() => { + const selectedCount = selectedItemIds === 'all' ? currentCollection.items.length : selectedItemIds.size; + + if (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); + return selectedItem?.name ?? ''; + } + // 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 ( + + + + + + + {selectedItemText} + + + ); + } + + // Case 3: No selected items - show title if provided, otherwise show root name + return ( + + {title ?? rootName} + + ); +}; + +export default SubHeaderLeftV2; diff --git a/src/elements/common/sub-header/__tests__/SubHeaderLeftV2.test.tsx b/src/elements/common/sub-header/__tests__/SubHeaderLeftV2.test.tsx new file mode 100644 index 0000000000..4757090818 --- /dev/null +++ b/src/elements/common/sub-header/__tests__/SubHeaderLeftV2.test.tsx @@ -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 = {}) => + render(); + +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(); + }); + }); +}); diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index 7e3ab04be5..404253121f 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -10,6 +10,8 @@ import throttle from 'lodash/throttle'; import uniqueid from 'lodash/uniqueId'; import { TooltipProvider } from '@box/blueprint-web'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; +import type { Selection } from 'react-aria-components'; + import CreateFolderDialog from '../common/create-folder-dialog'; import UploadDialog from '../common/upload-dialog'; import Header from '../common/header'; @@ -151,6 +153,7 @@ export interface ContentExplorerProps { staticHost?: string; staticPath?: string; theme?: Theme; + title?: string; token: Token; uploadHost?: string; } @@ -175,6 +178,7 @@ type State = { rootName: string; searchQuery: string; selected?: BoxItem; + selectedItemIds: Selection; sortBy: SortBy | string; sortDirection: SortDirection; view: View; @@ -297,6 +301,7 @@ class ContentExplorer extends Component { markers: [], metadataTemplate: {}, rootName: '', + selectedItemIds: new Set(), searchQuery: '', sortBy, sortDirection, @@ -333,7 +338,7 @@ class ContentExplorer extends Component { * @return {void} */ componentDidMount() { - const { currentFolderId, defaultView }: ContentExplorerProps = this.props; + const { currentFolderId, defaultView, metadataQuery }: ContentExplorerProps = this.props; this.rootElement = document.getElementById(this.id) as HTMLElement; this.appElement = this.rootElement.firstElementChild as HTMLElement; @@ -343,6 +348,7 @@ class ContentExplorer extends Component { break; case DEFAULT_VIEW_METADATA: this.showMetadataQueryResults(); + this.fetchFolderName(metadataQuery?.ancestor_folder_id); break; default: this.fetchFolder(currentFolderId); @@ -1524,6 +1530,25 @@ class ContentExplorer extends Component { return maxWidthColumns; }; + getMetadataViewProps = (): ContentExplorerProps['metadataViewProps'] => { + const { metadataViewProps } = this.props; + const { tableProps } = metadataViewProps ?? {}; + const { onSelectionChange } = tableProps ?? {}; + const { selectedItemIds } = this.state; + + return { + ...metadataViewProps, + tableProps: { + ...tableProps, + selectedKeys: selectedItemIds, + onSelectionChange: (ids: Selection) => { + onSelectionChange?.(ids); + this.setState({ selectedItemIds: ids }); + }, + }, + }; + }; + /** * Change the current view mode * @@ -1599,6 +1624,31 @@ class ContentExplorer extends Component { }); }; + clearSelectedItemIds = () => { + this.setState({ selectedItemIds: new Set() }); + }; + + /** + * Fetches the folder name and stores it in state rootName if successful + * + * @private + * @return {void} + */ + fetchFolderName = (folderId?: string) => { + if (!folderId) { + return; + } + + this.api.getFolderAPI(false).getFolderFields( + folderId, + ({ name }) => { + this.setState({ rootName: name }); + }, + this.errorCallback, + { fields: [FIELD_NAME] }, + ); + }; + /** * Renders the file picker * @@ -1632,7 +1682,6 @@ class ContentExplorer extends Component { measureRef, messages, fieldsToShow, - metadataViewProps, onDownload, onPreview, onUpload, @@ -1645,6 +1694,7 @@ class ContentExplorer extends Component { staticPath, previewLibraryVersion, theme, + title, token, uploadHost, }: ContentExplorerProps = this.props; @@ -1683,6 +1733,8 @@ class ContentExplorer extends Component { const hasNextMarker: boolean = !!markers[currentPageNumber + 1]; const hasPreviousMarker: boolean = currentPageNumber === 1 || !!markers[currentPageNumber - 1]; + const metadataViewProps = this.getMetadataViewProps(); + /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ return ( @@ -1707,12 +1759,15 @@ class ContentExplorer extends Component { gridMinColumns={GRID_VIEW_MIN_COLUMNS} maxGridColumnCountForWidth={maxGridColumnCount} onUpload={this.upload} + onClearSelectedItemIds={this.clearSelectedItemIds} onCreate={this.createFolder} onGridViewSliderChange={this.onGridViewSliderChange} onItemClick={this.fetchFolder} onSortChange={this.sort} onViewModeChange={this.changeViewMode} portalElement={this.rootElement} + selectedItemIds={this.state.selectedItemIds} + title={title} />