@unofficialbox/box-open-elements 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,14 +11,51 @@ export declare class ContentUploaderController extends Controller<UploaderState,
11
11
  private counter;
12
12
  private readonly files;
13
13
  private readonly abortControllers;
14
+ /** Directory path -> the in-flight or settled promise for its folder id. */
15
+ private readonly folderIds;
16
+ /**
17
+ * Aborted only when the controller is destroyed.
18
+ *
19
+ * Folder creation is shared by every file in that folder, so it cannot belong
20
+ * to any one of them: cancelling the file that happened to start first would
21
+ * abort the shared request and fail all its siblings — as an abort, so they
22
+ * would report themselves cancelled by the person, which never happened.
23
+ */
24
+ private readonly lifetime;
14
25
  constructor(config: UploaderSessionConfig);
15
26
  get concurrency(): number;
16
27
  get autoStart(): boolean;
28
+ /**
29
+ * Slots left before `fileLimit` is reached. Settled items still count: the
30
+ * limit is on the queue a person is looking at, not on throughput.
31
+ */
32
+ private remainingCapacity;
33
+ /**
34
+ * Resolve a directory path to a destination folder id, creating each segment
35
+ * that does not exist yet.
36
+ *
37
+ * The **promise** is memoised rather than the id, because concurrent uploads
38
+ * into the same folder resolve the same path at the same moment; caching only
39
+ * the settled value would let two of them each create "docs" and scatter the
40
+ * files across two folders with the same name.
41
+ */
42
+ private resolveFolderId;
17
43
  /**
18
44
  * Validate and enqueue files. Rejected files emit `itemRejected` and never
19
45
  * enter the queue. Returns the accepted queue items.
20
46
  */
21
47
  addFiles(files: UploadFileLike[]): UploadQueueItem[];
48
+ /**
49
+ * Enqueue files that carry a directory path, as a dropped folder does.
50
+ *
51
+ * A path is only honoured when the transport can create folders. Without
52
+ * that capability the files are refused rather than flattened into the
53
+ * destination root — see `UploadTransport.createFolder`.
54
+ */
55
+ addEntries(entries: Array<{
56
+ file: UploadFileLike;
57
+ path?: string;
58
+ }>): UploadQueueItem[];
22
59
  /** Begin (or resume) uploading queued items, up to the concurrency limit. */
23
60
  start(): void;
24
61
  /** Abort an in-flight upload or withdraw a queued item. */
