gbs-add-block 1.2.11 → 1.2.13
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/README.md +4 -2
- package/index.cjs +7 -1
- package/package.json +1 -1
- package/source/beta-components/fileuploader/README.md +99 -0
- package/source/beta-components/fileuploader/__tests__/core.test.ts +395 -0
- package/source/beta-components/fileuploader/core/format.ts +79 -0
- package/source/beta-components/fileuploader/core/index.ts +26 -0
- package/source/beta-components/fileuploader/core/store.ts +432 -0
- package/source/beta-components/fileuploader/core/transport.ts +145 -0
- package/source/beta-components/fileuploader/core/types.ts +126 -0
- package/source/beta-components/fileuploader/core/validate.ts +83 -0
- package/source/beta-components/fileuploader/index.ts +7 -0
- package/source/beta-components/fileuploader/react/FileRow.tsx +268 -0
- package/source/beta-components/fileuploader/react/FileUploader.tsx +515 -0
- package/source/beta-components/fileuploader/react/icons.tsx +80 -0
- package/source/beta-components/fileuploader/react/locale.ts +33 -0
- package/source/beta-components/fileuploader/react/props.ts +31 -0
- package/source/beta-components/fileuploader/react/useFileUploader.ts +32 -0
- package/source/beta-components/fileuploader/styles.css +395 -0
- package/source/beta-components/toaster/README.md +128 -0
- package/source/beta-components/toaster/__tests__/core.test.ts +256 -0
- package/source/beta-components/toaster/core/api.ts +87 -0
- package/source/beta-components/toaster/core/index.ts +14 -0
- package/source/beta-components/toaster/core/store.ts +211 -0
- package/source/beta-components/toaster/core/toast.ts +12 -0
- package/source/beta-components/toaster/core/types.ts +78 -0
- package/source/beta-components/toaster/index.ts +6 -0
- package/source/beta-components/toaster/react/ToastItem.tsx +164 -0
- package/source/beta-components/toaster/react/Toaster.tsx +187 -0
- package/source/beta-components/toaster/react/icons.tsx +56 -0
- package/source/beta-components/toaster/react/locale.ts +6 -0
- package/source/beta-components/toaster/react/props.ts +15 -0
- package/source/beta-components/toaster/react/useToasts.ts +11 -0
- package/source/beta-components/toaster/styles.css +283 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# GBS Building Blocks 2.0 (v1.2.
|
|
1
|
+
# GBS Building Blocks 2.0 (v1.2.13)
|
|
2
2
|
|
|
3
3
|
Latest and upgraded version of GBS building blocks with headless UI and removed dependencies.
|
|
4
4
|
|
|
@@ -14,9 +14,11 @@ DataGrid and Combobox use the redesigned core API and design system. Install the
|
|
|
14
14
|
npx gbs-add-block -a DataGrid -beta
|
|
15
15
|
npx gbs-add-block -a Combobox -beta
|
|
16
16
|
npx gbs-add-block -a DatePicker -beta
|
|
17
|
+
npx gbs-add-block -a Toaster -beta
|
|
18
|
+
npx gbs-add-block -a FileUploader -beta
|
|
17
19
|
```
|
|
18
20
|
|
|
19
|
-
## What's New 🎉 (Ver 1.2.
|
|
21
|
+
## What's New 🎉 (Ver 1.2.13)
|
|
20
22
|
|
|
21
23
|
- Update Candidate for next major change 2.0.0
|
|
22
24
|
|
package/index.cjs
CHANGED
|
@@ -35,7 +35,13 @@ const CONFIG = {
|
|
|
35
35
|
"UsePaginatedData",
|
|
36
36
|
"UseUploader",
|
|
37
37
|
],
|
|
38
|
-
betaComponents: [
|
|
38
|
+
betaComponents: [
|
|
39
|
+
"DataGrid",
|
|
40
|
+
"Combobox",
|
|
41
|
+
"DatePicker",
|
|
42
|
+
"Toaster",
|
|
43
|
+
"FileUploader",
|
|
44
|
+
],
|
|
39
45
|
// Define component dependencies
|
|
40
46
|
dependencies: {
|
|
41
47
|
FormRenderer: ["Select", "MultiSelect", "Input", "DatePicker"],
|
package/package.json
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# FileUploader
|
|
2
|
+
|
|
3
|
+
Drop, browse or paste files and upload them in resumable chunks — styled to
|
|
4
|
+
match the DataGrid, Combobox, DatePicker and Toaster. No runtime dependencies
|
|
5
|
+
besides React 19.
|
|
6
|
+
|
|
7
|
+
- Chunked uploads with per-file progress, pause, resume, retry and cancel.
|
|
8
|
+
- Parallel files, automatic retries with backoff, per-chunk abort.
|
|
9
|
+
- Validation (type, size, count, duplicates, custom) with readable reasons.
|
|
10
|
+
- Image previews that you can turn off, and files already on the server.
|
|
11
|
+
- Works with a plain form: without an endpoint, the files post with the form.
|
|
12
|
+
- Keyboard, screen reader, dark mode and right-to-left support.
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { FileUploader } from "@/components/file-uploader";
|
|
18
|
+
import "@/components/file-uploader/styles.css";
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Basic usage
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
<FileUploader
|
|
25
|
+
label="Attachments"
|
|
26
|
+
multiple
|
|
27
|
+
accept=".pdf,image/*"
|
|
28
|
+
maxSize={20 * 1024 * 1024}
|
|
29
|
+
endpoint="/api/upload"
|
|
30
|
+
onUploadComplete={(items) => console.log(items.map((item) => item.response))}
|
|
31
|
+
/>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Files are uploaded when the user presses **Upload**, when you call
|
|
35
|
+
`ref.current.upload()`, or right away with `autoUpload`.
|
|
36
|
+
|
|
37
|
+
## Upload protocol
|
|
38
|
+
|
|
39
|
+
Each chunk is a `multipart/form-data` POST with these fields (rename any with
|
|
40
|
+
`fieldNames`; the defaults match the Go `chunk-uploader` package):
|
|
41
|
+
|
|
42
|
+
| Field | Value |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `uploadId` | Same for every chunk of one upload; group chunks by it. |
|
|
45
|
+
| `fileName` | Original file name. |
|
|
46
|
+
| `chunkIndex` | 0-based. Small files are sent as chunk `0` of `1`. |
|
|
47
|
+
| `totalChunks` | Number of chunks. |
|
|
48
|
+
| `fileSize` | Size of the whole file in bytes. |
|
|
49
|
+
| `additionalParams` | JSON from `params`, when given. |
|
|
50
|
+
| `chunk` | The bytes. |
|
|
51
|
+
|
|
52
|
+
Chunks of one file are sent in order, one at a time. Answer each with a 2xx; the
|
|
53
|
+
body of the last answer is kept as `item.response` (JSON is parsed). 5xx, 408,
|
|
54
|
+
429 and network errors are retried with backoff; other 4xx fail the file.
|
|
55
|
+
|
|
56
|
+
## Props
|
|
57
|
+
|
|
58
|
+
| Prop | Type | Default | Description |
|
|
59
|
+
| --- | --- | --- | --- |
|
|
60
|
+
| `multiple` | `boolean` | `false` | Several files; otherwise a new file replaces the current one. |
|
|
61
|
+
| `accept` | `string` | — | `.pdf`, `image/*`, MIME types. |
|
|
62
|
+
| `maxFiles` / `maxSize` / `minSize` | `number` | — | Count, and bytes per file. |
|
|
63
|
+
| `validate` | `(file) => string \| null` | — | Return a message to reject. |
|
|
64
|
+
| `allowDuplicates` | `boolean` | `false` | Allow the same file twice (name, size and date). |
|
|
65
|
+
| `endpoint` | `string \| (request) => string` | — | Where chunks go. |
|
|
66
|
+
| `method`, `headers`, `withCredentials` | — | `POST` | Request options; `headers` may be an async function. |
|
|
67
|
+
| `params` | `object \| (file) => object` | — | Sent as `additionalParams`. |
|
|
68
|
+
| `fieldNames` | `Partial<ChunkFieldNames>` | Go names | Rename form fields. |
|
|
69
|
+
| `parseResponse` | `(body, xhr) => unknown` | JSON, else text | Custom response parsing. |
|
|
70
|
+
| `transport` | `(request) => Promise` | HTTP | Replace the sender entirely. |
|
|
71
|
+
| `chunkSize` | `number` | 5 MiB | Bytes per request. |
|
|
72
|
+
| `concurrency` | `number` | `3` | Files uploading at once. |
|
|
73
|
+
| `retries` / `retryDelay` | `number` | `3` / `1000` | Attempts per chunk; delay doubles each time. |
|
|
74
|
+
| `autoUpload` | `boolean` | `false` | Upload as soon as files are added. |
|
|
75
|
+
| `preview` | `boolean` | `true` | Image thumbnails. |
|
|
76
|
+
| `existingFiles` | `ExistingFile[]` | — | Files already stored, with download links. |
|
|
77
|
+
| `onRemoveExisting` | `(file) => void` | — | Shows a remove button on existing files. |
|
|
78
|
+
| `removable` | `boolean` | `true` | Remove buttons. |
|
|
79
|
+
| `name` | `string` | — | Form field. With an endpoint, stored ids post; without, the files do. |
|
|
80
|
+
| `getFileId` | `(item) => string` | from response | The id posted for an uploaded file. |
|
|
81
|
+
| `label`, `description`, `error`, `required`, `disabled`, `size`, `locale` | — | — | Field and display options. |
|
|
82
|
+
| `className`, `classNames`, `style`, `localeText` | — | — | Slots: `root`, `label`, `dropzone`, `rejections`, `list`, `item`, `thumb`, `progress`, `actions`, `footer`. |
|
|
83
|
+
| `onChange`, `onRejected`, `onFileSuccess`, `onFileError`, `onUploadComplete` | — | — | Events. |
|
|
84
|
+
| `ref` | `Ref<FileUploaderHandle>` | — | `open`, `addFiles`, `upload`, `pause`, `cancel`, `clear`, `getFiles`, `getItems`. |
|
|
85
|
+
|
|
86
|
+
## Headless use
|
|
87
|
+
|
|
88
|
+
`useFileUploader(config)` returns `{ store, items, rejections }` for a custom UI.
|
|
89
|
+
The framework-free core — `createUploaderStore`, `createHttpTransport`,
|
|
90
|
+
`buildChunkForm`, `partitionFiles`, `matchesAccept`, `formatBytes`, `summarize` —
|
|
91
|
+
has no React imports.
|
|
92
|
+
|
|
93
|
+
## Known limits
|
|
94
|
+
|
|
95
|
+
- Chunks of one file are sequential; parallelism is across files.
|
|
96
|
+
- Pausing aborts the chunk in flight and sends it again on resume.
|
|
97
|
+
- Progress lives in memory: reloading the page loses it (the server may still
|
|
98
|
+
hold the chunks under the `uploadId`).
|
|
99
|
+
- Dropped folders are not expanded.
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fileKind, formatBytes, readFileId } from "../core/format";
|
|
3
|
+
import { chunkCount, createUploaderStore, summarize } from "../core/store";
|
|
4
|
+
import { buildChunkForm, isRetryable, UploadHttpError } from "../core/transport";
|
|
5
|
+
import type { ChunkRequest, Transport } from "../core/types";
|
|
6
|
+
import { matchesAccept, partitionFiles } from "../core/validate";
|
|
7
|
+
|
|
8
|
+
const file = (name: string, size = 10, type = "", lastModified = 1) =>
|
|
9
|
+
new File([new Uint8Array(size)], name, { type, lastModified });
|
|
10
|
+
|
|
11
|
+
/** Resolves every chunk right away, answering like the Go chunk-uploader. */
|
|
12
|
+
function instantTransport(handler?: (request: ChunkRequest) => unknown) {
|
|
13
|
+
const calls: ChunkRequest[] = [];
|
|
14
|
+
const transport: Transport = async (request) => {
|
|
15
|
+
calls.push(request);
|
|
16
|
+
request.onProgress(request.chunk.size);
|
|
17
|
+
if (handler) return handler(request);
|
|
18
|
+
return request.index === request.total - 1
|
|
19
|
+
? { status: "complete", metadata: { storedName: `${request.uploadId}.bin` } }
|
|
20
|
+
: { status: "chunk_received" };
|
|
21
|
+
};
|
|
22
|
+
return { transport, calls };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Chunks wait until the test settles them; aborting rejects like a real request. */
|
|
26
|
+
function manualTransport() {
|
|
27
|
+
const pending: { request: ChunkRequest; resolve(value: unknown): void }[] = [];
|
|
28
|
+
const transport: Transport = (request) =>
|
|
29
|
+
new Promise((resolve, reject) => {
|
|
30
|
+
pending.push({ request, resolve });
|
|
31
|
+
request.signal.addEventListener(
|
|
32
|
+
"abort",
|
|
33
|
+
() => reject(new DOMException("Aborted", "AbortError")),
|
|
34
|
+
{ once: true },
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
return { transport, pending };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("validation", () => {
|
|
41
|
+
it("reads accept strings like the browser does", () => {
|
|
42
|
+
const pdf = { name: "Report.PDF", type: "application/pdf" };
|
|
43
|
+
expect(matchesAccept(pdf, ".pdf")).toBe(true);
|
|
44
|
+
expect(matchesAccept(pdf, "image/*, .pdf")).toBe(true);
|
|
45
|
+
expect(matchesAccept(pdf, "application/pdf")).toBe(true);
|
|
46
|
+
expect(matchesAccept(pdf, "image/*")).toBe(false);
|
|
47
|
+
expect(matchesAccept(pdf, "")).toBe(true);
|
|
48
|
+
expect(matchesAccept({ name: "a.png", type: "image/png" }, "image/*")).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("keeps only the first valid file in single mode", () => {
|
|
52
|
+
const result = partitionFiles([file("a.txt"), file("b.txt")], [file("old.txt")], {});
|
|
53
|
+
expect(result.accepted.map((f) => f.name)).toEqual(["a.txt"]);
|
|
54
|
+
expect(result.rejected).toEqual([expect.objectContaining({ code: "too-many-files" })]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("counts files already selected against maxFiles", () => {
|
|
58
|
+
const result = partitionFiles([file("b.txt"), file("c.txt")], [file("a.txt")], {
|
|
59
|
+
multiple: true,
|
|
60
|
+
maxFiles: 2,
|
|
61
|
+
});
|
|
62
|
+
expect(result.accepted.map((f) => f.name)).toEqual(["b.txt"]);
|
|
63
|
+
expect(result.rejected[0]).toMatchObject({ code: "too-many-files" });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("rejects by type, size, duplicate and custom rule, with a reason each", () => {
|
|
67
|
+
const existing = [file("same.txt", 10)];
|
|
68
|
+
const result = partitionFiles(
|
|
69
|
+
[
|
|
70
|
+
file("photo.png", 10, "image/png"),
|
|
71
|
+
file("big.txt", 500),
|
|
72
|
+
file("tiny.txt", 1),
|
|
73
|
+
file("same.txt", 10),
|
|
74
|
+
file("secret.txt", 10),
|
|
75
|
+
file("ok.txt", 10),
|
|
76
|
+
],
|
|
77
|
+
existing,
|
|
78
|
+
{
|
|
79
|
+
multiple: true,
|
|
80
|
+
accept: ".txt",
|
|
81
|
+
maxSize: 100,
|
|
82
|
+
minSize: 5,
|
|
83
|
+
validate: (f) => (f.name.startsWith("secret") ? "No secrets" : null),
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
expect(result.accepted.map((f) => f.name)).toEqual(["ok.txt"]);
|
|
87
|
+
expect(result.rejected.map((r) => [r.file.name, r.code, r.message])).toEqual([
|
|
88
|
+
["photo.png", "file-type", undefined],
|
|
89
|
+
["big.txt", "file-too-large", undefined],
|
|
90
|
+
["tiny.txt", "file-too-small", undefined],
|
|
91
|
+
["same.txt", "duplicate", undefined],
|
|
92
|
+
["secret.txt", "custom", "No secrets"],
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("tells duplicates apart by size and date, and can allow them", () => {
|
|
97
|
+
const existing = [file("a.txt", 10, "", 1)];
|
|
98
|
+
expect(partitionFiles([file("a.txt", 11, "", 1)], existing, { multiple: true }).accepted).toHaveLength(1);
|
|
99
|
+
expect(partitionFiles([file("a.txt", 10, "", 2)], existing, { multiple: true }).accepted).toHaveLength(1);
|
|
100
|
+
expect(
|
|
101
|
+
partitionFiles([file("a.txt", 10, "", 1)], existing, { multiple: true, allowDuplicates: true })
|
|
102
|
+
.accepted,
|
|
103
|
+
).toHaveLength(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("formatting", () => {
|
|
108
|
+
it("formats sizes in steps of 1024", () => {
|
|
109
|
+
expect(formatBytes(0, "en-US")).toBe("0 B");
|
|
110
|
+
expect(formatBytes(512, "en-US")).toBe("512 B");
|
|
111
|
+
expect(formatBytes(1536, "en-US")).toBe("1.5 kB");
|
|
112
|
+
expect(formatBytes(5 * 1024 * 1024, "en-US")).toBe("5 MB");
|
|
113
|
+
// German separates the unit with a non-breaking space.
|
|
114
|
+
expect(formatBytes(1.5 * 1024 ** 3, "de-DE")).toMatch(/^1,5\sGB$/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("picks an icon family from the type or the extension", () => {
|
|
118
|
+
expect(fileKind({ name: "a", type: "image/png" })).toBe("image");
|
|
119
|
+
expect(fileKind({ name: "clip.MOV" })).toBe("video");
|
|
120
|
+
expect(fileKind({ name: "a.pdf", type: "application/pdf" })).toBe("pdf");
|
|
121
|
+
expect(fileKind({ name: "sheet.xlsx", type: "" })).toBe("spreadsheet");
|
|
122
|
+
expect(fileKind({ name: "notes.docx" })).toBe("document");
|
|
123
|
+
expect(fileKind({ name: "backup.zip" })).toBe("archive");
|
|
124
|
+
expect(fileKind({ name: "unknown.xyz" })).toBe("other");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("finds the stored file id in common response shapes", () => {
|
|
128
|
+
expect(readFileId({ id: 7 })).toBe("7");
|
|
129
|
+
expect(readFileId({ documentId: "doc-1" })).toBe("doc-1");
|
|
130
|
+
expect(readFileId({ status: "complete", metadata: { storedName: "uuid.pdf" } })).toBe("uuid.pdf");
|
|
131
|
+
expect(readFileId("plain text")).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("transport", () => {
|
|
136
|
+
const request = (overrides: Partial<ChunkRequest> = {}): ChunkRequest => ({
|
|
137
|
+
file: file("report.pdf", 25),
|
|
138
|
+
chunk: new Blob([new Uint8Array(10)]),
|
|
139
|
+
index: 1,
|
|
140
|
+
total: 3,
|
|
141
|
+
offset: 10,
|
|
142
|
+
uploadId: "up-1",
|
|
143
|
+
signal: new AbortController().signal,
|
|
144
|
+
onProgress: () => {},
|
|
145
|
+
...overrides,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("builds the multipart body the Go chunk-uploader expects", async () => {
|
|
149
|
+
const form = buildChunkForm(request(), { params: { folder: "invoices" } });
|
|
150
|
+
expect(form.get("fileName")).toBe("report.pdf");
|
|
151
|
+
expect(form.get("chunkIndex")).toBe("1");
|
|
152
|
+
expect(form.get("totalChunks")).toBe("3");
|
|
153
|
+
expect(form.get("fileSize")).toBe("25");
|
|
154
|
+
expect(form.get("uploadId")).toBe("up-1");
|
|
155
|
+
expect(form.get("additionalParams")).toBe('{"folder":"invoices"}');
|
|
156
|
+
const chunk = form.get("chunk") as File;
|
|
157
|
+
expect(chunk.size).toBe(10);
|
|
158
|
+
expect(chunk.name).toBe("report.pdf");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("renames fields and leaves out empty params", () => {
|
|
162
|
+
const form = buildChunkForm(request(), {
|
|
163
|
+
fieldNames: { chunk: "file", fileName: "originalname" },
|
|
164
|
+
params: () => ({}),
|
|
165
|
+
});
|
|
166
|
+
expect(form.get("originalname")).toBe("report.pdf");
|
|
167
|
+
expect(form.get("file")).toBeInstanceOf(File);
|
|
168
|
+
expect(form.has("chunk")).toBe(false);
|
|
169
|
+
expect(form.has("additionalParams")).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("retries server and network failures, not client errors or aborts", () => {
|
|
173
|
+
expect(isRetryable(new UploadHttpError(503, null))).toBe(true);
|
|
174
|
+
expect(isRetryable(new UploadHttpError(429, null))).toBe(true);
|
|
175
|
+
expect(isRetryable(new UploadHttpError(408, null))).toBe(true);
|
|
176
|
+
expect(isRetryable(new UploadHttpError(413, null))).toBe(false);
|
|
177
|
+
expect(isRetryable(new Error("Network error"))).toBe(true);
|
|
178
|
+
expect(isRetryable(new DOMException("Aborted", "AbortError"))).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("selecting files", () => {
|
|
183
|
+
it("adds idle items with a chunk plan and reports changes", () => {
|
|
184
|
+
const onChange = vi.fn();
|
|
185
|
+
const onRejected = vi.fn();
|
|
186
|
+
const store = createUploaderStore({ multiple: true, chunkSize: 4, maxSize: 20, onChange, onRejected });
|
|
187
|
+
|
|
188
|
+
const { accepted, rejected } = store.addFiles([file("a.txt", 10), file("huge.txt", 50)]);
|
|
189
|
+
|
|
190
|
+
expect(accepted[0]).toMatchObject({ status: "idle", totalChunks: 3, chunkSize: 4, progress: 0 });
|
|
191
|
+
expect(rejected[0]).toMatchObject({ code: "file-too-large" });
|
|
192
|
+
expect(onChange).toHaveBeenCalledWith([accepted[0].file]);
|
|
193
|
+
expect(onRejected).toHaveBeenCalledWith(rejected);
|
|
194
|
+
expect(chunkCount(0, 4)).toBe(1);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("replaces the file in single mode and clears old rejections", () => {
|
|
198
|
+
const store = createUploaderStore({ accept: ".txt" });
|
|
199
|
+
store.addFiles([file("a.txt"), file("b.png")]);
|
|
200
|
+
expect(store.getSnapshot().rejections).toHaveLength(1);
|
|
201
|
+
|
|
202
|
+
store.addFiles([file("c.txt")]);
|
|
203
|
+
expect(store.getSnapshot().items.map((item) => item.file.name)).toEqual(["c.txt"]);
|
|
204
|
+
expect(store.getSnapshot().rejections).toHaveLength(0);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("removes, clears and dismisses", () => {
|
|
208
|
+
const onChange = vi.fn();
|
|
209
|
+
const store = createUploaderStore({ multiple: true, accept: ".txt", onChange });
|
|
210
|
+
const { accepted } = store.addFiles([file("a.txt"), file("b.txt"), file("c.png")]);
|
|
211
|
+
|
|
212
|
+
store.remove(accepted[0].id);
|
|
213
|
+
expect(store.getSnapshot().items).toHaveLength(1);
|
|
214
|
+
store.dismissRejection();
|
|
215
|
+
expect(store.getSnapshot().rejections).toHaveLength(0);
|
|
216
|
+
store.clear();
|
|
217
|
+
expect(store.getSnapshot().items).toHaveLength(0);
|
|
218
|
+
expect(onChange).toHaveBeenLastCalledWith([]);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("uploading", () => {
|
|
223
|
+
it("sends chunks in order with the same upload id, then succeeds", async () => {
|
|
224
|
+
const { transport, calls } = instantTransport();
|
|
225
|
+
const onFileSuccess = vi.fn();
|
|
226
|
+
const onComplete = vi.fn();
|
|
227
|
+
const store = createUploaderStore({ transport, chunkSize: 4, onFileSuccess, onComplete });
|
|
228
|
+
const { accepted } = store.addFiles([file("a.bin", 10)]);
|
|
229
|
+
|
|
230
|
+
const items = await store.upload();
|
|
231
|
+
|
|
232
|
+
expect(calls.map((c) => [c.index, c.offset, c.chunk.size, c.total])).toEqual([
|
|
233
|
+
[0, 0, 4, 3],
|
|
234
|
+
[1, 4, 4, 3],
|
|
235
|
+
[2, 8, 2, 3],
|
|
236
|
+
]);
|
|
237
|
+
expect(new Set(calls.map((c) => c.uploadId))).toEqual(new Set([accepted[0].uploadId]));
|
|
238
|
+
expect(items[0]).toMatchObject({ status: "success", progress: 1, uploadedBytes: 10, chunksDone: 3 });
|
|
239
|
+
expect(readFileId(items[0].response)).toBe(`${accepted[0].uploadId}.bin`);
|
|
240
|
+
expect(onFileSuccess).toHaveBeenCalledOnce();
|
|
241
|
+
await vi.waitFor(() => expect(onComplete).toHaveBeenCalledWith(items));
|
|
242
|
+
expect(summarize(store.getSnapshot().items)).toMatchObject({ progress: 1, busy: false });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("sends empty files as one chunk", async () => {
|
|
246
|
+
const { transport, calls } = instantTransport();
|
|
247
|
+
const store = createUploaderStore({ transport });
|
|
248
|
+
store.addFiles([file("empty.txt", 0)]);
|
|
249
|
+
const [item] = await store.upload();
|
|
250
|
+
expect(calls).toHaveLength(1);
|
|
251
|
+
expect(item.status).toBe("success");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("uploads at most `concurrency` files at once", async () => {
|
|
255
|
+
const { transport, pending } = manualTransport();
|
|
256
|
+
const store = createUploaderStore({ transport, multiple: true, concurrency: 2 });
|
|
257
|
+
store.addFiles([file("a.txt", 1, "", 1), file("b.txt", 1, "", 2), file("c.txt", 1, "", 3)]);
|
|
258
|
+
|
|
259
|
+
const done = store.upload();
|
|
260
|
+
await vi.waitFor(() => expect(pending).toHaveLength(2));
|
|
261
|
+
expect(store.getSnapshot().items.map((item) => item.status)).toEqual([
|
|
262
|
+
"uploading",
|
|
263
|
+
"uploading",
|
|
264
|
+
"queued",
|
|
265
|
+
]);
|
|
266
|
+
|
|
267
|
+
pending[0].resolve({});
|
|
268
|
+
await vi.waitFor(() => expect(pending).toHaveLength(3));
|
|
269
|
+
pending[1].resolve({});
|
|
270
|
+
pending[2].resolve({});
|
|
271
|
+
expect((await done).map((item) => item.status)).toEqual(["success", "success", "success"]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("retries a failed chunk with backoff, then carries on", async () => {
|
|
275
|
+
let failures = 0;
|
|
276
|
+
const { transport, calls } = instantTransport((request) => {
|
|
277
|
+
if (request.index === 1 && failures < 2) {
|
|
278
|
+
failures++;
|
|
279
|
+
throw new UploadHttpError(503, null);
|
|
280
|
+
}
|
|
281
|
+
return { ok: true };
|
|
282
|
+
});
|
|
283
|
+
const store = createUploaderStore({ transport, chunkSize: 4, retries: 3, retryDelay: 1 });
|
|
284
|
+
store.addFiles([file("a.bin", 10)]);
|
|
285
|
+
|
|
286
|
+
const [item] = await store.upload();
|
|
287
|
+
expect(item.status).toBe("success");
|
|
288
|
+
expect(calls.map((c) => c.index)).toEqual([0, 1, 1, 1, 2]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("gives up on client errors and keeps confirmed chunks for a retry", async () => {
|
|
292
|
+
let reject = true;
|
|
293
|
+
const { transport, calls } = instantTransport((request) => {
|
|
294
|
+
if (request.index === 1 && reject) throw new UploadHttpError(413, { message: "Too large" });
|
|
295
|
+
return {};
|
|
296
|
+
});
|
|
297
|
+
const onFileError = vi.fn();
|
|
298
|
+
const store = createUploaderStore({ transport, chunkSize: 4, retryDelay: 1, onFileError });
|
|
299
|
+
store.addFiles([file("a.bin", 10)]);
|
|
300
|
+
|
|
301
|
+
const [failed] = await store.upload();
|
|
302
|
+
expect(failed).toMatchObject({ status: "error", chunksDone: 1, uploadedBytes: 4 });
|
|
303
|
+
expect(failed.error).toContain("413");
|
|
304
|
+
expect(onFileError).toHaveBeenCalledWith(failed, expect.any(UploadHttpError));
|
|
305
|
+
|
|
306
|
+
reject = false;
|
|
307
|
+
const [retried] = await store.upload([failed.id]);
|
|
308
|
+
expect(retried.status).toBe("success");
|
|
309
|
+
expect(calls.map((c) => c.index)).toEqual([0, 1, 1, 2]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("pauses mid-file and resumes from the chunk that was interrupted", async () => {
|
|
313
|
+
const { transport, pending } = manualTransport();
|
|
314
|
+
const store = createUploaderStore({ transport, chunkSize: 4 });
|
|
315
|
+
const { accepted } = store.addFiles([file("a.bin", 10)]);
|
|
316
|
+
const { id, uploadId } = accepted[0];
|
|
317
|
+
|
|
318
|
+
void store.upload();
|
|
319
|
+
await vi.waitFor(() => expect(pending).toHaveLength(1));
|
|
320
|
+
pending[0].resolve({});
|
|
321
|
+
await vi.waitFor(() => expect(pending).toHaveLength(2));
|
|
322
|
+
|
|
323
|
+
store.pause(id);
|
|
324
|
+
await vi.waitFor(() => expect(store.getSnapshot().items[0].status).toBe("paused"));
|
|
325
|
+
expect(store.getSnapshot().items[0]).toMatchObject({ chunksDone: 1, uploadedBytes: 4 });
|
|
326
|
+
|
|
327
|
+
const resumed = store.upload([id]);
|
|
328
|
+
await vi.waitFor(() => expect(pending).toHaveLength(3));
|
|
329
|
+
expect(pending[2].request).toMatchObject({ index: 1, uploadId });
|
|
330
|
+
pending[2].resolve({});
|
|
331
|
+
await vi.waitFor(() => expect(pending).toHaveLength(4));
|
|
332
|
+
pending[3].resolve({});
|
|
333
|
+
expect((await resumed)[0].status).toBe("success");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("cancels, and starts over with a new upload id", async () => {
|
|
337
|
+
const { transport, pending } = manualTransport();
|
|
338
|
+
const store = createUploaderStore({ transport, chunkSize: 4 });
|
|
339
|
+
const { accepted } = store.addFiles([file("a.bin", 10)]);
|
|
340
|
+
|
|
341
|
+
const first = store.upload();
|
|
342
|
+
await vi.waitFor(() => expect(pending).toHaveLength(1));
|
|
343
|
+
store.cancel();
|
|
344
|
+
expect((await first)[0]).toMatchObject({ status: "canceled", progress: 0, chunksDone: 0 });
|
|
345
|
+
|
|
346
|
+
void store.upload();
|
|
347
|
+
await vi.waitFor(() => expect(pending).toHaveLength(2));
|
|
348
|
+
expect(pending[1].request.index).toBe(0);
|
|
349
|
+
expect(pending[1].request.uploadId).not.toBe(accepted[0].uploadId);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("aborts the request when a file is removed mid-upload", async () => {
|
|
353
|
+
const { transport, pending } = manualTransport();
|
|
354
|
+
const store = createUploaderStore({ transport });
|
|
355
|
+
const { accepted } = store.addFiles([file("a.bin", 10)]);
|
|
356
|
+
|
|
357
|
+
const done = store.upload();
|
|
358
|
+
await vi.waitFor(() => expect(pending).toHaveLength(1));
|
|
359
|
+
store.remove(accepted[0].id);
|
|
360
|
+
|
|
361
|
+
expect(pending[0].request.signal.aborted).toBe(true);
|
|
362
|
+
expect(await done).toEqual([]);
|
|
363
|
+
expect(store.getSnapshot().items).toHaveLength(0);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("uploads on add with autoUpload", async () => {
|
|
367
|
+
const { transport, calls } = instantTransport();
|
|
368
|
+
const store = createUploaderStore({ transport, autoUpload: true });
|
|
369
|
+
store.addFiles([file("a.txt", 3)]);
|
|
370
|
+
await vi.waitFor(() => expect(store.getSnapshot().items[0].status).toBe("success"));
|
|
371
|
+
expect(calls).toHaveLength(1);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("marks files as failed when there is nothing to send them with", async () => {
|
|
375
|
+
const store = createUploaderStore();
|
|
376
|
+
store.addFiles([file("a.txt")]);
|
|
377
|
+
const [item] = await store.upload();
|
|
378
|
+
expect(item.status).toBe("error");
|
|
379
|
+
expect(item.error).toMatch(/endpoint or a transport/);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("publishes progress once per whole percent", async () => {
|
|
383
|
+
const listener = vi.fn();
|
|
384
|
+
const transport: Transport = async (request) => {
|
|
385
|
+
for (let sent = 0; sent <= request.chunk.size; sent += 10) request.onProgress(sent);
|
|
386
|
+
return {};
|
|
387
|
+
};
|
|
388
|
+
const store = createUploaderStore({ transport, chunkSize: 100_000 });
|
|
389
|
+
store.addFiles([file("a.bin", 100_000)]);
|
|
390
|
+
store.subscribe(listener);
|
|
391
|
+
await store.upload();
|
|
392
|
+
// 10,000 progress calls; roughly one publish per percent plus the status changes.
|
|
393
|
+
expect(listener.mock.calls.length).toBeLessThan(120);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { FileKind } from "./types";
|
|
2
|
+
|
|
3
|
+
const UNITS = ["kilobyte", "megabyte", "gigabyte", "terabyte"] as const;
|
|
4
|
+
const formatters = new Map<string, Intl.NumberFormat>();
|
|
5
|
+
|
|
6
|
+
function formatter(locale: string | undefined, unit?: (typeof UNITS)[number]) {
|
|
7
|
+
const key = `${locale ?? ""}|${unit ?? "plain"}`;
|
|
8
|
+
let found = formatters.get(key);
|
|
9
|
+
if (!found) {
|
|
10
|
+
found = new Intl.NumberFormat(
|
|
11
|
+
locale,
|
|
12
|
+
unit
|
|
13
|
+
? { style: "unit", unit, unitDisplay: "short", maximumFractionDigits: 1 }
|
|
14
|
+
: { maximumFractionDigits: 0 },
|
|
15
|
+
);
|
|
16
|
+
formatters.set(key, found);
|
|
17
|
+
}
|
|
18
|
+
return found;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `1536` → `1.5 kB`, localized. Steps of 1024, as file managers show sizes. */
|
|
22
|
+
export function formatBytes(bytes: number, locale?: string): string {
|
|
23
|
+
let value = Math.max(0, bytes);
|
|
24
|
+
// Intl spells plain bytes out ("512 byte"), so they get a short suffix instead.
|
|
25
|
+
if (value < 1024) return `${formatter(locale).format(value)} B`;
|
|
26
|
+
let unit = -1;
|
|
27
|
+
while (value >= 1024 && unit < UNITS.length - 1) {
|
|
28
|
+
value /= 1024;
|
|
29
|
+
unit++;
|
|
30
|
+
}
|
|
31
|
+
return formatter(locale, UNITS[unit]).format(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const EXTENSIONS: Record<Exclude<FileKind, "other">, readonly string[]> = {
|
|
35
|
+
image: ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "avif", "heic", "ico"],
|
|
36
|
+
video: ["mp4", "mov", "webm", "mkv", "avi", "m4v"],
|
|
37
|
+
audio: ["mp3", "wav", "ogg", "m4a", "flac", "aac"],
|
|
38
|
+
pdf: ["pdf"],
|
|
39
|
+
spreadsheet: ["xls", "xlsx", "csv", "ods", "tsv"],
|
|
40
|
+
document: ["doc", "docx", "txt", "rtf", "odt", "md", "ppt", "pptx"],
|
|
41
|
+
archive: ["zip", "rar", "7z", "tar", "gz", "tgz"],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Picks an icon family from the MIME type, falling back to the extension. */
|
|
45
|
+
export function fileKind(file: { name: string; type?: string }): FileKind {
|
|
46
|
+
const type = (file.type ?? "").toLowerCase();
|
|
47
|
+
if (type.startsWith("image/")) return "image";
|
|
48
|
+
if (type.startsWith("video/")) return "video";
|
|
49
|
+
if (type.startsWith("audio/")) return "audio";
|
|
50
|
+
if (type.includes("pdf")) return "pdf";
|
|
51
|
+
if (type.includes("spreadsheet") || type.includes("excel") || type === "text/csv") {
|
|
52
|
+
return "spreadsheet";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const extension = file.name.toLowerCase().split(".").pop() ?? "";
|
|
56
|
+
for (const [kind, list] of Object.entries(EXTENSIONS)) {
|
|
57
|
+
if (list.includes(extension)) return kind as FileKind;
|
|
58
|
+
}
|
|
59
|
+
return "other";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const get = (value: unknown, key: string): unknown =>
|
|
63
|
+
value !== null && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
|
64
|
+
|
|
65
|
+
const asId = (value: unknown) =>
|
|
66
|
+
typeof value === "string" || typeof value === "number" ? String(value) : undefined;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Finds the stored file's id in common response shapes: `id`, `fileId`,
|
|
70
|
+
* `documentId`, or `metadata.storedName` as returned by the Go chunk-uploader.
|
|
71
|
+
*/
|
|
72
|
+
export function readFileId(response: unknown): string | undefined {
|
|
73
|
+
return (
|
|
74
|
+
asId(get(response, "id")) ??
|
|
75
|
+
asId(get(response, "fileId")) ??
|
|
76
|
+
asId(get(response, "documentId")) ??
|
|
77
|
+
asId(get(get(response, "metadata"), "storedName"))
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Framework-free: validation, chunk planning, the HTTP transport and the upload
|
|
2
|
+
// queue run without React, so they can be reused in a worker, a script or a test.
|
|
3
|
+
export {
|
|
4
|
+
chunkCount,
|
|
5
|
+
createUploaderStore,
|
|
6
|
+
createUploadId,
|
|
7
|
+
DEFAULT_CHUNK_SIZE,
|
|
8
|
+
DEFAULT_CONCURRENCY,
|
|
9
|
+
DEFAULT_RETRIES,
|
|
10
|
+
DEFAULT_RETRY_DELAY,
|
|
11
|
+
summarize,
|
|
12
|
+
} from "./store";
|
|
13
|
+
export type { AddResult, UploaderConfig, UploaderStore } from "./store";
|
|
14
|
+
export {
|
|
15
|
+
abortError,
|
|
16
|
+
buildChunkForm,
|
|
17
|
+
createHttpTransport,
|
|
18
|
+
DEFAULT_FIELD_NAMES,
|
|
19
|
+
isRetryable,
|
|
20
|
+
UploadHttpError,
|
|
21
|
+
} from "./transport";
|
|
22
|
+
export type { ChunkFieldNames, HttpTransportOptions, UploadParams } from "./transport";
|
|
23
|
+
export { fileKey, matchesAccept, partitionFiles } from "./validate";
|
|
24
|
+
export type { Partitioned, ValidationRules } from "./validate";
|
|
25
|
+
export { fileKind, formatBytes, readFileId } from "./format";
|
|
26
|
+
export type * from "./types";
|