Skip to content
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
8 changes: 8 additions & 0 deletions i18n/en-US.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
94 changes: 84 additions & 10 deletions src/api/Metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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<void> {
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;
Expand All @@ -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()) {
Expand All @@ -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<void> {
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
*
Expand Down
120 changes: 120 additions & 0 deletions src/api/__tests__/Metadata.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1700,6 +1700,7 @@ describe('api/Metadata', () => {
permissions: {
can_upload: true,
},
type: 'file',
};
const ops = [{ op: 'add' }, { op: 'test' }];
const cache = new Cache();
Expand Down Expand Up @@ -1767,6 +1768,7 @@ describe('api/Metadata', () => {
permissions: {
can_upload: true,
},
type: 'file',
};
const ops = [{ op: 'add' }, { op: 'test' }];
const cache = new Cache();
Expand Down Expand Up @@ -1833,6 +1835,7 @@ describe('api/Metadata', () => {
permissions: {
can_upload: true,
},
type: 'file',
};
const ops = [{ op: 'add' }, { op: 'test' }];
const cache = new Cache();
Expand Down Expand Up @@ -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');
Expand Down
25 changes: 25 additions & 0 deletions src/elements/common/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -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;
Loading