@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.
- package/dist/components/files/drop-zone.d.ts +55 -0
- package/dist/components/files/drop-zone.js +242 -21
- package/dist/foundations/files/directory-entries.d.ts +69 -0
- package/dist/foundations/files/directory-entries.js +149 -0
- package/dist/foundations/files/index.d.ts +1 -0
- package/dist/foundations/files/index.js +3 -0
- package/dist/foundations/tokens/box-defaults.js +36 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/patterns/content-uploader/content-uploader.d.ts +76 -0
- package/dist/patterns/content-uploader/content-uploader.js +260 -22
- package/dist/patterns/content-uploader/controller.d.ts +37 -0
- package/dist/patterns/content-uploader/controller.js +111 -2
- package/dist/patterns/content-uploader/types.d.ts +51 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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",
|