Skip to content
Closed
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
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"build:i18n": "node scripts/buildTranslations.js",
"build:npm": "npm-run-all clean build:i18n build:prod:npm build:prod:es",
"build:prod:analyze": "BUNDLE_ANALYSIS=true npm-run-all setup build:prod:npm",
"build:prod:dist": "NODE_ENV=production webpack --config scripts/webpack.config.js --mode production",
"build:prod:dist": "NODE_ENV=production webpack --config scripts/webpack.config.js --mode production --progress",
"build:prod:es": "NODE_ENV=production BABEL_ENV=npm yarn build:es --source-maps --ignore \"**/*.d.ts,**/__tests__/**,**/__mocks__/**\"",
"build:prod:npm": "BABEL_ENV=production OUTPUT=dist LANGUAGE=en-US REACT=true yarn build:prod:dist",
"build:prod:storybook": "NODE_ENV=production BABEL_ENV=production BROWSERSLIST_ENV=production LANGUAGE=en-US REACT=true storybook build -o storybook",
Expand Down Expand Up @@ -107,6 +107,9 @@
"commit-msg": "commitlint -e"
}
},
"dependencies": {
"box-typescript-sdk-gen": "^1.17.1"
},
Comment on lines +110 to +112
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Move box-typescript-sdk-gen to peerDeps (and devDeps) to avoid bundling the whole SDK.

This is a library; shipping the SDK in dependencies risks duplicate installs and bigger bundles for consumers. Make it a peer, keep a devDep for local builds. Don’t weigh folks down, fool.

Apply this diff:

