diff --git a/src/api/Metadata.js b/src/api/Metadata.js index 4a29dc64d0..7a5b8faaa6 100644 --- a/src/api/Metadata.js +++ b/src/api/Metadata.js @@ -783,10 +783,10 @@ class Metadata extends File { * API for deleting metadata on file * * @param {BoxItem} file - File object for which we are changing the description - * @param {string} scope - Metadata instance scope * @param {string} template - Metadata template key * @param {Function} successCallback - Success callback * @param {Function} errorCallback - Error callback + * @param isMetadataRedesign * @return {Promise} */ async deleteMetadata( @@ -794,6 +794,7 @@ class Metadata extends File { template: MetadataTemplate, successCallback: Function, errorCallback: ElementsErrorCallback, + isMetadataRedesign: boolean = false, ): Promise { this.errorCode = ERROR_CODE_DELETE_METADATA; if (!file || !template) { @@ -826,12 +827,21 @@ class Metadata extends File { const cache: APICache = this.getCache(); const key = this.getMetadataCacheKey(id); const metadata = cache.get(key); - metadata.editors.splice( - metadata.editors.findIndex( - editor => editor.template.scope === scope && editor.template.templateKey === templateKey, - ), - 1, - ); + if (isMetadataRedesign) { + metadata.templateInstances.splice( + metadata.templateInstances.findIndex( + instance => instance.scope === scope && instance.templateKey === templateKey, + ), + 1, + ); + } else { + metadata.editors.splice( + metadata.editors.findIndex( + editor => editor.template.scope === scope && editor.template.templateKey === templateKey, + ), + 1, + ); + } this.successHandler(); } } catch (e) { diff --git a/src/api/__tests__/Metadata.test.js b/src/api/__tests__/Metadata.test.js index 236840f705..8bfec0417e 100644 --- a/src/api/__tests__/Metadata.test.js +++ b/src/api/__tests__/Metadata.test.js @@ -193,6 +193,7 @@ describe('api/Metadata', () => { ], id: '123456', templateKey: 'instance_from_template', + scope: 'enterprise', }, true, ), @@ -219,7 +220,7 @@ describe('api/Metadata', () => { value: '2.1', }, ], - scope: undefined, + scope: 'enterprise', templateKey: 'instance_from_template', }); }); @@ -2161,6 +2162,50 @@ describe('api/Metadata', () => { editors: [], }); }); + test('should make request and update metadataInstances cache if isMetadataRedesign', async () => { + const success = jest.fn(); + const error = jest.fn(); + const file = { + id: 'id', + permissions: { + can_upload: true, + }, + }; + const cache = new Cache(); + const template = { id: '123', scope: 'scope', templateKey: 'templateKey' }; + + cache.set('metadata_id', { + templateInstances: [{ ...template, templateId: '123' }], + }); + + metadata.getMetadataUrl = jest.fn().mockReturnValueOnce('url'); + metadata.xhr.delete = jest.fn().mockReturnValueOnce({ data: 'foo' }); + metadata.isDestroyed = jest.fn().mockReturnValueOnce(false); + metadata.getCache = jest.fn().mockReturnValueOnce(cache); + metadata.getCacheKey = jest.fn().mockReturnValueOnce('cache_id'); + metadata.getMetadataCacheKey = jest.fn().mockReturnValueOnce('metadata_id'); + metadata.merge = jest.fn().mockReturnValueOnce('file'); + metadata.successHandler = jest.fn(); + metadata.errorHandler = jest.fn(); + + await metadata.deleteMetadata(file, template, success, error, true); + + expect(metadata.successCallback).toBe(success); + expect(metadata.errorCallback).toBe(error); + expect(metadata.getMetadataUrl).toHaveBeenCalledWith(file.id, 'scope', 'templateKey'); + expect(metadata.xhr.delete).toHaveBeenCalledWith({ + url: 'url', + id: 'file_id', + }); + expect(metadata.isDestroyed).toHaveBeenCalled(); + expect(metadata.getCache).toHaveBeenCalled(); + expect(metadata.getMetadataCacheKey).toHaveBeenCalledWith(file.id); + expect(metadata.successHandler).toHaveBeenCalled(); + expect(metadata.errorHandler).not.toHaveBeenCalled(); + expect(cache.get('metadata_id')).toEqual({ + templateInstances: [], + }); + }); test('should make request but not update cache or call success handler when destroyed', async () => { const success = jest.fn(); const error = jest.fn(); diff --git a/src/elements/content-sidebar/MetadataInstanceEditor.tsx b/src/elements/content-sidebar/MetadataInstanceEditor.tsx index bda6322443..efb597bdf9 100644 --- a/src/elements/content-sidebar/MetadataInstanceEditor.tsx +++ b/src/elements/content-sidebar/MetadataInstanceEditor.tsx @@ -5,13 +5,15 @@ import React from 'react'; export interface MetadataInstanceEditorProps { isBoxAiSuggestionsEnabled: boolean; isUnsavedChangesModalOpen: boolean; - template: MetadataTemplateInstance; onCancel: () => void; + onDelete: (metadataInstance: MetadataTemplateInstance) => void; + template: MetadataTemplateInstance; } const MetadataInstanceEditor: React.FC = ({ isBoxAiSuggestionsEnabled, isUnsavedChangesModalOpen, + onDelete, template, onCancel, }) => { @@ -21,9 +23,6 @@ const MetadataInstanceEditor: React.FC = ({ const handleCancel = () => { onCancel(); }; - const handleDelete = () => { - // TODO in a future PR - }; return ( @@ -32,7 +31,7 @@ const MetadataInstanceEditor: React.FC = ({ isLoading={false} selectedTemplateInstance={template} onCancel={handleCancel} - onDelete={handleDelete} + onDelete={onDelete} onSubmit={handleSubmit} isUnsavedChangesModalOpen={isUnsavedChangesModalOpen} setIsUnsavedChangesModalOpen={noop} diff --git a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx index 10f6372b85..7298617f74 100644 --- a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx +++ b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx @@ -69,12 +69,8 @@ function MetadataSidebarRedesign({ onError, isFeatureEnabled, }: MetadataSidebarRedesignProps) { - const { file, templates, errorMessage, status, templateInstances } = useSidebarMetadataFetcher( - api, - fileId, - onError, - isFeatureEnabled, - ); + const { handleDeleteMetadataInstance, file, templates, errorMessage, status, templateInstances } = + useSidebarMetadataFetcher(api, fileId, onError, isFeatureEnabled); const { formatMessage } = useIntl(); const [editingTemplate, setEditingTemplate] = React.useState(null); const [isUnsavedChangesModalOpen, setIsUnsavedChangesModalOpen] = React.useState(false); @@ -94,6 +90,11 @@ function MetadataSidebarRedesign({ setEditingTemplate(selectedTemplate); }; + const handleDeleteInstance = (metadataInstance: MetadataTemplateInstance) => { + handleDeleteMetadataInstance(metadataInstance); + setEditingTemplate(null); + }; + const metadataDropdown = status === STATUS.SUCCESS && templates && ( setEditingTemplate(null)} + onDelete={handleDeleteInstance} /> )} {showList && ( diff --git a/src/elements/content-sidebar/__tests__/MetadataInstanceEditor.test.tsx b/src/elements/content-sidebar/__tests__/MetadataInstanceEditor.test.tsx index b9b7789031..1f312749f6 100644 --- a/src/elements/content-sidebar/__tests__/MetadataInstanceEditor.test.tsx +++ b/src/elements/content-sidebar/__tests__/MetadataInstanceEditor.test.tsx @@ -24,6 +24,7 @@ describe('MetadataInstanceEditor', () => { isUnsavedChangesModalOpen: false, template: mockMetadataTemplateInstance, onCancel: jest.fn(), + onDelete: jest.fn(), }; test('should render MetadataInstanceForm with correct props', () => { diff --git a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx index 8c53c2f199..e49d9ea64d 100644 --- a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx +++ b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx @@ -69,6 +69,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { beforeEach(() => { mockUseSidebarMetadataFetcher.mockReturnValue({ + handleDeleteMetadataInstance: jest.fn(), templates: mockTemplates, templateInstances: [], errorMessage: null, @@ -111,6 +112,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { test('should render metadata sidebar with error', async () => { mockUseSidebarMetadataFetcher.mockReturnValue({ + handleDeleteMetadataInstance: jest.fn(), templateInstances: [], templates: [], errorMessage: { @@ -130,6 +132,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { test('should render metadata sidebar with loading indicator', async () => { mockUseSidebarMetadataFetcher.mockReturnValue({ + handleDeleteMetadataInstance: jest.fn(), templateInstances: [], templates: [], errorMessage: null, @@ -164,6 +167,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { test('should render metadata instance list when templates are present', () => { mockUseSidebarMetadataFetcher.mockReturnValue({ + handleDeleteMetadataInstance: jest.fn(), templateInstances: [mockCustomTemplateInstance], templates: mockTemplates, errorMessage: null, diff --git a/src/elements/content-sidebar/__tests__/useSidebarMetadataFetcher.test.tsx b/src/elements/content-sidebar/__tests__/useSidebarMetadataFetcher.test.tsx index d4ad55db66..8656de0db0 100644 --- a/src/elements/content-sidebar/__tests__/useSidebarMetadataFetcher.test.tsx +++ b/src/elements/content-sidebar/__tests__/useSidebarMetadataFetcher.test.tsx @@ -22,6 +22,18 @@ const mockTemplates = [ }, ]; +const mockTemplateInstances = [ + { + canEdit: true, + id: 'metadata_template_instance_1', + fields: [], + scope: 'global', + templateKey: 'properties', + type: 'properties', + hidden: false, + }, +]; + const mockAPI = { getFile: jest.fn((id, successCallback, errorCallback) => { try { @@ -35,11 +47,19 @@ const mockAPI = { successCallback({ editors: [], templates: mockTemplates, + templateInstances: mockTemplateInstances, }); } catch (error) { errorCallback(error); } }), + deleteMetadata: jest.fn((_file, template, successCallback, errorCallback) => { + try { + successCallback(template); + } catch (error) { + errorCallback(error); + } + }), }; const api = { getFileAPI: jest.fn().mockReturnValue(mockAPI), @@ -53,6 +73,13 @@ describe('useSidebarMetadataFetcher', () => { const setupHook = (fileId = '123') => renderHook(() => useSidebarMetadataFetcher(api, fileId, onErrorMock, isFeatureEnabledMock)); + beforeEach(() => { + onErrorMock.mockClear(); + mockAPI.getFile.mockClear(); + mockAPI.getMetadata.mockClear(); + mockAPI.deleteMetadata.mockClear(); + }); + test('should fetch the file and metadata successfully', async () => { const { result } = setupHook(); @@ -106,4 +133,46 @@ describe('useSidebarMetadataFetcher', () => { }), ); }); + + test('should handle metadata instance removal', async () => { + mockAPI.getMetadata.mockImplementation((file, successCallback) => { + successCallback({ templateInstances: mockTemplateInstances, templates: mockTemplates }); + }); + mockAPI.deleteMetadata.mockImplementation((file, template, successCallback) => { + successCallback(mockTemplateInstances[0]); + }); + + const { result } = setupHook(); + expect(result.current.templateInstances).toEqual(mockTemplateInstances); + + await waitFor(() => result.current.handleDeleteMetadataInstance(mockTemplateInstances[0])); + + expect(result.current.templates).toEqual(mockTemplates); + expect(result.current.templateInstances).toEqual([]); + expect(result.current.errorMessage).toBeNull(); + }); + + test('should handle metadata instance removal error', async () => { + mockAPI.getMetadata.mockImplementation((file, successCallback) => { + successCallback({ templateInstances: mockTemplateInstances, templates: mockTemplates }); + }); + mockAPI.deleteMetadata.mockImplementation((file, template, successCallback, errorCallback) => { + errorCallback(mockError, 'metadata_remove_error'); + }); + + const { result } = setupHook(); + expect(result.current.status).toEqual(STATUS.SUCCESS); + + await waitFor(() => result.current.handleDeleteMetadataInstance(mockTemplateInstances[0])); + + expect(result.current.status).toEqual(STATUS.ERROR); + expect(onErrorMock).toHaveBeenCalledWith( + mockError, + 'metadata_remove_error', + expect.objectContaining({ + error: mockError, + isErrorDisplayed: true, + }), + ); + }); }); diff --git a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts index a8513a43e6..9d4c7ec17a 100644 --- a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts +++ b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts @@ -5,7 +5,7 @@ import { type MetadataTemplate, type MetadataTemplateInstance } from '@box/metad import API from '../../../api'; import { type ElementsXhrError } from '../../../common/types/api'; import { isUserCorrectableError } from '../../../utils/error'; -import { FIELD_IS_EXTERNALLY_OWNED, FIELD_PERMISSIONS, FIELD_PERMISSIONS_CAN_UPLOAD} from '../../../constants'; +import { FIELD_IS_EXTERNALLY_OWNED, FIELD_PERMISSIONS, FIELD_PERMISSIONS_CAN_UPLOAD } from '../../../constants'; import messages from '../../common/messages'; @@ -19,8 +19,9 @@ export enum STATUS { SUCCESS = 'success', } interface DataFetcher { - file: BoxItem | null; errorMessage: MessageDescriptor | null; + file: BoxItem | null; + handleDeleteMetadataInstance: (metadataInstance: MetadataTemplateInstance) => void; status: STATUS; templateInstances: Array; templates: Array; @@ -115,6 +116,35 @@ function useSidebarMetadataFetcher( [onApiError], ); + const deleteMetadataInstanceSuccessCallback = React.useCallback( + (metadataInstance: MetadataTemplateInstance) => { + const updatedInstances = templateInstances.filter( + templateInstance => + templateInstance.scope !== metadataInstance.scope && + templateInstance.templateKey !== metadataInstance.templateKey, + ); + setTemplateInstances(updatedInstances); + }, + [templateInstances], + ); + + const handleDeleteMetadataInstance = React.useCallback( + (metadataInstance: MetadataTemplateInstance) => { + if (!file || !metadataInstance) { + return; + } + + api.getMetadataAPI(false).deleteMetadata( + file, + metadataInstance, + deleteMetadataInstanceSuccessCallback, + onApiError, + true, + ); + }, + [api, onApiError, file, deleteMetadataInstanceSuccessCallback], + ); + React.useEffect(() => { if (status === STATUS.IDLE) { setStatus(STATUS.LOADING); @@ -126,8 +156,9 @@ function useSidebarMetadataFetcher( }, [api, fetchFileErrorCallback, fetchFileSuccessCallback, fileId, status]); return { - file, errorMessage, + file, + handleDeleteMetadataInstance, status, templateInstances, templates,