From 96e5d3ad9bebee86cf151aee353dc55ea45b0200 Mon Sep 17 00:00:00 2001 From: jfox-box Date: Wed, 8 Oct 2025 14:47:50 -0700 Subject: [PATCH] feat(content-explorer): Disable selection while editing --- package.json | 4 +- src/elements/common/sub-header/SubHeader.tsx | 6 +- .../common/sub-header/SubHeaderRight.tsx | 6 +- src/elements/content-explorer/Content.tsx | 5 +- .../content-explorer/ContentExplorer.tsx | 105 +++++++++++------- .../content-explorer/MetadataSidePanel.tsx | 13 ++- .../MetadataViewContainer.tsx | 3 + .../__tests__/MetadataSidePanel.test.tsx | 38 +++++-- .../tests/MetadataView-visual.stories.tsx | 58 ++++++++++ src/elements/content-sidebar/SidebarPanels.js | 5 +- .../hooks/useSidebarMetadataFetcher.ts | 22 ++-- yarn.lock | 8 +- 12 files changed, 194 insertions(+), 79 deletions(-) diff --git a/package.json b/package.json index fdf4c393ba..096f4e0a21 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,7 @@ "@box/languages": "^1.0.0", "@box/metadata-editor": "^0.122.12", "@box/metadata-filter": "^1.30.1", - "@box/metadata-view": "^0.54.0", + "@box/metadata-view": "^0.59.0", "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/types": "^0.2.1", "@box/unified-share-modal": "^0.52.0", @@ -307,7 +307,7 @@ "@box/item-icon": "^0.27.1", "@box/metadata-editor": "^0.122.12", "@box/metadata-filter": "^1.30.1", - "@box/metadata-view": "^0.54.0", + "@box/metadata-view": "^0.59.0", "@box/react-virtualized": "^9.22.3-rc-box.10", "@box/types": "^0.2.1", "@box/unified-share-modal": "^0.52.0", diff --git a/src/elements/common/sub-header/SubHeader.tsx b/src/elements/common/sub-header/SubHeader.tsx index 38ec37f76b..91f3a5d30c 100644 --- a/src/elements/common/sub-header/SubHeader.tsx +++ b/src/elements/common/sub-header/SubHeader.tsx @@ -30,7 +30,7 @@ export interface SubHeaderProps { onGridViewSliderChange?: (newSliderValue: number) => void; onItemClick: (id: string | null, triggerNavigationEvent: boolean | null) => void; onSortChange: (sortBy: string, sortDirection: string) => void; - onMetadataSidePanelToggle?: () => void; + onSidePanelToggle?: () => void; onUpload: () => void; onViewModeChange?: (viewMode: ViewMode) => void; portalElement?: HTMLElement; @@ -57,7 +57,7 @@ const SubHeader = ({ onCreate, onItemClick, onSortChange, - onMetadataSidePanelToggle, + onSidePanelToggle, onUpload, onViewModeChange, portalElement, @@ -115,7 +115,7 @@ const SubHeader = ({ onCreate={onCreate} onGridViewSliderChange={onGridViewSliderChange} onSortChange={onSortChange} - onMetadataSidePanelToggle={onMetadataSidePanelToggle} + onSidePanelToggle={onSidePanelToggle} 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 e9d736d1cc..ec6898e261 100644 --- a/src/elements/common/sub-header/SubHeaderRight.tsx +++ b/src/elements/common/sub-header/SubHeaderRight.tsx @@ -32,7 +32,7 @@ export interface SubHeaderRightProps { onCreate: () => void; onGridViewSliderChange: (newSliderValue: number) => void; onSortChange: (sortBy: SortBy, sortDirection: SortDirection) => void; - onMetadataSidePanelToggle?: () => void; + onSidePanelToggle?: () => void; onUpload: () => void; onViewModeChange?: (viewMode: ViewMode) => void; portalElement?: HTMLElement; @@ -53,7 +53,7 @@ const SubHeaderRight = ({ onCreate, onGridViewSliderChange, onSortChange, - onMetadataSidePanelToggle, + onSidePanelToggle, onUpload, onViewModeChange, portalElement, @@ -107,7 +107,7 @@ const SubHeaderRight = ({ {bulkItemActions && bulkItemActions.length > 0 && ( )} - diff --git a/src/elements/content-explorer/Content.tsx b/src/elements/content-explorer/Content.tsx index a4bcb6e83d..70d178959c 100644 --- a/src/elements/content-explorer/Content.tsx +++ b/src/elements/content-explorer/Content.tsx @@ -32,6 +32,7 @@ export interface ContentProps extends Required, Required, Required; onMetadataFilter?: (fields: ExternalFilterValues) => void; onMetadataUpdate: ( @@ -59,6 +60,7 @@ const Content = ({ features, fieldsToShow = [], gridColumnCount, + isEditing = false, metadataTemplate, metadataViewProps, onMetadataFilter, @@ -94,6 +96,7 @@ const Content = ({ currentCollection={currentCollection} isLoading={percentLoaded !== 100} hasError={view === VIEW_ERROR} + isEditing={isEditing} metadataTemplate={metadataTemplate} onMetadataFilter={onMetadataFilter} onSortChange={onSortChange} diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index 194c70949f..0a470fe0a0 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -171,6 +171,12 @@ export interface ContentExplorerProps { uploadHost?: string; } +enum SidePanelState { + CLOSED = 'CLOSED', + OPEN = 'OPEN', + EDITING = 'EDITING', +} + type State = { currentCollection: Collection; currentOffset: number; @@ -182,7 +188,6 @@ type State = { isCreateFolderModalOpen: boolean; isDeleteModalOpen: boolean; isLoading: boolean; - isMetadataSidePanelOpen: boolean; isPreviewModalOpen: boolean; isRenameModalOpen: boolean; isShareModalOpen: boolean; @@ -194,6 +199,7 @@ type State = { searchQuery: string; selected?: BoxItem; selectedItemIds: Selection; + sidePanelState: SidePanelState; sortBy: SortBy | string; sortDirection: SortDirection; view: View; @@ -310,7 +316,7 @@ class ContentExplorer extends Component { isCreateFolderModalOpen: false, isDeleteModalOpen: false, isLoading: false, - isMetadataSidePanelOpen: false, + sidePanelState: SidePanelState.CLOSED, isPreviewModalOpen: false, isRenameModalOpen: false, isShareModalOpen: false, @@ -1024,21 +1030,17 @@ class ContentExplorer extends Component { validateSelectedItemIds = (items: BoxItem[]): void => { const { selectedItemIds } = this.state; - if (selectedItemIds === 'all' || selectedItemIds.size === 0) { - // If all/none items are selected, no need to change anything - return; - } - - const validSelectedIds = new Set(); + let validSelectedIds = new Set(); - items.forEach(item => { - if (selectedItemIds.has(item.id)) { - validSelectedIds.add(item.id); - } - }); + if (selectedItemIds === 'all') { + validSelectedIds = new Set(items.map(item => item.id)); + } else if (selectedItemIds.size > 0) { + validSelectedIds = new Set(items.filter(item => selectedItemIds.has(item.id)).map(item => item.id)); + } if (!isEqual(validSelectedIds, selectedItemIds)) { - this.setState({ selectedItemIds: validSelectedIds }); + this.handleSelectedIdsChange(validSelectedIds, true); + this.closeSidePanel(); } }; @@ -1511,6 +1513,7 @@ class ContentExplorer extends Component { isShareModalOpen: false, isUploadModalOpen: false, isPreviewModalOpen: false, + sidePanelState: SidePanelState.CLOSED, }); const { @@ -1652,7 +1655,6 @@ class ContentExplorer extends Component { 'hasError' | 'currentCollection' | 'metadataTemplate' > => { const { metadataViewProps } = this.props; - const { onSelectionChange } = metadataViewProps ?? {}; const { currentPageNumber, markers, selectedItemIds } = this.state; const hasNextMarker: boolean = !!markers[currentPageNumber + 1]; const hasPrevMarker: boolean = currentPageNumber === 1 || !!markers[currentPageNumber - 1]; @@ -1660,14 +1662,7 @@ class ContentExplorer extends Component { return { ...metadataViewProps, selectedKeys: selectedItemIds, - onSelectionChange: (ids: Selection) => { - onSelectionChange?.(ids); - const isSelectionEmpty = ids !== 'all' && ids.size === 0; - this.setState({ - selectedItemIds: ids, - ...(isSelectionEmpty && { isMetadataSidePanelOpen: false }), - }); - }, + onSelectionChange: this.handleSelectedIdsChange, paginationProps: { onMarkerBasedPageChange: this.markerBasedPaginate, hasNextMarker, @@ -1754,11 +1749,28 @@ class ContentExplorer extends Component { }); }; - clearSelectedItemIds = () => { + handleSelectedIdsChange = (ids: Selection, allowDuringEditing: boolean = false) => { + const { metadataViewProps } = this.props; + const { onSelectionChange: onSelectionChangeExternal } = metadataViewProps; + + if (!allowDuringEditing && this.state.sidePanelState === SidePanelState.EDITING) { + return; + } + + onSelectionChangeExternal?.(ids); + this.setState({ - selectedItemIds: new Set(), - isMetadataSidePanelOpen: false, + selectedItemIds: ids, }); + + const isSelectionEmpty = ids !== 'all' && ids.size === 0; + if (isSelectionEmpty) { + this.closeSidePanel(); + } + }; + + clearSelectedItemIds = () => { + this.handleSelectedIdsChange(new Set(), true); }; /** @@ -1767,20 +1779,27 @@ class ContentExplorer extends Component { * @private * @return {void} */ - onMetadataSidePanelToggle = () => { + onSidePanelToggle = () => { this.setState(prevState => ({ - isMetadataSidePanelOpen: !prevState.isMetadataSidePanelOpen, + sidePanelState: + prevState.sidePanelState === SidePanelState.CLOSED ? SidePanelState.OPEN : SidePanelState.CLOSED, })); }; - /** - * Close metadata side panel - * - * @private - * @return {void} - */ - closeMetadataSidePanel = () => { - this.setState({ isMetadataSidePanelOpen: false }); + closeSidePanel = () => { + this.setState({ + sidePanelState: SidePanelState.CLOSED, + }); + }; + + onSidePanelEditingChange = (isEditing: boolean) => { + const { sidePanelState } = this.state; + + if (sidePanelState !== SidePanelState.CLOSED) { + this.setState({ + sidePanelState: isEditing ? SidePanelState.EDITING : SidePanelState.OPEN, + }); + } }; filterMetadata = (fields: ExternalFilterValues) => { @@ -1848,7 +1867,7 @@ class ContentExplorer extends Component { isCreateFolderModalOpen, isDeleteModalOpen, isLoading, - isMetadataSidePanelOpen, + sidePanelState, isPreviewModalOpen, isRenameModalOpen, isShareModalOpen, @@ -1878,6 +1897,9 @@ class ContentExplorer extends Component { const metadataViewProps = this.getMetadataViewProps(); + const isSidePanelOpen = sidePanelState !== SidePanelState.CLOSED; + const isEditing = sidePanelState === SidePanelState.EDITING; + /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ return ( @@ -1911,7 +1933,7 @@ class ContentExplorer extends Component { onGridViewSliderChange={this.onGridViewSliderChange} onItemClick={this.fetchFolder} onSortChange={this.sort} - onMetadataSidePanelToggle={this.onMetadataSidePanelToggle} + onSidePanelToggle={this.onSidePanelToggle} onViewModeChange={this.changeViewMode} portalElement={this.rootElement} selectedItemIds={selectedItemIds} @@ -1927,6 +1949,7 @@ class ContentExplorer extends Component { currentCollection={currentCollection} features={features} gridColumnCount={Math.min(gridColumnCount, maxGridColumnCount)} + isEditing={isEditing} isMedium={isMedium} isSmall={isSmall} isTouch={isTouch} @@ -1964,11 +1987,13 @@ class ContentExplorer extends Component { )} - {isDefaultViewMetadata && isMetadataViewV2Feature && isMetadataSidePanelOpen && ( + {isDefaultViewMetadata && isMetadataViewV2Feature && isSidePanelOpen && ( void; onClose: () => void; onUpdate: ( items: BoxItem[], @@ -41,6 +43,8 @@ export interface MetadataSidePanelProps { const MetadataSidePanel = ({ currentCollection, metadataTemplate, + isEditing, + onEditingChange, onClose, onUpdate, refreshCollection, @@ -48,7 +52,6 @@ const MetadataSidePanel = ({ }: MetadataSidePanelProps) => { const { addNotification } = useNotification(); const { formatMessage } = useIntl(); - const [isEditing, setIsEditing] = useState(false); const [isUnsavedChangesModalOpen, setIsUnsavedChangesModalOpen] = useState(false); const selectedItemText = useSelectedItemText(currentCollection, selectedItemIds); @@ -59,16 +62,16 @@ const MetadataSidePanel = ({ const templateInstance = useTemplateInstance(metadataTemplate, selectedItems, isEditing); const handleMetadataInstanceEdit = () => { - setIsEditing(true); + onEditingChange(true); }; const handleMetadataInstanceFormCancel = () => { - setIsEditing(false); + onEditingChange(false); }; const handleMetadataInstanceFormDiscardUnsavedChanges = () => { setIsUnsavedChangesModalOpen(false); - setIsEditing(false); + onEditingChange(false); }; const handleUpdateMetadataSuccess = () => { @@ -81,7 +84,7 @@ const MetadataSidePanel = ({ typeIconAriaLabel: formatMessage(messages.success), variant: 'success', }); - setIsEditing(false); + onEditingChange(false); refreshCollection(); }; diff --git a/src/elements/content-explorer/MetadataViewContainer.tsx b/src/elements/content-explorer/MetadataViewContainer.tsx index 99221d3da1..c409d520f3 100644 --- a/src/elements/content-explorer/MetadataViewContainer.tsx +++ b/src/elements/content-explorer/MetadataViewContainer.tsx @@ -108,6 +108,7 @@ function transformInternalFieldsToPublic(fields: FilterValues): ExternalFilterVa export interface MetadataViewContainerProps extends Omit { actionBarProps?: ActionBarProps; currentCollection: Collection; + isEditing?: boolean; metadataTemplate: MetadataTemplate; onMetadataFilter: (fields: ExternalFilterValues) => void; /* Internally controlled onSortChange prop for the MetadataView component. */ @@ -118,6 +119,7 @@ const MetadataViewContainer = ({ actionBarProps, columns, currentCollection, + isEditing = false, metadataTemplate, onMetadataFilter, onSortChange: onSortChangeInternal, @@ -265,6 +267,7 @@ const MetadataViewContainer = ({ columns={newColumns} items={items} tableProps={newTableProps} + areSelectionCheckboxesDisabled={isEditing} {...rest} /> ); diff --git a/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx b/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx index e02b4e0a0a..322a173a67 100644 --- a/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx +++ b/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx @@ -63,8 +63,30 @@ const mockMetadataTemplate = { const mockOnClose = jest.fn(); +const TestWrapper = ({ + initialProps, + onStateChange, +}: { + initialProps: Omit; + onStateChange?: (isEditing: boolean) => void; +}) => { + const [isEditing, setIsEditing] = React.useState(false); + + const handleEditingChange = (editing: boolean) => { + setIsEditing(editing); + onStateChange?.(editing); + }; + + return ( + + + + + ); +}; + describe('elements/content-explorer/MetadataSidePanel', () => { - const defaultProps: MetadataSidePanelProps = { + const defaultProps: Omit = { currentCollection: mockCollection, metadataTemplate: mockMetadataTemplate, onClose: mockOnClose, @@ -73,13 +95,13 @@ describe('elements/content-explorer/MetadataSidePanel', () => { selectedItemIds: new Set(['1']), }; - const renderComponent = (props: Partial = {}) => - render( - - - - , - ); + const renderComponent = ( + props: Partial> = {}, + onStateChange?: (isEditing: boolean) => void, + ) => { + const mergedProps = { ...defaultProps, ...props }; + return render(); + }; test('renders the metadata title', () => { renderComponent(); 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 226f17f4d8..fde40d11c3 100644 --- a/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx +++ b/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx @@ -250,6 +250,64 @@ export const sidePanelOpenWithMultipleItemsSelected: Story = { }, }; +export const disableSelectionInEditMode: Story = { + args: { + ...metadataViewV2ElementProps, + + metadataViewProps: { + columns, + isSelectionEnabled: true, + }, + }, + play: async ({ canvas }) => { + await waitFor(() => { + expect(canvas.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); + }); + + // Start editing + await userEvent.click(canvas.getByLabelText('Select all')); + await userEvent.click(canvas.getByRole('button', { name: 'Metadata' })); + await userEvent.click(canvas.getByLabelText('Edit templateName')); + expect(canvas.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(canvas.getByRole('button', { name: 'Save' })).toBeInTheDocument(); + + // Verify checkboxes are disabled + expect(canvas.getByLabelText('Select all')).toBeDisabled(); + expect(within(canvas.getByRole('row', { name: /Child 2/i })).getByRole('checkbox')).toBeDisabled(); + }, +}; + +export const clearSelectionInEditMode: Story = { + args: { + ...metadataViewV2ElementProps, + metadataViewProps: { + columns, + isSelectionEnabled: true, + }, + }, + play: async ({ canvas }) => { + await waitFor(() => { + expect(canvas.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); + }); + + // Start editing + await userEvent.click(canvas.getByLabelText('Select all')); + await userEvent.click(canvas.getByRole('button', { name: 'Metadata' })); + await userEvent.click(canvas.getByLabelText('Edit templateName')); + + // Clear selection in subheader + await userEvent.click(canvas.getByLabelText('Clear selection')); + + // Verify sidebar is closed and no items are selected + expect(canvas.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument(); + expect(canvas.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument(); + expect(canvas.queryByText('Mock Template')).not.toBeInTheDocument(); + + expect(canvas.getByLabelText('Select all')).not.toBeChecked(); + expect(within(canvas.getByRole('row', { name: /Child 2/i })).getByRole('checkbox')).not.toBeChecked(); + }, +}; + const meta: Meta = { title: 'Elements/ContentExplorer/tests/MetadataView/visual', component: ContentExplorer, diff --git a/src/elements/content-sidebar/SidebarPanels.js b/src/elements/content-sidebar/SidebarPanels.js index e860dc8f98..1e6a854796 100644 --- a/src/elements/content-sidebar/SidebarPanels.js +++ b/src/elements/content-sidebar/SidebarPanels.js @@ -173,7 +173,10 @@ class SidebarPanels extends React.Component { } }; - setBoxAiSidebarCacheValue = (key: 'agents' | 'encodedSession' | 'questions' | 'shouldShowLandingPage' | 'suggestedQuestions', value: any) => { + setBoxAiSidebarCacheValue = ( + key: 'agents' | 'encodedSession' | 'questions' | 'shouldShowLandingPage' | 'suggestedQuestions', + value: any, + ) => { this.boxAiSidebarCache[key] = value; }; diff --git a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts index 7bc231fac2..fda69bd954 100644 --- a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts +++ b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts @@ -179,18 +179,16 @@ function useSidebarMetadataFetcher( const handleCreateMetadataInstance = React.useCallback( async (templateInstance: MetadataTemplateInstance, successCallback: () => void): Promise => { - await api - .getMetadataAPI(false) - .createMetadataRedesign( - file, - templateInstance, - () => { - successCallback(); - onSuccess(SUCCESS_CODE_CREATE_METADATA_TEMPLATE_INSTANCE, true); - }, - (error: ElementsXhrError, code: string) => - onApiError(error, code, messages.sidebarMetadataEditingErrorContent), - ); + await api.getMetadataAPI(false).createMetadataRedesign( + file, + templateInstance, + () => { + successCallback(); + onSuccess(SUCCESS_CODE_CREATE_METADATA_TEMPLATE_INSTANCE, true); + }, + (error: ElementsXhrError, code: string) => + onApiError(error, code, messages.sidebarMetadataEditingErrorContent), + ); }, [api, file, onApiError, onSuccess], ); diff --git a/yarn.lock b/yarn.lock index c5cc9d0f80..b6592901a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1522,10 +1522,10 @@ resolved "https://registry.yarnpkg.com/@box/metadata-filter/-/metadata-filter-1.30.1.tgz#8bf2fdd8d0f68c725fc4ea98e86ebdc41194d15c" integrity sha512-QiPXQQdKkr3l80+oxdT1hJaQ6CjrHD3PGdSZ17okiJxN3UEqmfBjUchMAb9H4yH+wwGkc32ndUCCfyI7NvMwHw== -"@box/metadata-view@^0.54.0": - version "0.54.0" - resolved "https://registry.yarnpkg.com/@box/metadata-view/-/metadata-view-0.54.0.tgz#8d83d3b0e562fcaef17e3315205b32a6219fd4bb" - integrity sha512-NO9+w7DnyrriehdXGuqrLuORhXKCYaCZMqzZH7mElMD+tV2Oi89oo9PAQ7st/VxXbrYJIAhsn6gJoGvWsLOyVQ== +"@box/metadata-view@^0.59.0": + version "0.59.0" + resolved "https://registry.yarnpkg.com/@box/metadata-view/-/metadata-view-0.59.0.tgz#0081af7baea0745d5909f0053e876017d6de9c8d" + integrity sha512-CIPW7aMog4K1QZyaXMqCnn7N+Gwi1YXr0HvnPs3Tl9dB+OGdLXH3u1IuX9nyh2L6ZHCNvdzHzhlEq4g+R+Klqg== "@box/react-virtualized@^9.22.3-rc-box.10": version "9.22.3-rc-box.10"