From 7a1372d061d93122524c0320760bbc82abc6a916 Mon Sep 17 00:00:00 2001 From: Jerry Jiang Date: Thu, 14 Aug 2025 17:22:45 -0500 Subject: [PATCH 1/4] feat(metadata-view): Implement metadata sidepanel --- src/elements/common/sub-header/SubHeader.tsx | 4 + .../common/sub-header/SubHeaderLeftV2.tsx | 25 +-- .../common/sub-header/SubHeaderRight.tsx | 11 +- .../content-explorer/ContentExplorer.scss | 17 ++ .../content-explorer/ContentExplorer.tsx | 191 +++++++++++------- .../content-explorer/MetadataSidePanel.scss | 22 ++ .../content-explorer/MetadataSidePanel.tsx | 133 ++++++++++++ .../__tests__/ContentExplorer.test.tsx | 95 +++++++-- .../__tests__/MetadataSidePanel.test.tsx | 127 ++++++++++++ .../tests/MetadataView-visual.stories.tsx | 26 +++ src/elements/content-explorer/utils.ts | 66 ++++++ 11 files changed, 607 insertions(+), 110 deletions(-) create mode 100644 src/elements/content-explorer/MetadataSidePanel.scss create mode 100644 src/elements/content-explorer/MetadataSidePanel.tsx create mode 100644 src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx create mode 100644 src/elements/content-explorer/utils.ts diff --git a/src/elements/common/sub-header/SubHeader.tsx b/src/elements/common/sub-header/SubHeader.tsx index 7f3afbca73..a58d1b9f80 100644 --- a/src/elements/common/sub-header/SubHeader.tsx +++ b/src/elements/common/sub-header/SubHeader.tsx @@ -28,6 +28,7 @@ export interface SubHeaderProps { onGridViewSliderChange?: (newSliderValue: number) => void; onItemClick: (id: string | null, triggerNavigationEvent: boolean | null) => void; onSortChange: (sortBy: string, sortDirection: string) => void; + onToggleMetadataSidePanel?: () => void; onUpload: () => void; onViewModeChange?: (viewMode: ViewMode) => void; portalElement?: HTMLElement; @@ -53,6 +54,7 @@ const SubHeader = ({ onCreate, onItemClick, onSortChange, + onToggleMetadataSidePanel, onUpload, onViewModeChange, portalElement, @@ -109,9 +111,11 @@ const SubHeader = ({ onCreate={onCreate} onGridViewSliderChange={onGridViewSliderChange} onSortChange={onSortChange} + onToggleMetadataSidePanel={onToggleMetadataSidePanel} onUpload={onUpload} onViewModeChange={onViewModeChange} portalElement={portalElement} + selectedItemIds={selectedItemIds} view={view} viewMode={viewMode} /> diff --git a/src/elements/common/sub-header/SubHeaderLeftV2.tsx b/src/elements/common/sub-header/SubHeaderLeftV2.tsx index 252c1f1ee3..0aa00f5445 100644 --- a/src/elements/common/sub-header/SubHeaderLeftV2.tsx +++ b/src/elements/common/sub-header/SubHeaderLeftV2.tsx @@ -1,8 +1,9 @@ -import React, { useMemo } from 'react'; +import * as React from 'react'; import { useIntl } from 'react-intl'; import { XMark } from '@box/blueprint-web-assets/icons/Fill/index'; import { IconButton, PageHeader, Text } from '@box/blueprint-web'; import type { Selection } from 'react-aria-components'; +import { useSelectedItemText } from '../../content-explorer/utils'; import type { Collection } from '../../../common/types/core'; import messages from '../messages'; @@ -20,27 +21,7 @@ const SubHeaderLeftV2 = (props: SubHeaderLeftV2Props) => { const { currentCollection, onClearSelectedItemIds, rootName, selectedItemIds, title } = props; const { formatMessage } = useIntl(); - // Generate selected item text based on selected keys - const selectedItemText: string = useMemo(() => { - const selectedCount = selectedItemIds === 'all' ? currentCollection.items.length : selectedItemIds.size; - - if (selectedCount === 0) { - return ''; - } - - // Case 1: Single selected item - show item name - if (selectedCount === 1) { - const selectedKey = - selectedItemIds === 'all' ? currentCollection.items[0].id : selectedItemIds.values().next().value; - const selectedItem = currentCollection.items.find(item => item.id === selectedKey); - return selectedItem?.name ?? ''; - } - // Case 2: Multiple selected items - show count - if (selectedCount > 1) { - return formatMessage(messages.numFilesSelected, { numSelected: selectedCount }); - } - return ''; - }, [currentCollection.items, formatMessage, selectedItemIds]); + const selectedItemText = useSelectedItemText(currentCollection, selectedItemIds); // Case 1 and 2: selected item text with X button if (selectedItemText) { diff --git a/src/elements/common/sub-header/SubHeaderRight.tsx b/src/elements/common/sub-header/SubHeaderRight.tsx index deac81b762..5226e195ea 100644 --- a/src/elements/common/sub-header/SubHeaderRight.tsx +++ b/src/elements/common/sub-header/SubHeaderRight.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Button } from '@box/blueprint-web'; import { Pencil } from '@box/blueprint-web-assets/icons/Fill'; import { useIntl } from 'react-intl'; +import type { Selection } from 'react-aria-components'; import Sort from './Sort'; import Add from './Add'; import GridViewSlider from '../../../components/grid-view/GridViewSlider'; @@ -27,9 +28,11 @@ export interface SubHeaderRightProps { onCreate: () => void; onGridViewSliderChange: (newSliderValue: number) => void; onSortChange: (sortBy: SortBy, sortDirection: SortDirection) => void; + onToggleMetadataSidePanel?: () => void; onUpload: () => void; onViewModeChange?: (viewMode: ViewMode) => void; portalElement?: HTMLElement; + selectedItemIds?: Selection; view: View; viewMode: ViewMode; } @@ -45,9 +48,11 @@ const SubHeaderRight = ({ onCreate, onGridViewSliderChange, onSortChange, + onToggleMetadataSidePanel, onUpload, onViewModeChange, portalElement, + selectedItemIds, view, viewMode, }: SubHeaderRightProps) => { @@ -60,6 +65,8 @@ const SubHeaderRight = ({ const showSort: boolean = isFolder && hasItems; const showAdd: boolean = (!!canUpload || !!canCreateNewFolder) && isFolder; const isMetadataView: boolean = view === VIEW_METADATA; + const isMetadataViewV2ItemSelected: boolean = + selectedItemIds && (selectedItemIds === 'all' || selectedItemIds.size > 0); return (
{!isMetadataView && ( @@ -90,8 +97,8 @@ const SubHeaderRight = ({ )} - {isMetadataView && isMetadataViewV2Feature && ( - )} diff --git a/src/elements/content-explorer/ContentExplorer.scss b/src/elements/content-explorer/ContentExplorer.scss index 5741e32036..ed41f40bbf 100644 --- a/src/elements/content-explorer/ContentExplorer.scss +++ b/src/elements/content-explorer/ContentExplorer.scss @@ -7,5 +7,22 @@ .bcpr { z-index: 1; // Prevents overlay issues with list-item when a file is previewed } + + .bce-container { + display: flex; + height: 100%; + min-height: 0; + } + + .bce-main { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + } + + .bce-sidepanel { + margin-left: var(--space-4); + } } } diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index 404253121f..bd38c3cbb6 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -24,6 +24,7 @@ import ThemingStyles from '../common/theming'; import API from '../../api'; import MetadataQueryAPIHelperV2 from './MetadataQueryAPIHelper'; import MetadataQueryAPIHelper from '../../features/metadata-based-view/MetadataQueryAPIHelper'; +import MetadataSidePanel from './MetadataSidePanel'; import Footer from './Footer'; import PreviewDialog from '../common/preview-dialog/PreviewDialog'; import ShareDialog from './ShareDialog'; @@ -169,6 +170,7 @@ type State = { isCreateFolderModalOpen: boolean; isDeleteModalOpen: boolean; isLoading: boolean; + isMetadataSidePanelOpen: boolean; isPreviewModalOpen: boolean; isRenameModalOpen: boolean; isShareModalOpen: boolean; @@ -294,6 +296,7 @@ class ContentExplorer extends Component { isCreateFolderModalOpen: false, isDeleteModalOpen: false, isLoading: false, + isMetadataSidePanelOpen: false, isPreviewModalOpen: false, isRenameModalOpen: false, isShareModalOpen: false, @@ -1543,7 +1546,11 @@ class ContentExplorer extends Component { selectedKeys: selectedItemIds, onSelectionChange: (ids: Selection) => { onSelectionChange?.(ids); - this.setState({ selectedItemIds: ids }); + const isSelectionEmpty = ids !== 'all' && ids.size === 0; + this.setState({ + selectedItemIds: ids, + ...(isSelectionEmpty && { isMetadataSidePanelOpen: false }), + }); }, }, }; @@ -1625,7 +1632,32 @@ class ContentExplorer extends Component { }; clearSelectedItemIds = () => { - this.setState({ selectedItemIds: new Set() }); + this.setState({ + selectedItemIds: new Set(), + isMetadataSidePanelOpen: false, + }); + }; + + /** + * Toggle metadata side panel visibility + * + * @private + * @return {void} + */ + toggleMetadataSidePanel = () => { + this.setState(prevState => ({ + isMetadataSidePanelOpen: !prevState.isMetadataSidePanelOpen, + })); + }; + + /** + * Close metadata side panel + * + * @private + * @return {void} + */ + closeMetadataSidePanel = () => { + this.setState({ isMetadataSidePanelOpen: false }); }; /** @@ -1725,6 +1757,7 @@ class ContentExplorer extends Component { const allowUpload: boolean = canUpload && !!can_upload; const allowCreate: boolean = canCreateNewFolder && !!can_upload; const isDefaultViewMetadata: boolean = defaultView === DEFAULT_VIEW_METADATA; + const isMetadataViewV2Feature = isFeatureEnabled(features, 'contentExplorer.metadataViewV2'); const isErrorView: boolean = view === VIEW_ERROR; const viewMode = this.getViewMode(); @@ -1743,76 +1776,96 @@ class ContentExplorer extends Component {
- {!isDefaultViewMetadata &&
} - - +
+
+ {!isDefaultViewMetadata && ( +
+ )} + + - - {!isErrorView && ( -
- -
- )} + + {!isErrorView && ( +
+ +
+ )} +
+ {isDefaultViewMetadata && + isMetadataViewV2Feature && + this.state.isMetadataSidePanelOpen && ( +
+ +
+ )} +
{allowUpload && !!this.appElement ? ( void; + metadataTemplate: MetadataTemplate; + selectedItemIds: Selection; +} + +const MetadataSidePanel = ({ + currentCollection, + closeMetadataSidePanel, + selectedItemIds, + metadataTemplate, +}: MetadataSidePanelProps) => { + const { formatMessage } = useIntl(); + const [editingTemplate, setEditingTemplate] = useState(false); + const [isUnsavedChangesModalOpen, setIsUnsavedChangesModalOpen] = useState(false); + + const selectedItemText = useSelectedItemText(currentCollection, selectedItemIds); + const selectedItems = + selectedItemIds === 'all' + ? currentCollection.items + : currentCollection.items.filter(item => selectedItemIds.has(item.id)); + const templateInstance = getTemplateInstance(metadataTemplate, selectedItems); + + const handleMetadataInstanceEdit = () => { + setEditingTemplate(true); + }; + + const handleMetadataInstanceFormCancel = () => { + setEditingTemplate(false); + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleMetadataInstanceFormChange = (values: FormValues) => { + // TODO: Implement on form change + }; + + const handleMetadataInstanceFormDiscardUnsavedChanges = () => { + setIsUnsavedChangesModalOpen(false); + setEditingTemplate(false); + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleMetadataInstanceFormSubmit = async (values: FormValues, operations: JSONPatchOperations) => { + // TODO: Implement onSave callback + }; + + return ( + + +
+
+ + {formatMessage(messages.sidebarMetadataTitle)} + +
+ + + {selectedItemText} + +
+
+ +
+
+ +
+ + {editingTemplate ? ( + + ) : ( + + )} + +
+
+
+ ); +}; + +export default MetadataSidePanel; diff --git a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx index 8b77e83f69..3999a71330 100644 --- a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx +++ b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx @@ -1,5 +1,7 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; +import { MetadataFieldType } from '@box/metadata-view'; + import { render, screen, waitFor, within } from '../../../test-utils/testing-library'; import { ContentExplorerComponent as ContentExplorer, ContentExplorerProps } from '../ContentExplorer'; import { mockRecentItems, mockRootFolder, mockRootFolderSharedLink } from '../../common/__mocks__/mockRootFolder'; @@ -77,7 +79,13 @@ describe('elements/content-explorer/ContentExplorer', () => { const renderComponent = ({ features, ...props }: Partial = {}) => { return render( - + , ); }; @@ -414,31 +422,84 @@ describe('elements/content-explorer/ContentExplorer', () => { expect(screen.getByText('Technology')).toBeInTheDocument(); expect(screen.getByText('November 16, 2023')).toBeInTheDocument(); }); + describe('Metadata View V2', () => { - test('should render metadata view button', async () => { - renderComponent({ - defaultView: 'metadata', - features: { - contentExplorer: { - metadataViewV2: true, - }, + const { scope: templateScope, templateKey } = mockSchema; + const metadataScopeAndKey = `${templateScope}.${templateKey}`; + const metadataFieldNamePrefix = `metadata.${metadataScopeAndKey}`; + const metadataQuery = { + from: metadataScopeAndKey, + ancestor_folder_id: '0', + sort_by: [ + { + field_key: `${metadataFieldNamePrefix}.${mockSchema.fields[0].key}`, // Default to sorting by the first field in the schema + direction: 'asc', }, - }); + ], + fields: [ + // Default to returning all fields in the metadata template schema, and name as a standalone (non-metadata) field + ...mockSchema.fields.map(field => `${metadataFieldNamePrefix}.${field.key}`), + 'name', + ], + }; + const fieldsToShow = [ + { key: `${metadataFieldNamePrefix}.name`, canEdit: false, displayName: 'Alias' }, + { key: `${metadataFieldNamePrefix}.industry`, canEdit: true }, + { key: `${metadataFieldNamePrefix}.last_contacted_at`, canEdit: true }, + { key: `${metadataFieldNamePrefix}.role`, canEdit: true }, + ]; + const columns = [ + { + // Always include the name column + textValue: 'Name', + id: 'name', + type: 'string' as const, + allowSorting: true, + minWidth: 150, + maxWidth: 150, + }, + ...mockSchema.fields.map(field => ({ + textValue: field.displayName, + id: `${metadataFieldNamePrefix}.${field.key}`, + type: field.type as MetadataFieldType, + allowSorting: true, + minWidth: 150, + maxWidth: 150, + })), + ]; + const defaultView = 'metadata'; + const metadataViewV2ElementProps = { + metadataViewProps: { + columns, + metadataTemplate: mockSchema, + tableProps: { + isSelectAllEnabled: true, + }, + }, + metadataQuery, + fieldsToShow, + defaultView, + features: { + contentExplorer: { + metadataViewV2: true, + }, + }, + }; - // two separate promises need to be resolved before the component is ready + test('should render metadata view button', async () => { + renderComponent(metadataViewV2ElementProps); await waitFor(() => { - expect(screen.getByText('Please wait while the items load...')).toBeInTheDocument(); + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); }); + expect(screen.queryByRole('button', { name: 'Switch to Grid View' })).toBeInTheDocument(); await waitFor(() => { - expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + expect(screen.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); }); - expect(screen.queryByRole('searchbox', { name: 'Search files and folders' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Preview Test Folder' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Switch to Grid View' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Sort' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument(); + const selectAllCheckbox = screen.getByLabelText('Select all'); + await userEvent.click(selectAllCheckbox); + expect(screen.getByRole('button', { name: 'Metadata' })).toBeInTheDocument(); }); }); diff --git a/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx b/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx new file mode 100644 index 0000000000..64f0a2e9bd --- /dev/null +++ b/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx @@ -0,0 +1,127 @@ +import * as React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '../../../test-utils/testing-library'; +import MetadataSidePanel, { MetadataSidePanelProps } from '../MetadataSidePanel'; + +// Mock scrollTo method +Object.defineProperty(Element.prototype, 'scrollTo', { + value: jest.fn(), + writable: true, +}); + +const mockCollection = { + items: [ + { + id: '1', + name: 'Test File 1.pdf', + type: 'file', + metadata: { + enterprise_123: { + mockTemplate: { + alias: 'mock-alias-1', + }, + }, + }, + }, + { + id: '2', + name: 'Test File 2.docx', + type: 'file', + metadata: { + enterprise_123: { + mockTemplate: { + alias: 'mock-alias-2', + }, + }, + }, + }, + ], + nextMarker: null, + offset: 0, + totalCount: 2, +}; + +const mockMetadataTemplate = { + id: 'template-id', + displayName: 'Mock Template', + scope: 'enterprise_123', + templateKey: 'mockTemplate', + type: 'metadata_template', + hidden: false, + fields: [ + { + id: '123', + key: 'alias', + displayName: 'Alias', + type: 'string', + hidden: false, + options: [], + }, + ], +}; + +const mockCloseMetadataSidePanel = jest.fn(); + +describe('elements/content-explorer/MetadataSidePanel', () => { + const defaultProps: MetadataSidePanelProps = { + currentCollection: mockCollection, + closeMetadataSidePanel: mockCloseMetadataSidePanel, + metadataTemplate: mockMetadataTemplate, + selectedItemIds: new Set(['1']), + }; + + const renderComponent = (props: Partial = {}) => + render(); + + test('renders the metadata title', () => { + renderComponent(); + expect(screen.getByText('Metadata')).toBeInTheDocument(); + }); + + test('renders the close button with proper aria-label', () => { + renderComponent(); + const closeButton = screen.getByLabelText('Clear selection'); + expect(closeButton).toBeInTheDocument(); + }); + + test('renders the selected item text', () => { + renderComponent(); + expect(screen.getByText('Test File 1.pdf')).toBeInTheDocument(); + }); + + test('renders metadata instance (view mode) by default', () => { + renderComponent(); + const editTemplateButton = screen.getByLabelText('Edit Mock Template'); + expect(editTemplateButton).toBeInTheDocument(); + }); + + test('renders field value of selected item', () => { + renderComponent(); + const fieldValue = screen.getByText('mock-alias-1'); + expect(fieldValue).toBeInTheDocument(); + }); + + test('call closeMetadataSidePanel when close button is clicked', async () => { + renderComponent(); + const closeButton = screen.getByLabelText('Clear selection'); + await userEvent.click(closeButton); + expect(mockCloseMetadataSidePanel).toHaveBeenCalledTimes(1); + }); + + test('render correct subtitle when multiple items are selected', () => { + renderComponent({ selectedItemIds: new Set(['1', '2']) }); + const subtitle = screen.getByText('2 files selected'); + expect(subtitle).toBeInTheDocument(); + }); + + test('render cancel and submit button when in edit mode', async () => { + renderComponent(); + const editTemplateButton = screen.getByLabelText('Edit Mock Template'); + await userEvent.click(editTemplateButton); + + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + expect(cancelButton).toBeInTheDocument(); + const submitButton = screen.getByRole('button', { name: 'Save' }); + expect(submitButton).toBeInTheDocument(); + }); +}); diff --git a/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx b/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx index 93cecdca8a..97f6ceedfe 100644 --- a/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx +++ b/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx @@ -163,6 +163,32 @@ export const metadataViewV2WithInitialFilterValues: Story = { }, }; +export const sidePanelOpenWithSingleItemSelected: Story = { + args: { + ...metadataViewV2ElementProps, + metadataViewProps: { + columns, + tableProps: { + isSelectAllEnabled: true, + }, + }, + }, + + play: async ({ canvas }) => { + await waitFor(() => { + expect(canvas.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); + }); + + // Select the first row by clicking its checkbox + const firstRow = canvas.getByRole('row', { name: /Child 2/i }); + const checkbox = within(firstRow).getByRole('checkbox'); + await userEvent.click(checkbox); + + const metadataButton = canvas.getByRole('button', { name: 'Metadata' }); + await userEvent.click(metadataButton); + }, +}; + const meta: Meta = { title: 'Elements/ContentExplorer/tests/MetadataView/visual', component: ContentExplorer, diff --git a/src/elements/content-explorer/utils.ts b/src/elements/content-explorer/utils.ts new file mode 100644 index 0000000000..aa66320b00 --- /dev/null +++ b/src/elements/content-explorer/utils.ts @@ -0,0 +1,66 @@ +import { useMemo } from 'react'; +import { useIntl } from 'react-intl'; + +import { MetadataTemplate } from '@box/metadata-editor'; +import type { Selection } from 'react-aria-components'; +import type { BoxItem, Collection } from '../../common/types/core'; + +import messages from '../common/messages'; + +// Get selected item text +export function useSelectedItemText(currentCollection: Collection, selectedItemIds: Selection): string { + const { formatMessage } = useIntl(); + + return useMemo(() => { + const selectedCount = selectedItemIds === 'all' ? currentCollection.items.length : selectedItemIds.size; + if (selectedCount === 0) return ''; + + // Case 1: Single selected item - show item name + if (selectedCount === 1) { + const selectedKey = + selectedItemIds === 'all' ? currentCollection.items[0].id : selectedItemIds.values().next().value; + const selectedItem = currentCollection.items.find(item => item.id === selectedKey); + return selectedItem?.name ?? ''; + } + + // Case 2: Multiple selected items - show count + return formatMessage(messages.numFilesSelected, { numSelected: selectedCount }); + }, [currentCollection.items, formatMessage, selectedItemIds]); +} + +// Get template instance based on metadata template and selected items +export function getTemplateInstance(metadataTemplate: MetadataTemplate, selectedItems: BoxItem[]) { + const { displayName, fields, hidden, id, scope, templateKey, type } = metadataTemplate; + + const selectedItemsFields = fields.map(field => { + const { + displayName: fieldDisplayName, + hidden: fieldHidden, + id: fieldId, + key, + options, + type: fieldType, + } = field; + return { + displayName: fieldDisplayName, + hidden: fieldHidden, + id: fieldId, + key, + options, + type: fieldType, + // TODO: Add support for multiple selected items + value: selectedItems[0].metadata[scope][templateKey][key], + }; + }); + + return { + canEdit: true, + displayName, + hidden, + id, + fields: selectedItemsFields, + scope, + templateKey, + type, + }; +} From 61a4281fe8bd4204124def3cfc2670911ed140b4 Mon Sep 17 00:00:00 2001 From: Jerry Jiang Date: Fri, 15 Aug 2025 16:16:24 -0500 Subject: [PATCH 2/4] feat(metadata-view): resolve comments --- .../__tests__/ContentExplorer.test.tsx | 2 +- src/elements/content-explorer/utils.ts | 18 +++++------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx index 3999a71330..72294d6af7 100644 --- a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx +++ b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; -import { MetadataFieldType } from '@box/metadata-view'; +import type { MetadataFieldType } from '@box/metadata-view'; import { render, screen, waitFor, within } from '../../../test-utils/testing-library'; import { ContentExplorerComponent as ContentExplorer, ContentExplorerProps } from '../ContentExplorer'; diff --git a/src/elements/content-explorer/utils.ts b/src/elements/content-explorer/utils.ts index aa66320b00..4c3abf0c6c 100644 --- a/src/elements/content-explorer/utils.ts +++ b/src/elements/content-explorer/utils.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useIntl } from 'react-intl'; -import { MetadataTemplate } from '@box/metadata-editor'; +import type { MetadataTemplate } from '@box/metadata-editor'; import type { Selection } from 'react-aria-components'; import type { BoxItem, Collection } from '../../common/types/core'; @@ -32,16 +32,8 @@ export function useSelectedItemText(currentCollection: Collection, selectedItemI export function getTemplateInstance(metadataTemplate: MetadataTemplate, selectedItems: BoxItem[]) { const { displayName, fields, hidden, id, scope, templateKey, type } = metadataTemplate; - const selectedItemsFields = fields.map(field => { - const { - displayName: fieldDisplayName, - hidden: fieldHidden, - id: fieldId, - key, - options, - type: fieldType, - } = field; - return { + const selectedItemsFields = fields.map( + ({ displayName: fieldDisplayName, hidden: fieldHidden, id: fieldId, key, options, type: fieldType }) => ({ displayName: fieldDisplayName, hidden: fieldHidden, id: fieldId, @@ -50,8 +42,8 @@ export function getTemplateInstance(metadataTemplate: MetadataTemplate, selected type: fieldType, // TODO: Add support for multiple selected items value: selectedItems[0].metadata[scope][templateKey][key], - }; - }); + }), + ); return { canEdit: true, From 057f5b862717d4359a8e2cc5a0db2610d0b51fba Mon Sep 17 00:00:00 2001 From: Jerry Jiang Date: Mon, 18 Aug 2025 13:07:00 -0500 Subject: [PATCH 3/4] feat(metadata-view): resolve comments --- .../common/sub-header/SubHeaderRight.tsx | 6 +- .../content-explorer/ContentExplorer.scss | 13 +- .../content-explorer/ContentExplorer.tsx | 174 +++++++++--------- .../content-explorer/MetadataSidePanel.scss | 14 +- .../content-explorer/MetadataSidePanel.tsx | 46 +++-- .../__tests__/ContentExplorer.test.tsx | 7 +- .../__tests__/MetadataSidePanel.test.tsx | 4 +- 7 files changed, 124 insertions(+), 140 deletions(-) diff --git a/src/elements/common/sub-header/SubHeaderRight.tsx b/src/elements/common/sub-header/SubHeaderRight.tsx index 5226e195ea..9472d750ac 100644 --- a/src/elements/common/sub-header/SubHeaderRight.tsx +++ b/src/elements/common/sub-header/SubHeaderRight.tsx @@ -65,8 +65,10 @@ const SubHeaderRight = ({ const showSort: boolean = isFolder && hasItems; const showAdd: boolean = (!!canUpload || !!canCreateNewFolder) && isFolder; const isMetadataView: boolean = view === VIEW_METADATA; - const isMetadataViewV2ItemSelected: boolean = - selectedItemIds && (selectedItemIds === 'all' || selectedItemIds.size > 0); + const isMetadataViewV2ItemSelected: boolean = !!( + selectedItemIds && + (selectedItemIds === 'all' || selectedItemIds.size > 0) + ); return (
{!isMetadataView && ( diff --git a/src/elements/content-explorer/ContentExplorer.scss b/src/elements/content-explorer/ContentExplorer.scss index ed41f40bbf..ca1fcf948a 100644 --- a/src/elements/content-explorer/ContentExplorer.scss +++ b/src/elements/content-explorer/ContentExplorer.scss @@ -8,21 +8,16 @@ z-index: 1; // Prevents overlay issues with list-item when a file is previewed } - .bce-container { - display: flex; - height: 100%; - min-height: 0; + .be-app-element { + flex-direction: row; + gap: var(--space-4); } - .bce-main { + .bce-ContentExplorer-main { display: flex; flex: 1; flex-direction: column; min-width: 0; } - - .bce-sidepanel { - margin-left: var(--space-4); - } } } diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index 7811b42679..10454ce394 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -1738,6 +1738,7 @@ class ContentExplorer extends Component { isCreateFolderModalOpen, isDeleteModalOpen, isLoading, + isMetadataSidePanelOpen, isPreviewModalOpen, isRenameModalOpen, isShareModalOpen, @@ -1746,6 +1747,7 @@ class ContentExplorer extends Component { metadataTemplate, rootName, selected, + selectedItemIds, view, }: State = this.state; @@ -1774,96 +1776,90 @@ class ContentExplorer extends Component {
-
-
- {!isDefaultViewMetadata && ( -
- )} - - - - - - {!isErrorView && ( -
- -
- )} -
- {isDefaultViewMetadata && - isMetadataViewV2Feature && - this.state.isMetadataSidePanelOpen && ( -
- -
- )} +
+ {!isDefaultViewMetadata && ( +
+ )} + + + + + + {!isErrorView && ( +
+ +
+ )}
+ {isDefaultViewMetadata && isMetadataViewV2Feature && isMetadataSidePanelOpen && ( + + )}
{allowUpload && !!this.appElement ? ( void; + onClose: () => void; metadataTemplate: MetadataTemplate; selectedItemIds: Selection; } const MetadataSidePanel = ({ currentCollection, - closeMetadataSidePanel, + onClose, selectedItemIds, metadataTemplate, }: MetadataSidePanelProps) => { const { formatMessage } = useIntl(); - const [editingTemplate, setEditingTemplate] = useState(false); + const [isEditing, setIsEditing] = useState(false); const [isUnsavedChangesModalOpen, setIsUnsavedChangesModalOpen] = useState(false); const selectedItemText = useSelectedItemText(currentCollection, selectedItemIds); @@ -46,11 +46,11 @@ const MetadataSidePanel = ({ const templateInstance = getTemplateInstance(metadataTemplate, selectedItems); const handleMetadataInstanceEdit = () => { - setEditingTemplate(true); + setIsEditing(true); }; const handleMetadataInstanceFormCancel = () => { - setEditingTemplate(false); + setIsEditing(false); }; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -60,7 +60,7 @@ const MetadataSidePanel = ({ const handleMetadataInstanceFormDiscardUnsavedChanges = () => { setIsUnsavedChangesModalOpen(false); - setEditingTemplate(false); + setIsEditing(false); }; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -71,30 +71,28 @@ const MetadataSidePanel = ({ return ( -
-
- - {formatMessage(messages.sidebarMetadataTitle)} +
+ + {formatMessage(messages.sidebarMetadataTitle)} + +
+ + + {selectedItemText} -
- - - {selectedItemText} - -
-
+ -
+
- {editingTemplate ? ( + {isEditing ? ( { const metadataFieldNamePrefix = `metadata.${metadataScopeAndKey}`; const metadataQuery = { from: metadataScopeAndKey, - ancestor_folder_id: '0', + ancestor_folder_id: '69083462919', sort_by: [ { field_key: `${metadataFieldNamePrefix}.${mockSchema.fields[0].key}`, // Default to sorting by the first field in the schema @@ -491,7 +491,10 @@ describe('elements/content-explorer/ContentExplorer', () => { await waitFor(() => { expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); }); - expect(screen.queryByRole('button', { name: 'Switch to Grid View' })).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Switch to Grid View' })).toBeInTheDocument(); + }); await waitFor(() => { expect(screen.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); diff --git a/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx b/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx index 64f0a2e9bd..9342f2d4dc 100644 --- a/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx +++ b/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import userEvent from '@testing-library/user-event'; import { render, screen } from '../../../test-utils/testing-library'; -import MetadataSidePanel, { MetadataSidePanelProps } from '../MetadataSidePanel'; +import MetadataSidePanel, { type MetadataSidePanelProps } from '../MetadataSidePanel'; // Mock scrollTo method Object.defineProperty(Element.prototype, 'scrollTo', { @@ -65,7 +65,7 @@ const mockCloseMetadataSidePanel = jest.fn(); describe('elements/content-explorer/MetadataSidePanel', () => { const defaultProps: MetadataSidePanelProps = { currentCollection: mockCollection, - closeMetadataSidePanel: mockCloseMetadataSidePanel, + onClose: mockCloseMetadataSidePanel, metadataTemplate: mockMetadataTemplate, selectedItemIds: new Set(['1']), }; From 23d8f1c7432a78a9c4576aea56de4a1e233d7c01 Mon Sep 17 00:00:00 2001 From: Jerry Jiang Date: Mon, 18 Aug 2025 14:20:44 -0500 Subject: [PATCH 4/4] feat(metadata-view): resolve nits --- src/elements/common/sub-header/SubHeader.tsx | 6 +++--- src/elements/common/sub-header/SubHeaderRight.tsx | 13 +++++-------- src/elements/content-explorer/ContentExplorer.tsx | 4 ++-- src/elements/content-explorer/MetadataSidePanel.tsx | 7 +------ .../__tests__/MetadataSidePanel.test.tsx | 12 ++++++------ 5 files changed, 17 insertions(+), 25 deletions(-) diff --git a/src/elements/common/sub-header/SubHeader.tsx b/src/elements/common/sub-header/SubHeader.tsx index a58d1b9f80..004f9421ef 100644 --- a/src/elements/common/sub-header/SubHeader.tsx +++ b/src/elements/common/sub-header/SubHeader.tsx @@ -28,7 +28,7 @@ export interface SubHeaderProps { onGridViewSliderChange?: (newSliderValue: number) => void; onItemClick: (id: string | null, triggerNavigationEvent: boolean | null) => void; onSortChange: (sortBy: string, sortDirection: string) => void; - onToggleMetadataSidePanel?: () => void; + onMetadataSidePanelToggle?: () => void; onUpload: () => void; onViewModeChange?: (viewMode: ViewMode) => void; portalElement?: HTMLElement; @@ -54,7 +54,7 @@ const SubHeader = ({ onCreate, onItemClick, onSortChange, - onToggleMetadataSidePanel, + onMetadataSidePanelToggle, onUpload, onViewModeChange, portalElement, @@ -111,7 +111,7 @@ const SubHeader = ({ onCreate={onCreate} onGridViewSliderChange={onGridViewSliderChange} onSortChange={onSortChange} - onToggleMetadataSidePanel={onToggleMetadataSidePanel} + onMetadataSidePanelToggle={onMetadataSidePanelToggle} onUpload={onUpload} onViewModeChange={onViewModeChange} portalElement={portalElement} diff --git a/src/elements/common/sub-header/SubHeaderRight.tsx b/src/elements/common/sub-header/SubHeaderRight.tsx index 9472d750ac..017071162a 100644 --- a/src/elements/common/sub-header/SubHeaderRight.tsx +++ b/src/elements/common/sub-header/SubHeaderRight.tsx @@ -28,7 +28,7 @@ export interface SubHeaderRightProps { onCreate: () => void; onGridViewSliderChange: (newSliderValue: number) => void; onSortChange: (sortBy: SortBy, sortDirection: SortDirection) => void; - onToggleMetadataSidePanel?: () => void; + onMetadataSidePanelToggle?: () => void; onUpload: () => void; onViewModeChange?: (viewMode: ViewMode) => void; portalElement?: HTMLElement; @@ -48,7 +48,7 @@ const SubHeaderRight = ({ onCreate, onGridViewSliderChange, onSortChange, - onToggleMetadataSidePanel, + onMetadataSidePanelToggle, onUpload, onViewModeChange, portalElement, @@ -65,10 +65,7 @@ const SubHeaderRight = ({ const showSort: boolean = isFolder && hasItems; const showAdd: boolean = (!!canUpload || !!canCreateNewFolder) && isFolder; const isMetadataView: boolean = view === VIEW_METADATA; - const isMetadataViewV2ItemSelected: boolean = !!( - selectedItemIds && - (selectedItemIds === 'all' || selectedItemIds.size > 0) - ); + const hasSelectedItems: boolean = !!(selectedItemIds && (selectedItemIds === 'all' || selectedItemIds.size > 0)); return (
{!isMetadataView && ( @@ -99,8 +96,8 @@ const SubHeaderRight = ({ )} - {isMetadataView && isMetadataViewV2Feature && isMetadataViewV2ItemSelected && ( - )} diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index 10454ce394..fecd0588bc 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -1663,7 +1663,7 @@ class ContentExplorer extends Component { * @private * @return {void} */ - toggleMetadataSidePanel = () => { + onMetadataSidePanelToggle = () => { this.setState(prevState => ({ isMetadataSidePanelOpen: !prevState.isMetadataSidePanelOpen, })); @@ -1800,7 +1800,7 @@ class ContentExplorer extends Component { onGridViewSliderChange={this.onGridViewSliderChange} onItemClick={this.fetchFolder} onSortChange={this.sort} - onToggleMetadataSidePanel={this.toggleMetadataSidePanel} + onMetadataSidePanelToggle={this.onMetadataSidePanelToggle} onViewModeChange={this.changeViewMode} portalElement={this.rootElement} selectedItemIds={selectedItemIds} diff --git a/src/elements/content-explorer/MetadataSidePanel.tsx b/src/elements/content-explorer/MetadataSidePanel.tsx index 1fd10826a2..707b841a8e 100644 --- a/src/elements/content-explorer/MetadataSidePanel.tsx +++ b/src/elements/content-explorer/MetadataSidePanel.tsx @@ -82,12 +82,7 @@ const MetadataSidePanel = ({
- +
diff --git a/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx b/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx index 9342f2d4dc..7b811b38e0 100644 --- a/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx +++ b/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx @@ -60,12 +60,12 @@ const mockMetadataTemplate = { ], }; -const mockCloseMetadataSidePanel = jest.fn(); +const mockOnClose = jest.fn(); describe('elements/content-explorer/MetadataSidePanel', () => { const defaultProps: MetadataSidePanelProps = { currentCollection: mockCollection, - onClose: mockCloseMetadataSidePanel, + onClose: mockOnClose, metadataTemplate: mockMetadataTemplate, selectedItemIds: new Set(['1']), }; @@ -80,7 +80,7 @@ describe('elements/content-explorer/MetadataSidePanel', () => { test('renders the close button with proper aria-label', () => { renderComponent(); - const closeButton = screen.getByLabelText('Clear selection'); + const closeButton = screen.getByLabelText('Close'); expect(closeButton).toBeInTheDocument(); }); @@ -101,11 +101,11 @@ describe('elements/content-explorer/MetadataSidePanel', () => { expect(fieldValue).toBeInTheDocument(); }); - test('call closeMetadataSidePanel when close button is clicked', async () => { + test('call onClose when close button is clicked', async () => { renderComponent(); - const closeButton = screen.getByLabelText('Clear selection'); + const closeButton = screen.getByLabelText('Close'); await userEvent.click(closeButton); - expect(mockCloseMetadataSidePanel).toHaveBeenCalledTimes(1); + expect(mockOnClose).toHaveBeenCalledTimes(1); }); test('render correct subtitle when multiple items are selected', () => {