diff --git a/i18n/en-US.properties b/i18n/en-US.properties index 5a572fdd4d..0b799107a2 100644 --- a/i18n/en-US.properties +++ b/i18n/en-US.properties @@ -582,12 +582,18 @@ be.messageCenter.previewError = Sorry, we're having trouble showing this image. be.messageCenter.product = Product # Title for the message center modal be.messageCenter.title = What's New +# Text shown in error notification banner +be.metadataUpdateErrorNotification = Unable to save changes. Please try again. +# Text shown in success notification banner +be.metadataUpdateSuccessNotification = {numSelected, plural, =1 {1 document updated} other {# documents updated} } # Text for modified date with modified prefix. be.modifiedDate = Modified {date} # Text for modified date with user with modified prefix. be.modifiedDateBy = Modified {date} by {name} # Label for a button that displays more options be.moreOptions = More options +# Display text for field when there are multiple items selected and have different value +be.multipleValues = Multiple Values # Name ascending option shown in the share access drop down select. be.nameASC = Name: A → Z # Name descending option shown in the share access drop down select. @@ -834,6 +840,8 @@ be.skillUnknownError = Something went wrong with running this skill or fetching be.sort = Sort # Label for status skill card in the preview sidebar be.statusSkill = Status +# Generic success label. +be.success = Success # Shown instead of todays date. be.today = today # Label for keywords/topics skill section in the preview sidebar diff --git a/src/api/Metadata.js b/src/api/Metadata.js index 9a0ff296fb..ddc567aaa4 100644 --- a/src/api/Metadata.js +++ b/src/api/Metadata.js @@ -16,7 +16,7 @@ import partition from 'lodash/partition'; import uniq from 'lodash/uniq'; import uniqueId from 'lodash/uniqueId'; import { getBadItemError, getBadPermissionsError, isUserCorrectableError } from '../utils/error'; -import { getTypedFileId } from '../utils/file'; +import { getTypedFileId, getTypedFolderId } from '../utils/file'; import { handleOnAbort, formatMetadataFieldValue } from './utils'; import File from './File'; import { @@ -115,6 +115,21 @@ class Metadata extends File { return baseUrl; } + /** + * API URL for metadata + * + * @param {string} id - a Box folder id + * @param {string} field - metadata field + * @return {string} base url for files + */ + getMetadataUrlForFolder(id: string, scope?: string, template?: string): string { + const baseUrl = `${this.getBaseApiUrl()}/folders/${id}/metadata`; + if (scope && template) { + return `${baseUrl}/${scope}/${template}`; + } + return baseUrl; + } + /** * API URL for metadata templates for a scope * @@ -810,27 +825,33 @@ class Metadata extends File { } /** - * API for patching metadata on file + * API for patching metadata on item (file/folder) * - * @param {BoxItem} file - File object for which we are changing the description + * @param {BoxItem} item - File/Folder object for which we are changing the description * @param {Object} template - Metadata template * @param {Array} operations - Array of JSON patch operations * @param {Function} successCallback - Success callback * @param {Function} errorCallback - Error callback + * @param {boolean} suppressCallbacks - Boolean to decide whether suppress callbacks or not * @return {Promise} */ async updateMetadata( - file: BoxItem, + item: BoxItem, template: MetadataTemplate, operations: JSONPatchOperations, successCallback: Function, errorCallback: ElementsErrorCallback, + suppressCallbacks?: boolean, ): Promise { this.errorCode = ERROR_CODE_UPDATE_METADATA; - this.successCallback = successCallback; - this.errorCallback = errorCallback; + if (!suppressCallbacks) { + // Only set callbacks when we intend to invoke them for this call + // so that callers performing bulk operations can suppress per-item callbacks + this.successCallback = successCallback; + this.errorCallback = errorCallback; + } - const { id, permissions } = file; + const { id, permissions, type } = item; if (!id || !permissions) { this.errorHandler(getBadItemError()); return; @@ -845,11 +866,14 @@ class Metadata extends File { try { const metadata = await this.xhr.put({ - url: this.getMetadataUrl(id, template.scope, template.templateKey), + url: + type === 'file' + ? this.getMetadataUrl(id, template.scope, template.templateKey) + : this.getMetadataUrlForFolder(id, template.scope, template.templateKey), headers: { [HEADER_CONTENT_TYPE]: 'application/json-patch+json', }, - id: getTypedFileId(id), + id: type === 'file' ? getTypedFileId(id) : getTypedFolderId(id), data: operations, }); if (!this.isDestroyed()) { @@ -864,13 +888,63 @@ class Metadata extends File { editor, ); } - this.successHandler(editor); + if (!suppressCallbacks) { + this.successHandler(editor); + } } } catch (e) { + if (suppressCallbacks) { + // Let the caller decide how to handle errors (e.g., aggregate for bulk operations) + throw e; + } this.errorHandler(e); } } + /** + * API for bulk patching metadata on items (file/folder) + * + * @param {BoxItem[]} items - File/Folder object for which we are changing the description + * @param {Object} template - Metadata template + * @param {Array} operations - Array of JSON patch operations for each item + * @param {Function} successCallback - Success callback + * @param {Function} errorCallback - Error callback + * @return {Promise} + */ + async bulkUpdateMetadata( + items: BoxItem[], + template: MetadataTemplate, + operations: JSONPatchOperations[], + successCallback: Function, + errorCallback: ElementsErrorCallback, + ): Promise { + this.errorCode = ERROR_CODE_UPDATE_METADATA; + this.successCallback = successCallback; + this.errorCallback = errorCallback; + + try { + const updatePromises = items.map(async (item, index) => { + try { + // Suppress per-item callbacks; aggregate outcome at the bulk level only + await this.updateMetadata(item, template, operations[index], successCallback, errorCallback, true); + } catch (e) { + // Re-throw to be caught by Promise.all and handled once below + throw new Error(`Failed to update metadata: ${e.message || e}`); + } + }); + + await Promise.all(updatePromises); + + if (!this.isDestroyed()) { + this.successHandler(); + } + } catch (e) { + if (!this.isDestroyed()) { + this.errorHandler(e); + } + } + } + /** * API for patching metadata on file * diff --git a/src/api/__tests__/Metadata.test.js b/src/api/__tests__/Metadata.test.js index fd90a02099..00c17e5c58 100644 --- a/src/api/__tests__/Metadata.test.js +++ b/src/api/__tests__/Metadata.test.js @@ -1700,6 +1700,7 @@ describe('api/Metadata', () => { permissions: { can_upload: true, }, + type: 'file', }; const ops = [{ op: 'add' }, { op: 'test' }]; const cache = new Cache(); @@ -1767,6 +1768,7 @@ describe('api/Metadata', () => { permissions: { can_upload: true, }, + type: 'file', }; const ops = [{ op: 'add' }, { op: 'test' }]; const cache = new Cache(); @@ -1833,6 +1835,7 @@ describe('api/Metadata', () => { permissions: { can_upload: true, }, + type: 'file', }; const ops = [{ op: 'add' }, { op: 'test' }]; const cache = new Cache(); @@ -1894,6 +1897,123 @@ describe('api/Metadata', () => { }); }); + describe('bulkUpdateMetadata()', () => { + test('should call updateMetadata for each item and call successHandler when all succeed', async () => { + const success = jest.fn(); + const error = jest.fn(); + const items = [ + { id: '1', name: 'file1', permissions: { can_upload: true }, type: 'file' }, + { id: '2', name: 'file2', permissions: { can_upload: true }, type: 'file' }, + ]; + const template = { scope: 'scope', templateKey: 'templateKey' }; + const ops = [[{ op: 'replace', path: '/foo', value: 'a' }], [{ op: 'replace', path: '/foo', value: 'b' }]]; + + metadata.updateMetadata = jest.fn().mockResolvedValue(undefined); + metadata.isDestroyed = jest.fn().mockReturnValue(false); + metadata.successHandler = jest.fn(); + metadata.errorHandler = jest.fn(); + + await metadata.bulkUpdateMetadata(items, template, ops, success, error); + + expect(metadata.errorCode).toBe(ERROR_CODE_UPDATE_METADATA); + expect(metadata.successCallback).toBe(success); + expect(metadata.errorCallback).toBe(error); + expect(metadata.updateMetadata).toHaveBeenCalledTimes(2); + expect(metadata.updateMetadata).toHaveBeenNthCalledWith( + 1, + items[0], + template, + ops[0], + success, + error, + true, + ); + expect(metadata.updateMetadata).toHaveBeenNthCalledWith( + 2, + items[1], + template, + ops[1], + success, + error, + true, + ); + expect(metadata.isDestroyed).toHaveBeenCalledTimes(1); + expect(metadata.successHandler).toHaveBeenCalledTimes(1); + expect(metadata.errorHandler).not.toHaveBeenCalled(); + }); + + test('should call errorHandler with aggregated error when any update fails', async () => { + const success = jest.fn(); + const error = jest.fn(); + const items = [ + { id: '1', name: 'file1', permissions: { can_upload: true }, type: 'file' }, + { id: '2', name: 'file2', permissions: { can_upload: true }, type: 'file' }, + ]; + const template = { scope: 'scope', templateKey: 'templateKey' }; + const ops = [[], []]; + + metadata.updateMetadata = jest + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('mock error')); + metadata.isDestroyed = jest.fn().mockReturnValue(false); + metadata.successHandler = jest.fn(); + metadata.errorHandler = jest.fn(); + + await metadata.bulkUpdateMetadata(items, template, ops, success, error); + + expect(metadata.updateMetadata).toHaveBeenCalledTimes(2); + expect(metadata.errorHandler).toHaveBeenCalledTimes(1); + const errArg = metadata.errorHandler.mock.calls[0][0]; + expect(errArg).toBeInstanceOf(Error); + expect(errArg.message).toContain('Failed to update metadata'); + expect(errArg.message).toContain('mock error'); + expect(metadata.successHandler).not.toHaveBeenCalled(); + }); + + test('should not call successHandler when destroyed after successful updates', async () => { + const success = jest.fn(); + const error = jest.fn(); + const items = [ + { id: '1', name: 'file1', permissions: { can_upload: true }, type: 'file' }, + { id: '2', name: 'file2', permissions: { can_upload: true }, type: 'file' }, + ]; + const template = { scope: 'scope', templateKey: 'templateKey' }; + const ops = [[], []]; + + metadata.updateMetadata = jest.fn().mockResolvedValue(undefined); + metadata.isDestroyed = jest.fn().mockReturnValue(true); + metadata.successHandler = jest.fn(); + metadata.errorHandler = jest.fn(); + + await metadata.bulkUpdateMetadata(items, template, ops, success, error); + + expect(metadata.successHandler).not.toHaveBeenCalled(); + expect(metadata.errorHandler).not.toHaveBeenCalled(); + }); + + test('should not call errorHandler when destroyed after failure', async () => { + const success = jest.fn(); + const error = jest.fn(); + const items = [ + { id: '1', name: 'file1', permissions: { can_upload: true }, type: 'file' }, + { id: '2', name: 'file2', permissions: { can_upload: true }, type: 'file' }, + ]; + const template = { scope: 'scope', templateKey: 'templateKey' }; + const ops = [[], []]; + + metadata.updateMetadata = jest.fn().mockRejectedValue(new Error('mock error')); + metadata.isDestroyed = jest.fn().mockReturnValue(true); + metadata.successHandler = jest.fn(); + metadata.errorHandler = jest.fn(); + + await metadata.bulkUpdateMetadata(items, template, ops, success, error); + + expect(metadata.successHandler).not.toHaveBeenCalled(); + expect(metadata.errorHandler).not.toHaveBeenCalled(); + }); + }); + describe('updateMetadataRedesign()', () => { test('should call error callback with a bad item error when no id', () => { jest.spyOn(ErrorUtil, 'getBadItemError').mockReturnValueOnce('error'); diff --git a/src/elements/common/messages.js b/src/elements/common/messages.js index 2557ab6241..d9773595e3 100644 --- a/src/elements/common/messages.js +++ b/src/elements/common/messages.js @@ -27,6 +27,11 @@ const messages = defineMessages({ description: 'Generic error label.', defaultMessage: 'Error', }, + success: { + id: 'be.success', + description: 'Generic success label.', + defaultMessage: 'Success', + }, preview: { id: 'be.preview', description: 'Label for preview action.', @@ -1105,6 +1110,26 @@ const messages = defineMessages({ } `, }, + multipleValues: { + id: 'be.multipleValues', + description: 'Display text for field when there are multiple items selected and have different value', + defaultMessage: 'Multiple Values', + }, + metadataUpdateErrorNotification: { + id: 'be.metadataUpdateErrorNotification', + description: 'Text shown in error notification banner', + defaultMessage: 'Unable to save changes. Please try again.', + }, + metadataUpdateSuccessNotification: { + id: 'be.metadataUpdateSuccessNotification', + description: 'Text shown in success notification banner', + defaultMessage: ` + {numSelected, plural, + =1 {1 document updated} + other {# documents updated} + } + `, + }, }); export default messages; diff --git a/src/elements/content-explorer/ContentExplorer.tsx b/src/elements/content-explorer/ContentExplorer.tsx index 4011268574..b0f286e6a3 100644 --- a/src/elements/content-explorer/ContentExplorer.tsx +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -8,9 +8,10 @@ import getProp from 'lodash/get'; import noop from 'lodash/noop'; import throttle from 'lodash/throttle'; import uniqueid from 'lodash/uniqueId'; -import { TooltipProvider } from '@box/blueprint-web'; +import { Notification, TooltipProvider } from '@box/blueprint-web'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import type { Key, Selection } from 'react-aria-components'; +import type { MetadataTemplateField } from '@box/metadata-editor'; import CreateFolderDialog from '../common/create-folder-dialog'; import UploadDialog from '../common/upload-dialog'; @@ -77,6 +78,7 @@ import { import type { ViewMode } from '../common/flowTypes'; import type { ItemAction } from '../common/item'; import type { Theme } from '../common/theming'; +import type { JSONPatchOperations } from '../../common/types/api'; import type { MetadataQuery, FieldsToShow } from '../../common/types/metadataQueries'; import type { MetadataFieldValue, MetadataTemplate } from '../../common/types/metadata'; import type { @@ -492,6 +494,38 @@ class ContentExplorer extends Component { ); } + /** + * Update selected items' metadata instances based on original and new field values in the metadata instance form + * + * @private + * @return {void} + */ + updateMetadataV2 = async ( + items: BoxItem[], + operations: JSONPatchOperations, + templateOldFields: MetadataTemplateField[], + templateNewFields: MetadataTemplateField[], + successCallback: () => void, + errorCallback: ErrorCallback, + ) => { + if (items.length === 1) { + await this.metadataQueryAPIHelper.updateMetadataWithOperations( + items[0], + operations, + successCallback, + errorCallback, + ); + } else { + await this.metadataQueryAPIHelper.bulkUpdateMetadata( + items, + templateOldFields, + templateNewFields, + successCallback, + errorCallback, + ); + } + }; + /** * Resets the collection so that the loading bar starts showing * @@ -1785,188 +1819,193 @@ class ContentExplorer extends Component { /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ return ( - -
- -
-
- {!isDefaultViewMetadata && ( -
+ + + +
+ +
+
+ {!isDefaultViewMetadata && ( +
+ )} + + + + + + {!isErrorView && ( +
+ +
+ )} +
+ {isDefaultViewMetadata && isMetadataViewV2Feature && isMetadataSidePanelOpen && ( + )} - - + {allowUpload && !!this.appElement ? ( + - - - - {!isErrorView && ( -
- -
- )} -
- {isDefaultViewMetadata && isMetadataViewV2Feature && isMetadataSidePanelOpen && ( - + ) : null} + {canRename && selected && !!this.appElement ? ( + + ) : null} + {canShare && selected && !!this.appElement ? ( + + ) : null} + {canPreview && selected && !!this.appElement ? ( + - )} + ) : null}
- {allowUpload && !!this.appElement ? ( - - ) : null} - {allowCreate && !!this.appElement ? ( - - ) : null} - {canDelete && selected && !!this.appElement ? ( - - ) : null} - {canRename && selected && !!this.appElement ? ( - - ) : null} - {canShare && selected && !!this.appElement ? ( - - ) : null} - {canPreview && selected && !!this.appElement ? ( - - ) : null} -
- + + ); /* eslint-enable jsx-a11y/no-static-element-interactions */ diff --git a/src/elements/content-explorer/MetadataQueryAPIHelper.ts b/src/elements/content-explorer/MetadataQueryAPIHelper.ts index ba091a856e..6f4fbbb3af 100644 --- a/src/elements/content-explorer/MetadataQueryAPIHelper.ts +++ b/src/elements/content-explorer/MetadataQueryAPIHelper.ts @@ -3,8 +3,10 @@ import find from 'lodash/find'; import getProp from 'lodash/get'; import includes from 'lodash/includes'; import isArray from 'lodash/isArray'; -import isNil from 'lodash/isNil'; +import type { MetadataTemplateField } from '@box/metadata-editor'; +import type { MetadataFieldType } from '@box/metadata-view'; import API from '../../api'; +import { areFieldValuesEqual, isEmptyValue, isMultiValuesField } from './utils'; import { JSON_PATCH_OP_ADD, @@ -14,7 +16,7 @@ import { METADATA_FIELD_TYPE_ENUM, METADATA_FIELD_TYPE_MULTISELECT, } from '../../common/constants'; -import { FIELD_NAME, FIELD_METADATA, FIELD_EXTENSION } from '../../constants'; +import { FIELD_NAME, FIELD_METADATA, FIELD_EXTENSION, FIELD_PERMISSIONS } from '../../constants'; import type { MetadataQuery as MetadataQueryType, MetadataQueryResponseData } from '../../common/types/metadataQueries'; import type { @@ -57,13 +59,18 @@ export default class MetadataQueryAPIHelper { oldValue: MetadataFieldValue | null, newValue: MetadataFieldValue | null, ): JSONPatchOperations => { + // check if two values are the same, return empty operations if so + if (areFieldValuesEqual(oldValue, newValue)) { + return []; + } + let operation = JSON_PATCH_OP_REPLACE; - if (isNil(oldValue) && newValue) { + if (isEmptyValue(oldValue) && !isEmptyValue(newValue)) { operation = JSON_PATCH_OP_ADD; } - if (oldValue && isNil(newValue)) { + if (!isEmptyValue(oldValue) && isEmptyValue(newValue)) { operation = JSON_PATCH_OP_REMOVE; } @@ -170,6 +177,51 @@ export default class MetadataQueryAPIHelper { return this.api.getMetadataAPI(true).getSchemaByTemplateKey(this.templateKey); }; + /** + * Generate operations for all fields update in the metadata sidepanel + * + * @private + * @return {JSONPatchOperations} + */ + generateOperations = ( + item: BoxItem, + templateOldFields: MetadataTemplateField[], + templateNewFields: MetadataTemplateField[], + ): JSONPatchOperations => { + const { scope, templateKey } = this.metadataTemplate; + const itemFields = item.metadata[scope][templateKey]; + const operations = templateNewFields.flatMap(newField => { + let newFieldValue = newField.value; + const { key, type } = newField; + // when retrieve value from float type field, it gives a string instead + if (type === 'float' && newFieldValue !== '') { + newFieldValue = Number(newFieldValue); + } + const oldField = templateOldFields.find(f => f.key === key); + const oldFieldValue = oldField.value; + + /* + Generate operations array based on all the fields' orignal value and the incoming updated value. + + Edge Case: + If there are multiple items shared different value for enum or multi-select field, the form will + return 'Multiple values' as the value. In this case, it needs to generate operation based on the + actual item's field value. + */ + const shouldUseItemFieldValue = + isMultiValuesField(type as MetadataFieldType, oldFieldValue) && + !isMultiValuesField(type as MetadataFieldType, newFieldValue); + + return this.createJSONPatchOperations( + key, + shouldUseItemFieldValue ? itemFields[key] : oldFieldValue, + newFieldValue, + ); + }); + + return operations; + }; + queryMetadata = (): Promise => { return new Promise((resolve, reject) => { this.api.getMetadataQueryAPI().queryMetadata(this.metadataQuery, resolve, reject, { forceFetch: true }); @@ -205,6 +257,34 @@ export default class MetadataQueryAPIHelper { .updateMetadata(file, this.metadataTemplate, operations, successCallback, errorCallback); }; + updateMetadataWithOperations = ( + item: BoxItem, + operations: JSONPatchOperations, + successCallback: () => void, + errorCallback: ErrorCallback, + ): Promise => { + return this.api + .getMetadataAPI(true) + .updateMetadata(item, this.metadataTemplate, operations, successCallback, errorCallback); + }; + + bulkUpdateMetadata = ( + items: BoxItem[], + templateOldFields: MetadataTemplateField[], + templateNewFields: MetadataTemplateField[], + successCallback: () => void, + errorCallback: ErrorCallback, + ): Promise => { + const operations: JSONPatchOperations = []; + items.forEach(item => { + const operation = this.generateOperations(item, templateOldFields, templateNewFields); + operations.push(operation); + }); + return this.api + .getMetadataAPI(true) + .bulkUpdateMetadata(items, this.metadataTemplate, operations, successCallback, errorCallback); + }; + /** * Verify that the metadata query has required fields and update it if necessary * For a file item, default fields included in the response are "type", "id", "etag" @@ -225,6 +305,11 @@ export default class MetadataQueryAPIHelper { clonedFields.push(FIELD_EXTENSION); } + // This field is necessary to check if the user has permission to update metadata + if (!clonedFields.includes(FIELD_PERMISSIONS)) { + clonedFields.push(FIELD_PERMISSIONS); + } + clonedQuery.fields = clonedFields; return clonedQuery; diff --git a/src/elements/content-explorer/MetadataSidePanel.tsx b/src/elements/content-explorer/MetadataSidePanel.tsx index 707b841a8e..cb8d152f57 100644 --- a/src/elements/content-explorer/MetadataSidePanel.tsx +++ b/src/elements/content-explorer/MetadataSidePanel.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { useIntl } from 'react-intl'; -import { IconButton, SidePanel, Text } from '@box/blueprint-web'; +import { IconButton, SidePanel, Text, useNotification } from '@box/blueprint-web'; import { XMark } from '@box/blueprint-web-assets/icons/Fill/index'; import { FileDefault } from '@box/blueprint-web-assets/icons/Line/index'; import { @@ -10,12 +10,13 @@ import { JSONPatchOperations, MetadataInstance, MetadataInstanceForm, + type MetadataTemplateField, } from '@box/metadata-editor'; import type { Selection } from 'react-aria-components'; -import type { Collection } from '../../common/types/core'; +import type { BoxItem, Collection } from '../../common/types/core'; import type { MetadataTemplate } from '../../common/types/metadata'; -import { getTemplateInstance, useSelectedItemText } from './utils'; +import { useTemplateInstance, useSelectedItemText } from './utils'; import messages from '../common/messages'; @@ -23,17 +24,29 @@ import './MetadataSidePanel.scss'; export interface MetadataSidePanelProps { currentCollection: Collection; - onClose: () => void; metadataTemplate: MetadataTemplate; + onClose: () => void; + onUpdate: ( + items: BoxItem[], + operations: JSONPatchOperations, + templateOldFields: MetadataTemplateField[], + templateNewFields: MetadataTemplateField[], + successCallback: () => void, + errorCallback: ErrorCallback, + ) => Promise; + refreshCollection: () => void; selectedItemIds: Selection; } const MetadataSidePanel = ({ currentCollection, + metadataTemplate, onClose, + onUpdate, + refreshCollection, selectedItemIds, - metadataTemplate, }: MetadataSidePanelProps) => { + const { addNotification } = useNotification(); const { formatMessage } = useIntl(); const [isEditing, setIsEditing] = useState(false); const [isUnsavedChangesModalOpen, setIsUnsavedChangesModalOpen] = useState(false); @@ -43,7 +56,7 @@ const MetadataSidePanel = ({ selectedItemIds === 'all' ? currentCollection.items : currentCollection.items.filter(item => selectedItemIds.has(item.id)); - const templateInstance = getTemplateInstance(metadataTemplate, selectedItems); + const templateInstance = useTemplateInstance(metadataTemplate, selectedItems, isEditing); const handleMetadataInstanceEdit = () => { setIsEditing(true); @@ -53,19 +66,47 @@ const MetadataSidePanel = ({ setIsEditing(false); }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const handleMetadataInstanceFormChange = (values: FormValues) => { - // TODO: Implement on form change - }; - const handleMetadataInstanceFormDiscardUnsavedChanges = () => { setIsUnsavedChangesModalOpen(false); setIsEditing(false); }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleUpdateMetadataSuccess = () => { + addNotification({ + closeButtonAriaLabel: formatMessage(messages.close), + sensitivity: 'foreground', + styledText: formatMessage(messages.metadataUpdateSuccessNotification, { + numSelected: selectedItems.length, + }), + typeIconAriaLabel: formatMessage(messages.success), + variant: 'success', + }); + setIsEditing(false); + refreshCollection(); + }; + + const handleUpdateMetadataError = () => { + addNotification({ + closeButtonAriaLabel: formatMessage(messages.close), + sensitivity: 'foreground', + styledText: formatMessage(messages.metadataUpdateErrorNotification), + typeIconAriaLabel: formatMessage(messages.error), + variant: 'error', + }); + }; + const handleMetadataInstanceFormSubmit = async (values: FormValues, operations: JSONPatchOperations) => { - // TODO: Implement onSave callback + const { fields: templateNewFields } = values.metadata; + const { fields: templateOldFields } = templateInstance; + + await onUpdate( + selectedItems, + operations, + templateOldFields, + templateNewFields, + handleUpdateMetadataSuccess, + handleUpdateMetadataError, + ); }; return ( @@ -99,7 +140,7 @@ const MetadataSidePanel = ({ isUnsavedChangesModalOpen={isUnsavedChangesModalOpen} selectedTemplateInstance={templateInstance} onCancel={handleMetadataInstanceFormCancel} - onChange={handleMetadataInstanceFormChange} + onChange={null} onDelete={null} onDiscardUnsavedChanges={handleMetadataInstanceFormDiscardUnsavedChanges} onSubmit={handleMetadataInstanceFormSubmit} diff --git a/src/elements/content-explorer/__tests__/MetadataQueryAPIHelper.test.ts b/src/elements/content-explorer/__tests__/MetadataQueryAPIHelper.test.ts index b996a6265d..9fa66481c6 100644 --- a/src/elements/content-explorer/__tests__/MetadataQueryAPIHelper.test.ts +++ b/src/elements/content-explorer/__tests__/MetadataQueryAPIHelper.test.ts @@ -8,7 +8,7 @@ import { JSON_PATCH_OP_REPLACE, JSON_PATCH_OP_TEST, } from '../../../common/constants'; -import { FIELD_METADATA, FIELD_NAME, FIELD_EXTENSION } from '../../../constants'; +import { FIELD_METADATA, FIELD_NAME, FIELD_EXTENSION, FIELD_PERMISSIONS } from '../../../constants'; describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { let metadataQueryAPIHelper; @@ -426,27 +426,30 @@ describe('features/metadata-based-view/MetadataQueryAPIHelper', () => { expect(isArray(updatedMetadataQuery.fields)).toBe(true); expect(includes(updatedMetadataQuery.fields, FIELD_NAME)).toBe(true); expect(includes(updatedMetadataQuery.fields, FIELD_EXTENSION)).toBe(true); + expect(includes(updatedMetadataQuery.fields, FIELD_PERMISSIONS)).toBe(true); if (index === 2) { - // Verify "name" and "extension" are added to pre-existing fields + // Verify "name", "extension" and "permission" are added to pre-existing fields expect(updatedMetadataQuery.fields).toEqual([ ...mdQueryWithoutNameField.fields, FIELD_NAME, FIELD_EXTENSION, + FIELD_PERMISSIONS, ]); } if (index === 4) { - // Verify "extension" is added when "name" exists but "extension" doesn't + // Verify "extension" and "permission" are added when "name" exists but "extension" and "permission" don't expect(updatedMetadataQuery.fields).toEqual([ ...mdQueryWithoutExtensionField.fields, FIELD_EXTENSION, + FIELD_PERMISSIONS, ]); } if (index === 5) { - // No change, original query has all necessary fields - expect(updatedMetadataQuery.fields).toEqual(mdQueryWithBothFields.fields); + // Verify "permission" is added + expect(updatedMetadataQuery.fields).toEqual([...mdQueryWithBothFields.fields, FIELD_PERMISSIONS]); } }, ); diff --git a/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx b/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx index 7b811b38e0..e02b4e0a0a 100644 --- a/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx +++ b/src/elements/content-explorer/__tests__/MetadataSidePanel.test.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import userEvent from '@testing-library/user-event'; -import { render, screen } from '../../../test-utils/testing-library'; +import { Notification } from '@box/blueprint-web'; +import { render, screen, waitFor } from '../../../test-utils/testing-library'; import MetadataSidePanel, { type MetadataSidePanelProps } from '../MetadataSidePanel'; // Mock scrollTo method @@ -65,13 +66,20 @@ const mockOnClose = jest.fn(); describe('elements/content-explorer/MetadataSidePanel', () => { const defaultProps: MetadataSidePanelProps = { currentCollection: mockCollection, - onClose: mockOnClose, metadataTemplate: mockMetadataTemplate, + onClose: mockOnClose, + onUpdate: jest.fn(), + refreshCollection: jest.fn(), selectedItemIds: new Set(['1']), }; const renderComponent = (props: Partial = {}) => - render(); + render( + + + + , + ); test('renders the metadata title', () => { renderComponent(); @@ -124,4 +132,138 @@ describe('elements/content-explorer/MetadataSidePanel', () => { const submitButton = screen.getByRole('button', { name: 'Save' }); expect(submitButton).toBeInTheDocument(); }); + + test('switches back to view mode when cancel button is clicked', async () => { + renderComponent(); + const editTemplateButton = screen.getByLabelText('Edit Mock Template'); + await userEvent.click(editTemplateButton); + + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + await userEvent.click(cancelButton); + + // Should be back in view mode + expect(screen.getByLabelText('Edit Mock Template')).toBeInTheDocument(); + }); + + test('calls onUpdate when form is submitted for single item', async () => { + const mockUpdateMetadata = jest.fn().mockResolvedValue(undefined); + renderComponent({ onUpdate: mockUpdateMetadata }); + + const editTemplateButton = screen.getByLabelText('Edit Mock Template'); + await userEvent.click(editTemplateButton); + + const submitButton = screen.getByRole('button', { name: 'Save' }); + await userEvent.click(submitButton); + + expect(mockUpdateMetadata).toHaveBeenCalledWith( + [mockCollection.items[0]], + expect.any(Array), + expect.any(Array), + expect.any(Array), + expect.any(Function), + expect.any(Function), + ); + }); + + test('calls onUpdate when multiple items are selected', async () => { + const mockUpdateMetadata = jest.fn().mockResolvedValue(undefined); + + renderComponent({ + selectedItemIds: new Set(['1', '2']), + onUpdate: mockUpdateMetadata, + }); + + const editTemplateButton = screen.getByLabelText('Edit Mock Template'); + await userEvent.click(editTemplateButton); + + const submitButton = screen.getByRole('button', { name: 'Save' }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(mockUpdateMetadata).toHaveBeenCalledTimes(1); + }); + }); + + test('displays success notification when metadata update succeeds', async () => { + const mockUpdateMetadata = jest.fn().mockImplementation((_, __, ___, ____, successCallback) => { + successCallback(); + return Promise.resolve(); + }); + const mockRefreshCollection = jest.fn(); + + renderComponent({ + onUpdate: mockUpdateMetadata, + refreshCollection: mockRefreshCollection, + }); + + const editTemplateButton = screen.getByLabelText('Edit Mock Template'); + await userEvent.click(editTemplateButton); + + const submitButton = screen.getByRole('button', { name: 'Save' }); + await userEvent.click(submitButton); + + expect(screen.getByText('1 document updated')).toBeInTheDocument(); + expect(mockRefreshCollection).toHaveBeenCalledTimes(1); + expect(screen.getByLabelText('Edit Mock Template')).toBeInTheDocument(); // Back to view mode + }); + + test('displays error notification when metadata update fails', async () => { + const mockUpdateMetadata = jest.fn().mockImplementation((_, __, ___, ____, _____, errorCallback) => { + errorCallback(); + return Promise.resolve(); + }); + + renderComponent({ onUpdate: mockUpdateMetadata }); + + const editTemplateButton = screen.getByLabelText('Edit Mock Template'); + await userEvent.click(editTemplateButton); + + const submitButton = screen.getByRole('button', { name: 'Save' }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText('Unable to save changes. Please try again.')).toBeInTheDocument(); + }); + }); + + test('handles "all" selection correctly', () => { + renderComponent({ selectedItemIds: 'all' }); + const subtitle = screen.getByText('2 files selected'); + expect(subtitle).toBeInTheDocument(); + }); + + test('displays "Multiple Values" for items with different field values', () => { + const collectionWithDifferentValues = { + ...mockCollection, + items: [ + { + ...mockCollection.items[0], + metadata: { + enterprise_123: { + mockTemplate: { + alias: 'value-1', + }, + }, + }, + }, + { + ...mockCollection.items[1], + metadata: { + enterprise_123: { + mockTemplate: { + alias: 'value-2', + }, + }, + }, + }, + ], + }; + + renderComponent({ + currentCollection: collectionWithDifferentValues, + selectedItemIds: new Set(['1', '2']), + }); + + expect(screen.getByText('Multiple Values')).toBeInTheDocument(); + }); }); 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 f3d56bd0b3..c854553462 100644 --- a/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx +++ b/src/elements/content-explorer/stories/tests/MetadataView-visual.stories.tsx @@ -238,6 +238,37 @@ export const metadataViewV2WithBulkItemActionMenuShowsItemActionMenu: Story = { }, }; +export const sidePanelOpenWithMultipleItemsSelected: Story = { + args: { + ...metadataViewV2ElementProps, + metadataViewProps: { + columns, + tableProps: { + isSelectAllEnabled: true, + }, + }, + }, + + play: async ({ canvas }) => { + await waitFor(() => { + expect(canvas.getByRole('row', { name: /Child 2/i })).toBeInTheDocument(); + }); + + // Select the first row by clicking its checkbox + const firstItem = canvas.getByRole('row', { name: /Child 2/i }); + const checkbox = within(firstItem).getByRole('checkbox'); + await userEvent.click(checkbox); + + // Select the second row by clicking its checkbox + const secondItem = canvas.getAllByRole('row', { name: /Child 1/i })[0]; + const secondCheckbox = within(secondItem).getByRole('checkbox'); + await userEvent.click(secondCheckbox); + + const metadataButton = canvas.getByRole('button', { name: 'Metadata' }); + await userEvent.click(metadataButton); + }, +}; + const meta: Meta = { title: 'Elements/ContentExplorer/tests/MetadataView/visual', component: ContentExplorer, diff --git a/src/elements/content-explorer/utils.ts b/src/elements/content-explorer/utils.ts index 4c3abf0c6c..07991feb4b 100644 --- a/src/elements/content-explorer/utils.ts +++ b/src/elements/content-explorer/utils.ts @@ -1,12 +1,24 @@ -import { useMemo } from 'react'; +import React, { useMemo } from 'react'; import { useIntl } from 'react-intl'; +import isNil from 'lodash/isNil'; +import xor from 'lodash/xor'; -import type { MetadataTemplate } from '@box/metadata-editor'; +import { + MULTI_VALUE_DEFAULT_OPTION, + MULTI_VALUE_DEFAULT_VALUE, + type MetadataTemplate, + type MetadataFormFieldValue, +} from '@box/metadata-editor'; +import type { MetadataFieldType } from '@box/metadata-view'; import type { Selection } from 'react-aria-components'; import type { BoxItem, Collection } from '../../common/types/core'; import messages from '../common/messages'; +// Specific type for metadata field value in the item +// Note: Item doesn't have field value in metadata object if that field is not set, so the value will be undefined in this case +type ItemMetadataFieldValue = string | number | Array | null | undefined; + // Get selected item text export function useSelectedItemText(currentCollection: Collection, selectedItemIds: Selection): string { const { formatMessage } = useIntl(); @@ -28,21 +40,146 @@ export function useSelectedItemText(currentCollection: Collection, selectedItemI }, [currentCollection.items, formatMessage, selectedItemIds]); } +// Check if the field value is empty. +// Note: 0 doesn't represent empty here because of float type field +export function isEmptyValue(value: ItemMetadataFieldValue) { + if (isNil(value)) { + return true; + } + + // date, string, enum + if (value === '') { + return true; + } + + // multiSelect + if (Array.isArray(value) && value.length === 0) { + return true; + } + + // float + if (Number.isNaN(value)) { + return true; + } + + return false; +} + +// Check if the field values are equal based on the field types +export function areFieldValuesEqual(value1: ItemMetadataFieldValue, value2: ItemMetadataFieldValue) { + if (isEmptyValue(value1) && isEmptyValue(value2)) { + return true; + } + + // Handle multiSelect arrays comparison + if (Array.isArray(value1) && Array.isArray(value2)) { + return xor(value1, value2).length === 0; + } + + return value1 === value2; +} + +// Return default form value by field type +function getDefaultValueByFieldType(fieldType: MetadataFieldType) { + if (fieldType === 'date' || fieldType === 'enum' || fieldType === 'float' || fieldType === 'string') { + return ''; + } + if (fieldType === 'multiSelect') { + return []; + } + return undefined; +} + +// Set the field value in Metadata Form based on the field type +function getFieldValue(fieldType: MetadataFieldType, fieldValue: ItemMetadataFieldValue) { + if (isNil(fieldValue)) { + return getDefaultValueByFieldType(fieldType); + } + return fieldValue; +} + +// Check if the field value in Metadata Form is multi-values such as "Multiple values" +export function isMultiValuesField(fieldType: MetadataFieldType, fieldValue: MetadataFormFieldValue) { + if (fieldType === 'multiSelect') { + return Array.isArray(fieldValue) && fieldValue.length === 1 && fieldValue[0] === MULTI_VALUE_DEFAULT_VALUE; + } + if (fieldType === 'enum') { + return fieldValue === MULTI_VALUE_DEFAULT_VALUE; + } + return false; +} + // Get template instance based on metadata template and selected items -export function getTemplateInstance(metadataTemplate: MetadataTemplate, selectedItems: BoxItem[]) { +export function useTemplateInstance(metadataTemplate: MetadataTemplate, selectedItems: BoxItem[], isEditing: boolean) { + const { formatMessage } = useIntl(); const { displayName, fields, hidden, id, scope, templateKey, type } = metadataTemplate; const selectedItemsFields = fields.map( - ({ displayName: fieldDisplayName, hidden: fieldHidden, id: fieldId, key, options, type: fieldType }) => ({ - displayName: fieldDisplayName, - hidden: fieldHidden, - id: fieldId, - key, - options, - type: fieldType, - // TODO: Add support for multiple selected items - value: selectedItems[0].metadata[scope][templateKey][key], - }), + ({ displayName: fieldDisplayName, hidden: fieldHidden, id: fieldId, key, options, type: fieldType }) => { + const defaultItemField = { + displayName: fieldDisplayName, + hidden: fieldHidden, + id: fieldId, + key, + options, + type: fieldType, + value: getFieldValue(fieldType as MetadataFieldType, undefined), + }; + + const firstSelectedItem = selectedItems[0]; + const firstSelectedItemFieldValue = firstSelectedItem.metadata[scope][templateKey][key]; + + // Case 1: Single selected item + if (selectedItems.length <= 1) { + return { + ...defaultItemField, + value: firstSelectedItemFieldValue, + }; + } + + // Case 2.1: Multiple selected items, but all have the same initial value + const allItemsHaveSameInitialValue = selectedItems.every(selectedItem => + areFieldValuesEqual(selectedItem.metadata[scope][templateKey][key], firstSelectedItemFieldValue), + ); + + if (allItemsHaveSameInitialValue) { + return { + ...defaultItemField, + value: getFieldValue(fieldType as MetadataFieldType, firstSelectedItemFieldValue), + }; + } + + // Case 2.2: Multiple selected items, but some have different initial values + // Case 2.2.1: Edit Mode + if (isEditing) { + let fieldValue = getFieldValue(fieldType as MetadataFieldType, undefined); + // Add MultiValue Option if the field is multiSelect or enum + if (fieldType === 'multiSelect' || fieldType === 'enum') { + fieldValue = fieldType === 'enum' ? MULTI_VALUE_DEFAULT_VALUE : [MULTI_VALUE_DEFAULT_VALUE]; + const multiValueOption = options?.find(option => option.key === MULTI_VALUE_DEFAULT_VALUE); + if (!multiValueOption) { + options?.push(MULTI_VALUE_DEFAULT_OPTION); + } + } + return { + ...defaultItemField, + value: fieldValue, + }; + } + + /** + * Case: 2.2.2 View Mode + * + * We want to show "Multiple values" label for multiple dates across files selection. + * We use fragment here to bypass check in shared feature. + * This feature tries to parse string as date if the string is passed as value. + */ + const multipleValuesText = formatMessage(messages.multipleValues); + return { + ...defaultItemField, + value: React.createElement(React.Fragment, null, multipleValuesText), + }; + }, ); return {