-  "dependencies": {
-    "box-typescript-sdk-gen": "^1.17.1"
-  },
+  "dependencies": {},
   "devDependencies": {
+    "box-typescript-sdk-gen": "^1.17.1",

And add to peerDependencies:

   "peerDependencies": {
+    "box-typescript-sdk-gen": ">=1.17.1",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"dependencies": {
"box-typescript-sdk-gen": "^1.17.1"
},
- "dependencies": {
- "box-typescript-sdk-gen": "^1.17.1"
"dependencies": {},

"devDependencies": {
"@babel/cli": "^7.24.7",
"@babel/core": "^7.24.7",
Expand Down Expand Up @@ -136,7 +139,7 @@
"@box/languages": "^1.0.0",
"@box/metadata-editor": "^0.122.12",
"@box/metadata-filter": "^1.19.2",
"@box/metadata-view": "^0.48.1",
"@box/metadata-view": "^0.41.2",
"@box/react-virtualized": "^9.22.3-rc-box.10",
"@box/types": "^0.2.1",
"@cfaester/enzyme-adapter-react-18": "^0.8.0",
Expand Down Expand Up @@ -303,7 +306,7 @@
"@box/item-icon": "^0.17.15",
"@box/metadata-editor": "^0.122.12",
"@box/metadata-filter": "^1.19.2",
"@box/metadata-view": "^0.48.1",
"@box/metadata-view": "^0.41.2",
"@box/react-virtualized": "^9.22.3-rc-box.10",
"@box/types": "^0.2.1",
"@hapi/address": "^2.1.4",
Expand Down
4 changes: 4 additions & 0 deletions scripts/jest/jest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ Object.defineProperty(global, 'TextEncoder', {
value: util.TextEncoder,
});

Object.defineProperty(global, 'TextDecoder', {
value: util.TextDecoder,
});

global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
Expand Down
2 changes: 1 addition & 1 deletion scripts/jest/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,6 @@ module.exports = {
testMatch: ['**/__tests__/**/*.test.+(js|jsx|ts|tsx)'],
testPathIgnorePatterns: ['stories.test.js$', 'stories.test.tsx$', 'stories.test.d.ts'],
transformIgnorePatterns: [
'node_modules/(?!(@box/react-virtualized/dist/es|@box/cldr-data|@box/blueprint-web|@box/blueprint-web-assets|@box/metadata-editor|@box/box-ai-content-answers|@box/box-ai-agent-selector|@box/item-icon|@box/combobox-with-api|@box/tree|@box/metadata-filter|@box/metadata-view|@box/types|@box/box-item-type-selector)/)',
'node_modules/(?!(@box/react-virtualized/dist/es|@box/cldr-data|@box/blueprint-web|@box/blueprint-web-assets|@box/metadata-editor|@box/box-ai-content-answers|@box/box-ai-agent-selector|@box/item-icon|@box/combobox-with-api|@box/tree|@box/metadata-filter|@box/metadata-view|@box/types|@box/box-item-type-selector|jose)/)',
],
};
9 changes: 9 additions & 0 deletions src/api/APIFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import OpenWithAPI from './OpenWith';
import MetadataQueryAPI from './MetadataQuery';
import BoxEditAPI from './box-edit';
import IntelligenceAPI from './Intelligence';
// $FlowFixMe
import ZipDownloadAPI from './ZipDownload';
import { DEFAULT_HOSTNAME_API, DEFAULT_HOSTNAME_UPLOAD, TYPE_FOLDER, TYPE_FILE, TYPE_WEBLINK } from '../constants';
import type { ItemType } from '../common/types/core';
import type { APIOptions } from '../common/types/api';
Expand Down Expand Up @@ -204,6 +206,8 @@ class APIFactory {
*/
intelligenceAPI: IntelligenceAPI;

zipDownloadAPI: ZipDownloadAPI;

Comment on lines +209 to +210
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add destroy handling for zipDownloadAPI to prevent leaks

APIFactory.destroy currently does not clear zipDownloadAPI, unlike other APIs. Add symmetrical cleanup to avoid retaining instances/options.

Apply this diff near the other cleanup blocks in destroy():

         if (this.intelligenceAPI) {
             this.intelligenceAPI.destroy();
             delete this.intelligenceAPI;
         }
+
+        if (this.zipDownloadAPI) {
+            // No-op if destroy is not implemented
+            if (typeof this.zipDownloadAPI.destroy === 'function') {
+                this.zipDownloadAPI.destroy();
+            }
+            delete this.zipDownloadAPI;
+        }

And consider adding a no-op destroy() method to ZipDownloadAPI for consistency.

I can add the no-op destroy() to ZipDownloadAPI if you want.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
zipDownloadAPI: ZipDownloadAPI;
if (this.intelligenceAPI) {
this.intelligenceAPI.destroy();
delete this.intelligenceAPI;
}
if (this.zipDownloadAPI) {
// No-op if destroy() is not implemented
if (typeof this.zipDownloadAPI.destroy === 'function') {
this.zipDownloadAPI.destroy();
}
delete this.zipDownloadAPI;
}
🧰 Tools
🪛 Biome (2.1.2)

[error] 208-208: return types can only be used in TypeScript files

remove this type annotation

(parse)

🤖 Prompt for AI Agents
In src/api/APIFactory.js around lines 208-209, APIFactory.destroy() currently
omits cleanup for zipDownloadAPI; add the same symmetrical teardown as other
APIs by calling zipDownloadAPI.destroy() if present (or safe-call) and then
setting this.zipDownloadAPI = undefined/null inside the destroy() method
alongside the other cleanup blocks; also add a no-op destroy() method to the
ZipDownloadAPI class (or confirm one exists) so the safe-call is valid and
consistent.

/**
* [constructor]
*
Expand Down Expand Up @@ -857,6 +861,11 @@ class APIFactory {
this.intelligenceAPI = new IntelligenceAPI(this.options);
return this.intelligenceAPI;
}

getZipDownloadAPI(): ZipDownloadAPI {
this.zipDownloadAPI = new ZipDownloadAPI(this.options);
return this.zipDownloadAPI;
}
Comment on lines +865 to +868
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

❓ Verification inconclusive

Guard against non-string tokens; require a concrete token for ZipDownload.

ZipDownloadAPI expects a string token, but APIFactory forwards options where token can be string|function. That’ll blow up at runtime, fool.

Apply this diff to enforce a concrete token and optionally accept a file name:

-    getZipDownloadAPI(): ZipDownloadAPI {
-        this.zipDownloadAPI = new ZipDownloadAPI(this.options);
-        return this.zipDownloadAPI;
-    }
+    getZipDownloadAPI(opts?: { downloadFileName?: string }): ZipDownloadAPI {
+        const { token } = this.options;
+        if (typeof token !== 'string') {
+            throw new Error('ZipDownloadAPI requires a concrete string token. Resolve function/async tokens before calling getZipDownloadAPI.');
+        }
+        this.zipDownloadAPI = new ZipDownloadAPI({
+            token,
+            downloadFileName: opts?.downloadFileName,
+        });
+        return this.zipDownloadAPI;
+    }

If you prefer passing the token explicitly (to mirror past patterns), I can switch the signature to getZipDownloadAPI(token: string, opts?)—say the word.


Guard getZipDownloadAPI against non-string tokens, fool—throw if token isn’t a string!

  • Throw an Error in getZipDownloadAPI when this.options.token isn’t a string before new ZipDownloadAPI(...)
  • Add an optional opts parameter for downloadFileName and pass it into the ZipDownloadAPI constructor
🧰 Tools
🪛 Biome (2.1.2)

[error] 865-865: return type annotation are a TypeScript only feature. Convert your file to a TypeScript file or remove the syntax.

TypeScript only syntax

(parse)

🤖 Prompt for AI Agents
In src/api/APIFactory.js around lines 865 to 868, getZipDownloadAPI must
validate this.options.token and accept an optional opts parameter for
downloadFileName: before instantiating ZipDownloadAPI, check that typeof
this.options.token === 'string' and if not throw a new Error with a clear
message; change the method signature to accept an optional opts parameter (e.g.,
opts = {}) and pass that opts into the new ZipDownloadAPI(this.options, opts)
constructor so the downloadFileName option is forwarded.

}

export default APIFactory;
122 changes: 12 additions & 110 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, getTypedFolderId } from '../utils/file';
import { getTypedFileId } from '../utils/file';
import { handleOnAbort, formatMetadataFieldValue } from './utils';
import File from './File';
import {
Expand Down Expand Up @@ -90,16 +90,6 @@ class Metadata extends File {
return `${this.getMetadataCacheKey(id)}_classification`;
}

/**
* Creates a key for the metadata template schema cache
*
* @param {string} templateKey - template key
* @return {string} key
*/
getMetadataTemplateSchemaCacheKey(templateKey: string): string {
return `${CACHE_PREFIX_METADATA}template_schema_${templateKey}`;
}

/**
* API URL for metadata
*
Expand All @@ -115,21 +105,6 @@ 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 @@ -362,23 +337,9 @@ class Metadata extends File {
* @param {string} templateKey - template key
* @return {Promise} Promise object of metadata template
*/
async getSchemaByTemplateKey(templateKey: string): Promise<MetadataTemplateSchemaResponse> {
const cache: APICache = this.getCache();
const key = this.getMetadataTemplateSchemaCacheKey(templateKey);

// Return cached value if it exists
if (cache.has(key)) {
return cache.get(key);
}

// Fetch from API if not cached
getSchemaByTemplateKey(templateKey: string): Promise<MetadataTemplateSchemaResponse> {
const url = this.getMetadataTemplateSchemaUrl(templateKey);
const response = await this.xhr.get({ url });

// Cache the response
cache.set(key, response);

return response;
return this.xhr.get({ url });
}

/**
Expand Down Expand Up @@ -825,33 +786,27 @@ class Metadata extends File {
}

/**
* API for patching metadata on item (file/folder)
* API for patching metadata on file
*
* @param {BoxItem} item - File/Folder object for which we are changing the description
* @param {BoxItem} file - File 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(
item: BoxItem,
file: BoxItem,
template: MetadataTemplate,
operations: JSONPatchOperations,
successCallback: Function,
errorCallback: ElementsErrorCallback,
suppressCallbacks?: boolean,
): Promise<void> {
this.errorCode = ERROR_CODE_UPDATE_METADATA;
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;
}
this.successCallback = successCallback;
this.errorCallback = errorCallback;

const { id, permissions, type } = item;
const { id, permissions } = file;
if (!id || !permissions) {
this.errorHandler(getBadItemError());
return;
Expand All @@ -866,14 +821,11 @@ class Metadata extends File {

try {
const metadata = await this.xhr.put({
url:
type === 'file'
? this.getMetadataUrl(id, template.scope, template.templateKey)
: this.getMetadataUrlForFolder(id, template.scope, template.templateKey),
url: this.getMetadataUrl(id, template.scope, template.templateKey),
headers: {
[HEADER_CONTENT_TYPE]: 'application/json-patch+json',
},
id: type === 'file' ? getTypedFileId(id) : getTypedFolderId(id),
id: getTypedFileId(id),
data: operations,
});
if (!this.isDestroyed()) {
Expand All @@ -888,63 +840,13 @@ class Metadata extends File {
editor,
);
}
if (!suppressCallbacks) {
this.successHandler(editor);
}
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
104 changes: 104 additions & 0 deletions src/api/ZipDownload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* @file ZipDownload class for creating and downloading ZIP archives using Box TypeScript SDK
* @author Box
*/

import { BoxClient, BoxDeveloperTokenAuth } from 'box-typescript-sdk-gen';
import { ZipDownloadRequest } from 'box-typescript-sdk-gen/lib/schemas/zipDownloadRequest.generated.d.ts.js';

export interface ZipDownloadItem {
id: string;
type: 'file' | 'folder';
}

export interface ZipDownloadOptions {
token: string;
downloadFileName?: string;
}

export interface ZipDownloadResponse {
downloadUrl?: string;
statusUrl?: string;
expiresAt?: string;
state?: 'in_progress' | 'failed' | 'succeeded';
totalCount?: number;
downloadedCount?: number;
skippedCount?: number;
}

/**
* ZipDownload class for creating and downloading ZIP archives from Box items
* Uses the box-typescript-sdk-gen for modern TypeScript support
*/
export default class ZipDownloadAPI {
private client: BoxClient;

private options: ZipDownloadOptions;

/**
* Constructor
* @param options - Configuration options including auth token
*/
constructor(options: ZipDownloadOptions) {
this.options = options;

// Initialize Box client with developer token authentication
const auth = new BoxDeveloperTokenAuth({ token: options.token });
this.client = new BoxClient({
auth,
});
}

/**
* Create a ZIP download request and initiate the download
* @param items - Array of file and folder items to include in ZIP
* @returns Promise resolving to the ZIP download response
*/
async createZipDownload(items: ZipDownloadItem[]): Promise<ZipDownloadResponse> {
if (!items || items.length === 0) {
throw new Error('Items array cannot be empty');
}

// Create the ZIP download request
const zipRequest: ZipDownloadRequest = {
items,
downloadFileName: this.options.downloadFileName,
};

try {
// Create the ZIP download using the Box SDK
const zipDownload = await this.client.zipDownloads.createZipDownload(zipRequest);

// Only download if we have a download URL
if (zipDownload.downloadUrl) {
this.downloadZipFile(zipDownload.downloadUrl);
}

return zipDownload as unknown as ZipDownloadResponse;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to create ZIP download: ${errorMessage}`);
}
}

/**
* Download the ZIP file bytestream to the user's device using window.open
* @param url - The URL of the ZIP file to download
*/
private downloadZipFile(url: string): void {
try {
// Open in new tab - user can save from there
window.open(url, '_blank', 'noopener,noreferrer');

window.focus();

// Clean up after a delay to allow the download to start
setTimeout(() => {
URL.revokeObjectURL(url);
}, 1000);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to download ZIP file: ${errorMessage}`);
}
}
}
Loading