From c6744860705826ad04440b3e3ad035ad4033e6a8 Mon Sep 17 00:00:00 2001 From: Greg Wong Date: Wed, 9 Jul 2025 18:58:29 -0400 Subject: [PATCH 01/14] feat(metadata-view): Add MetadataView V2 --- package.json | 28 +++++++++++++ scripts/jest/jest.config.js | 4 ++ src/common/types/core.js | 3 +- src/elements/content-explorer/Content.tsx | 13 ++++++ .../MetadataViewContainer.tsx | 42 +++++++++++++++++++ .../__tests__/MetadataViewContainer.test.tsx | 23 ++++++++++ .../stories/MetadataView.stories.tsx | 4 ++ .../MetadataQueryAPIHelper.js | 24 +++++++++-- .../__tests__/MetadataQueryAPIHelper.test.js | 1 + yarn.lock | 7 ++++ 10 files changed, 144 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 02c45a2825..114cc1f4d8 100644 --- a/package.json +++ b/package.json @@ -124,19 +124,33 @@ "@babel/preset-typescript": "^7.24.7", "@babel/template": "^7.24.7", "@babel/types": "^7.24.7", +<<<<<<< HEAD "@box/blueprint-web": "12.43.0", "@box/blueprint-web-assets": "4.61.5", "@box/box-ai-agent-selector": "^0.48.5", +======= + "@box/blueprint-web": "^12.41.0", + "@box/blueprint-web-assets": "^4.60.9", + "@box/box-ai-agent-selector": "^0.41.10", +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) "@box/box-ai-content-answers": "^0.124.1", "@box/box-item-type-selector": "^0.61.12", "@box/cldr-data": "^34.2.0", "@box/combobox-with-api": "^0.34.9", "@box/frontend": "^11.0.1", +<<<<<<< HEAD "@box/item-icon": "^0.17.0", "@box/languages": "^1.0.0", "@box/metadata-editor": "^0.122.12", "@box/metadata-filter": "^1.16.12", "@box/metadata-view": "^0.29.4", +======= + "@box/item-icon": "^0.16.3", + "@box/languages": "^1.0.0", + "@box/metadata-editor": "^0.122.0", + "@box/metadata-filter": "^1.16.3", + "@box/metadata-view": "^0.28.0", +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/types": "^0.2.1", "@cfaester/enzyme-adapter-react-18": "^0.8.0", @@ -293,17 +307,31 @@ "webpack-dev-server": "^5.2.1" }, "peerDependencies": { +<<<<<<< HEAD "@box/blueprint-web": "12.43.0", "@box/blueprint-web-assets": "4.61.5", "@box/box-ai-agent-selector": "^0.48.5", +======= + "@box/blueprint-web": "^12.41.0", + "@box/blueprint-web-assets": "^4.60.9", + "@box/box-ai-agent-selector": "^0.41.10", +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) "@box/box-ai-content-answers": "^0.124.1", "@box/box-item-type-selector": "^0.61.12", "@box/cldr-data": ">=34.2.0", "@box/combobox-with-api": "^0.34.9", +<<<<<<< HEAD "@box/item-icon": "^0.17.0", "@box/metadata-editor": "^0.122.12", "@box/metadata-filter": "^1.16.12", "@box/metadata-view": "^0.29.4", +======= + "@box/item-icon": "^0.16.3", + "@box/languages": "^1.0.0", + "@box/metadata-editor": "^0.122.0", + "@box/metadata-filter": "^1.16.3", + "@box/metadata-view": "^0.28.0", +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/types": "^0.2.1", "@hapi/address": "^2.1.4", diff --git a/scripts/jest/jest.config.js b/scripts/jest/jest.config.js index c558824b13..4756eda8e9 100644 --- a/scripts/jest/jest.config.js +++ b/scripts/jest/jest.config.js @@ -26,6 +26,10 @@ module.exports = { testMatch: ['**/__tests__/**/*.test.+(js|jsx|ts|tsx)'], testPathIgnorePatterns: ['stories.test.js$', 'stories.test.tsx$', 'stories.test.d.ts'], transformIgnorePatterns: [ +<<<<<<< HEAD 'node_modules/(?!(@box/react-virtualized/dist/es|@box/cldr-data|@box/blueprint-web|@box/blueprint-web-assets|@box/metadata-editor|@box/box-ai-content-answers|@box/box-ai-agent-selector|@box/item-icon|@box/combobox-with-api|@box/tree|@box/metadata-filter|@box/metadata-view|@box/types|@box/box-item-type-selector)/)', +======= + 'node_modules/(?!(@box/react-virtualized/dist/es|@box/cldr-data|@box/blueprint-web|@box/blueprint-web-assets|@box/metadata-editor|@box/box-ai-content-answers|@box/box-ai-agent-selector|@box/item-icon|@box/combobox-with-api|@box/tree|@box/metadata-filter|@box/metadata-view|@box/types)/)', +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) ], }; diff --git a/src/common/types/core.js b/src/common/types/core.js index a1b4f47572..2834d3b342 100644 --- a/src/common/types/core.js +++ b/src/common/types/core.js @@ -34,7 +34,7 @@ import { PERMISSION_CAN_DOWNLOAD, PERMISSION_CAN_PREVIEW, } from '../../constants'; -import type { MetadataType } from './metadata'; +import type { MetadataType, MetadataTemplate } from './metadata'; type Token = null | typeof undefined | string | Function; type TokenReadWrite = { read: string, write?: string }; @@ -394,6 +394,7 @@ type Collection = { breadcrumbs?: Array, id?: string, items?: Array, + metadataTemplate?: MetadataTemplate, name?: string, nextMarker?: ?string, offset?: number, diff --git a/src/elements/content-explorer/Content.tsx b/src/elements/content-explorer/Content.tsx index 5bb42fe8ef..c2bc7ca359 100644 --- a/src/elements/content-explorer/Content.tsx +++ b/src/elements/content-explorer/Content.tsx @@ -36,8 +36,12 @@ export interface ContentProps extends Required, Required; +======= + metadataProps?: Omit; +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) onMetadataUpdate: ( item: BoxItem, field: string, @@ -55,8 +59,12 @@ const Content = ({ features, fieldsToShow = [], gridColumnCount, +<<<<<<< HEAD metadataTemplate, metadataViewProps, +======= + metadataProps, +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) onMetadataUpdate, onSortChange, view, @@ -88,8 +96,13 @@ const Content = ({ currentCollection={currentCollection} isLoading={percentLoaded !== 100} hasError={view === VIEW_ERROR} +<<<<<<< HEAD metadataTemplate={metadataTemplate} {...metadataViewProps} +======= + onSortChange={onSortChange} + {...metadataProps} +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) /> )} {!isViewEmpty && isListView && ( diff --git a/src/elements/content-explorer/MetadataViewContainer.tsx b/src/elements/content-explorer/MetadataViewContainer.tsx index f142563f54..8b33ce518c 100644 --- a/src/elements/content-explorer/MetadataViewContainer.tsx +++ b/src/elements/content-explorer/MetadataViewContainer.tsx @@ -1,26 +1,47 @@ import * as React from 'react'; import { MetadataView, type MetadataViewProps } from '@box/metadata-view'; +<<<<<<< HEAD import type { MetadataTemplate } from '../../common/types/metadata'; +======= + +import type { SortDescriptor } from 'react-aria-components'; +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) import type { Collection } from '../../common/types/core'; export interface MetadataViewContainerProps extends Omit { currentCollection: Collection; +<<<<<<< HEAD metadataTemplate: MetadataTemplate; +======= + onSortChange?: (sortBy: string, sortDirection: string) => void; +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) } const MetadataViewContainer = ({ actionBarProps, columns, currentCollection, +<<<<<<< HEAD metadataTemplate, ...rest }: MetadataViewContainerProps) => { const { items = [] } = currentCollection; +======= + onSortChange, + tableProps, + ...rest +}: MetadataViewContainerProps) => { + const { items = [], metadataTemplate } = currentCollection; +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) const filterGroups = React.useMemo( () => [ { +<<<<<<< HEAD toggleable: true, +======= + togglable: true, +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) filters: metadataTemplate?.fields?.map(field => { return { @@ -36,14 +57,35 @@ const MetadataViewContainer = ({ [metadataTemplate], ); +<<<<<<< HEAD + return ( + { + onSortChange?.(sortDescriptor.column as string, sortDescriptor.direction); + }, + [onSortChange], + ); + return ( >>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) actionBarProps={{ ...actionBarProps, filterGroups, }} +<<<<<<< HEAD columns={columns} items={items} +======= + tableProps={{ + ...tableProps, + onSortChange: handleSortChange, + }} +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) {...rest} /> ); diff --git a/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx b/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx index 16df4b3a0e..ff03bd5f77 100644 --- a/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx +++ b/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx @@ -5,6 +5,11 @@ import type { Collection } from '../../../common/types/core'; import type { MetadataTemplate, MetadataTemplateField } from '../../../common/types/metadata'; describe('elements/content-explorer/MetadataViewContainer', () => { +<<<<<<< HEAD +======= + const mockOnSortChange = jest.fn(); + +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) const mockItems = [ { id: '1', name: 'File 1.txt', type: 'file' }, { id: '2', name: 'File 2.pdf', type: 'file' }, @@ -40,6 +45,10 @@ describe('elements/content-explorer/MetadataViewContainer', () => { const mockCollection: Collection = { id: '0', items: mockItems, +<<<<<<< HEAD +======= + metadataTemplate: mockMetadataTemplate, +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) percentLoaded: 100, }; @@ -64,16 +73,30 @@ describe('elements/content-explorer/MetadataViewContainer', () => { maxWidth: 250, }, ], +<<<<<<< HEAD metadataTemplate: mockMetadataTemplate, +======= + onSortChange: mockOnSortChange, +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) }; const renderComponent = (props: Partial = {}) => { return render(); }; +<<<<<<< HEAD test('should render MetadataView component', () => { renderComponent(); +======= + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should render MetadataView component', () => { + renderComponent(); + screen.debug(null, 10000); +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) expect(screen.getByRole('button', { name: 'All Filters' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Name' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Industry' })).toBeInTheDocument(); diff --git a/src/elements/content-explorer/stories/MetadataView.stories.tsx b/src/elements/content-explorer/stories/MetadataView.stories.tsx index cb19394aca..d48f0eda2f 100644 --- a/src/elements/content-explorer/stories/MetadataView.stories.tsx +++ b/src/elements/content-explorer/stories/MetadataView.stories.tsx @@ -79,7 +79,11 @@ type Story = StoryObj; export const metadataView: Story = { args: { +<<<<<<< HEAD metadataViewProps: { +======= + metadataProps: { +>>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) columns, tableProps: { isSelectAllEnabled: true, diff --git a/src/features/metadata-based-view/MetadataQueryAPIHelper.js b/src/features/metadata-based-view/MetadataQueryAPIHelper.js index 84608c1e08..d0f3de5be0 100644 --- a/src/features/metadata-based-view/MetadataQueryAPIHelper.js +++ b/src/features/metadata-based-view/MetadataQueryAPIHelper.js @@ -20,7 +20,7 @@ import { METADATA_FIELD_TYPE_ENUM, METADATA_FIELD_TYPE_MULTISELECT, } from '../../common/constants'; -import { FIELD_NAME, FIELD_METADATA } from '../../constants'; +import { FIELD_NAME, FIELD_METADATA, FIELD_EXTENSION, FIELD_CREATED_AT } from '../../constants'; import type { MetadataQuery as MetadataQueryType, MetadataQueryResponseData } from '../../common/types/metadataQueries'; import type { @@ -54,8 +54,11 @@ export default class MetadataQueryAPIHelper { metadataQuery: MetadataQueryType; - constructor(api: API) { + isV2: boolean; + + constructor(api: API, isV2: boolean = false) { this.api = api; + this.isV2 = isV2; } createJSONPatchOperations = ( @@ -153,23 +156,26 @@ export default class MetadataQueryAPIHelper { const { metadata } = metadataEntry; return { ...metadataEntry, - metadata: this.flattenMetadata(metadata), + metadata: this.isV2 ? metadata : this.flattenMetadata(metadata), }; }; filterMetdataQueryResponse = (response: MetadataQueryResponseData): MetadataQueryResponseData => { const { entries = [], next_marker } = response; return { - entries: entries.filter(entry => getProp(entry, 'type') === ITEM_TYPE_FILE), // return only file items + entries: this.isV2 ? entries : entries.filter(entry => getProp(entry, 'type') === ITEM_TYPE_FILE), // return only file items next_marker, }; }; getFlattenedDataWithTypes = (templateSchemaResponse?: MetadataTemplateSchemaResponse): Collection => { this.metadataTemplate = getProp(templateSchemaResponse, 'data'); + const { entries, next_marker }: MetadataQueryResponseData = this.metadataQueryResponseData; + return { items: entries.map(this.flattenResponseEntry), + metadataTemplate: this.metadataTemplate, nextMarker: next_marker, }; }; @@ -238,6 +244,16 @@ export default class MetadataQueryAPIHelper { if (!clonedFields.includes(FIELD_NAME)) { clonedFields.push(FIELD_NAME); } + if (this.isV2) { + if (!clonedFields.includes(FIELD_EXTENSION)) { + clonedFields.push(FIELD_EXTENSION); + } + + if (!clonedFields.includes(FIELD_CREATED_AT)) { + clonedFields.push(FIELD_CREATED_AT); + } + } + clonedQuery.fields = clonedFields; return clonedQuery; diff --git a/src/features/metadata-based-view/__tests__/MetadataQueryAPIHelper.test.js b/src/features/metadata-based-view/__tests__/MetadataQueryAPIHelper.test.js index 63fece081c..9a77459ac8 100644 --- a/src/features/metadata-based-view/__tests__/MetadataQueryAPIHelper.test.js +++ b/src/features/metadata-based-view/__tests__/MetadataQueryAPIHelper.test.js @@ -186,6 +186,7 @@ describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { ]; const flattenedDataWithTypes = { items: flattenedResponse, + metadataTemplate: template, nextMarker: metadataQueryResponse.next_marker, }; const getSchemaByTemplateKeyFunc = jest.fn().mockReturnValueOnce(Promise.resolve(templateSchemaResponse)); diff --git a/yarn.lock b/yarn.lock index 4b5ed6fb4c..45753877fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2311,6 +2311,13 @@ dependencies: "@swc/helpers" "^0.5.0" +"@internationalized/string@^3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@internationalized/string/-/string-3.2.7.tgz#76ae10f1e6e1fdaec7d0028a3f807d37a71bd2dd" + integrity sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A== + dependencies: + "@swc/helpers" "^0.5.0" + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" From 74b5ccb837333825beef2d311e2d073ff92e8198 Mon Sep 17 00:00:00 2001 From: Jonathan Fox Date: Fri, 25 Jul 2025 11:55:15 -0700 Subject: [PATCH 02/14] feat: Add new subheader to ContentExplorer for MetadataViewV2 --- src/elements/common/sub-header/SubHeader.scss | 4 ++ src/elements/common/sub-header/SubHeader.tsx | 21 ++++++- .../SubHeaderLeftMetadataViewV2.scss | 18 ++++++ .../SubHeaderLeftMetadataViewV2.tsx | 57 +++++++++++++++++++ .../content-explorer/ContentExplorer.tsx | 26 ++++++++- 5 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.scss create mode 100644 src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx diff --git a/src/elements/common/sub-header/SubHeader.scss b/src/elements/common/sub-header/SubHeader.scss index d8a3bc8bc0..cfd80e58a3 100644 --- a/src/elements/common/sub-header/SubHeader.scss +++ b/src/elements/common/sub-header/SubHeader.scss @@ -20,4 +20,8 @@ .bce.be-is-small & { border-bottom: 0 none; } + + &.be-sub-header--metadata-view { + padding: 0; + } } diff --git a/src/elements/common/sub-header/SubHeader.tsx b/src/elements/common/sub-header/SubHeader.tsx index 06dca1dfc7..7ab95a1826 100644 --- a/src/elements/common/sub-header/SubHeader.tsx +++ b/src/elements/common/sub-header/SubHeader.tsx @@ -1,7 +1,10 @@ import * as React from 'react'; import noop from 'lodash/noop'; +import classNames from 'classnames'; import { PageHeader } from '@box/blueprint-web'; +import { Selection } from 'react-aria-components'; import SubHeaderLeft from './SubHeaderLeft'; +import SubheaderLeftMetadataViewV2 from './SubHeaderLeftMetadataViewV2'; import SubHeaderRight from './SubHeaderRight'; import type { ViewMode } from '../flowTypes'; import type { View, Collection } from '../../../common/types/core'; @@ -30,6 +33,8 @@ export interface SubHeaderProps { rootName?: string; view: View; viewMode?: ViewMode; + selectedKeys: Selection; + onClearSelectedKeys: () => void; } const SubHeader = ({ @@ -52,6 +57,8 @@ const SubHeader = ({ rootName, view, viewMode = VIEW_MODE_LIST, + selectedKeys, + onClearSelectedKeys, }: SubHeaderProps) => { const isMetadataViewV2Feature = useFeatureEnabled('contentExplorer.metadataViewV2'); @@ -60,7 +67,11 @@ const SubHeader = ({ } return ( - + {view !== VIEW_METADATA && !isMetadataViewV2Feature && ( )} + {isMetadataViewV2Feature && ( + + )} void; +} + +const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => { + const { currentCollection, title, selectedKeys, onClearSelectedKeys } = props; + + const selectedItemText = useMemo(() => { + const selectedCount = selectedKeys === 'all' ? currentCollection.items.length : selectedKeys.size; + + if (selectedCount === 0) { + return ''; + } + + // Case 1: Single selected item - show item name + if (selectedCount === 1) { + const selectedKey = + selectedKeys === 'all' ? currentCollection.items[0].id : selectedKeys.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 `${selectedCount} files selected`; + } + return ''; + }, [currentCollection.items, selectedKeys]); + + // Case 1 and 2: selected item text with X button + if (selectedItemText) { + return ( +
+ + {selectedItemText} +
+ ); + } + + // Case 3: No selected items - show title if provided + return ( +
+ {title || 'Metadata View'} +
+ ); +}; + +export default SubHeaderLeftMetadataViewV2; diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index 7e3ab04be5..1f393bce74 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -10,6 +10,7 @@ import throttle from 'lodash/throttle'; import uniqueid from 'lodash/uniqueId'; import { TooltipProvider } from '@box/blueprint-web'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; +import { Selection } from 'react-aria-components'; import CreateFolderDialog from '../common/create-folder-dialog'; import UploadDialog from '../common/upload-dialog'; import Header from '../common/header'; @@ -175,6 +176,7 @@ type State = { rootName: string; searchQuery: string; selected?: BoxItem; + selectedKeys: Selection; sortBy: SortBy | string; sortDirection: SortDirection; view: View; @@ -297,6 +299,7 @@ class ContentExplorer extends Component { markers: [], metadataTemplate: {}, rootName: '', + selectedKeys: new Set(), searchQuery: '', sortBy, sortDirection, @@ -1599,6 +1602,10 @@ class ContentExplorer extends Component { }); }; + handleClearSelectedKeys = () => { + this.setState({ selectedKeys: new Set() }); + }; + /** * Renders the file picker * @@ -1683,6 +1690,21 @@ class ContentExplorer extends Component { const hasNextMarker: boolean = !!markers[currentPageNumber + 1]; const hasPreviousMarker: boolean = currentPageNumber === 1 || !!markers[currentPageNumber - 1]; + console.log({ currentCollection }); + + const combinedMetadataViewProps = { + ...metadataViewProps, + tableProps: { + ...metadataViewProps?.tableProps, + selectedKeys: this.state.selectedKeys, + onSelectionChange: (keys: Selection) => { + console.log('onSelectionChange', { keys }); + metadataViewProps?.tableProps?.onSelectionChange?.(keys); + this.setState({ selectedKeys: keys }); + }, + }, + }; + /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ return ( @@ -1713,6 +1735,8 @@ class ContentExplorer extends Component { onSortChange={this.sort} onViewModeChange={this.changeViewMode} portalElement={this.rootElement} + selectedKeys={this.state.selectedKeys} + onClearSelectedKeys={this.handleClearSelectedKeys} /> { itemActions={itemActions} fieldsToShow={fieldsToShow} metadataTemplate={metadataTemplate} - metadataViewProps={metadataViewProps} + metadataViewProps={combinedMetadataViewProps} onItemClick={this.onItemClick} onItemDelete={this.delete} onItemDownload={this.download} From 2157dc330acb8d13f2a6a236a3926d61958e1b8d Mon Sep 17 00:00:00 2001 From: Jonathan Fox Date: Fri, 25 Jul 2025 14:49:31 -0700 Subject: [PATCH 03/14] fix: use Blueprint Text and i18n, remove logs --- i18n/en-US.properties | 2 ++ src/elements/common/sub-header/SubHeader.tsx | 8 ++++--- .../SubHeaderLeftMetadataViewV2.scss | 13 ++--------- .../SubHeaderLeftMetadataViewV2.tsx | 22 +++++++++++-------- .../content-explorer/ContentExplorer.tsx | 5 ++++- src/features/content-explorer/messages.js | 11 ++++++++++ 6 files changed, 37 insertions(+), 24 deletions(-) diff --git a/i18n/en-US.properties b/i18n/en-US.properties index ebe51b3059..f3f797a4be 100644 --- a/i18n/en-US.properties +++ b/i18n/en-US.properties @@ -1066,6 +1066,8 @@ boxui.contentExplorer.move = Move boxui.contentExplorer.name = Name # Text shown on button used to create a new folder boxui.contentExplorer.newFolder = New Folder +# Text shown to indicate the number of files selected +boxui.contentExplorer.numFilesSelected = {numSelected, plural, =0 {0 files selected} one {1 file selected} other {# files selected} } # Text shown to indicate the number of folders selected boxui.contentExplorer.numFoldersSelected = {numSelected, plural, =0 {0 folders selected} one {1 folder selected} other {# folders selected} } # Text shown to indicate the number of items selected with Include Subfolders feature diff --git a/src/elements/common/sub-header/SubHeader.tsx b/src/elements/common/sub-header/SubHeader.tsx index 7ab95a1826..fbf243ead8 100644 --- a/src/elements/common/sub-header/SubHeader.tsx +++ b/src/elements/common/sub-header/SubHeader.tsx @@ -33,6 +33,7 @@ export interface SubHeaderProps { rootName?: string; view: View; viewMode?: ViewMode; + metadataViewTitle?: string; selectedKeys: Selection; onClearSelectedKeys: () => void; } @@ -45,8 +46,10 @@ const SubHeader = ({ gridMaxColumns = 0, gridMinColumns = 0, maxGridColumnCountForWidth = 0, + metadataViewTitle, onGridViewSliderChange = noop, isSmall, + onClearSelectedKeys, onCreate, onItemClick, onSortChange, @@ -55,10 +58,9 @@ const SubHeader = ({ portalElement, rootId, rootName, + selectedKeys, view, viewMode = VIEW_MODE_LIST, - selectedKeys, - onClearSelectedKeys, }: SubHeaderProps) => { const isMetadataViewV2Feature = useFeatureEnabled('contentExplorer.metadataViewV2'); @@ -86,7 +88,7 @@ const SubHeader = ({ )} {isMetadataViewV2Feature && ( .bdl-CloseButton { margin: 0; } } diff --git a/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx b/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx index b89afb0e17..f7f42627e4 100644 --- a/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx +++ b/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx @@ -1,19 +1,23 @@ import React, { useMemo } from 'react'; import { Selection } from 'react-aria-components'; +import { FormattedMessage } from 'react-intl'; +import { Text } from '@box/blueprint-web'; + import CloseButton from '../../../components/close-button/CloseButton'; +import messages from '../../../features/content-explorer/messages'; import type { Collection } from '../../../common/types/core'; import './SubHeaderLeftMetadataViewV2.scss'; interface SubHeaderLeftMetadataViewV2Props { currentCollection: Collection; - title?: string; - selectedKeys: Selection; + metadataViewTitle?: string; onClearSelectedKeys?: () => void; + selectedKeys: Selection; } const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => { - const { currentCollection, title, selectedKeys, onClearSelectedKeys } = props; + const { currentCollection, metadataViewTitle, selectedKeys, onClearSelectedKeys } = props; const selectedItemText = useMemo(() => { const selectedCount = selectedKeys === 'all' ? currentCollection.items.length : selectedKeys.size; @@ -31,7 +35,7 @@ const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => } // Case 2: Multiple selected items - show count if (selectedCount > 1) { - return `${selectedCount} files selected`; + return ; } return ''; }, [currentCollection.items, selectedKeys]); @@ -39,18 +43,18 @@ const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => // Case 1 and 2: selected item text with X button if (selectedItemText) { return ( -
+
- {selectedItemText} + {selectedItemText}
); } // Case 3: No selected items - show title if provided return ( -
- {title || 'Metadata View'} -
+ + {metadataViewTitle} + ); }; diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index 1f393bce74..9f448dec29 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -133,6 +133,7 @@ export interface ContentExplorerProps { messages?: StringMap; metadataQuery?: MetadataQuery; metadataViewProps?: Omit; + metadataViewTitle?: string; onCreate?: (item: BoxItem) => void; onDelete?: (item: BoxItem) => void; onDownload?: (item: BoxItem) => void; @@ -239,6 +240,7 @@ class ContentExplorer extends Component { }, contentUploaderProps: {}, metadataViewProps: {}, + metadataViewTitle: '', }; /** @@ -1640,6 +1642,7 @@ class ContentExplorer extends Component { messages, fieldsToShow, metadataViewProps, + metadataViewTitle, onDownload, onPreview, onUpload, @@ -1698,7 +1701,6 @@ class ContentExplorer extends Component { ...metadataViewProps?.tableProps, selectedKeys: this.state.selectedKeys, onSelectionChange: (keys: Selection) => { - console.log('onSelectionChange', { keys }); metadataViewProps?.tableProps?.onSelectionChange?.(keys); this.setState({ selectedKeys: keys }); }, @@ -1737,6 +1739,7 @@ class ContentExplorer extends Component { portalElement={this.rootElement} selectedKeys={this.state.selectedKeys} onClearSelectedKeys={this.handleClearSelectedKeys} + metadataViewTitle={metadataViewTitle} /> Date: Fri, 25 Jul 2025 16:42:29 -0700 Subject: [PATCH 04/14] fix: Show ancestor folder name if no title --- src/elements/common/sub-header/SubHeader.tsx | 18 ++++++--- .../SubHeaderLeftMetadataViewV2.tsx | 40 ++++++++++++++++--- .../content-explorer/ContentExplorer.tsx | 1 + 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/elements/common/sub-header/SubHeader.tsx b/src/elements/common/sub-header/SubHeader.tsx index fbf243ead8..f12b7d34f9 100644 --- a/src/elements/common/sub-header/SubHeader.tsx +++ b/src/elements/common/sub-header/SubHeader.tsx @@ -7,13 +7,16 @@ import SubHeaderLeft from './SubHeaderLeft'; import SubheaderLeftMetadataViewV2 from './SubHeaderLeftMetadataViewV2'; import SubHeaderRight from './SubHeaderRight'; import type { ViewMode } from '../flowTypes'; +import type API from '../../../api'; import type { View, Collection } from '../../../common/types/core'; +import type { MetadataQuery } from '../../../common/types/metadataQueries'; import { VIEW_MODE_LIST, VIEW_METADATA } from '../../../constants'; import { useFeatureEnabled } from '../feature-checking'; import './SubHeader.scss'; export interface SubHeaderProps { + api?: API; canCreateNewFolder: boolean; canUpload: boolean; currentCollection: Collection; @@ -22,6 +25,9 @@ export interface SubHeaderProps { gridMinColumns?: number; isSmall: boolean; maxGridColumnCountForWidth?: number; + metadataQuery?: MetadataQuery; + metadataViewTitle?: string; + onClearSelectedKeys: () => void; onCreate: () => void; onGridViewSliderChange?: (newSliderValue: number) => void; onItemClick: (id: string | null, triggerNavigationEvent: boolean | null) => void; @@ -31,14 +37,13 @@ export interface SubHeaderProps { portalElement?: HTMLElement; rootId: string; rootName?: string; + selectedKeys: Selection; view: View; viewMode?: ViewMode; - metadataViewTitle?: string; - selectedKeys: Selection; - onClearSelectedKeys: () => void; } const SubHeader = ({ + api, canCreateNewFolder, canUpload, currentCollection, @@ -46,6 +51,7 @@ const SubHeader = ({ gridMaxColumns = 0, gridMinColumns = 0, maxGridColumnCountForWidth = 0, + metadataQuery, metadataViewTitle, onGridViewSliderChange = noop, isSmall, @@ -88,10 +94,12 @@ const SubHeader = ({ )} {isMetadataViewV2Feature && ( )} diff --git a/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx b/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx index f7f42627e4..bf4f15ebb8 100644 --- a/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx +++ b/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx @@ -1,24 +1,54 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState, useEffect } from 'react'; import { Selection } from 'react-aria-components'; import { FormattedMessage } from 'react-intl'; import { Text } from '@box/blueprint-web'; +import type API from '../../../api'; +import FolderAPI from '../../../api/Folder'; +import type { Collection } from '../../../common/types/core'; +import type { MetadataQuery } from '../../../common/types/metadataQueries'; import CloseButton from '../../../components/close-button/CloseButton'; import messages from '../../../features/content-explorer/messages'; -import type { Collection } from '../../../common/types/core'; import './SubHeaderLeftMetadataViewV2.scss'; interface SubHeaderLeftMetadataViewV2Props { + api?: API; currentCollection: Collection; + metadataQuery?: MetadataQuery; metadataViewTitle?: string; onClearSelectedKeys?: () => void; selectedKeys: Selection; } const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => { - const { currentCollection, metadataViewTitle, selectedKeys, onClearSelectedKeys } = props; + const { api, currentCollection, metadataQuery, metadataViewTitle, selectedKeys, onClearSelectedKeys } = props; + const [ancestorFolderName, setAncestorFolderName] = useState(null); + + // Fetch ancestor folder name with metadataQuery.ancestor_folder_id + useEffect(() => { + if (api && metadataQuery?.ancestor_folder_id) { + if (metadataQuery.ancestor_folder_id === '0') { + setAncestorFolderName('All Files'); + } else { + // Create dedicated FolderAPI instance to avoid interfering with main API + const dedicatedFolderAPI = new FolderAPI(api.options); + + dedicatedFolderAPI.getFolderFields( + metadataQuery.ancestor_folder_id, + (folderInfo: { name?: string }) => { + setAncestorFolderName(folderInfo.name ?? null); + }, + () => { + setAncestorFolderName(null); + }, + { fields: ['name'] }, + ); + } + } + }, [api, metadataQuery?.ancestor_folder_id]); + // Generate selected item text based on selected keys const selectedItemText = useMemo(() => { const selectedCount = selectedKeys === 'all' ? currentCollection.items.length : selectedKeys.size; @@ -50,10 +80,10 @@ const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => ); } - // Case 3: No selected items - show title if provided + // Case 3: No selected items - show title if provided, otherwise show ancestor folder name return ( - {metadataViewTitle} + {metadataViewTitle ?? ancestorFolderName} ); }; diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index 9f448dec29..493791a137 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -1718,6 +1718,7 @@ class ContentExplorer extends Component { {!isDefaultViewMetadata &&
} Date: Mon, 28 Jul 2025 13:50:20 -0700 Subject: [PATCH 05/14] fix: Add shouldDestroy to getFolderAPI() --- src/api/APIFactory.js | 6 ++++-- .../common/sub-header/SubHeaderLeftMetadataViewV2.tsx | 10 +++++----- 2 files changed, 9 insertions(+), 7 deletions(-) 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/sub-header/SubHeaderLeftMetadataViewV2.tsx b/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx index bf4f15ebb8..0049ca1631 100644 --- a/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx +++ b/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx @@ -4,7 +4,6 @@ import { FormattedMessage } from 'react-intl'; import { Text } from '@box/blueprint-web'; import type API from '../../../api'; -import FolderAPI from '../../../api/Folder'; import type { Collection } from '../../../common/types/core'; import type { MetadataQuery } from '../../../common/types/metadataQueries'; import CloseButton from '../../../components/close-button/CloseButton'; @@ -31,10 +30,9 @@ const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => if (metadataQuery.ancestor_folder_id === '0') { setAncestorFolderName('All Files'); } else { - // Create dedicated FolderAPI instance to avoid interfering with main API - const dedicatedFolderAPI = new FolderAPI(api.options); + const folderAPI = api.getFolderAPI(false); - dedicatedFolderAPI.getFolderFields( + folderAPI.getFolderFields( metadataQuery.ancestor_folder_id, (folderInfo: { name?: string }) => { setAncestorFolderName(folderInfo.name ?? null); @@ -45,6 +43,8 @@ const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => { fields: ['name'] }, ); } + } else { + setAncestorFolderName(null); } }, [api, metadataQuery?.ancestor_folder_id]); @@ -52,7 +52,7 @@ const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => const selectedItemText = useMemo(() => { const selectedCount = selectedKeys === 'all' ? currentCollection.items.length : selectedKeys.size; - if (selectedCount === 0) { + if (typeof selectedCount !== 'number' || selectedCount === 0) { return ''; } From c889e03f5a67220c20acbffcd47b193b86976697 Mon Sep 17 00:00:00 2001 From: Jonathan Fox Date: Mon, 28 Jul 2025 14:37:59 -0700 Subject: [PATCH 06/14] fix: add tests for SubHeaderLeftMetadataViewV2 --- .../SubHeaderLeftMetadataViewV2.tsx | 11 +- .../SubHeaderLeftMetadataViewV2.test.tsx | 249 ++++++++++++++++++ 2 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx diff --git a/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx b/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx index 0049ca1631..61f68535a5 100644 --- a/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx +++ b/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState, useEffect } from 'react'; import { Selection } from 'react-aria-components'; -import { FormattedMessage } from 'react-intl'; +import { useIntl } from 'react-intl'; import { Text } from '@box/blueprint-web'; import type API from '../../../api'; @@ -23,6 +23,7 @@ interface SubHeaderLeftMetadataViewV2Props { const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => { const { api, currentCollection, metadataQuery, metadataViewTitle, selectedKeys, onClearSelectedKeys } = props; const [ancestorFolderName, setAncestorFolderName] = useState(null); + const { formatMessage } = useIntl(); // Fetch ancestor folder name with metadataQuery.ancestor_folder_id useEffect(() => { @@ -61,14 +62,16 @@ const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => const selectedKey = selectedKeys === 'all' ? currentCollection.items[0].id : selectedKeys.values().next().value; const selectedItem = currentCollection.items.find(item => item.id === selectedKey); - return selectedItem?.name; + if (typeof selectedItem?.name === 'string') { + return selectedItem.name as string; + } } // Case 2: Multiple selected items - show count if (selectedCount > 1) { - return ; + return formatMessage(messages.numFilesSelected, { numSelected: selectedCount }); } return ''; - }, [currentCollection.items, selectedKeys]); + }, [currentCollection.items, formatMessage, selectedKeys]); // Case 1 and 2: selected item text with X button if (selectedItemText) { diff --git a/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx b/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx new file mode 100644 index 0000000000..2adb5d29ab --- /dev/null +++ b/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx @@ -0,0 +1,249 @@ +import * as React from 'react'; +import type { Selection } from 'react-aria-components'; +import type API from '../../../../api'; +import type { Collection } from '../../../../common/types/core'; +import type { MetadataQuery } from '../../../../common/types/metadataQueries'; +import { render, screen, waitFor } from '../../../../test-utils/testing-library'; +import SubHeaderLeftMetadataViewV2 from '../SubHeaderLeftMetadataViewV2'; + +interface SubHeaderLeftMetadataViewV2Props { + api?: API; + currentCollection: Collection; + metadataQuery?: MetadataQuery; + metadataViewTitle?: string; + onClearSelectedKeys?: () => void; + selectedKeys: Selection; +} + +// Mock the API +const mockAPI = { + getFolderAPI: jest.fn(() => ({ + getFolderFields: jest.fn(), + })), +} as unknown as API; + +const mockCollection: Collection = { + items: [ + { id: '1', name: 'file1.txt' }, + { id: '2', name: 'file2.txt' }, + { id: '3', name: 'file3.txt' }, + ], +}; + +const defaultProps: SubHeaderLeftMetadataViewV2Props = { + api: mockAPI, + currentCollection: mockCollection, + selectedKeys: new Set(), +}; + +const renderComponent = (props: Partial = {}) => + render(); + +describe('elements/common/sub-header/SubHeaderLeftMetadataViewV2', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when no items are selected', () => { + test('should render metadata view title when provided', () => { + renderComponent({ + metadataViewTitle: 'Custom Metadata View', + selectedKeys: new Set(), + }); + + expect(screen.getByText('Custom Metadata View')).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + + test('should render ancestor folder name when no metadata view title is provided', async () => { + const mockGetFolderFields = jest.fn((folderId, successCallback) => { + successCallback({ name: 'Test Folder' }); + }); + + (mockAPI.getFolderAPI as jest.Mock).mockReturnValue({ + getFolderFields: mockGetFolderFields, + }); + + renderComponent({ + metadataQuery: { ancestor_folder_id: '123' }, + selectedKeys: new Set(), + }); + + await waitFor(() => { + expect(screen.getByText('Test Folder')).toBeInTheDocument(); + }); + + expect(mockGetFolderFields).toHaveBeenCalledWith('123', expect.any(Function), expect.any(Function), { + fields: ['name'], + }); + }); + + test('should render "All Files" when ancestor folder id is "0"', () => { + renderComponent({ + metadataQuery: { ancestor_folder_id: '0' }, + selectedKeys: new Set(), + }); + + expect(screen.getByText('All Files')).toBeInTheDocument(); + }); + + test('should handle API error gracefully', async () => { + const mockGetFolderFields = jest.fn((folderId, successCallback, errorCallback) => { + errorCallback(); + }); + + (mockAPI.getFolderAPI as jest.Mock).mockReturnValue({ + getFolderFields: mockGetFolderFields, + }); + + renderComponent({ + metadataQuery: { ancestor_folder_id: '123' }, + selectedKeys: new Set(), + }); + + await waitFor(() => { + expect(screen.queryByText('Test Folder')).not.toBeInTheDocument(); + }); + }); + + test('should not fetch folder info when no ancestor folder id', () => { + const mockGetFolderFields = jest.fn(); + + (mockAPI.getFolderAPI as jest.Mock).mockReturnValue({ + getFolderFields: mockGetFolderFields, + }); + + renderComponent({ + selectedKeys: new Set(), + }); + + expect(mockGetFolderFields).not.toHaveBeenCalled(); + }); + }); + + describe('when items are selected', () => { + test('should render single selected item name', () => { + renderComponent({ + selectedKeys: new Set(['1']), + }); + + expect(screen.getByText('file1.txt')).toBeInTheDocument(); + expect(screen.getByRole('button')).toBeInTheDocument(); // Close button + }); + + test('should render multiple selected items count', () => { + renderComponent({ + selectedKeys: 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({ + selectedKeys: 'all', + }); + + expect(screen.getByText('3 files selected')).toBeInTheDocument(); + expect(screen.getByRole('button')).toBeInTheDocument(); // Close button + }); + + test('should call onClearSelectedKeys when close button is clicked', () => { + const mockOnClearSelectedKeys = jest.fn(); + + renderComponent({ + selectedKeys: new Set(['1']), + onClearSelectedKeys: mockOnClearSelectedKeys, + }); + + const closeButton = screen.getByRole('button'); + closeButton.click(); + + expect(mockOnClearSelectedKeys).toHaveBeenCalledTimes(1); + }); + + test('should handle selected item not found in collection', () => { + renderComponent({ + selectedKeys: 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: [] }, + selectedKeys: new Set(['1']), + }); + + // Should not crash and should not render any selected item text + expect(screen.queryByText('file1.txt')).not.toBeInTheDocument(); + }); + }); + + describe('component structure', () => { + test('should render with correct CSS classes when items are selected', () => { + renderComponent({ + selectedKeys: new Set(['1']), + }); + + const container = screen.getByText('file1.txt').closest('div'); + expect(container).toHaveClass('be-sub-header-left-selected-container'); + }); + + test('should render close button with correct CSS class', () => { + renderComponent({ + selectedKeys: new Set(['1']), + }); + + const closeButton = screen.getByRole('button'); + expect(closeButton).toHaveClass('be-sub-header-left-selected-close-button'); + }); + + test('should render title with correct CSS class when no items selected', () => { + renderComponent({ + metadataViewTitle: 'Test Title', + selectedKeys: new Set(), + }); + + const title = screen.getByRole('heading', { level: 1 }); + expect(title).toHaveClass('be-sub-header-left-title'); + }); + }); + + describe('edge cases', () => { + test('should handle undefined metadataQuery', () => { + renderComponent({ + metadataQuery: undefined, + selectedKeys: new Set(), + }); + + // Should not crash and should not fetch folder info + expect(mockAPI.getFolderAPI).not.toHaveBeenCalled(); + }); + + test('should handle undefined api', () => { + renderComponent({ + api: undefined, + metadataQuery: { ancestor_folder_id: '123' }, + selectedKeys: new Set(), + }); + + // Should not crash and should not fetch folder info + expect(mockAPI.getFolderAPI).not.toHaveBeenCalled(); + }); + + test('should handle zero selected items', () => { + renderComponent({ + selectedKeys: new Set(), + }); + + // Should render title instead of selected items + expect(screen.queryByRole('button')).not.toBeInTheDocument(); // No close button + }); + }); +}); From b2addbff06103e89e3c1f5b09f48819fa0bc211c Mon Sep 17 00:00:00 2001 From: Jonathan Fox Date: Tue, 29 Jul 2025 11:48:17 -0700 Subject: [PATCH 07/14] fix: Move i18n from features to elements/common --- src/elements/common/messages.js | 11 +++++++++++ .../common/sub-header/SubHeaderLeftMetadataViewV2.tsx | 2 +- src/features/content-explorer/messages.js | 11 ----------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/elements/common/messages.js b/src/elements/common/messages.js index 19338fc3fe..7ffaab53a6 100644 --- a/src/elements/common/messages.js +++ b/src/elements/common/messages.js @@ -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: 'boxui.contentExplorer.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/SubHeaderLeftMetadataViewV2.tsx b/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx index 61f68535a5..0bdaf3b26e 100644 --- a/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx +++ b/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx @@ -7,7 +7,7 @@ import type API from '../../../api'; import type { Collection } from '../../../common/types/core'; import type { MetadataQuery } from '../../../common/types/metadataQueries'; import CloseButton from '../../../components/close-button/CloseButton'; -import messages from '../../../features/content-explorer/messages'; +import messages from '../messages'; import './SubHeaderLeftMetadataViewV2.scss'; diff --git a/src/features/content-explorer/messages.js b/src/features/content-explorer/messages.js index 2f8e09edc0..68f17d3c20 100644 --- a/src/features/content-explorer/messages.js +++ b/src/features/content-explorer/messages.js @@ -104,17 +104,6 @@ const messages = defineMessages({ description: 'Text shown to indicate the number of folders selected', id: 'boxui.contentExplorer.numFoldersSelected', }, - numFilesSelected: { - defaultMessage: ` - {numSelected, plural, - =0 {0 files selected} - one {1 file selected} - other {# files selected} - } - `, - description: 'Text shown to indicate the number of files selected', - id: 'boxui.contentExplorer.numFilesSelected', - }, emptySearch: { defaultMessage: "Sorry, we couldn't find what you're looking for.", description: 'Text shown in the list when there are no search results', From 6bcf8f90d6e71b2dd24590098a690455d10d4a75 Mon Sep 17 00:00:00 2001 From: Jonathan Fox Date: Tue, 29 Jul 2025 13:35:29 -0700 Subject: [PATCH 08/14] refactor: Move ancestor folder name to ContentExplorer --- src/elements/common/sub-header/SubHeader.tsx | 10 +-- .../SubHeaderLeftMetadataViewV2.tsx | 39 ++------ .../SubHeaderLeftMetadataViewV2.test.tsx | 88 +++---------------- .../content-explorer/ContentExplorer.tsx | 50 ++++++++++- .../__tests__/ContentExplorer.test.tsx | 74 ++++++++++++++++ 5 files changed, 143 insertions(+), 118 deletions(-) diff --git a/src/elements/common/sub-header/SubHeader.tsx b/src/elements/common/sub-header/SubHeader.tsx index f12b7d34f9..2beb169338 100644 --- a/src/elements/common/sub-header/SubHeader.tsx +++ b/src/elements/common/sub-header/SubHeader.tsx @@ -2,14 +2,14 @@ import * as React from 'react'; import noop from 'lodash/noop'; import classNames from 'classnames'; import { PageHeader } from '@box/blueprint-web'; -import { Selection } from 'react-aria-components'; +import type { Selection } from 'react-aria-components'; + import SubHeaderLeft from './SubHeaderLeft'; import SubheaderLeftMetadataViewV2 from './SubHeaderLeftMetadataViewV2'; import SubHeaderRight from './SubHeaderRight'; import type { ViewMode } from '../flowTypes'; import type API from '../../../api'; import type { View, Collection } from '../../../common/types/core'; -import type { MetadataQuery } from '../../../common/types/metadataQueries'; import { VIEW_MODE_LIST, VIEW_METADATA } from '../../../constants'; import { useFeatureEnabled } from '../feature-checking'; @@ -25,7 +25,7 @@ export interface SubHeaderProps { gridMinColumns?: number; isSmall: boolean; maxGridColumnCountForWidth?: number; - metadataQuery?: MetadataQuery; + metadataAncestorFolderName?: string | null; metadataViewTitle?: string; onClearSelectedKeys: () => void; onCreate: () => void; @@ -51,7 +51,7 @@ const SubHeader = ({ gridMaxColumns = 0, gridMinColumns = 0, maxGridColumnCountForWidth = 0, - metadataQuery, + metadataAncestorFolderName, metadataViewTitle, onGridViewSliderChange = noop, isSmall, @@ -96,7 +96,7 @@ const SubHeader = ({ void; selectedKeys: Selection; } const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => { - const { api, currentCollection, metadataQuery, metadataViewTitle, selectedKeys, onClearSelectedKeys } = props; - const [ancestorFolderName, setAncestorFolderName] = useState(null); + const { currentCollection, metadataAncestorFolderName, metadataViewTitle, onClearSelectedKeys, selectedKeys } = + props; const { formatMessage } = useIntl(); - // Fetch ancestor folder name with metadataQuery.ancestor_folder_id - useEffect(() => { - if (api && metadataQuery?.ancestor_folder_id) { - if (metadataQuery.ancestor_folder_id === '0') { - setAncestorFolderName('All Files'); - } else { - const folderAPI = api.getFolderAPI(false); - - folderAPI.getFolderFields( - metadataQuery.ancestor_folder_id, - (folderInfo: { name?: string }) => { - setAncestorFolderName(folderInfo.name ?? null); - }, - () => { - setAncestorFolderName(null); - }, - { fields: ['name'] }, - ); - } - } else { - setAncestorFolderName(null); - } - }, [api, metadataQuery?.ancestor_folder_id]); - // Generate selected item text based on selected keys const selectedItemText = useMemo(() => { const selectedCount = selectedKeys === 'all' ? currentCollection.items.length : selectedKeys.size; @@ -86,7 +59,7 @@ const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => // Case 3: No selected items - show title if provided, otherwise show ancestor folder name return ( - {metadataViewTitle ?? ancestorFolderName} + {metadataViewTitle ?? metadataAncestorFolderName} ); }; diff --git a/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx b/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx index 2adb5d29ab..d1d941884e 100644 --- a/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx +++ b/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx @@ -3,12 +3,13 @@ import type { Selection } from 'react-aria-components'; import type API from '../../../../api'; import type { Collection } from '../../../../common/types/core'; import type { MetadataQuery } from '../../../../common/types/metadataQueries'; -import { render, screen, waitFor } from '../../../../test-utils/testing-library'; +import { render, screen } from '../../../../test-utils/testing-library'; import SubHeaderLeftMetadataViewV2 from '../SubHeaderLeftMetadataViewV2'; interface SubHeaderLeftMetadataViewV2Props { api?: API; currentCollection: Collection; + metadataAncestorFolderName?: string | null; metadataQuery?: MetadataQuery; metadataViewTitle?: string; onClearSelectedKeys?: () => void; @@ -48,76 +49,32 @@ describe('elements/common/sub-header/SubHeaderLeftMetadataViewV2', () => { test('should render metadata view title when provided', () => { renderComponent({ metadataViewTitle: 'Custom Metadata View', + metadataAncestorFolderName: 'Test Folder', selectedKeys: new Set(), }); + // Custom title should override ancestor folder name expect(screen.getByText('Custom Metadata View')).toBeInTheDocument(); - expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + expect(screen.queryByText('Test Folder')).not.toBeInTheDocument(); }); - test('should render ancestor folder name when no metadata view title is provided', async () => { - const mockGetFolderFields = jest.fn((folderId, successCallback) => { - successCallback({ name: 'Test Folder' }); - }); - - (mockAPI.getFolderAPI as jest.Mock).mockReturnValue({ - getFolderFields: mockGetFolderFields, - }); - - renderComponent({ - metadataQuery: { ancestor_folder_id: '123' }, - selectedKeys: new Set(), - }); - - await waitFor(() => { - expect(screen.getByText('Test Folder')).toBeInTheDocument(); - }); - - expect(mockGetFolderFields).toHaveBeenCalledWith('123', expect.any(Function), expect.any(Function), { - fields: ['name'], - }); - }); - - test('should render "All Files" when ancestor folder id is "0"', () => { + test('should render ancestor folder name when no metadata view title is provided', () => { renderComponent({ - metadataQuery: { ancestor_folder_id: '0' }, + metadataAncestorFolderName: 'Test Folder', selectedKeys: new Set(), }); - expect(screen.getByText('All Files')).toBeInTheDocument(); + expect(screen.getByText('Test Folder')).toBeInTheDocument(); }); - test('should handle API error gracefully', async () => { - const mockGetFolderFields = jest.fn((folderId, successCallback, errorCallback) => { - errorCallback(); - }); - - (mockAPI.getFolderAPI as jest.Mock).mockReturnValue({ - getFolderFields: mockGetFolderFields, - }); - + test('should handle null ancestor folder name gracefully', () => { renderComponent({ - metadataQuery: { ancestor_folder_id: '123' }, + metadataAncestorFolderName: null, selectedKeys: new Set(), }); - await waitFor(() => { - expect(screen.queryByText('Test Folder')).not.toBeInTheDocument(); - }); - }); - - test('should not fetch folder info when no ancestor folder id', () => { - const mockGetFolderFields = jest.fn(); - - (mockAPI.getFolderAPI as jest.Mock).mockReturnValue({ - getFolderFields: mockGetFolderFields, - }); - - renderComponent({ - selectedKeys: new Set(), - }); - - expect(mockGetFolderFields).not.toHaveBeenCalled(); + // Should not crash and should not render any folder name + expect(screen.queryByText('Test Folder')).not.toBeInTheDocument(); }); }); @@ -216,27 +173,6 @@ describe('elements/common/sub-header/SubHeaderLeftMetadataViewV2', () => { }); describe('edge cases', () => { - test('should handle undefined metadataQuery', () => { - renderComponent({ - metadataQuery: undefined, - selectedKeys: new Set(), - }); - - // Should not crash and should not fetch folder info - expect(mockAPI.getFolderAPI).not.toHaveBeenCalled(); - }); - - test('should handle undefined api', () => { - renderComponent({ - api: undefined, - metadataQuery: { ancestor_folder_id: '123' }, - selectedKeys: new Set(), - }); - - // Should not crash and should not fetch folder info - expect(mockAPI.getFolderAPI).not.toHaveBeenCalled(); - }); - test('should handle zero selected items', () => { renderComponent({ selectedKeys: new Set(), diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index 493791a137..b7b37015b7 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -10,7 +10,8 @@ import throttle from 'lodash/throttle'; import uniqueid from 'lodash/uniqueId'; import { TooltipProvider } from '@box/blueprint-web'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; -import { Selection } from 'react-aria-components'; +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'; @@ -173,6 +174,7 @@ type State = { isShareModalOpen: boolean; isUploadModalOpen: boolean; markers: Array; + metadataAncestorFolderName: string | null; metadataTemplate: MetadataTemplate; rootName: string; searchQuery: string; @@ -299,6 +301,7 @@ class ContentExplorer extends Component { isShareModalOpen: false, isUploadModalOpen: false, markers: [], + metadataAncestorFolderName: null, metadataTemplate: {}, rootName: '', selectedKeys: new Set(), @@ -338,7 +341,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; @@ -348,6 +351,7 @@ class ContentExplorer extends Component { break; case DEFAULT_VIEW_METADATA: this.showMetadataQueryResults(); + this.fetchMetadataAncestorFolderName(metadataQuery?.ancestor_folder_id); break; default: this.fetchFolder(currentFolderId); @@ -362,8 +366,11 @@ class ContentExplorer extends Component { * @inheritdoc * @return {void} */ - componentDidUpdate({ currentFolderId: prevFolderId }: ContentExplorerProps, prevState: State): void { - const { currentFolderId }: ContentExplorerProps = this.props; + componentDidUpdate( + { currentFolderId: prevFolderId, metadataQuery: prevMetadataQuery }: ContentExplorerProps, + prevState: State, + ): void { + const { currentFolderId, metadataQuery }: ContentExplorerProps = this.props; const { currentCollection: { id }, }: State = prevState; @@ -375,6 +382,10 @@ class ContentExplorer extends Component { if (typeof currentFolderId === 'string' && id !== currentFolderId) { this.fetchFolder(currentFolderId); } + + if (prevMetadataQuery?.ancestor_folder_id !== metadataQuery?.ancestor_folder_id) { + this.fetchMetadataAncestorFolderName(metadataQuery?.ancestor_folder_id); + } } /** @@ -1608,6 +1619,35 @@ class ContentExplorer extends Component { this.setState({ selectedKeys: new Set() }); }; + /** + * Fetches the metadata ancestor folder name + * + * @private + * @return {void} + */ + fetchMetadataAncestorFolderName = (ancestorFolderId?: string) => { + if (!ancestorFolderId) { + this.setState({ metadataAncestorFolderName: null }); + return; + } + + if (ancestorFolderId === '0') { + this.setState({ metadataAncestorFolderName: 'All Files' }); + return; + } + + this.api.getFolderAPI(false).getFolderFields( + ancestorFolderId, + (folderInfo: { name?: string }) => { + this.setState({ metadataAncestorFolderName: folderInfo.name ?? null }); + }, + () => { + this.setState({ metadataAncestorFolderName: null }); + }, + { fields: ['name'] }, + ); + }; + /** * Renders the file picker * @@ -1673,6 +1713,7 @@ class ContentExplorer extends Component { isShareModalOpen, isUploadModalOpen, markers, + metadataAncestorFolderName, metadataTemplate, rootName, selected, @@ -1740,6 +1781,7 @@ class ContentExplorer extends Component { portalElement={this.rootElement} selectedKeys={this.state.selectedKeys} onClearSelectedKeys={this.handleClearSelectedKeys} + metadataAncestorFolderName={metadataAncestorFolderName} metadataViewTitle={metadataViewTitle} /> diff --git a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx index 8b77e83f69..6eeb01f841 100644 --- a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx +++ b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx @@ -7,6 +7,22 @@ import { mockMetadata, mockSchema } from '../../common/__mocks__/mockMetadata'; import mockSubFolder from '../../common/__mocks__/mockSubfolder'; import { FeatureProvider } from '../../common/feature-checking'; +const mockGetFolderFields = jest.fn(); +const mockGetFolderAPI = jest.fn(() => ({ + getFolderFields: mockGetFolderFields, +})); +const mockGetMetadataQueryAPI = jest.fn(() => ({ + queryMetadata: jest.fn(), +})); + +jest.mock('../../../api', () => { + return jest.fn().mockImplementation(() => ({ + getFolderAPI: mockGetFolderAPI, + getMetadataQueryAPI: mockGetMetadataQueryAPI, + destroy: jest.fn(), + })); +}); + jest.mock('../../../utils/Xhr', () => { return jest.fn().mockImplementation(() => { return { @@ -86,6 +102,10 @@ describe('elements/content-explorer/ContentExplorer', () => { rootElement = document.createElement('div'); rootElement.appendChild(document.createElement('div')); document.body.appendChild(rootElement); + + mockGetFolderFields.mockImplementation((_folderId, successCallback) => { + successCallback({ name: 'Test Folder Name' }); + }); }); afterEach(() => { @@ -442,6 +462,60 @@ describe('elements/content-explorer/ContentExplorer', () => { expect(screen.getByRole('button', { name: 'Metadata' })).toBeInTheDocument(); }); }); + + describe('Metadata Ancestor Folder Name', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should fetch ancestor folder name when metadata view is loaded with ancestor_folder_id', async () => { + const metadataQuery = { + from: 'enterprise_0.templateName', + ancestor_folder_id: '123', + fields: ['metadata.enterprise_0.templateName.industry'], + }; + + // Mock successful API response + mockGetFolderFields.mockImplementation((_folderId, successCallback) => { + successCallback({ name: 'Test Folder Name' }); + }); + + renderComponent({ + metadataQuery, + defaultView: 'metadata', + }); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + }); + + // Verify the API was called with correct parameters + expect(mockGetFolderAPI).toHaveBeenCalledWith(false); + expect(mockGetFolderFields).toHaveBeenCalledWith('123', expect.any(Function), expect.any(Function), { + fields: ['name'], + }); + }); + + test('should handle ancestor_folder_id of "0" without API call', async () => { + const metadataQuery = { + from: 'enterprise_0.templateName', + ancestor_folder_id: '0', + fields: ['metadata.enterprise_0.templateName.industry'], + }; + + renderComponent({ + metadataQuery, + defaultView: 'metadata', + }); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + }); + + // Verify no API call was made for "0" + expect(mockGetFolderFields).not.toHaveBeenCalled(); + }); + }); }); describe('Preview', () => { From 5ef36fa25cf6172dc905aaee158f6cce7e04ea12 Mon Sep 17 00:00:00 2001 From: Jonathan Fox Date: Tue, 29 Jul 2025 13:54:00 -0700 Subject: [PATCH 09/14] chore: rename classnames to match SUIT CSS --- src/elements/common/sub-header/SubHeader.scss | 2 +- src/elements/common/sub-header/SubHeader.tsx | 2 +- .../SubHeaderLeftMetadataViewV2.scss | 8 +- .../SubHeaderLeftMetadataViewV2.tsx | 9 ++- .../SubHeaderLeftMetadataViewV2.test.tsx | 6 +- .../__tests__/ContentExplorer.test.tsx | 74 ------------------- 6 files changed, 15 insertions(+), 86 deletions(-) diff --git a/src/elements/common/sub-header/SubHeader.scss b/src/elements/common/sub-header/SubHeader.scss index cfd80e58a3..9a8fe18146 100644 --- a/src/elements/common/sub-header/SubHeader.scss +++ b/src/elements/common/sub-header/SubHeader.scss @@ -21,7 +21,7 @@ border-bottom: 0 none; } - &.be-sub-header--metadata-view { + &.SubHeader--metadataView { padding: 0; } } diff --git a/src/elements/common/sub-header/SubHeader.tsx b/src/elements/common/sub-header/SubHeader.tsx index 2beb169338..4ef1411a0e 100644 --- a/src/elements/common/sub-header/SubHeader.tsx +++ b/src/elements/common/sub-header/SubHeader.tsx @@ -76,7 +76,7 @@ const SubHeader = ({ return ( diff --git a/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.scss b/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.scss index 844c6b425b..8d16808a40 100644 --- a/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.scss +++ b/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.scss @@ -1,9 +1,9 @@ -.be-sub-header-left-selected-container { +.SubHeaderLeftMetadataViewV2-selectedContainer { display: flex; align-items: center; gap: 12px; +} - > .bdl-CloseButton { - margin: 0; - } +.SubHeaderLeftMetadataViewV2-clearSelectedKeysButton { + margin: 0; } diff --git a/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx b/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx index 2e756e1b7a..7528fc5238 100644 --- a/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx +++ b/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx @@ -49,8 +49,11 @@ const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => // Case 1 and 2: selected item text with X button if (selectedItemText) { return ( -
- +
+ {selectedItemText}
); @@ -58,7 +61,7 @@ const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => // Case 3: No selected items - show title if provided, otherwise show ancestor folder name return ( - + {metadataViewTitle ?? metadataAncestorFolderName} ); diff --git a/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx b/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx index d1d941884e..08af13e858 100644 --- a/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx +++ b/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx @@ -149,7 +149,7 @@ describe('elements/common/sub-header/SubHeaderLeftMetadataViewV2', () => { }); const container = screen.getByText('file1.txt').closest('div'); - expect(container).toHaveClass('be-sub-header-left-selected-container'); + expect(container).toHaveClass('SubHeaderLeftMetadataViewV2-selectedContainer'); }); test('should render close button with correct CSS class', () => { @@ -158,7 +158,7 @@ describe('elements/common/sub-header/SubHeaderLeftMetadataViewV2', () => { }); const closeButton = screen.getByRole('button'); - expect(closeButton).toHaveClass('be-sub-header-left-selected-close-button'); + expect(closeButton).toHaveClass('SubHeaderLeftMetadataViewV2-clearSelectedKeysButton'); }); test('should render title with correct CSS class when no items selected', () => { @@ -168,7 +168,7 @@ describe('elements/common/sub-header/SubHeaderLeftMetadataViewV2', () => { }); const title = screen.getByRole('heading', { level: 1 }); - expect(title).toHaveClass('be-sub-header-left-title'); + expect(title).toBeInTheDocument(); }); }); diff --git a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx index 6eeb01f841..8b77e83f69 100644 --- a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx +++ b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx @@ -7,22 +7,6 @@ import { mockMetadata, mockSchema } from '../../common/__mocks__/mockMetadata'; import mockSubFolder from '../../common/__mocks__/mockSubfolder'; import { FeatureProvider } from '../../common/feature-checking'; -const mockGetFolderFields = jest.fn(); -const mockGetFolderAPI = jest.fn(() => ({ - getFolderFields: mockGetFolderFields, -})); -const mockGetMetadataQueryAPI = jest.fn(() => ({ - queryMetadata: jest.fn(), -})); - -jest.mock('../../../api', () => { - return jest.fn().mockImplementation(() => ({ - getFolderAPI: mockGetFolderAPI, - getMetadataQueryAPI: mockGetMetadataQueryAPI, - destroy: jest.fn(), - })); -}); - jest.mock('../../../utils/Xhr', () => { return jest.fn().mockImplementation(() => { return { @@ -102,10 +86,6 @@ describe('elements/content-explorer/ContentExplorer', () => { rootElement = document.createElement('div'); rootElement.appendChild(document.createElement('div')); document.body.appendChild(rootElement); - - mockGetFolderFields.mockImplementation((_folderId, successCallback) => { - successCallback({ name: 'Test Folder Name' }); - }); }); afterEach(() => { @@ -462,60 +442,6 @@ describe('elements/content-explorer/ContentExplorer', () => { expect(screen.getByRole('button', { name: 'Metadata' })).toBeInTheDocument(); }); }); - - describe('Metadata Ancestor Folder Name', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - test('should fetch ancestor folder name when metadata view is loaded with ancestor_folder_id', async () => { - const metadataQuery = { - from: 'enterprise_0.templateName', - ancestor_folder_id: '123', - fields: ['metadata.enterprise_0.templateName.industry'], - }; - - // Mock successful API response - mockGetFolderFields.mockImplementation((_folderId, successCallback) => { - successCallback({ name: 'Test Folder Name' }); - }); - - renderComponent({ - metadataQuery, - defaultView: 'metadata', - }); - - await waitFor(() => { - expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); - }); - - // Verify the API was called with correct parameters - expect(mockGetFolderAPI).toHaveBeenCalledWith(false); - expect(mockGetFolderFields).toHaveBeenCalledWith('123', expect.any(Function), expect.any(Function), { - fields: ['name'], - }); - }); - - test('should handle ancestor_folder_id of "0" without API call', async () => { - const metadataQuery = { - from: 'enterprise_0.templateName', - ancestor_folder_id: '0', - fields: ['metadata.enterprise_0.templateName.industry'], - }; - - renderComponent({ - metadataQuery, - defaultView: 'metadata', - }); - - await waitFor(() => { - expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); - }); - - // Verify no API call was made for "0" - expect(mockGetFolderFields).not.toHaveBeenCalled(); - }); - }); }); describe('Preview', () => { From 015674e40e03ae301a54b5bb3a550d62887468a3 Mon Sep 17 00:00:00 2001 From: Jonathan Fox Date: Tue, 29 Jul 2025 14:43:19 -0700 Subject: [PATCH 10/14] chore: Add missed files --- i18n/en-US.properties | 4 +- package.json | 28 ------------- scripts/jest/jest.config.js | 4 -- src/common/types/core.js | 3 +- src/elements/common/messages.js | 2 +- src/elements/common/sub-header/SubHeader.tsx | 3 -- .../SubHeaderLeftMetadataViewV2.tsx | 8 ++-- .../SubHeaderLeftMetadataViewV2.test.tsx | 23 ---------- src/elements/content-explorer/Content.tsx | 13 ------ .../content-explorer/ContentExplorer.tsx | 5 +-- .../MetadataViewContainer.tsx | 42 ------------------- .../__tests__/MetadataViewContainer.test.tsx | 23 ---------- .../stories/MetadataView.stories.tsx | 4 -- .../MetadataQueryAPIHelper.js | 24 ++--------- .../__tests__/MetadataQueryAPIHelper.test.js | 1 - 15 files changed, 12 insertions(+), 175 deletions(-) diff --git a/i18n/en-US.properties b/i18n/en-US.properties index f3f797a4be..9b6d5ed13c 100644 --- a/i18n/en-US.properties +++ b/i18n/en-US.properties @@ -100,6 +100,8 @@ be.collaboratedFolder = Collaborated Folder be.collapse = Collapse # Label for complete state. be.complete = Complete +# Text shown to indicate the number of files selected +be.contentExplorer.numFilesSelected = {numSelected, plural, =0 {0 files selected} one {1 file selected} other {# files selected} } # Text shown to users when opening the content insights flyout and there is an error be.contentInsights.contentAnalyticsErrorText = There was a problem loading content insights. Please try again. # Message shown when the user does not have access to view content insights anymore @@ -1066,8 +1068,6 @@ boxui.contentExplorer.move = Move boxui.contentExplorer.name = Name # Text shown on button used to create a new folder boxui.contentExplorer.newFolder = New Folder -# Text shown to indicate the number of files selected -boxui.contentExplorer.numFilesSelected = {numSelected, plural, =0 {0 files selected} one {1 file selected} other {# files selected} } # Text shown to indicate the number of folders selected boxui.contentExplorer.numFoldersSelected = {numSelected, plural, =0 {0 folders selected} one {1 folder selected} other {# folders selected} } # Text shown to indicate the number of items selected with Include Subfolders feature diff --git a/package.json b/package.json index 114cc1f4d8..02c45a2825 100644 --- a/package.json +++ b/package.json @@ -124,33 +124,19 @@ "@babel/preset-typescript": "^7.24.7", "@babel/template": "^7.24.7", "@babel/types": "^7.24.7", -<<<<<<< HEAD "@box/blueprint-web": "12.43.0", "@box/blueprint-web-assets": "4.61.5", "@box/box-ai-agent-selector": "^0.48.5", -======= - "@box/blueprint-web": "^12.41.0", - "@box/blueprint-web-assets": "^4.60.9", - "@box/box-ai-agent-selector": "^0.41.10", ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) "@box/box-ai-content-answers": "^0.124.1", "@box/box-item-type-selector": "^0.61.12", "@box/cldr-data": "^34.2.0", "@box/combobox-with-api": "^0.34.9", "@box/frontend": "^11.0.1", -<<<<<<< HEAD "@box/item-icon": "^0.17.0", "@box/languages": "^1.0.0", "@box/metadata-editor": "^0.122.12", "@box/metadata-filter": "^1.16.12", "@box/metadata-view": "^0.29.4", -======= - "@box/item-icon": "^0.16.3", - "@box/languages": "^1.0.0", - "@box/metadata-editor": "^0.122.0", - "@box/metadata-filter": "^1.16.3", - "@box/metadata-view": "^0.28.0", ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/types": "^0.2.1", "@cfaester/enzyme-adapter-react-18": "^0.8.0", @@ -307,31 +293,17 @@ "webpack-dev-server": "^5.2.1" }, "peerDependencies": { -<<<<<<< HEAD "@box/blueprint-web": "12.43.0", "@box/blueprint-web-assets": "4.61.5", "@box/box-ai-agent-selector": "^0.48.5", -======= - "@box/blueprint-web": "^12.41.0", - "@box/blueprint-web-assets": "^4.60.9", - "@box/box-ai-agent-selector": "^0.41.10", ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) "@box/box-ai-content-answers": "^0.124.1", "@box/box-item-type-selector": "^0.61.12", "@box/cldr-data": ">=34.2.0", "@box/combobox-with-api": "^0.34.9", -<<<<<<< HEAD "@box/item-icon": "^0.17.0", "@box/metadata-editor": "^0.122.12", "@box/metadata-filter": "^1.16.12", "@box/metadata-view": "^0.29.4", -======= - "@box/item-icon": "^0.16.3", - "@box/languages": "^1.0.0", - "@box/metadata-editor": "^0.122.0", - "@box/metadata-filter": "^1.16.3", - "@box/metadata-view": "^0.28.0", ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/types": "^0.2.1", "@hapi/address": "^2.1.4", diff --git a/scripts/jest/jest.config.js b/scripts/jest/jest.config.js index 4756eda8e9..c558824b13 100644 --- a/scripts/jest/jest.config.js +++ b/scripts/jest/jest.config.js @@ -26,10 +26,6 @@ module.exports = { testMatch: ['**/__tests__/**/*.test.+(js|jsx|ts|tsx)'], testPathIgnorePatterns: ['stories.test.js$', 'stories.test.tsx$', 'stories.test.d.ts'], transformIgnorePatterns: [ -<<<<<<< HEAD 'node_modules/(?!(@box/react-virtualized/dist/es|@box/cldr-data|@box/blueprint-web|@box/blueprint-web-assets|@box/metadata-editor|@box/box-ai-content-answers|@box/box-ai-agent-selector|@box/item-icon|@box/combobox-with-api|@box/tree|@box/metadata-filter|@box/metadata-view|@box/types|@box/box-item-type-selector)/)', -======= - 'node_modules/(?!(@box/react-virtualized/dist/es|@box/cldr-data|@box/blueprint-web|@box/blueprint-web-assets|@box/metadata-editor|@box/box-ai-content-answers|@box/box-ai-agent-selector|@box/item-icon|@box/combobox-with-api|@box/tree|@box/metadata-filter|@box/metadata-view|@box/types)/)', ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) ], }; diff --git a/src/common/types/core.js b/src/common/types/core.js index 2834d3b342..a1b4f47572 100644 --- a/src/common/types/core.js +++ b/src/common/types/core.js @@ -34,7 +34,7 @@ import { PERMISSION_CAN_DOWNLOAD, PERMISSION_CAN_PREVIEW, } from '../../constants'; -import type { MetadataType, MetadataTemplate } from './metadata'; +import type { MetadataType } from './metadata'; type Token = null | typeof undefined | string | Function; type TokenReadWrite = { read: string, write?: string }; @@ -394,7 +394,6 @@ type Collection = { breadcrumbs?: Array, id?: string, items?: Array, - metadataTemplate?: MetadataTemplate, name?: string, nextMarker?: ?string, offset?: number, diff --git a/src/elements/common/messages.js b/src/elements/common/messages.js index 7ffaab53a6..dd00e3d7b4 100644 --- a/src/elements/common/messages.js +++ b/src/elements/common/messages.js @@ -1090,7 +1090,7 @@ const messages = defineMessages({ defaultMessage: 'Personal Folder', }, numFilesSelected: { - id: 'boxui.contentExplorer.numFilesSelected', + id: 'be.contentExplorer.numFilesSelected', description: 'Text shown to indicate the number of files selected', defaultMessage: ` {numSelected, plural, diff --git a/src/elements/common/sub-header/SubHeader.tsx b/src/elements/common/sub-header/SubHeader.tsx index 4ef1411a0e..0b127d7f54 100644 --- a/src/elements/common/sub-header/SubHeader.tsx +++ b/src/elements/common/sub-header/SubHeader.tsx @@ -25,7 +25,6 @@ export interface SubHeaderProps { gridMinColumns?: number; isSmall: boolean; maxGridColumnCountForWidth?: number; - metadataAncestorFolderName?: string | null; metadataViewTitle?: string; onClearSelectedKeys: () => void; onCreate: () => void; @@ -51,7 +50,6 @@ const SubHeader = ({ gridMaxColumns = 0, gridMinColumns = 0, maxGridColumnCountForWidth = 0, - metadataAncestorFolderName, metadataViewTitle, onGridViewSliderChange = noop, isSmall, @@ -96,7 +94,6 @@ const SubHeader = ({ void; selectedKeys: Selection; } const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => { - const { currentCollection, metadataAncestorFolderName, metadataViewTitle, onClearSelectedKeys, selectedKeys } = - props; + const { currentCollection, metadataViewTitle, onClearSelectedKeys, selectedKeys } = props; const { formatMessage } = useIntl(); // Generate selected item text based on selected keys @@ -59,10 +57,10 @@ const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => ); } - // Case 3: No selected items - show title if provided, otherwise show ancestor folder name + // Case 3: No selected items - show title return ( - {metadataViewTitle ?? metadataAncestorFolderName} + {metadataViewTitle} ); }; diff --git a/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx b/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx index 08af13e858..f7217d0539 100644 --- a/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx +++ b/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx @@ -9,7 +9,6 @@ import SubHeaderLeftMetadataViewV2 from '../SubHeaderLeftMetadataViewV2'; interface SubHeaderLeftMetadataViewV2Props { api?: API; currentCollection: Collection; - metadataAncestorFolderName?: string | null; metadataQuery?: MetadataQuery; metadataViewTitle?: string; onClearSelectedKeys?: () => void; @@ -49,32 +48,10 @@ describe('elements/common/sub-header/SubHeaderLeftMetadataViewV2', () => { test('should render metadata view title when provided', () => { renderComponent({ metadataViewTitle: 'Custom Metadata View', - metadataAncestorFolderName: 'Test Folder', selectedKeys: new Set(), }); - // Custom title should override ancestor folder name expect(screen.getByText('Custom Metadata View')).toBeInTheDocument(); - expect(screen.queryByText('Test Folder')).not.toBeInTheDocument(); - }); - - test('should render ancestor folder name when no metadata view title is provided', () => { - renderComponent({ - metadataAncestorFolderName: 'Test Folder', - selectedKeys: new Set(), - }); - - expect(screen.getByText('Test Folder')).toBeInTheDocument(); - }); - - test('should handle null ancestor folder name gracefully', () => { - renderComponent({ - metadataAncestorFolderName: null, - selectedKeys: new Set(), - }); - - // Should not crash and should not render any folder name - expect(screen.queryByText('Test Folder')).not.toBeInTheDocument(); }); }); diff --git a/src/elements/content-explorer/Content.tsx b/src/elements/content-explorer/Content.tsx index c2bc7ca359..5bb42fe8ef 100644 --- a/src/elements/content-explorer/Content.tsx +++ b/src/elements/content-explorer/Content.tsx @@ -36,12 +36,8 @@ export interface ContentProps extends Required, Required; -======= - metadataProps?: Omit; ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) onMetadataUpdate: ( item: BoxItem, field: string, @@ -59,12 +55,8 @@ const Content = ({ features, fieldsToShow = [], gridColumnCount, -<<<<<<< HEAD metadataTemplate, metadataViewProps, -======= - metadataProps, ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) onMetadataUpdate, onSortChange, view, @@ -96,13 +88,8 @@ const Content = ({ currentCollection={currentCollection} isLoading={percentLoaded !== 100} hasError={view === VIEW_ERROR} -<<<<<<< HEAD metadataTemplate={metadataTemplate} {...metadataViewProps} -======= - onSortChange={onSortChange} - {...metadataProps} ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) /> )} {!isViewEmpty && isListView && ( diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index b7b37015b7..6c9dcfac98 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -1734,8 +1734,6 @@ class ContentExplorer extends Component { const hasNextMarker: boolean = !!markers[currentPageNumber + 1]; const hasPreviousMarker: boolean = currentPageNumber === 1 || !!markers[currentPageNumber - 1]; - console.log({ currentCollection }); - const combinedMetadataViewProps = { ...metadataViewProps, tableProps: { @@ -1772,6 +1770,7 @@ class ContentExplorer extends Component { gridMaxColumns={GRID_VIEW_MAX_COLUMNS} gridMinColumns={GRID_VIEW_MIN_COLUMNS} maxGridColumnCountForWidth={maxGridColumnCount} + metadataViewTitle={metadataViewTitle || metadataAncestorFolderName} onUpload={this.upload} onCreate={this.createFolder} onGridViewSliderChange={this.onGridViewSliderChange} @@ -1781,8 +1780,6 @@ class ContentExplorer extends Component { portalElement={this.rootElement} selectedKeys={this.state.selectedKeys} onClearSelectedKeys={this.handleClearSelectedKeys} - metadataAncestorFolderName={metadataAncestorFolderName} - metadataViewTitle={metadataViewTitle} /> >>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) import type { Collection } from '../../common/types/core'; export interface MetadataViewContainerProps extends Omit { currentCollection: Collection; -<<<<<<< HEAD metadataTemplate: MetadataTemplate; -======= - onSortChange?: (sortBy: string, sortDirection: string) => void; ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) } const MetadataViewContainer = ({ actionBarProps, columns, currentCollection, -<<<<<<< HEAD metadataTemplate, ...rest }: MetadataViewContainerProps) => { const { items = [] } = currentCollection; -======= - onSortChange, - tableProps, - ...rest -}: MetadataViewContainerProps) => { - const { items = [], metadataTemplate } = currentCollection; ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) const filterGroups = React.useMemo( () => [ { -<<<<<<< HEAD toggleable: true, -======= - togglable: true, ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) filters: metadataTemplate?.fields?.map(field => { return { @@ -57,35 +36,14 @@ const MetadataViewContainer = ({ [metadataTemplate], ); -<<<<<<< HEAD - return ( - { - onSortChange?.(sortDescriptor.column as string, sortDescriptor.direction); - }, - [onSortChange], - ); - return ( >>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) actionBarProps={{ ...actionBarProps, filterGroups, }} -<<<<<<< HEAD columns={columns} items={items} -======= - tableProps={{ - ...tableProps, - onSortChange: handleSortChange, - }} ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) {...rest} /> ); diff --git a/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx b/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx index ff03bd5f77..16df4b3a0e 100644 --- a/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx +++ b/src/elements/content-explorer/__tests__/MetadataViewContainer.test.tsx @@ -5,11 +5,6 @@ import type { Collection } from '../../../common/types/core'; import type { MetadataTemplate, MetadataTemplateField } from '../../../common/types/metadata'; describe('elements/content-explorer/MetadataViewContainer', () => { -<<<<<<< HEAD -======= - const mockOnSortChange = jest.fn(); - ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) const mockItems = [ { id: '1', name: 'File 1.txt', type: 'file' }, { id: '2', name: 'File 2.pdf', type: 'file' }, @@ -45,10 +40,6 @@ describe('elements/content-explorer/MetadataViewContainer', () => { const mockCollection: Collection = { id: '0', items: mockItems, -<<<<<<< HEAD -======= - metadataTemplate: mockMetadataTemplate, ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) percentLoaded: 100, }; @@ -73,30 +64,16 @@ describe('elements/content-explorer/MetadataViewContainer', () => { maxWidth: 250, }, ], -<<<<<<< HEAD metadataTemplate: mockMetadataTemplate, -======= - onSortChange: mockOnSortChange, ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) }; const renderComponent = (props: Partial = {}) => { return render(); }; -<<<<<<< HEAD test('should render MetadataView component', () => { renderComponent(); -======= - beforeEach(() => { - jest.clearAllMocks(); - }); - - test('should render MetadataView component', () => { - renderComponent(); - screen.debug(null, 10000); ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) expect(screen.getByRole('button', { name: 'All Filters' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Name' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Industry' })).toBeInTheDocument(); diff --git a/src/elements/content-explorer/stories/MetadataView.stories.tsx b/src/elements/content-explorer/stories/MetadataView.stories.tsx index d48f0eda2f..cb19394aca 100644 --- a/src/elements/content-explorer/stories/MetadataView.stories.tsx +++ b/src/elements/content-explorer/stories/MetadataView.stories.tsx @@ -79,11 +79,7 @@ type Story = StoryObj; export const metadataView: Story = { args: { -<<<<<<< HEAD metadataViewProps: { -======= - metadataProps: { ->>>>>>> b49985c8b (feat(metadata-view): Add MetadataView V2) columns, tableProps: { isSelectAllEnabled: true, diff --git a/src/features/metadata-based-view/MetadataQueryAPIHelper.js b/src/features/metadata-based-view/MetadataQueryAPIHelper.js index d0f3de5be0..84608c1e08 100644 --- a/src/features/metadata-based-view/MetadataQueryAPIHelper.js +++ b/src/features/metadata-based-view/MetadataQueryAPIHelper.js @@ -20,7 +20,7 @@ import { METADATA_FIELD_TYPE_ENUM, METADATA_FIELD_TYPE_MULTISELECT, } from '../../common/constants'; -import { FIELD_NAME, FIELD_METADATA, FIELD_EXTENSION, FIELD_CREATED_AT } from '../../constants'; +import { FIELD_NAME, FIELD_METADATA } from '../../constants'; import type { MetadataQuery as MetadataQueryType, MetadataQueryResponseData } from '../../common/types/metadataQueries'; import type { @@ -54,11 +54,8 @@ export default class MetadataQueryAPIHelper { metadataQuery: MetadataQueryType; - isV2: boolean; - - constructor(api: API, isV2: boolean = false) { + constructor(api: API) { this.api = api; - this.isV2 = isV2; } createJSONPatchOperations = ( @@ -156,26 +153,23 @@ export default class MetadataQueryAPIHelper { const { metadata } = metadataEntry; return { ...metadataEntry, - metadata: this.isV2 ? metadata : this.flattenMetadata(metadata), + metadata: this.flattenMetadata(metadata), }; }; filterMetdataQueryResponse = (response: MetadataQueryResponseData): MetadataQueryResponseData => { const { entries = [], next_marker } = response; return { - entries: this.isV2 ? entries : entries.filter(entry => getProp(entry, 'type') === ITEM_TYPE_FILE), // return only file items + entries: entries.filter(entry => getProp(entry, 'type') === ITEM_TYPE_FILE), // return only file items next_marker, }; }; getFlattenedDataWithTypes = (templateSchemaResponse?: MetadataTemplateSchemaResponse): Collection => { this.metadataTemplate = getProp(templateSchemaResponse, 'data'); - const { entries, next_marker }: MetadataQueryResponseData = this.metadataQueryResponseData; - return { items: entries.map(this.flattenResponseEntry), - metadataTemplate: this.metadataTemplate, nextMarker: next_marker, }; }; @@ -244,16 +238,6 @@ export default class MetadataQueryAPIHelper { if (!clonedFields.includes(FIELD_NAME)) { clonedFields.push(FIELD_NAME); } - if (this.isV2) { - if (!clonedFields.includes(FIELD_EXTENSION)) { - clonedFields.push(FIELD_EXTENSION); - } - - if (!clonedFields.includes(FIELD_CREATED_AT)) { - clonedFields.push(FIELD_CREATED_AT); - } - } - clonedQuery.fields = clonedFields; return clonedQuery; diff --git a/src/features/metadata-based-view/__tests__/MetadataQueryAPIHelper.test.js b/src/features/metadata-based-view/__tests__/MetadataQueryAPIHelper.test.js index 9a77459ac8..63fece081c 100644 --- a/src/features/metadata-based-view/__tests__/MetadataQueryAPIHelper.test.js +++ b/src/features/metadata-based-view/__tests__/MetadataQueryAPIHelper.test.js @@ -186,7 +186,6 @@ describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { ]; const flattenedDataWithTypes = { items: flattenedResponse, - metadataTemplate: template, nextMarker: metadataQueryResponse.next_marker, }; const getSchemaByTemplateKeyFunc = jest.fn().mockReturnValueOnce(Promise.resolve(templateSchemaResponse)); From a6c5da49e7986456d1d2bd83e7f2893d7d878ebe Mon Sep 17 00:00:00 2001 From: Jonathan Fox Date: Tue, 29 Jul 2025 16:20:35 -0700 Subject: [PATCH 11/14] refactor: Rename SubHeaderLeftMetadataViewV2 to SubHeaderLeftV2 --- src/elements/common/sub-header/SubHeader.scss | 2 +- src/elements/common/sub-header/SubHeader.tsx | 8 +-- .../SubHeaderLeftMetadataViewV2.scss | 9 --- .../common/sub-header/SubHeaderLeftV2.scss | 9 +++ ...MetadataViewV2.tsx => SubHeaderLeftV2.tsx} | 22 ++++---- ...ewV2.test.tsx => SubHeaderLeftV2.test.tsx} | 55 ++++--------------- 6 files changed, 35 insertions(+), 70 deletions(-) delete mode 100644 src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.scss create mode 100644 src/elements/common/sub-header/SubHeaderLeftV2.scss rename src/elements/common/sub-header/{SubHeaderLeftMetadataViewV2.tsx => SubHeaderLeftV2.tsx} (75%) rename src/elements/common/sub-header/__tests__/{SubHeaderLeftMetadataViewV2.test.tsx => SubHeaderLeftV2.test.tsx} (70%) diff --git a/src/elements/common/sub-header/SubHeader.scss b/src/elements/common/sub-header/SubHeader.scss index 9a8fe18146..829e4d6154 100644 --- a/src/elements/common/sub-header/SubHeader.scss +++ b/src/elements/common/sub-header/SubHeader.scss @@ -21,7 +21,7 @@ border-bottom: 0 none; } - &.SubHeader--metadataView { + &.SubHeader--noPadding { padding: 0; } } diff --git a/src/elements/common/sub-header/SubHeader.tsx b/src/elements/common/sub-header/SubHeader.tsx index 0b127d7f54..f1565104e3 100644 --- a/src/elements/common/sub-header/SubHeader.tsx +++ b/src/elements/common/sub-header/SubHeader.tsx @@ -5,7 +5,7 @@ import { PageHeader } from '@box/blueprint-web'; import type { Selection } from 'react-aria-components'; import SubHeaderLeft from './SubHeaderLeft'; -import SubheaderLeftMetadataViewV2 from './SubHeaderLeftMetadataViewV2'; +import SubHeaderLeftV2 from './SubHeaderLeftV2'; import SubHeaderRight from './SubHeaderRight'; import type { ViewMode } from '../flowTypes'; import type API from '../../../api'; @@ -74,7 +74,7 @@ const SubHeader = ({ return ( @@ -91,10 +91,10 @@ const SubHeader = ({ /> )} {isMetadataViewV2Feature && ( - diff --git a/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.scss b/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.scss deleted file mode 100644 index 8d16808a40..0000000000 --- a/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.scss +++ /dev/null @@ -1,9 +0,0 @@ -.SubHeaderLeftMetadataViewV2-selectedContainer { - display: flex; - align-items: center; - gap: 12px; -} - -.SubHeaderLeftMetadataViewV2-clearSelectedKeysButton { - margin: 0; -} diff --git a/src/elements/common/sub-header/SubHeaderLeftV2.scss b/src/elements/common/sub-header/SubHeaderLeftV2.scss new file mode 100644 index 0000000000..9337192f6f --- /dev/null +++ b/src/elements/common/sub-header/SubHeaderLeftV2.scss @@ -0,0 +1,9 @@ +.SubHeaderLeftV2-selectedContainer { + display: flex; + align-items: center; + gap: 12px; +} + +.SubHeaderLeftV2-clearSelectedKeysButton { + margin: 0; +} diff --git a/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx b/src/elements/common/sub-header/SubHeaderLeftV2.tsx similarity index 75% rename from src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx rename to src/elements/common/sub-header/SubHeaderLeftV2.tsx index b11903a28c..aef3497a45 100644 --- a/src/elements/common/sub-header/SubHeaderLeftMetadataViewV2.tsx +++ b/src/elements/common/sub-header/SubHeaderLeftV2.tsx @@ -6,18 +6,19 @@ import type API from '../../../api'; import type { Collection } from '../../../common/types/core'; import CloseButton from '../../../components/close-button/CloseButton'; import messages from '../messages'; -import './SubHeaderLeftMetadataViewV2.scss'; -interface SubHeaderLeftMetadataViewV2Props { +import './SubHeaderLeftV2.scss'; + +export interface SubHeaderLeftV2Props { api?: API; currentCollection: Collection; - metadataViewTitle?: string; + title?: string; onClearSelectedKeys?: () => void; selectedKeys: Selection; } -const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => { - const { currentCollection, metadataViewTitle, onClearSelectedKeys, selectedKeys } = props; +const SubHeaderLeftV2 = (props: SubHeaderLeftV2Props) => { + const { currentCollection, title, onClearSelectedKeys, selectedKeys } = props; const { formatMessage } = useIntl(); // Generate selected item text based on selected keys @@ -47,11 +48,8 @@ const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => // Case 1 and 2: selected item text with X button if (selectedItemText) { return ( -
- +
+ {selectedItemText}
); @@ -60,9 +58,9 @@ const SubHeaderLeftMetadataViewV2 = (props: SubHeaderLeftMetadataViewV2Props) => // Case 3: No selected items - show title return ( - {metadataViewTitle} + {title} ); }; -export default SubHeaderLeftMetadataViewV2; +export default SubHeaderLeftV2; diff --git a/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx b/src/elements/common/sub-header/__tests__/SubHeaderLeftV2.test.tsx similarity index 70% rename from src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx rename to src/elements/common/sub-header/__tests__/SubHeaderLeftV2.test.tsx index f7217d0539..9b0cff591d 100644 --- a/src/elements/common/sub-header/__tests__/SubHeaderLeftMetadataViewV2.test.tsx +++ b/src/elements/common/sub-header/__tests__/SubHeaderLeftV2.test.tsx @@ -1,26 +1,8 @@ import * as React from 'react'; -import type { Selection } from 'react-aria-components'; -import type API from '../../../../api'; -import type { Collection } from '../../../../common/types/core'; -import type { MetadataQuery } from '../../../../common/types/metadataQueries'; import { render, screen } from '../../../../test-utils/testing-library'; -import SubHeaderLeftMetadataViewV2 from '../SubHeaderLeftMetadataViewV2'; - -interface SubHeaderLeftMetadataViewV2Props { - api?: API; - currentCollection: Collection; - metadataQuery?: MetadataQuery; - metadataViewTitle?: string; - onClearSelectedKeys?: () => void; - selectedKeys: Selection; -} - -// Mock the API -const mockAPI = { - getFolderAPI: jest.fn(() => ({ - getFolderFields: jest.fn(), - })), -} as unknown as API; +import SubHeaderLeftV2 from '../SubHeaderLeftV2'; +import type { Collection } from '../../../../common/types/core'; +import type { SubHeaderLeftV2Props } from '../SubHeaderLeftV2'; const mockCollection: Collection = { items: [ @@ -30,24 +12,19 @@ const mockCollection: Collection = { ], }; -const defaultProps: SubHeaderLeftMetadataViewV2Props = { - api: mockAPI, +const defaultProps: SubHeaderLeftV2Props = { currentCollection: mockCollection, selectedKeys: new Set(), }; -const renderComponent = (props: Partial = {}) => - render(); - -describe('elements/common/sub-header/SubHeaderLeftMetadataViewV2', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); +const renderComponent = (props: Partial = {}) => + render(); +describe('elements/common/sub-header/SubHeaderLeftV2', () => { describe('when no items are selected', () => { - test('should render metadata view title when provided', () => { + test('should render metadata view title', () => { renderComponent({ - metadataViewTitle: 'Custom Metadata View', + title: 'Custom Metadata View', selectedKeys: new Set(), }); @@ -126,7 +103,7 @@ describe('elements/common/sub-header/SubHeaderLeftMetadataViewV2', () => { }); const container = screen.getByText('file1.txt').closest('div'); - expect(container).toHaveClass('SubHeaderLeftMetadataViewV2-selectedContainer'); + expect(container).toHaveClass('SubHeaderLeftV2-selectedContainer'); }); test('should render close button with correct CSS class', () => { @@ -135,17 +112,7 @@ describe('elements/common/sub-header/SubHeaderLeftMetadataViewV2', () => { }); const closeButton = screen.getByRole('button'); - expect(closeButton).toHaveClass('SubHeaderLeftMetadataViewV2-clearSelectedKeysButton'); - }); - - test('should render title with correct CSS class when no items selected', () => { - renderComponent({ - metadataViewTitle: 'Test Title', - selectedKeys: new Set(), - }); - - const title = screen.getByRole('heading', { level: 1 }); - expect(title).toBeInTheDocument(); + expect(closeButton).toHaveClass('SubHeaderLeftV2-clearSelectedKeysButton'); }); }); From e2c2e83dc5e4ae8cdfb4fbb2974a4f59470831f0 Mon Sep 17 00:00:00 2001 From: Jonathan Fox Date: Tue, 29 Jul 2025 16:30:58 -0700 Subject: [PATCH 12/14] chore: Remove unused api prop --- i18n/en-US.properties | 2 -- src/elements/common/sub-header/SubHeader.tsx | 4 ---- src/elements/common/sub-header/SubHeaderLeftV2.tsx | 2 -- src/elements/content-explorer/ContentExplorer.tsx | 1 - 4 files changed, 9 deletions(-) diff --git a/i18n/en-US.properties b/i18n/en-US.properties index 9b6d5ed13c..ebe51b3059 100644 --- a/i18n/en-US.properties +++ b/i18n/en-US.properties @@ -100,8 +100,6 @@ be.collaboratedFolder = Collaborated Folder be.collapse = Collapse # Label for complete state. be.complete = Complete -# Text shown to indicate the number of files selected -be.contentExplorer.numFilesSelected = {numSelected, plural, =0 {0 files selected} one {1 file selected} other {# files selected} } # Text shown to users when opening the content insights flyout and there is an error be.contentInsights.contentAnalyticsErrorText = There was a problem loading content insights. Please try again. # Message shown when the user does not have access to view content insights anymore diff --git a/src/elements/common/sub-header/SubHeader.tsx b/src/elements/common/sub-header/SubHeader.tsx index f1565104e3..e94e19e563 100644 --- a/src/elements/common/sub-header/SubHeader.tsx +++ b/src/elements/common/sub-header/SubHeader.tsx @@ -8,7 +8,6 @@ import SubHeaderLeft from './SubHeaderLeft'; import SubHeaderLeftV2 from './SubHeaderLeftV2'; import SubHeaderRight from './SubHeaderRight'; import type { ViewMode } from '../flowTypes'; -import type API from '../../../api'; import type { View, Collection } from '../../../common/types/core'; import { VIEW_MODE_LIST, VIEW_METADATA } from '../../../constants'; import { useFeatureEnabled } from '../feature-checking'; @@ -16,7 +15,6 @@ import { useFeatureEnabled } from '../feature-checking'; import './SubHeader.scss'; export interface SubHeaderProps { - api?: API; canCreateNewFolder: boolean; canUpload: boolean; currentCollection: Collection; @@ -42,7 +40,6 @@ export interface SubHeaderProps { } const SubHeader = ({ - api, canCreateNewFolder, canUpload, currentCollection, @@ -92,7 +89,6 @@ const SubHeader = ({ )} {isMetadataViewV2Feature && ( void; diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index 6c9dcfac98..c58a0973e3 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -1757,7 +1757,6 @@ class ContentExplorer extends Component { {!isDefaultViewMetadata &&
} Date: Wed, 30 Jul 2025 23:06:45 -0700 Subject: [PATCH 13/14] feat(metadata-view): Add Subheader for Metadata View v2 --- i18n/en-US.properties | 2 + src/elements/common/messages.js | 2 +- src/elements/common/sub-header/SubHeader.scss | 4 - src/elements/common/sub-header/SubHeader.tsx | 21 +++-- .../common/sub-header/SubHeaderLeftV2.scss | 8 +- .../common/sub-header/SubHeaderLeftV2.tsx | 39 +++++--- .../__tests__/SubHeaderLeftV2.test.tsx | 72 +++++--------- .../content-explorer/ContentExplorer.tsx | 94 +++++++++---------- yarn.lock | 7 -- 9 files changed, 108 insertions(+), 141 deletions(-) diff --git a/i18n/en-US.properties b/i18n/en-US.properties index ebe51b3059..8387456ac4 100644 --- a/i18n/en-US.properties +++ b/i18n/en-US.properties @@ -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 diff --git a/src/elements/common/messages.js b/src/elements/common/messages.js index dd00e3d7b4..54ee13dd30 100644 --- a/src/elements/common/messages.js +++ b/src/elements/common/messages.js @@ -1090,7 +1090,7 @@ const messages = defineMessages({ defaultMessage: 'Personal Folder', }, numFilesSelected: { - id: 'be.contentExplorer.numFilesSelected', + id: 'be.numFilesSelected', description: 'Text shown to indicate the number of files selected', defaultMessage: ` {numSelected, plural, diff --git a/src/elements/common/sub-header/SubHeader.scss b/src/elements/common/sub-header/SubHeader.scss index 829e4d6154..d8a3bc8bc0 100644 --- a/src/elements/common/sub-header/SubHeader.scss +++ b/src/elements/common/sub-header/SubHeader.scss @@ -20,8 +20,4 @@ .bce.be-is-small & { border-bottom: 0 none; } - - &.SubHeader--noPadding { - padding: 0; - } } diff --git a/src/elements/common/sub-header/SubHeader.tsx b/src/elements/common/sub-header/SubHeader.tsx index e94e19e563..7f3afbca73 100644 --- a/src/elements/common/sub-header/SubHeader.tsx +++ b/src/elements/common/sub-header/SubHeader.tsx @@ -23,8 +23,7 @@ export interface SubHeaderProps { gridMinColumns?: number; isSmall: boolean; maxGridColumnCountForWidth?: number; - metadataViewTitle?: string; - onClearSelectedKeys: () => void; + onClearSelectedItemIds: () => void; onCreate: () => void; onGridViewSliderChange?: (newSliderValue: number) => void; onItemClick: (id: string | null, triggerNavigationEvent: boolean | null) => void; @@ -34,7 +33,8 @@ export interface SubHeaderProps { portalElement?: HTMLElement; rootId: string; rootName?: string; - selectedKeys: Selection; + selectedItemIds: Selection; + title?: string; view: View; viewMode?: ViewMode; } @@ -47,10 +47,9 @@ const SubHeader = ({ gridMaxColumns = 0, gridMinColumns = 0, maxGridColumnCountForWidth = 0, - metadataViewTitle, onGridViewSliderChange = noop, isSmall, - onClearSelectedKeys, + onClearSelectedItemIds, onCreate, onItemClick, onSortChange, @@ -59,7 +58,8 @@ const SubHeader = ({ portalElement, rootId, rootName, - selectedKeys, + selectedItemIds, + title, view, viewMode = VIEW_MODE_LIST, }: SubHeaderProps) => { @@ -71,7 +71,7 @@ const SubHeader = ({ return ( @@ -90,9 +90,10 @@ const SubHeader = ({ {isMetadataViewV2Feature && ( )} diff --git a/src/elements/common/sub-header/SubHeaderLeftV2.scss b/src/elements/common/sub-header/SubHeaderLeftV2.scss index 9337192f6f..3725952d6b 100644 --- a/src/elements/common/sub-header/SubHeaderLeftV2.scss +++ b/src/elements/common/sub-header/SubHeaderLeftV2.scss @@ -1,9 +1,3 @@ -.SubHeaderLeftV2-selectedContainer { - display: flex; - align-items: center; +.be-sub-header-left-v2-selected { gap: 12px; } - -.SubHeaderLeftV2-clearSelectedKeysButton { - margin: 0; -} diff --git a/src/elements/common/sub-header/SubHeaderLeftV2.tsx b/src/elements/common/sub-header/SubHeaderLeftV2.tsx index d7a6fb3540..59aa82b62c 100644 --- a/src/elements/common/sub-header/SubHeaderLeftV2.tsx +++ b/src/elements/common/sub-header/SubHeaderLeftV2.tsx @@ -1,27 +1,28 @@ import React, { useMemo } from 'react'; import { useIntl } from 'react-intl'; -import { Text } from '@box/blueprint-web'; +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 CloseButton from '../../../components/close-button/CloseButton'; import messages from '../messages'; import './SubHeaderLeftV2.scss'; export interface SubHeaderLeftV2Props { currentCollection: Collection; + onClearSelectedItemIds?: () => void; + rootName?: string; + selectedItemIds: Selection; title?: string; - onClearSelectedKeys?: () => void; - selectedKeys: Selection; } const SubHeaderLeftV2 = (props: SubHeaderLeftV2Props) => { - const { currentCollection, title, onClearSelectedKeys, selectedKeys } = props; + const { currentCollection, onClearSelectedItemIds, rootName, selectedItemIds, title } = props; const { formatMessage } = useIntl(); // Generate selected item text based on selected keys const selectedItemText = useMemo(() => { - const selectedCount = selectedKeys === 'all' ? currentCollection.items.length : selectedKeys.size; + const selectedCount = selectedItemIds === 'all' ? currentCollection.items.length : selectedItemIds.size; if (typeof selectedCount !== 'number' || selectedCount === 0) { return ''; @@ -30,7 +31,7 @@ const SubHeaderLeftV2 = (props: SubHeaderLeftV2Props) => { // Case 1: Single selected item - show item name if (selectedCount === 1) { const selectedKey = - selectedKeys === 'all' ? currentCollection.items[0].id : selectedKeys.values().next().value; + 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; @@ -41,22 +42,32 @@ const SubHeaderLeftV2 = (props: SubHeaderLeftV2Props) => { return formatMessage(messages.numFilesSelected, { numSelected: selectedCount }); } return ''; - }, [currentCollection.items, formatMessage, selectedKeys]); + }, [currentCollection.items, formatMessage, selectedItemIds]); // Case 1 and 2: selected item text with X button if (selectedItemText) { return ( -
- - {selectedItemText} -
+ + + + + + + {selectedItemText} + + ); } - // Case 3: No selected items - show title + // Case 3: No selected items - show title if provided, otherwise show root name return ( - {title} + {title || rootName} ); }; diff --git a/src/elements/common/sub-header/__tests__/SubHeaderLeftV2.test.tsx b/src/elements/common/sub-header/__tests__/SubHeaderLeftV2.test.tsx index 9b0cff591d..4757090818 100644 --- a/src/elements/common/sub-header/__tests__/SubHeaderLeftV2.test.tsx +++ b/src/elements/common/sub-header/__tests__/SubHeaderLeftV2.test.tsx @@ -14,7 +14,7 @@ const mockCollection: Collection = { const defaultProps: SubHeaderLeftV2Props = { currentCollection: mockCollection, - selectedKeys: new Set(), + selectedItemIds: new Set(), }; const renderComponent = (props: Partial = {}) => @@ -22,20 +22,31 @@ const renderComponent = (props: Partial = {}) => describe('elements/common/sub-header/SubHeaderLeftV2', () => { describe('when no items are selected', () => { - test('should render metadata view title', () => { + test('should render title if provided', () => { renderComponent({ - title: 'Custom Metadata View', - selectedKeys: new Set(), + rootName: 'Custom Folder', + title: 'Custom Title', + selectedItemIds: new Set(), }); - expect(screen.getByText('Custom Metadata View')).toBeInTheDocument(); + 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({ - selectedKeys: new Set(['1']), + selectedItemIds: new Set(['1']), }); expect(screen.getByText('file1.txt')).toBeInTheDocument(); @@ -44,7 +55,7 @@ describe('elements/common/sub-header/SubHeaderLeftV2', () => { test('should render multiple selected items count', () => { renderComponent({ - selectedKeys: new Set(['1', '2']), + selectedItemIds: new Set(['1', '2']), }); expect(screen.getByText('2 files selected')).toBeInTheDocument(); @@ -53,30 +64,30 @@ describe('elements/common/sub-header/SubHeaderLeftV2', () => { test('should render all items selected count', () => { renderComponent({ - selectedKeys: 'all', + selectedItemIds: 'all', }); expect(screen.getByText('3 files selected')).toBeInTheDocument(); expect(screen.getByRole('button')).toBeInTheDocument(); // Close button }); - test('should call onClearSelectedKeys when close button is clicked', () => { - const mockOnClearSelectedKeys = jest.fn(); + test('should call onClearSelectedItemIds when close button is clicked', () => { + const mockOnClearSelectedItemIds = jest.fn(); renderComponent({ - selectedKeys: new Set(['1']), - onClearSelectedKeys: mockOnClearSelectedKeys, + selectedItemIds: new Set(['1']), + onClearSelectedItemIds: mockOnClearSelectedItemIds, }); const closeButton = screen.getByRole('button'); closeButton.click(); - expect(mockOnClearSelectedKeys).toHaveBeenCalledTimes(1); + expect(mockOnClearSelectedItemIds).toHaveBeenCalledTimes(1); }); test('should handle selected item not found in collection', () => { renderComponent({ - selectedKeys: new Set(['999']), // Non-existent ID + selectedItemIds: new Set(['999']), // Non-existent ID }); // Should not crash and should not render any selected item text @@ -88,42 +99,11 @@ describe('elements/common/sub-header/SubHeaderLeftV2', () => { test('should handle empty collection with selected items', () => { renderComponent({ currentCollection: { items: [] }, - selectedKeys: new Set(['1']), + selectedItemIds: new Set(['1']), }); // Should not crash and should not render any selected item text expect(screen.queryByText('file1.txt')).not.toBeInTheDocument(); }); }); - - describe('component structure', () => { - test('should render with correct CSS classes when items are selected', () => { - renderComponent({ - selectedKeys: new Set(['1']), - }); - - const container = screen.getByText('file1.txt').closest('div'); - expect(container).toHaveClass('SubHeaderLeftV2-selectedContainer'); - }); - - test('should render close button with correct CSS class', () => { - renderComponent({ - selectedKeys: new Set(['1']), - }); - - const closeButton = screen.getByRole('button'); - expect(closeButton).toHaveClass('SubHeaderLeftV2-clearSelectedKeysButton'); - }); - }); - - describe('edge cases', () => { - test('should handle zero selected items', () => { - renderComponent({ - selectedKeys: new Set(), - }); - - // Should render title instead of selected items - expect(screen.queryByRole('button')).not.toBeInTheDocument(); // No close button - }); - }); }); diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index c58a0973e3..46cf67c40b 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -134,7 +134,6 @@ export interface ContentExplorerProps { messages?: StringMap; metadataQuery?: MetadataQuery; metadataViewProps?: Omit; - metadataViewTitle?: string; onCreate?: (item: BoxItem) => void; onDelete?: (item: BoxItem) => void; onDownload?: (item: BoxItem) => void; @@ -154,6 +153,7 @@ export interface ContentExplorerProps { staticHost?: string; staticPath?: string; theme?: Theme; + title?: string; token: Token; uploadHost?: string; } @@ -174,12 +174,11 @@ type State = { isShareModalOpen: boolean; isUploadModalOpen: boolean; markers: Array; - metadataAncestorFolderName: string | null; metadataTemplate: MetadataTemplate; rootName: string; searchQuery: string; selected?: BoxItem; - selectedKeys: Selection; + selectedItemIds: Selection; sortBy: SortBy | string; sortDirection: SortDirection; view: View; @@ -242,7 +241,7 @@ class ContentExplorer extends Component { }, contentUploaderProps: {}, metadataViewProps: {}, - metadataViewTitle: '', + title: '', }; /** @@ -301,10 +300,9 @@ class ContentExplorer extends Component { isShareModalOpen: false, isUploadModalOpen: false, markers: [], - metadataAncestorFolderName: null, metadataTemplate: {}, rootName: '', - selectedKeys: new Set(), + selectedItemIds: new Set(), searchQuery: '', sortBy, sortDirection, @@ -351,7 +349,7 @@ class ContentExplorer extends Component { break; case DEFAULT_VIEW_METADATA: this.showMetadataQueryResults(); - this.fetchMetadataAncestorFolderName(metadataQuery?.ancestor_folder_id); + this.fetchFolderName(metadataQuery?.ancestor_folder_id); break; default: this.fetchFolder(currentFolderId); @@ -366,11 +364,8 @@ class ContentExplorer extends Component { * @inheritdoc * @return {void} */ - componentDidUpdate( - { currentFolderId: prevFolderId, metadataQuery: prevMetadataQuery }: ContentExplorerProps, - prevState: State, - ): void { - const { currentFolderId, metadataQuery }: ContentExplorerProps = this.props; + componentDidUpdate({ currentFolderId: prevFolderId }: ContentExplorerProps, prevState: State): void { + const { currentFolderId }: ContentExplorerProps = this.props; const { currentCollection: { id }, }: State = prevState; @@ -382,10 +377,6 @@ class ContentExplorer extends Component { if (typeof currentFolderId === 'string' && id !== currentFolderId) { this.fetchFolder(currentFolderId); } - - if (prevMetadataQuery?.ancestor_folder_id !== metadataQuery?.ancestor_folder_id) { - this.fetchMetadataAncestorFolderName(metadataQuery?.ancestor_folder_id); - } } /** @@ -1540,6 +1531,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 * @@ -1615,36 +1625,28 @@ class ContentExplorer extends Component { }); }; - handleClearSelectedKeys = () => { - this.setState({ selectedKeys: new Set() }); + clearSelectedItemIds = () => { + this.setState({ selectedItemIds: new Set() }); }; /** - * Fetches the metadata ancestor folder name + * Fetches the folder name and stores it in state rootName if successful * * @private * @return {void} */ - fetchMetadataAncestorFolderName = (ancestorFolderId?: string) => { - if (!ancestorFolderId) { - this.setState({ metadataAncestorFolderName: null }); - return; - } - - if (ancestorFolderId === '0') { - this.setState({ metadataAncestorFolderName: 'All Files' }); + fetchFolderName = (folderId?: string) => { + if (!folderId) { return; } this.api.getFolderAPI(false).getFolderFields( - ancestorFolderId, - (folderInfo: { name?: string }) => { - this.setState({ metadataAncestorFolderName: folderInfo.name ?? null }); - }, - () => { - this.setState({ metadataAncestorFolderName: null }); + folderId, + ({ name }) => { + this.setState({ rootName: name }); }, - { fields: ['name'] }, + this.errorCallback, + { fields: [FIELD_NAME] }, ); }; @@ -1681,8 +1683,6 @@ class ContentExplorer extends Component { measureRef, messages, fieldsToShow, - metadataViewProps, - metadataViewTitle, onDownload, onPreview, onUpload, @@ -1695,6 +1695,7 @@ class ContentExplorer extends Component { staticPath, previewLibraryVersion, theme, + title, token, uploadHost, }: ContentExplorerProps = this.props; @@ -1713,7 +1714,6 @@ class ContentExplorer extends Component { isShareModalOpen, isUploadModalOpen, markers, - metadataAncestorFolderName, metadataTemplate, rootName, selected, @@ -1734,17 +1734,7 @@ class ContentExplorer extends Component { const hasNextMarker: boolean = !!markers[currentPageNumber + 1]; const hasPreviousMarker: boolean = currentPageNumber === 1 || !!markers[currentPageNumber - 1]; - const combinedMetadataViewProps = { - ...metadataViewProps, - tableProps: { - ...metadataViewProps?.tableProps, - selectedKeys: this.state.selectedKeys, - onSelectionChange: (keys: Selection) => { - metadataViewProps?.tableProps?.onSelectionChange?.(keys); - this.setState({ selectedKeys: keys }); - }, - }, - }; + const metadataViewProps = this.getMetadataViewProps(); /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ @@ -1769,16 +1759,16 @@ class ContentExplorer extends Component { gridMaxColumns={GRID_VIEW_MAX_COLUMNS} gridMinColumns={GRID_VIEW_MIN_COLUMNS} maxGridColumnCountForWidth={maxGridColumnCount} - metadataViewTitle={metadataViewTitle || metadataAncestorFolderName} onUpload={this.upload} + onClearSelectedItemIds={this.clearSelectedItemIds} onCreate={this.createFolder} onGridViewSliderChange={this.onGridViewSliderChange} onItemClick={this.fetchFolder} onSortChange={this.sort} onViewModeChange={this.changeViewMode} portalElement={this.rootElement} - selectedKeys={this.state.selectedKeys} - onClearSelectedKeys={this.handleClearSelectedKeys} + selectedItemIds={this.state.selectedItemIds} + title={title} /> { itemActions={itemActions} fieldsToShow={fieldsToShow} metadataTemplate={metadataTemplate} - metadataViewProps={combinedMetadataViewProps} + metadataViewProps={metadataViewProps} onItemClick={this.onItemClick} onItemDelete={this.delete} onItemDownload={this.download} diff --git a/yarn.lock b/yarn.lock index 45753877fd..4b5ed6fb4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2311,13 +2311,6 @@ dependencies: "@swc/helpers" "^0.5.0" -"@internationalized/string@^3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@internationalized/string/-/string-3.2.7.tgz#76ae10f1e6e1fdaec7d0028a3f807d37a71bd2dd" - integrity sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A== - dependencies: - "@swc/helpers" "^0.5.0" - "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" From e3fdcb1266251128ab4c106494d47e4ddb13f2b6 Mon Sep 17 00:00:00 2001 From: Jonathan Fox Date: Thu, 31 Jul 2025 21:55:44 -0700 Subject: [PATCH 14/14] feat(metadata-view): Add Subheader for Metadata View v2 --- i18n/en-US.properties | 2 ++ src/elements/common/messages.js | 5 +++++ .../common/sub-header/SubHeaderLeftV2.scss | 4 ++-- .../common/sub-header/SubHeaderLeftV2.tsx | 16 +++++++--------- .../content-explorer/ContentExplorer.tsx | 1 - 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/i18n/en-US.properties b/i18n/en-US.properties index 8387456ac4..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 diff --git a/src/elements/common/messages.js b/src/elements/common/messages.js index 54ee13dd30..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.', diff --git a/src/elements/common/sub-header/SubHeaderLeftV2.scss b/src/elements/common/sub-header/SubHeaderLeftV2.scss index 3725952d6b..fd5fdc35d3 100644 --- a/src/elements/common/sub-header/SubHeaderLeftV2.scss +++ b/src/elements/common/sub-header/SubHeaderLeftV2.scss @@ -1,3 +1,3 @@ -.be-sub-header-left-v2-selected { - gap: 12px; +.be-SubHeaderLeftV2--selection { + gap: var(--space-3); } diff --git a/src/elements/common/sub-header/SubHeaderLeftV2.tsx b/src/elements/common/sub-header/SubHeaderLeftV2.tsx index 59aa82b62c..252c1f1ee3 100644 --- a/src/elements/common/sub-header/SubHeaderLeftV2.tsx +++ b/src/elements/common/sub-header/SubHeaderLeftV2.tsx @@ -21,10 +21,10 @@ const SubHeaderLeftV2 = (props: SubHeaderLeftV2Props) => { const { formatMessage } = useIntl(); // Generate selected item text based on selected keys - const selectedItemText = useMemo(() => { + const selectedItemText: string = useMemo(() => { const selectedCount = selectedItemIds === 'all' ? currentCollection.items.length : selectedItemIds.size; - if (typeof selectedCount !== 'number' || selectedCount === 0) { + if (selectedCount === 0) { return ''; } @@ -33,9 +33,7 @@ const SubHeaderLeftV2 = (props: SubHeaderLeftV2Props) => { 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; - } + return selectedItem?.name ?? ''; } // Case 2: Multiple selected items - show count if (selectedCount > 1) { @@ -47,10 +45,10 @@ const SubHeaderLeftV2 = (props: SubHeaderLeftV2Props) => { // Case 1 and 2: selected item text with X button if (selectedItemText) { return ( - + { - {selectedItemText} + {selectedItemText} ); @@ -67,7 +65,7 @@ const SubHeaderLeftV2 = (props: SubHeaderLeftV2Props) => { // Case 3: No selected items - show title if provided, otherwise show root name return ( - {title || rootName} + {title ?? rootName} ); }; diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index 46cf67c40b..404253121f 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -241,7 +241,6 @@ class ContentExplorer extends Component { }, contentUploaderProps: {}, metadataViewProps: {}, - title: '', }; /**