diff --git a/src/elements/content-sidebar/MetadataInstanceEditor.tsx b/src/elements/content-sidebar/MetadataInstanceEditor.tsx index c5884fdade..d03839e06e 100644 --- a/src/elements/content-sidebar/MetadataInstanceEditor.tsx +++ b/src/elements/content-sidebar/MetadataInstanceEditor.tsx @@ -16,6 +16,7 @@ export interface MetadataInstanceEditorProps { template: MetadataTemplateInstance; onSubmit: (values: FormValues, operations: JSONPatchOperations) => Promise; setIsUnsavedChangesModalOpen: (isUnsavedChangesModalOpen: boolean) => void; + onUnsavedChangesModalCancel: () => void; } const MetadataInstanceEditor: React.FC = ({ @@ -27,6 +28,7 @@ const MetadataInstanceEditor: React.FC = ({ setIsUnsavedChangesModalOpen, template, onCancel, + onUnsavedChangesModalCancel, }) => { const handleCancel = () => { onCancel(); @@ -43,6 +45,7 @@ const MetadataInstanceEditor: React.FC = ({ onSubmit={onSubmit} setIsUnsavedChangesModalOpen={setIsUnsavedChangesModalOpen} onDelete={onDelete} + onUnsavedChangesModalCancel={onUnsavedChangesModalCancel} /> ); diff --git a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx index b60b27000c..98c81c8473 100644 --- a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx +++ b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx @@ -90,19 +90,40 @@ function MetadataSidebarRedesign({ const [isDeleteButtonDisabled, setIsDeleteButtonDisabled] = React.useState(false); const [selectedTemplates, setSelectedTemplates] = React.useState>(templateInstances); + const [pendingTemplateToEdit, setPendingTemplateToEdit] = React.useState(null); React.useEffect(() => { setSelectedTemplates(templateInstances); }, [templateInstances]); - const handleUnsavedChanges = () => { - setIsUnsavedChangesModalOpen(true); + const handleTemplateSelect = (selectedTemplate: MetadataTemplate) => { + if (editingTemplate) { + setPendingTemplateToEdit(convertTemplateToTemplateInstance(file, selectedTemplate)); + setIsUnsavedChangesModalOpen(true); + } else { + setSelectedTemplates([...selectedTemplates, selectedTemplate]); + setEditingTemplate(convertTemplateToTemplateInstance(file, selectedTemplate)); + setIsDeleteButtonDisabled(true); + } }; - const handleTemplateSelect = (selectedTemplate: MetadataTemplate) => { - setSelectedTemplates([...selectedTemplates, selectedTemplate]); - setEditingTemplate(convertTemplateToTemplateInstance(file, selectedTemplate)); - setIsDeleteButtonDisabled(true); + const handleCancel = () => { + setEditingTemplate(null); + setSelectedTemplates(templateInstances); + }; + + const handleCancelUnsavedChanges = () => { + // check if user tried to edit another template before unsaved changes modal + if (pendingTemplateToEdit) { + setEditingTemplate(pendingTemplateToEdit); + setSelectedTemplates([...templateInstances, pendingTemplateToEdit]); + setIsDeleteButtonDisabled(true); + + setPendingTemplateToEdit(null); + setIsUnsavedChangesModalOpen(false); + } else { + handleCancel(); + } }; const handleDeleteInstance = (metadataInstance: MetadataTemplateInstance) => { @@ -128,9 +149,7 @@ function MetadataSidebarRedesign({ { - editingTemplate ? handleUnsavedChanges() : handleTemplateSelect(selectedTemplate); - }} + onSelect={handleTemplateSelect} /> ); @@ -166,7 +185,8 @@ function MetadataSidebarRedesign({ isBoxAiSuggestionsEnabled={isBoxAiSuggestionsEnabled} isDeleteButtonDisabled={isDeleteButtonDisabled} isUnsavedChangesModalOpen={isUnsavedChangesModalOpen} - onCancel={() => setEditingTemplate(null)} + onCancel={handleCancel} + onUnsavedChangesModalCancel={handleCancelUnsavedChanges} onSubmit={handleSubmit} onDelete={handleDeleteInstance} template={editingTemplate} diff --git a/src/elements/content-sidebar/__tests__/MetadataInstanceEditor.test.tsx b/src/elements/content-sidebar/__tests__/MetadataInstanceEditor.test.tsx index 3d56958ba1..a16ee03ba6 100644 --- a/src/elements/content-sidebar/__tests__/MetadataInstanceEditor.test.tsx +++ b/src/elements/content-sidebar/__tests__/MetadataInstanceEditor.test.tsx @@ -1,8 +1,13 @@ import React from 'react'; import { type MetadataTemplateInstance } from '@box/metadata-editor'; +import userEvent from '@testing-library/user-event'; import { screen, render } from '../../../test-utils/testing-library'; import MetadataInstanceEditor, { MetadataInstanceEditorProps } from '../MetadataInstanceEditor'; +const mockOnCancel = jest.fn(); +const mockOnUnsavedChangesModalCancel = jest.fn(); +const mockSetIsUnsavedChangesModalOpen = jest.fn(); + describe('MetadataInstanceEditor', () => { const mockCustomMetadataTemplate: MetadataTemplateInstance = { id: 'template-id', @@ -19,6 +24,25 @@ describe('MetadataInstanceEditor', () => { displayName: 'Template Name', canEdit: true, }; + + const mockCustomMetadataTemplateWithField: MetadataTemplateInstance = { + id: 'template-id', + fields: [ + { + id: '1', + type: 'string', + key: 'signature', + hidden: false, + displayName: 'Signature', + }, + ], + scope: 'global', + templateKey: 'customTemplate', + type: 'template-id', + hidden: false, + canEdit: true, + }; + const mockMetadataTemplateInstance: MetadataTemplateInstance = { ...mockCustomMetadataTemplate, displayName: 'Template Name', @@ -29,10 +53,11 @@ describe('MetadataInstanceEditor', () => { isDeleteButtonDisabled: false, isUnsavedChangesModalOpen: false, template: mockMetadataTemplate, - onCancel: jest.fn(), + onCancel: mockOnCancel, onDelete: jest.fn(), onSubmit: jest.fn(), - setIsUnsavedChangesModalOpen: jest.fn(), + setIsUnsavedChangesModalOpen: mockSetIsUnsavedChangesModalOpen, + onUnsavedChangesModalCancel: mockOnUnsavedChangesModalCancel, }; test('should render MetadataInstanceForm with correct props', () => { @@ -50,25 +75,11 @@ describe('MetadataInstanceEditor', () => { expect(templateHeader).toBeInTheDocument(); }); - test('should render UnsavedChangesModal if isUnsavedChangesModalOpen is true', () => { - // Mock window.matchMedia to simulate media query behavior for this test, - // as the UnsavedChangesModal component relies on it. - Object.defineProperty(window, 'matchMedia', { - writable: true, - value: jest.fn().mockImplementation(query => ({ - matches: false, - media: query, - onchange: null, - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), - }); - + test('should render UnsavedChangesModal if isUnsavedChangesModalOpen is true', async () => { const props = { ...defaultProps, isUnsavedChangesModalOpen: true }; - render(); + const { findByText } = render(); - const unsavedChangesModal = screen.getByText('Unsaved Changes'); + const unsavedChangesModal = await findByText('Unsaved Changes'); expect(unsavedChangesModal).toBeInTheDocument(); }); @@ -86,4 +97,40 @@ describe('MetadataInstanceEditor', () => { const deleteButton = screen.getByRole('button', { name: 'Delete' }); expect(deleteButton).toBeEnabled(); }); + + test('Should call onCancel when canceling editing', async () => { + const props: MetadataInstanceEditorProps = { ...defaultProps, template: mockCustomMetadataTemplate }; + const { findByRole } = render(); + const cancelButton = await findByRole('button', { name: 'Cancel' }); + + await userEvent.click(cancelButton); + + expect(mockOnCancel).toHaveBeenCalled(); + }); + + test('Should call onUnsavedChangesModalCancel instead onCancel when canceling through UnsavedChangesModal', async () => { + const props: MetadataInstanceEditorProps = { + ...defaultProps, + template: mockCustomMetadataTemplateWithField, + }; + const { rerender, findByRole, findByText } = render(); + const input = await findByRole('textbox'); + const cancelButton = await findByRole('button', { name: 'Cancel' }); + + await userEvent.type(input, 'Lorem ipsum dolor.'); + await userEvent.click(cancelButton); + + expect(mockOnCancel).not.toHaveBeenCalled(); + expect(mockSetIsUnsavedChangesModalOpen).toHaveBeenCalledWith(true); + + rerender(); + const unsavedChangesModal = await findByText('Unsaved Changes'); + + expect(unsavedChangesModal).toBeInTheDocument(); + const unsavedChangesModalCancelButton = await findByRole('button', { name: 'Cancel' }); + + await userEvent.click(unsavedChangesModalCancelButton); + + expect(mockOnUnsavedChangesModalCancel).toHaveBeenCalled(); + }); }); diff --git a/src/elements/content-sidebar/stories/tests/MetadataSidebarRedesign-visual.stories.tsx b/src/elements/content-sidebar/stories/tests/MetadataSidebarRedesign-visual.stories.tsx index a9c699137b..abec549672 100644 --- a/src/elements/content-sidebar/stories/tests/MetadataSidebarRedesign-visual.stories.tsx +++ b/src/elements/content-sidebar/stories/tests/MetadataSidebarRedesign-visual.stories.tsx @@ -248,3 +248,33 @@ export const DeleteButtonIsEnabledWhenEditingMetadataTemplateInstance: StoryObj< expect(deleteButton).toBeEnabled(); }, }; + +export const MetadataInstanceEditorAddTemplateAgainAfterCancel: StoryObj = { + args: { + fileId: '416047501580', + metadataSidebarProps: defaultMetadataSidebarProps, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const addTemplateButton = await canvas.findByRole('button', { name: 'Add template' }, { timeout: 5000 }); + + await userEvent.click(addTemplateButton); + + const templateMetadataOption = canvas.getByRole('option', { name: 'My Template' }); + expect(templateMetadataOption).not.toHaveAttribute('aria-disabled'); + await userEvent.click(templateMetadataOption); + + // Check if currently open template is disabled in dropdown + await userEvent.click(addTemplateButton); + const templateMetadataOptionDisabled = canvas.getByRole('option', { name: 'My Template' }); + expect(templateMetadataOptionDisabled).toHaveAttribute('aria-disabled'); + + // Check if template available again after cancelling + const cancelButton = await canvas.findByRole('button', { name: 'Cancel' }); + await userEvent.click(cancelButton); + await userEvent.click(addTemplateButton); + const templateMetadataOptionEnabled = canvas.getByRole('option', { name: 'My Template' }); + expect(templateMetadataOptionEnabled).not.toHaveAttribute('aria-disabled'); + }, +};