Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(metadata-sidebar): handle delete metadata instance #3662

Merged
merged 7 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions src/api/Metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -783,17 +783,18 @@ 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(
file: BoxItem,
template: MetadataTemplate,
successCallback: Function,
errorCallback: ElementsErrorCallback,
isMetadataRedesign: boolean = false,
): Promise<void> {
this.errorCode = ERROR_CODE_DELETE_METADATA;
if (!file || !template) {
Expand Down Expand Up @@ -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) {
Expand Down
47 changes: 46 additions & 1 deletion src/api/__tests__/Metadata.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ describe('api/Metadata', () => {
],
id: '123456',
templateKey: 'instance_from_template',
scope: 'enterprise',
},
true,
),
Expand All @@ -219,7 +220,7 @@ describe('api/Metadata', () => {
value: '2.1',
},
],
scope: undefined,
scope: 'enterprise',
templateKey: 'instance_from_template',
});
});
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 4 additions & 5 deletions src/elements/content-sidebar/MetadataInstanceEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<MetadataInstanceEditorProps> = ({
isBoxAiSuggestionsEnabled,
isUnsavedChangesModalOpen,
onDelete,
template,
onCancel,
}) => {
Expand All @@ -21,9 +23,6 @@ const MetadataInstanceEditor: React.FC<MetadataInstanceEditorProps> = ({
const handleCancel = () => {
onCancel();
};
const handleDelete = () => {
// TODO in a future PR
};

return (
<AutofillContextProvider isAiSuggestionsFeatureEnabled={isBoxAiSuggestionsEnabled}>
Expand All @@ -32,7 +31,7 @@ const MetadataInstanceEditor: React.FC<MetadataInstanceEditorProps> = ({
isLoading={false}
selectedTemplateInstance={template}
onCancel={handleCancel}
onDelete={handleDelete}
onDelete={onDelete}
onSubmit={handleSubmit}
isUnsavedChangesModalOpen={isUnsavedChangesModalOpen}
setIsUnsavedChangesModalOpen={noop}
Expand Down
14 changes: 8 additions & 6 deletions src/elements/content-sidebar/MetadataSidebarRedesign.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<MetadataTemplateInstance | null>(null);
const [isUnsavedChangesModalOpen, setIsUnsavedChangesModalOpen] = React.useState<boolean>(false);
Expand All @@ -94,6 +90,11 @@ function MetadataSidebarRedesign({
setEditingTemplate(selectedTemplate);
};

const handleDeleteInstance = (metadataInstance: MetadataTemplateInstance) => {
handleDeleteMetadataInstance(metadataInstance);
setEditingTemplate(null);
};

const metadataDropdown = status === STATUS.SUCCESS && templates && (
<AddMetadataTemplateDropdown
availableTemplates={templates}
Expand Down Expand Up @@ -139,6 +140,7 @@ function MetadataSidebarRedesign({
isUnsavedChangesModalOpen={isUnsavedChangesModalOpen}
template={editingTemplate}
onCancel={() => setEditingTemplate(null)}
onDelete={handleDeleteInstance}
/>
)}
{showList && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe('MetadataInstanceEditor', () => {
isUnsavedChangesModalOpen: false,
template: mockMetadataTemplateInstance,
onCancel: jest.fn(),
onDelete: jest.fn(),
};

test('should render MetadataInstanceForm with correct props', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => {

beforeEach(() => {
mockUseSidebarMetadataFetcher.mockReturnValue({
handleDeleteMetadataInstance: jest.fn(),
templates: mockTemplates,
templateInstances: [],
errorMessage: null,
Expand Down Expand Up @@ -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: {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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),
Expand All @@ -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();

Expand Down Expand Up @@ -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,
}),
);
});
});
37 changes: 34 additions & 3 deletions src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<MetadataTemplateInstance>;
templates: Array<MetadataTemplate>;
Expand Down Expand Up @@ -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);
Expand All @@ -126,8 +156,9 @@ function useSidebarMetadataFetcher(
}, [api, fetchFileErrorCallback, fetchFileSuccessCallback, fileId, status]);

return {
file,
errorMessage,
file,
handleDeleteMetadataInstance,
status,
templateInstances,
templates,
Expand Down
Loading