From 321ba7e715d51b269ad7bc566b197d0cab52b699 Mon Sep 17 00:00:00 2001 From: karolinaru <91914885+karolinaru@users.noreply.github.com> Date: Tue, 24 Sep 2024 19:19:32 +0200 Subject: [PATCH] feat(metadata-sidebar): Handle create metadata isntance (#3663) * chore(content-sidebar): Temporarily remove files while we be working with not yet publish internal library. Will remove this commit after the library will become publicly available on NPM and added to BUIE. * feat(metadata-sidebar): onSave api calls * feat(metadata-sidebar): tests fix * feat(metadata-sidebar): create metadata * feat(metadata-sidebar): fix errors * feat(metadata-sidebar): fix test * feat(metadata-sidebar): additional error test * feat(metadata-sidebar): gitignore * feat(metadata-sidebar): restore files * feat(metadata-sidebar): pr comments * feat(metadata-sidebar): test fix * feat(metadata-sidebar): success callback and api exceptions * feat(metadata-sidebar): unit test * feat(metadata-sidebar): test fix and pr comment * feat(metadata-sidebar): changes to separate types to pass pipeline * feat(metadata-sidebar): function name change * feat(metadata-sidebar): yarn.lock and nme change * feat(metadata-sidebar): function type change * feat(metadata-sidebar): function type change * feat(metadata-sidebar): yarn.lock update * feat(metadata-sidebar): optimize saving function * feat(metadata-sidebar): direct imports and extracted function * feat(metadata-sidebar): remove empty update function * feat(metadata-sidebar): metadata-editor bump * feat(metadata-sidebar): isLoadin prop remove after bump --------- Co-authored-by: Dawid Jankowiak --- package.json | 14 +- src/api/Metadata.js | 71 ++++- src/api/__tests__/Metadata.test.js | 264 ++++++++++++++++++ .../MetadataInstanceEditor.tsx | 23 +- .../MetadataSidebarRedesign.tsx | 39 ++- .../__tests__/MetadataInstanceEditor.test.tsx | 9 +- .../MetadataSidebarRedesign.test.tsx | 4 + ...convertTemplateToTemplateInstance.test.tsx | 43 +++ .../useSidebarMetadataFetcher.test.tsx | 68 +++++ .../hooks/useSidebarMetadataFetcher.ts | 15 + .../convertTemplateToTemplateInstance.ts | 19 ++ yarn.lock | 42 ++- 12 files changed, 572 insertions(+), 39 deletions(-) create mode 100644 src/elements/content-sidebar/__tests__/convertTemplateToTemplateInstance.test.tsx create mode 100644 src/elements/content-sidebar/utils/convertTemplateToTemplateInstance.ts diff --git a/package.json b/package.json index 09055c2748..a4e94596a8 100644 --- a/package.json +++ b/package.json @@ -92,11 +92,7 @@ "last 2 Edge versions", "last 2 iOS versions" ], - "development": [ - "last 1 Chrome versions", - "last 1 Firefox versions", - "last 1 Safari versions" - ] + "development": ["last 1 Chrome versions", "last 1 Firefox versions", "last 1 Safari versions"] }, "husky": { "hooks": { @@ -132,7 +128,7 @@ "@box/cldr-data": "^34.2.0", "@box/frontend": "^10.0.0", "@box/languages": "^1.0.0", - "@box/metadata-editor": "^0.50.5", + "@box/metadata-editor": "^0.53.0", "@box/react-virtualized": "9.22.3-rc-box.9", "@cfaester/enzyme-adapter-react-18": "^0.8.0", "@chromatic-com/storybook": "^1.6.1", @@ -308,7 +304,7 @@ "@box/blueprint-web-assets": "^4.21.0", "@box/box-ai-content-answers": "^0.49.1", "@box/cldr-data": ">=34.2.0", - "@box/metadata-editor": "^0.50.5", + "@box/metadata-editor": "^0.53.0", "@box/react-virtualized": "9.22.3-rc-box.9", "@hapi/address": "^2.1.4", "axios": "^0.25.0", @@ -366,8 +362,6 @@ } }, "msw": { - "workerDirectory": [ - ".storybook/public" - ] + "workerDirectory": [".storybook/public"] } } diff --git a/src/api/Metadata.js b/src/api/Metadata.js index 7a5b8faaa6..86f6c06e17 100644 --- a/src/api/Metadata.js +++ b/src/api/Metadata.js @@ -756,7 +756,6 @@ class Metadata extends File { errorCallback(getBadPermissionsError(), this.errorCode); return; } - this.successCallback = successCallback; this.errorCallback = errorCallback; @@ -779,6 +778,76 @@ class Metadata extends File { } } + /** + * API for creating metadata on file + * + * @param {BoxItem} file - File object for which we are changing the description + * @param {Object} template - Metadata Redesign template + * @param {Function} successCallback - Success callback + * @param {Function} errorCallback - Error callback + * @return {Promise} + */ + async createMetadataRedesign( + file: BoxItem, + template: MetadataTemplateInstance, + successCallback: Function, + errorCallback: ElementsErrorCallback, + ): Promise { + this.errorCode = ERROR_CODE_CREATE_METADATA; + if (!file || !template) { + errorCallback(getBadItemError(), this.errorCode); + return; + } + + const { id, permissions, is_externally_owned }: BoxItem = file; + + if (!id || !permissions) { + errorCallback(getBadItemError(), this.errorCode); + return; + } + + const canEdit = !!permissions.can_upload; + const isProperties = + template.templateKey === METADATA_TEMPLATE_PROPERTIES && template.scope === METADATA_SCOPE_GLOBAL; + + if (!canEdit || (is_externally_owned && !isProperties)) { + errorCallback(getBadPermissionsError(), this.errorCode); + return; + } + this.successCallback = successCallback; + this.errorCallback = errorCallback; + + try { + const fieldsValues = template.fields.reduce((acc, obj) => { + let { value } = obj; + // API does not accept string for float type + if (obj.type === 'float' && value) value = parseFloat(obj.value); + // API does not accept empty string for enum type + if (obj.type === 'enum' && value && value.length === 0) value = undefined; + acc[obj.key] = value; + return acc; + }, {}); + + const metadata = await this.xhr.post({ + url: this.getMetadataUrl(id, template.scope, template.templateKey), + id: getTypedFileId(id), + data: fieldsValues, + }); + + if (!this.isDestroyed()) { + const cache: APICache = this.getCache(); + const key = this.getMetadataCacheKey(id); + const cachedMetadata = cache.get(key); + + const templateInstance = { ...template, type: metadata.data.$type }; + cachedMetadata.templateInstances.push(templateInstance); + this.successHandler(templateInstance); + } + } catch (e) { + this.errorHandler(e); + } + } + /** * API for deleting metadata on file * diff --git a/src/api/__tests__/Metadata.test.js b/src/api/__tests__/Metadata.test.js index 8bfec0417e..be33aa2361 100644 --- a/src/api/__tests__/Metadata.test.js +++ b/src/api/__tests__/Metadata.test.js @@ -2048,6 +2048,270 @@ describe('api/Metadata', () => { }); }); + describe('createMetadataRedesign()', () => { + test('should call error callback with a bad item error when no file', () => { + jest.spyOn(ErrorUtil, 'getBadItemError').mockReturnValueOnce('error'); + const successCallback = jest.fn(); + const errorCallback = jest.fn(); + metadata.createMetadataRedesign(undefined, {}, successCallback, errorCallback); + expect(errorCallback).toBeCalledWith('error', ERROR_CODE_CREATE_METADATA); + expect(successCallback).not.toBeCalled(); + expect(ErrorUtil.getBadItemError).toBeCalled(); + }); + test('should call error callback with a bad item error when no template', () => { + jest.spyOn(ErrorUtil, 'getBadItemError').mockReturnValueOnce('error'); + const successCallback = jest.fn(); + const errorCallback = jest.fn(); + metadata.createMetadataRedesign({}, undefined, successCallback, errorCallback); + expect(errorCallback).toBeCalledWith('error', ERROR_CODE_CREATE_METADATA); + expect(successCallback).not.toBeCalled(); + expect(ErrorUtil.getBadItemError).toBeCalled(); + }); + test('should call error callback with a bad item error when no id', () => { + jest.spyOn(ErrorUtil, 'getBadItemError').mockReturnValueOnce('error'); + const successCallback = jest.fn(); + const errorCallback = jest.fn(); + metadata.createMetadataRedesign({}, {}, successCallback, errorCallback); + expect(errorCallback).toBeCalledWith('error', ERROR_CODE_CREATE_METADATA); + expect(successCallback).not.toBeCalled(); + expect(ErrorUtil.getBadItemError).toBeCalled(); + }); + test('should call error callback with a bad item error when no permissions', () => { + jest.spyOn(ErrorUtil, 'getBadItemError').mockReturnValueOnce('error'); + const successCallback = jest.fn(); + const errorCallback = jest.fn(); + metadata.createMetadataRedesign({ id: 'id' }, {}, successCallback, errorCallback); + expect(errorCallback).toBeCalledWith('error', ERROR_CODE_CREATE_METADATA); + expect(successCallback).not.toBeCalled(); + expect(ErrorUtil.getBadItemError).toBeCalled(); + }); + test('should call error callback with a bad permissions error', () => { + ErrorUtil.getBadPermissionsError = jest.fn().mockReturnValueOnce('error'); + const successCallback = jest.fn(); + const errorCallback = jest.fn(); + metadata.createMetadataRedesign({ id: 'id', permissions: {} }, {}, successCallback, errorCallback); + expect(errorCallback).toBeCalledWith('error', ERROR_CODE_CREATE_METADATA); + expect(successCallback).not.toBeCalled(); + expect(ErrorUtil.getBadPermissionsError).toBeCalled(); + }); + test('should call error callback with a bad permissions error when can upload is false', () => { + ErrorUtil.getBadPermissionsError = jest.fn().mockReturnValueOnce('error'); + const successCallback = jest.fn(); + const errorCallback = jest.fn(); + metadata.createMetadataRedesign( + { id: 'id', permissions: { can_upload: false } }, + {}, + successCallback, + errorCallback, + ); + expect(errorCallback).toBeCalledWith('error', ERROR_CODE_CREATE_METADATA); + expect(successCallback).not.toBeCalled(); + expect(ErrorUtil.getBadPermissionsError).toBeCalled(); + }); + test('should call error callback when file is externally owned and template isnt global', () => { + ErrorUtil.getBadPermissionsError = jest.fn().mockReturnValueOnce('error'); + const successCallback = jest.fn(); + const errorCallback = jest.fn(); + metadata.createMetadataRedesign( + { + id: 'id', + permissions: { can_upload: true }, + is_externally_owned: true, + }, + { scope: 'global', template: 'foo' }, + successCallback, + errorCallback, + ); + expect(errorCallback).toBeCalledWith('error', ERROR_CODE_CREATE_METADATA); + expect(successCallback).not.toBeCalled(); + expect(ErrorUtil.getBadPermissionsError).toBeCalled(); + }); + test('should call error callback when file is externally owned and template isnt properties', () => { + ErrorUtil.getBadPermissionsError = jest.fn().mockReturnValueOnce('error'); + const successCallback = jest.fn(); + const errorCallback = jest.fn(); + metadata.createMetadataRedesign( + { + id: 'id', + permissions: { can_upload: true }, + is_externally_owned: true, + }, + { scope: 'blah', template: 'properties' }, + successCallback, + errorCallback, + ); + expect(errorCallback).toBeCalledWith('error', ERROR_CODE_CREATE_METADATA); + expect(successCallback).not.toBeCalled(); + expect(ErrorUtil.getBadPermissionsError).toBeCalled(); + }); + test('should make request and update cache and call success handler', async () => { + const success = jest.fn(); + const error = jest.fn(); + const file = { + id: 'id', + permissions: { + can_upload: true, + }, + }; + const cache = new Cache(); + const template = { scope: 'scope', templateKey: 'templateKey', fields: [] }; + + const priorMetadata = { + instance: { + id: 'instance_id', + data: { + foo: 'bar', + }, + }, + }; + + const updatedMetadata = { + ...template, + type: undefined, + }; + + cache.set('metadata_id', { + templateInstances: [priorMetadata], + }); + + metadata.getMetadataUrl = jest.fn().mockReturnValueOnce('url'); + metadata.xhr.post = 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.createMetadataRedesign(file, template, success, error); + + expect(metadata.successCallback).toBe(success); + expect(metadata.errorCallback).toBe(error); + expect(metadata.getMetadataUrl).toHaveBeenCalledWith(file.id, 'scope', 'templateKey'); + expect(metadata.xhr.post).toHaveBeenCalledWith({ + url: 'url', + id: 'file_id', + data: {}, + }); + expect(metadata.isDestroyed).toHaveBeenCalled(); + expect(metadata.getCache).toHaveBeenCalled(); + expect(metadata.getMetadataCacheKey).toHaveBeenCalledWith(file.id); + expect(metadata.successHandler).toHaveBeenCalledWith(updatedMetadata); + expect(metadata.errorHandler).not.toHaveBeenCalled(); + expect(cache.get('metadata_id')).toEqual({ + templateInstances: [priorMetadata, updatedMetadata], + }); + }); + test('should make request but not update cache or call success handler when destroyed', async () => { + const success = jest.fn(); + const error = jest.fn(); + const file = { + id: 'id', + permissions: { + can_upload: true, + }, + }; + const cache = new Cache(); + const template = { scope: 'scope', templateKey: 'templateKey', fields: [] }; + + const priorMetadata = { + instance: { + id: 'instance_id', + data: { + foo: 'bar', + }, + }, + }; + + cache.set('metadata_id', { + templateInstances: [priorMetadata], + }); + + metadata.getMetadataUrl = jest.fn().mockReturnValueOnce('url'); + metadata.xhr.post = jest.fn().mockReturnValueOnce({ data: 'foo' }); + metadata.isDestroyed = jest.fn().mockReturnValueOnce(true); + 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.createMetadataRedesign(file, template, success, error); + + expect(metadata.successCallback).toBe(success); + expect(metadata.errorCallback).toBe(error); + expect(metadata.getMetadataUrl).toHaveBeenCalledWith(file.id, 'scope', 'templateKey'); + expect(metadata.xhr.post).toHaveBeenCalledWith({ + url: 'url', + id: 'file_id', + data: {}, + }); + expect(metadata.isDestroyed).toHaveBeenCalled(); + expect(metadata.getCache).not.toHaveBeenCalled(); + expect(metadata.getMetadataCacheKey).not.toHaveBeenCalled(); + expect(metadata.successHandler).not.toHaveBeenCalled(); + expect(metadata.errorHandler).not.toHaveBeenCalled(); + expect(cache.get('metadata_id')).toEqual({ + templateInstances: [priorMetadata], + }); + }); + test('should make request and call error handler for error', async () => { + const success = jest.fn(); + const error = jest.fn(); + const file = { + id: 'id', + permissions: { + can_upload: true, + }, + }; + const cache = new Cache(); + const template = { scope: 'scope', templateKey: 'templateKey', fields: [] }; + const xhrError = new Error('error'); + const priorMetadata = { + instance: { + id: 'instance_id', + data: { + foo: 'bar', + }, + }, + }; + + cache.set('metadata_id', { + templateInstances: [priorMetadata], + }); + + metadata.getMetadataUrl = jest.fn().mockReturnValueOnce('url'); + metadata.xhr.post = jest.fn().mockReturnValueOnce(Promise.reject(xhrError)); + 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.createMetadataRedesign(file, template, success, error); + + expect(metadata.successCallback).toBe(success); + expect(metadata.errorCallback).toBe(error); + expect(metadata.getMetadataUrl).toHaveBeenCalledWith(file.id, 'scope', 'templateKey'); + expect(metadata.xhr.post).toHaveBeenCalledWith({ + url: 'url', + id: 'file_id', + data: {}, + }); + expect(metadata.isDestroyed).not.toHaveBeenCalled(); + expect(metadata.getCache).not.toHaveBeenCalled(); + expect(metadata.getMetadataCacheKey).not.toHaveBeenCalled(); + expect(metadata.successHandler).not.toHaveBeenCalled(); + expect(cache.get('metadata_id')).toEqual({ + templateInstances: [priorMetadata], + }); + expect(metadata.errorHandler).toHaveBeenCalledWith(xhrError); + }); + }); + describe('deleteMetadata()', () => { test('should call error callback with a bad item error when no file', () => { jest.spyOn(ErrorUtil, 'getBadItemError').mockReturnValueOnce('error'); diff --git a/src/elements/content-sidebar/MetadataInstanceEditor.tsx b/src/elements/content-sidebar/MetadataInstanceEditor.tsx index efb597bdf9..772d1eb920 100644 --- a/src/elements/content-sidebar/MetadataInstanceEditor.tsx +++ b/src/elements/content-sidebar/MetadataInstanceEditor.tsx @@ -1,5 +1,10 @@ -import { AutofillContextProvider, MetadataInstanceForm, type MetadataTemplateInstance } from '@box/metadata-editor'; -import noop from 'lodash/noop'; +import { + AutofillContextProvider, + MetadataInstanceForm, + type FormValues, + type JSONPatchOperations, + type MetadataTemplateInstance, +} from '@box/metadata-editor'; import React from 'react'; export interface MetadataInstanceEditorProps { @@ -8,18 +13,19 @@ export interface MetadataInstanceEditorProps { onCancel: () => void; onDelete: (metadataInstance: MetadataTemplateInstance) => void; template: MetadataTemplateInstance; + onSubmit: (values: FormValues, operations: JSONPatchOperations) => Promise; + setIsUnsavedChangesModalOpen: (isUnsavedChangesModalOpen: boolean) => void; } const MetadataInstanceEditor: React.FC = ({ isBoxAiSuggestionsEnabled, isUnsavedChangesModalOpen, onDelete, + onSubmit, + setIsUnsavedChangesModalOpen, template, onCancel, }) => { - const handleSubmit = () => { - // TODO in a future PR - }; const handleCancel = () => { onCancel(); }; @@ -28,13 +34,12 @@ const MetadataInstanceEditor: React.FC = ({ ); diff --git a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx index 7298617f74..88ccc192b9 100644 --- a/src/elements/content-sidebar/MetadataSidebarRedesign.tsx +++ b/src/elements/content-sidebar/MetadataSidebarRedesign.tsx @@ -10,6 +10,7 @@ import { AddMetadataTemplateDropdown, MetadataEmptyState, MetadataInstanceList, + type FormValues, type MetadataTemplateInstance, type MetadataTemplate, } from '@box/metadata-editor'; @@ -32,6 +33,7 @@ import { type WithLoggerProps } from '../../common/types/logging'; import messages from '../common/messages'; import './MetadataSidebarRedesign.scss'; import MetadataInstanceEditor from './MetadataInstanceEditor'; +import { convertTemplateToTemplateInstance } from './utils/convertTemplateToTemplateInstance'; const MARK_NAME_JS_READY = `${ORIGIN_METADATA_SIDEBAR_REDESIGN}_${EVENT_JS_READY}`; @@ -69,11 +71,21 @@ function MetadataSidebarRedesign({ onError, isFeatureEnabled, }: MetadataSidebarRedesignProps) { - const { handleDeleteMetadataInstance, file, templates, errorMessage, status, templateInstances } = - useSidebarMetadataFetcher(api, fileId, onError, isFeatureEnabled); + const { + handleCreateMetadataInstance, + 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); + const [selectedTemplates, setSelectedTemplates] = React.useState>(templateInstances); @@ -85,9 +97,9 @@ function MetadataSidebarRedesign({ setIsUnsavedChangesModalOpen(true); }; - const handleTemplateSelect = (selectedTemplate: MetadataTemplateInstance) => { + const handleTemplateSelect = (selectedTemplate: MetadataTemplate) => { setSelectedTemplates([...selectedTemplates, selectedTemplate]); - setEditingTemplate(selectedTemplate); + setEditingTemplate(convertTemplateToTemplateInstance(file, selectedTemplate)); }; const handleDeleteInstance = (metadataInstance: MetadataTemplateInstance) => { @@ -95,14 +107,23 @@ function MetadataSidebarRedesign({ setEditingTemplate(null); }; + const isExistingMetadataInstance = (): boolean => { + return ( + editingTemplate && !!templateInstances.find(templateInstance => templateInstance.id === editingTemplate.id) + ); + }; + + const handleSubmit = async (values: FormValues) => { + !isExistingMetadataInstance() && + handleCreateMetadataInstance(values.metadata as MetadataTemplateInstance, () => setEditingTemplate(null)); + }; + const metadataDropdown = status === STATUS.SUCCESS && templates && ( { - editingTemplate - ? handleUnsavedChanges() - : handleTemplateSelect(selectedTemplate as MetadataTemplateInstance); + editingTemplate ? handleUnsavedChanges() : handleTemplateSelect(selectedTemplate); }} /> ); @@ -138,9 +159,11 @@ function MetadataSidebarRedesign({ setEditingTemplate(null)} + onSubmit={handleSubmit} onDelete={handleDeleteInstance} + template={editingTemplate} + setIsUnsavedChangesModalOpen={setIsUnsavedChangesModalOpen} /> )} {showList && ( diff --git a/src/elements/content-sidebar/__tests__/MetadataInstanceEditor.test.tsx b/src/elements/content-sidebar/__tests__/MetadataInstanceEditor.test.tsx index 1f312749f6..33b133368b 100644 --- a/src/elements/content-sidebar/__tests__/MetadataInstanceEditor.test.tsx +++ b/src/elements/content-sidebar/__tests__/MetadataInstanceEditor.test.tsx @@ -14,6 +14,11 @@ describe('MetadataInstanceEditor', () => { canEdit: true, }; + const mockMetadataTemplate: MetadataTemplateInstance = { + ...mockCustomMetadataTemplate, + displayName: 'Template Name', + canEdit: true, + }; const mockMetadataTemplateInstance: MetadataTemplateInstance = { ...mockCustomMetadataTemplate, displayName: 'Template Name', @@ -22,9 +27,11 @@ describe('MetadataInstanceEditor', () => { const defaultProps: MetadataInstanceEditorProps = { isBoxAiSuggestionsEnabled: true, isUnsavedChangesModalOpen: false, - template: mockMetadataTemplateInstance, + template: mockMetadataTemplate, onCancel: jest.fn(), onDelete: jest.fn(), + onSubmit: jest.fn(), + setIsUnsavedChangesModalOpen: 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 e49d9ea64d..13f8b93940 100644 --- a/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx +++ b/src/elements/content-sidebar/__tests__/MetadataSidebarRedesign.test.tsx @@ -70,6 +70,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { beforeEach(() => { mockUseSidebarMetadataFetcher.mockReturnValue({ handleDeleteMetadataInstance: jest.fn(), + handleCreateMetadataInstance: jest.fn(), templates: mockTemplates, templateInstances: [], errorMessage: null, @@ -113,6 +114,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { test('should render metadata sidebar with error', async () => { mockUseSidebarMetadataFetcher.mockReturnValue({ handleDeleteMetadataInstance: jest.fn(), + handleCreateMetadataInstance: jest.fn(), templateInstances: [], templates: [], errorMessage: { @@ -132,6 +134,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { test('should render metadata sidebar with loading indicator', async () => { mockUseSidebarMetadataFetcher.mockReturnValue({ + handleCreateMetadataInstance: jest.fn(), handleDeleteMetadataInstance: jest.fn(), templateInstances: [], templates: [], @@ -173,6 +176,7 @@ describe('elements/content-sidebar/Metadata/MetadataSidebarRedesign', () => { errorMessage: null, status: STATUS.SUCCESS, file: mockFile, + handleCreateMetadataInstance: jest.fn(), }); renderComponent(); diff --git a/src/elements/content-sidebar/__tests__/convertTemplateToTemplateInstance.test.tsx b/src/elements/content-sidebar/__tests__/convertTemplateToTemplateInstance.test.tsx new file mode 100644 index 0000000000..10faebc76c --- /dev/null +++ b/src/elements/content-sidebar/__tests__/convertTemplateToTemplateInstance.test.tsx @@ -0,0 +1,43 @@ +import { type MetadataTemplate, type MetadataTemplateInstance } from '@box/metadata-editor'; +import { convertTemplateToTemplateInstance } from '../utils/convertTemplateToTemplateInstance'; +import { type BoxItem } from '../../../common/types/core'; + +describe('convertTemplateToTemplateInstance', () => { + it('should correctly convert template to template instance', () => { + const mockFile: BoxItem = { + permissions: { + can_upload: true, + }, + }; + const mockTemplate: MetadataTemplate = { + displayName: 'Test Template', + hidden: false, + id: '123', + fields: [], + scope: 'global', + templateKey: 'test_template', + type: 'metadata_template', + }; + + const mockTemplateInstance: MetadataTemplateInstance = { + canEdit: true, + displayName: 'Test Template', + hidden: false, + id: '123', + fields: [], + scope: 'global', + templateKey: 'test_template', + type: 'metadata_template', + }; + + const result = convertTemplateToTemplateInstance(mockFile, mockTemplate); + + expect(result.canEdit).toBe(mockTemplateInstance.canEdit); + expect(result.displayName).toBe(mockTemplateInstance.displayName); + expect(result.hidden).toBe(mockTemplateInstance.hidden); + expect(result.fields).toEqual(mockTemplateInstance.fields); + expect(result.scope).toBe(mockTemplateInstance.scope); + expect(result.templateKey).toBe(mockTemplateInstance.templateKey); + expect(result.type).toBe(mockTemplateInstance.type); + }); +}); diff --git a/src/elements/content-sidebar/__tests__/useSidebarMetadataFetcher.test.tsx b/src/elements/content-sidebar/__tests__/useSidebarMetadataFetcher.test.tsx index 8656de0db0..3d7920f64f 100644 --- a/src/elements/content-sidebar/__tests__/useSidebarMetadataFetcher.test.tsx +++ b/src/elements/content-sidebar/__tests__/useSidebarMetadataFetcher.test.tsx @@ -32,8 +32,27 @@ const mockTemplateInstances = [ type: 'properties', hidden: false, }, + { + canEdit: true, + id: 'metadata_template_instance_2', + fields: [], + scope: 'global', + templateKey: 'properties', + type: 'properties', + hidden: false, + }, ]; +const newTemplateInstance = { + canEdit: true, + id: 'metadata_template_instance_3', + fields: [], + scope: 'global', + templateKey: 'properties', + type: 'properties', + hidden: false, +}; + const mockAPI = { getFile: jest.fn((id, successCallback, errorCallback) => { try { @@ -60,6 +79,13 @@ const mockAPI = { errorCallback(error); } }), + createMetadataRedesign: jest.fn((_file, template, successCallback, errorCallback) => { + try { + successCallback(); + } catch (error) { + errorCallback(error); + } + }), }; const api = { getFileAPI: jest.fn().mockReturnValue(mockAPI), @@ -175,4 +201,46 @@ describe('useSidebarMetadataFetcher', () => { }), ); }); + + test('should handle metadata instance creation', async () => { + mockAPI.getMetadata.mockImplementation((file, successCallback) => { + successCallback({ templateInstances: mockTemplateInstances, templates: mockTemplates }); + }); + mockAPI.createMetadataRedesign.mockImplementation((file, template, successCallback) => { + successCallback(); + }); + + const successCallback = jest.fn(); + + const { result } = setupHook(); + + expect(result.current.templateInstances).toEqual(mockTemplateInstances); + await waitFor(() => result.current.handleCreateMetadataInstance(newTemplateInstance, successCallback)); + + expect(successCallback).toHaveBeenCalled(); + }); + + test('should handle metadata instance creation error', async () => { + mockAPI.getMetadata.mockImplementation((file, successCallback) => { + successCallback({ templateInstances: mockTemplateInstances, templates: mockTemplates }); + }); + mockAPI.createMetadataRedesign.mockImplementation((file, template, successCallback, errorCallback) => { + errorCallback(mockError, 'metadata_creation_error'); + }); + + const { result } = setupHook(); + expect(result.current.status).toBe(STATUS.SUCCESS); + + await waitFor(() => result.current.handleCreateMetadataInstance(newTemplateInstance, jest.fn())); + + expect(result.current.status).toBe(STATUS.ERROR); + expect(onErrorMock).toHaveBeenCalledWith( + mockError, + 'metadata_creation_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 9d4c7ec17a..822a5253bf 100644 --- a/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts +++ b/src/elements/content-sidebar/hooks/useSidebarMetadataFetcher.ts @@ -19,6 +19,7 @@ export enum STATUS { SUCCESS = 'success', } interface DataFetcher { + handleCreateMetadataInstance: (templateInstance: MetadataTemplateInstance, successCallback: () => void) => void; errorMessage: MessageDescriptor | null; file: BoxItem | null; handleDeleteMetadataInstance: (metadataInstance: MetadataTemplateInstance) => void; @@ -145,6 +146,19 @@ function useSidebarMetadataFetcher( [api, onApiError, file, deleteMetadataInstanceSuccessCallback], ); + const handleCreateMetadataInstance = React.useCallback( + (templateInstance: MetadataTemplateInstance, successCallback): void => { + api.getMetadataAPI(false).createMetadataRedesign( + file, + templateInstance, + successCallback, + (error: ElementsXhrError, code: string) => + onApiError(error, code, messages.sidebarMetadataEditingErrorContent), + ); + }, + [api, file, onApiError], + ); + React.useEffect(() => { if (status === STATUS.IDLE) { setStatus(STATUS.LOADING); @@ -156,6 +170,7 @@ function useSidebarMetadataFetcher( }, [api, fetchFileErrorCallback, fetchFileSuccessCallback, fileId, status]); return { + handleCreateMetadataInstance, errorMessage, file, handleDeleteMetadataInstance, diff --git a/src/elements/content-sidebar/utils/convertTemplateToTemplateInstance.ts b/src/elements/content-sidebar/utils/convertTemplateToTemplateInstance.ts new file mode 100644 index 0000000000..8e7cd0037b --- /dev/null +++ b/src/elements/content-sidebar/utils/convertTemplateToTemplateInstance.ts @@ -0,0 +1,19 @@ +import { type MetadataTemplate, type MetadataTemplateInstance } from '@box/metadata-editor'; +import uniqueId from 'lodash/uniqueId'; +import { type BoxItem } from '../../../common/types/core'; + +export const convertTemplateToTemplateInstance = ( + file: BoxItem | null, + template: MetadataTemplate, +): MetadataTemplateInstance => { + return { + canEdit: !!file.permissions.can_upload, + displayName: template.displayName, + hidden: template.hidden, + id: uniqueId('metadata_template_'), + fields: template.fields, + scope: template.scope, + templateKey: template.templateKey, + type: template.type, + }; +}; diff --git a/yarn.lock b/yarn.lock index 2567cc54f1..8d7767fc0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1564,10 +1564,10 @@ resolved "https://registry.yarnpkg.com/@box/languages/-/languages-1.1.2.tgz#cd4266b3da62da18560d881e10b429653186be29" integrity sha512-d64TGosx+KRmrLZj4CIyLp42LUiEbgBJ8n8cviMQwTJmfU0g+UwZqLjmQZR1j+Q9D64yV4xHzY9K1t5nInWWeQ== -"@box/metadata-editor@^0.50.5": - version "0.50.5" - resolved "https://registry.yarnpkg.com/@box/metadata-editor/-/metadata-editor-0.50.5.tgz#7036c6027dbc8369aa7a6bfaf8a285826c8aae2c" - integrity sha512-aTfzND4yQJTuszX/LioXX1EoUHVAZFaZNKP/60bZdkQP/QAZydO0W+HyHfoDK+0P6zsqIoLpIyoZgnSuGIRDFg== +"@box/metadata-editor@^0.53.0": + version "0.53.0" + resolved "https://registry.yarnpkg.com/@box/metadata-editor/-/metadata-editor-0.53.0.tgz#913851d2a8ad52e42b3f919a708f132d36f5be2b" + integrity sha512-W9BVauKnkHdaw3jmii2W25N0kWD0K9luITQpvM8zHjbHy5mNRAg8fX7tHmcNKR+kUyCpThW+QcWRHKf+3F3CsA== "@box/react-virtualized@9.22.3-rc-box.9": version "9.22.3-rc-box.9" @@ -22418,8 +22418,7 @@ string-replace-loader@^3.1.0: loader-utils "^2.0.0" schema-utils "^3.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - name string-width-cjs +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -22437,6 +22436,15 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" @@ -22575,8 +22583,7 @@ stringify-package@^1.0.0, stringify-package@^1.0.1: resolved "https://registry.yarnpkg.com/stringify-package/-/stringify-package-1.0.1.tgz#e5aa3643e7f74d0f28628b72f3dad5cecfc3ba85" integrity sha512-sa4DUQsYciMP1xhKWGuFM04fB0LG/9DlluZoSVywUMRNvzid6XucHK0/90xGxRoHrAaROrcHK1aPKaijCtSrhg== -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: - name strip-ansi-cjs +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -22604,6 +22611,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -24803,8 +24817,7 @@ worker-farm@^1.6.0, worker-farm@^1.7.0: dependencies: errno "~0.1.7" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: - name wrap-ansi-cjs +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -24847,6 +24860,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"