@@ -13,6 +13,17 @@ export class ContentUploaderController extends Controller {
13
13
  counter = 0;
14
14
  files = new Map();
15
15
  abortControllers = new Map();
16
+ /** Directory path -> the in-flight or settled promise for its folder id. */
17
+ folderIds = new Map();
18
+ /**
19
+ * Aborted only when the controller is destroyed.
20
+ *
21
+ * Folder creation is shared by every file in that folder, so it cannot belong
22
+ * to any one of them: cancelling the file that happened to start first would
23
+ * abort the shared request and fail all its siblings — as an abort, so they
24
+ * would report themselves cancelled by the person, which never happened.
25
+ */
26
+ lifetime = typeof AbortController === "function" ? new AbortController() : null;
16
27
  constructor(config) {
17
28
  super({ items: [], uploading: false });
18
29
  this.config = config;
@@ -24,18 +35,99 @@ export class ContentUploaderController extends Controller {
24
35
  get autoStart() {
25
36
  return this.config.autoStart ?? true;
26
37
  }
38
+ /**
39
+ * Slots left before `fileLimit` is reached. Settled items still count: the
40
+ * limit is on the queue a person is looking at, not on throughput.
41
+ */
42
+ remainingCapacity() {
43
+ const limit = this.config.fileLimit;
44
+ if (limit === undefined || !Number.isFinite(limit)) {
45
+ return Number.POSITIVE_INFINITY;
46
+ }
47
+ return Math.max(0, Math.floor(limit) - this.state.items.length);
48
+ }
49
+ /**
50
+ * Resolve a directory path to a destination folder id, creating each segment
51
+ * that does not exist yet.
52
+ *
53
+ * The **promise** is memoised rather than the id, because concurrent uploads
54
+ * into the same folder resolve the same path at the same moment; caching only
55
+ * the settled value would let two of them each create "docs" and scatter the
56
+ * files across two folders with the same name.
57
+ */
58
+ resolveFolderId(path) {
59
+ if (!path) {
60
+ return Promise.resolve(this.config.folderId);
61
+ }
62
+ const existing = this.folderIds.get(path);
63
+ if (existing) {
64
+ return existing;
65
+ }
66
+ const separator = path.lastIndexOf("/");
67
+ const parentPath = separator > 0 ? path.slice(0, separator) : "";
68
+ const name = separator > 0 ? path.slice(separator + 1) : path;
69
+ const pending = (async () => {
70
+ const parentFolderId = await this.resolveFolderId(parentPath);
71
+ const createFolder = this.config.transport.createFolder;
72
+ if (!createFolder) {
73
+ throw new Error("This transport cannot create folders");
74
+ }
75
+ const signal = this.lifetime?.signal;
76
+ const result = await createFolder.call(this.config.transport, {
77
+ name,
78
+ parentFolderId,
79
+ token: this.config.token,
80
+ ...(signal ? { signal } : {}),
81
+ });
82
+ return result.folderId;
83
+ })();
84
+ this.folderIds.set(path, pending);
85
+ // A failed creation must not be cached, or every file in that folder fails
86
+ // against a poisoned entry instead of retrying the folder.
87
+ void pending.catch(() => {
88
+ if (this.folderIds.get(path) === pending) {
89
+ this.folderIds.delete(path);
90
+ }
91
+ });
92
+ return pending;
93
+ }
27
94
  /**
28
95
  * Validate and enqueue files. Rejected files emit `itemRejected` and never
29
96
  * enter the queue. Returns the accepted queue items.
30
97
  */
31
98
  addFiles(files) {
99
+ return this.addEntries(files.map(file => ({ file, path: "" })));
100
+ }
101
+ /**
102
+ * Enqueue files that carry a directory path, as a dropped folder does.
103
+ *
104
+ * A path is only honoured when the transport can create folders. Without
105
+ * that capability the files are refused rather than flattened into the
106
+ * destination root — see `UploadTransport.createFolder`.
107
+ */
108
+ addEntries(entries) {
32
109
  const added = [];
33
- for (const file of files) {
110
+ const canCreateFolders = typeof this.config.transport.createFolder === "function";
111
+ // Counted against the live queue, so two drops in a row cannot together
112
+ // exceed the limit the way two independent checks would let them.
113
+ let remaining = this.remainingCapacity();
114
+ for (const entry of entries) {
115
+ const { file } = entry;
116
+ const path = entry.path ?? "";
117
+ if (path && !canCreateFolders) {
118
+ this.emit("itemRejected", { file, reason: "folder-unsupported" });
119
+ continue;
120
+ }
34
121
  const rejection = resolveUploadRejection(file, this.config);
35
122
  if (rejection) {
36
123
  this.emit("itemRejected", { file, reason: rejection });
37
124
  continue;
38
125
  }
126
+ if (remaining <= 0) {
127
+ this.emit("itemRejected", { file, reason: "file-limit-reached" });
128
+ continue;
129
+ }
130
+ remaining -= 1;
39
131
  this.counter += 1;
40
132
  const item = {
41
133
  id: `upload-${this.counter}`,
@@ -43,6 +135,7 @@ export class ContentUploaderController extends Controller {
43
135
  size: file.size,
44
136
  status: "queued",
45
137
  progress: 0,
138
+ ...(path ? { path } : {}),
46
139
  };
47
140
  this.files.set(item.id, file);
48
141
  added.push(item);
@@ -118,6 +211,8 @@ export class ContentUploaderController extends Controller {
118
211
  abortController.abort();
119
212
  }
120
213
  this.abortControllers.clear();
214
+ this.lifetime?.abort();
215
+ this.folderIds.clear();
121
216
  super.destroy();
122
217
  }
123
218
  getItem(itemId) {
@@ -169,10 +264,24 @@ export class ContentUploaderController extends Controller {
169
264
  this.emit("itemStarted", { item: started });
170
265
  }
171
266
  try {
267
+ // Resolved per upload rather than at enqueue time, so a folder is only
268
+ // created when a file in it actually starts — a queue cancelled before it
269
+ // ran leaves no empty folders behind.
270
+ const folderId = await this.resolveFolderId(item.path ?? "");
271
+ // Cancelling during folder creation cannot abort the shared request, so
272
+ // the cancellation is honoured here instead, before any bytes move.
273
+ if (abortController?.signal.aborted) {
274
+ // A plain Error, not a DOMException: the latter is not `instanceof
275
+ // Error` in a browser, so the abort check below would miss it and the
276
+ // item would report itself failed rather than cancelled.
277
+ const aborted = new Error("AbortError");
278
+ aborted.name = "AbortError";
279
+ throw aborted;
280
+ }
172
281
  const result = await this.config.transport.uploadFile({
173
282
  file,
174
283
  fileName: item.name,
175
- folderId: this.config.folderId,
284
+ folderId,
176
285
  token: this.config.token,
177
286
  language: this.config.language,
178
287
  ...(abortController ? { signal: abortController.signal } : {}),
@@ -19,6 +19,11 @@ export interface UploadQueueItem {
19
19
  errorMessage?: string;
20
20
  /** Remote file id from the transport once the upload succeeds. */
21
21
  fileId?: string;
22
+ /**
23
+ * Directory path relative to the destination folder, e.g. `"docs/2026"`, for
24
+ * a file that arrived inside a dropped folder. Empty for a loose file.
25
+ */
26
+ path?: string;
22
27
  }
23
28
  export interface UploadRequest {
24
29
  file: UploadFileLike;
@@ -35,6 +40,16 @@ export interface UploadResult {
35
40
  name?: string;
36
41
  size?: number;
37
42
  }
43
+ export interface CreateFolderRequest {
44
+ name: string;
45
+ /** The folder to create it in. */
46
+ parentFolderId: string;
47
+ token: string;
48
+ signal?: AbortSignal;
49
+ }
50
+ export interface CreateFolderResult {
51
+ folderId: string;
52
+ }
38
53
  /**
39
54
  * One narrow capability: move a single file to the destination folder. How —
40
55
  * multipart, chunked upload sessions, a BFF — is the transport's business;
@@ -42,6 +57,29 @@ export interface UploadResult {
42
57
  */
43
58
  export interface UploadTransport {
44
59
  uploadFile(request: UploadRequest): Promise<UploadResult>;
60
+ /**
61
+ * Recreate a dropped folder tree in the destination. **Optional**, because
62
+ * adding it as a requirement would break every transport already written
63
+ * against this interface, and because plenty of destinations have no notion
64
+ * of folders at all.
65
+ *
66
+ * Without it a folder drop is *refused* — every file in it is rejected with
67
+ * `folder-unsupported`. The alternative, flattening the tree into the
68
+ * destination root, silently scatters a hundred files out of the structure
69
+ * the person dropped them in, and there is no undo for that.
70
+ *
71
+ * A transport that implements this should be idempotent about an existing
72
+ * name, or map the conflict to the existing folder's id: a retried upload
73
+ * asks for the same folder again.
74
+ *
75
+ * Folder creation is **not** undone if the uploads into it then fail or are
76
+ * cancelled, so an interrupted folder upload leaves the folders it had got to
77
+ * behind, some of them empty. There is no atomic "create this tree" anywhere
78
+ * underneath, so the alternative would be the uploader deleting folders on a
79
+ * destination it does not own — far worse than a stray empty folder. Retrying
80
+ * reuses them; a host that wants them cleaned up owns that decision.
81
+ */
82
+ createFolder?(request: CreateFolderRequest): Promise<CreateFolderResult>;
45
83
  }
46
84
  export interface UploaderConstraints {
47
85
  /**
@@ -51,6 +89,16 @@ export interface UploaderConstraints {
51
89
  extensions?: string[];
52
90
  /** Reject files larger than this many bytes. Omitted means no limit. */
53
91
  maxFileSizeBytes?: number;
92
+ /**
93
+ * Most files the queue will hold. Further files are rejected with
94
+ * `file-limit-reached` rather than enqueued.
95
+ *
96
+ * Omitted means no limit, which is the wrong default for a drop target — a
97
+ * dropped folder can carry thousands of files, and the queue has no natural
98
+ * back pressure. Hosts should set one; `box-content-uploader` defaults it to
99
+ * 100, matching box-ui-elements.
100
+ */
101
+ fileLimit?: number;
54
102
  }
55
103
  export interface UploaderSessionConfig extends UploaderConstraints {
56
104
  folderId: string;
@@ -62,7 +110,9 @@ export interface UploaderSessionConfig extends UploaderConstraints {
62
110
  /** Start uploading as soon as files are added. Defaults to true. */
63
111
  autoStart?: boolean;
64
112
  }
65
- export type UploadRejectionReason = "extension-not-allowed" | "file-too-large";
113
+ export type UploadRejectionReason = "extension-not-allowed" | "file-too-large" | "file-limit-reached"
114
+ /** A folder was dropped but the transport cannot create folders. */
115
+ | "folder-unsupported";
66
116
  export interface UploaderState {
67
117
  items: UploadQueueItem[];
68
118
  /** True while any item is actively uploading. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unofficialbox/box-open-elements",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Box Open Elements: an open, framework-agnostic design system and web component library for Box-style experiences",
5
5
  "license": "MIT",
6
6
  "homepage": "https://unofficialbox.github.io/box-open-elements",