@pylonsync/client 0.22.47 → 0.22.50
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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { type ReactNode } from "react";
|
|
3
|
-
import { type FileVisibility, type UploadedFile } from "@pylonsync/react";
|
|
3
|
+
import { type FileVisibility, type QueuedUploadHandle, type UploadAttachTarget, type UploadedFile } from "@pylonsync/react";
|
|
4
4
|
export interface FileUploadProps {
|
|
5
5
|
/** Called once for each successfully uploaded file. */
|
|
6
6
|
onUploaded?: (file: UploadedFile, source: File) => void;
|
|
@@ -17,6 +17,19 @@ export interface FileUploadProps {
|
|
|
17
17
|
* only. "public": anyone, for content every visitor sees.
|
|
18
18
|
*/
|
|
19
19
|
visibility?: FileVisibility;
|
|
20
|
+
/**
|
|
21
|
+
* Keep each file on the device and upload it when there is a network
|
|
22
|
+
* (`queueUpload`), instead of uploading now. Needs `attach`.
|
|
23
|
+
*/
|
|
24
|
+
offline?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* With `offline`: the record each file belongs to and the field that
|
|
27
|
+
* gets its id (and `urlField`, its URL). A function is called once per
|
|
28
|
+
* file, for example to insert a row per photo and return its target.
|
|
29
|
+
*/
|
|
30
|
+
attach?: UploadAttachTarget | ((file: File) => UploadAttachTarget | Promise<UploadAttachTarget>);
|
|
31
|
+
/** With `offline`: called once for each file kept on the device. */
|
|
32
|
+
onQueued?: (queued: QueuedUploadHandle, source: File) => void;
|
|
20
33
|
/** Override the headline. */
|
|
21
34
|
label?: ReactNode;
|
|
22
35
|
/** Override the helper text. */
|
|
@@ -28,14 +41,20 @@ export interface FileUploadResult {
|
|
|
28
41
|
status: "ok" | "error";
|
|
29
42
|
uploaded?: UploadedFile;
|
|
30
43
|
error?: string;
|
|
44
|
+
/** With `offline`: the queued upload. */
|
|
45
|
+
queued?: QueuedUploadHandle;
|
|
31
46
|
}
|
|
32
47
|
/**
|
|
33
48
|
* Drop-in drag-and-drop file picker. Uploads each file with `uploadFile`
|
|
34
49
|
* (init, PUT, confirm), shows per-file progress and errors, and emits
|
|
35
50
|
* `onUploaded` as each file lands.
|
|
36
51
|
*
|
|
52
|
+
* With `offline` and `attach`, each file is kept on the device with
|
|
53
|
+
* `queueUpload` and shown from there at once; it uploads when there is a
|
|
54
|
+
* network and is then set on its record.
|
|
55
|
+
*
|
|
37
56
|
* Pure UI shell — no provider configuration of its own. Pylon's file
|
|
38
57
|
* router handles where bytes actually go (local disk for self-host,
|
|
39
58
|
* Stack0 / S3 for cloud).
|
|
40
59
|
*/
|
|
41
|
-
export declare function FileUpload({ onUploaded, onComplete, accept, multiple, maxSizeBytes, visibility, label, helperText, className, }: FileUploadProps): React.JSX.Element;
|
|
60
|
+
export declare function FileUpload({ onUploaded, onComplete, accept, multiple, maxSizeBytes, visibility, offline, attach, onQueued, label, helperText, className, }: FileUploadProps): React.JSX.Element;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.22.
|
|
6
|
+
"version": "0.22.50",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "src/index.ts",
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
@@ -17,18 +17,24 @@
|
|
|
17
17
|
"scripts": {
|
|
18
18
|
"build": "tsc -p tsconfig.build.json",
|
|
19
19
|
"check": "tsc -p tsconfig.json --noEmit",
|
|
20
|
-
"prepack": "bun run build"
|
|
20
|
+
"prepack": "bun run build",
|
|
21
|
+
"test": "bun test"
|
|
21
22
|
},
|
|
22
23
|
"dependencies": {
|
|
23
|
-
"@pylonsync/react": "0.22.
|
|
24
|
-
"@pylonsync/sync": "0.22.
|
|
24
|
+
"@pylonsync/react": "0.22.50",
|
|
25
|
+
"@pylonsync/sync": "0.22.50",
|
|
25
26
|
"clsx": "^2.1.1"
|
|
26
27
|
},
|
|
27
28
|
"peerDependencies": {
|
|
28
29
|
"react": ">=19.0.0"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
|
31
|
-
"@
|
|
32
|
+
"@happy-dom/global-registrator": "^20.10.0",
|
|
33
|
+
"@testing-library/react": "^16.3.0",
|
|
34
|
+
"@types/react": "^19.0.0",
|
|
35
|
+
"@types/react-dom": "^19.0.0",
|
|
36
|
+
"react": "^19.0.0",
|
|
37
|
+
"react-dom": "^19.0.0"
|
|
32
38
|
},
|
|
33
39
|
"files": [
|
|
34
40
|
"src",
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// <FileUpload offline attach>: each file is kept on the device and shown
|
|
2
|
+
// from there at once, set on its record, and uploaded when there is a
|
|
3
|
+
// network.
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
7
|
+
import { db, init } from "@pylonsync/react";
|
|
8
|
+
import { createTestEnv, type TestEnv } from "@pylonsync/sync/src/test-harness";
|
|
9
|
+
|
|
10
|
+
import { FileUpload } from "./FileUpload";
|
|
11
|
+
|
|
12
|
+
let env: TestEnv | null = null;
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
cleanup();
|
|
15
|
+
if (env) {
|
|
16
|
+
await env.dispose();
|
|
17
|
+
env = null;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("FileUpload offline", () => {
|
|
22
|
+
test("queues each file, shows it from the device, and attaches it once uploaded", async () => {
|
|
23
|
+
env = createTestEnv({ transport: "poll" });
|
|
24
|
+
env.signIn({ userId: "rep1" });
|
|
25
|
+
env.server.seed("Photo", [{ id: "p1", fileId: null, url: null }]);
|
|
26
|
+
// The component reads the default engine: point it at the harness.
|
|
27
|
+
init({
|
|
28
|
+
baseUrl: "http://test.invalid",
|
|
29
|
+
appName: "harness",
|
|
30
|
+
persist: false,
|
|
31
|
+
multiTab: false,
|
|
32
|
+
transport: "poll",
|
|
33
|
+
storage: (env.engine as unknown as { storage: never }).storage,
|
|
34
|
+
});
|
|
35
|
+
await db.sync.start();
|
|
36
|
+
// Hold the upload so the waiting stage is observable.
|
|
37
|
+
let release!: () => void;
|
|
38
|
+
env.server.files.putGate = new Promise<void>((r) => (release = r));
|
|
39
|
+
|
|
40
|
+
const queued: string[] = [];
|
|
41
|
+
const { container } = render(
|
|
42
|
+
<FileUpload
|
|
43
|
+
offline
|
|
44
|
+
accept="image/*"
|
|
45
|
+
attach={{ entity: "Photo", id: "p1", field: "fileId", urlField: "url" }}
|
|
46
|
+
onQueued={(q) => queued.push(q.ref)}
|
|
47
|
+
/>,
|
|
48
|
+
);
|
|
49
|
+
const input = container.querySelector("input[type=file]") as HTMLInputElement;
|
|
50
|
+
const file = new File([new Uint8Array(32)], "gutter.jpg", { type: "image/jpeg" });
|
|
51
|
+
await act(async () => {
|
|
52
|
+
fireEvent.change(input, { target: { files: [file] } });
|
|
53
|
+
});
|
|
54
|
+
await waitFor(() => expect(queued).toHaveLength(1));
|
|
55
|
+
// The record holds the local reference and the picker shows the copy.
|
|
56
|
+
expect(db.sync.store.get("Photo", "p1")).toMatchObject({ fileId: queued[0], url: queued[0] });
|
|
57
|
+
expect(screen.getByText("Waiting to upload")).toBeTruthy();
|
|
58
|
+
expect(container.querySelector("img")?.getAttribute("src")).toMatch(/^blob:/);
|
|
59
|
+
|
|
60
|
+
env.server.files.putGate = null;
|
|
61
|
+
await act(async () => {
|
|
62
|
+
release();
|
|
63
|
+
});
|
|
64
|
+
await waitFor(() => expect(screen.getByText("Uploaded")).toBeTruthy());
|
|
65
|
+
await waitFor(() =>
|
|
66
|
+
expect(db.sync.store.get("Photo", "p1")).toMatchObject({ fileId: "f_1", url: "/api/files/f_1" }),
|
|
67
|
+
);
|
|
68
|
+
db.sync.stop();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -9,7 +9,15 @@ import {
|
|
|
9
9
|
useRef,
|
|
10
10
|
useState,
|
|
11
11
|
} from "react";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
queueUpload,
|
|
14
|
+
uploadFile,
|
|
15
|
+
useOutbox,
|
|
16
|
+
type FileVisibility,
|
|
17
|
+
type QueuedUploadHandle,
|
|
18
|
+
type UploadAttachTarget,
|
|
19
|
+
type UploadedFile,
|
|
20
|
+
} from "@pylonsync/react";
|
|
13
21
|
import { cn } from "../lib/cn";
|
|
14
22
|
|
|
15
23
|
export interface FileUploadProps {
|
|
@@ -28,6 +36,21 @@ export interface FileUploadProps {
|
|
|
28
36
|
* only. "public": anyone, for content every visitor sees.
|
|
29
37
|
*/
|
|
30
38
|
visibility?: FileVisibility;
|
|
39
|
+
/**
|
|
40
|
+
* Keep each file on the device and upload it when there is a network
|
|
41
|
+
* (`queueUpload`), instead of uploading now. Needs `attach`.
|
|
42
|
+
*/
|
|
43
|
+
offline?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* With `offline`: the record each file belongs to and the field that
|
|
46
|
+
* gets its id (and `urlField`, its URL). A function is called once per
|
|
47
|
+
* file, for example to insert a row per photo and return its target.
|
|
48
|
+
*/
|
|
49
|
+
attach?:
|
|
50
|
+
| UploadAttachTarget
|
|
51
|
+
| ((file: File) => UploadAttachTarget | Promise<UploadAttachTarget>);
|
|
52
|
+
/** With `offline`: called once for each file kept on the device. */
|
|
53
|
+
onQueued?: (queued: QueuedUploadHandle, source: File) => void;
|
|
31
54
|
/** Override the headline. */
|
|
32
55
|
label?: ReactNode;
|
|
33
56
|
/** Override the helper text. */
|
|
@@ -40,6 +63,8 @@ export interface FileUploadResult {
|
|
|
40
63
|
status: "ok" | "error";
|
|
41
64
|
uploaded?: UploadedFile;
|
|
42
65
|
error?: string;
|
|
66
|
+
/** With `offline`: the queued upload. */
|
|
67
|
+
queued?: QueuedUploadHandle;
|
|
43
68
|
}
|
|
44
69
|
|
|
45
70
|
/**
|
|
@@ -47,6 +72,10 @@ export interface FileUploadResult {
|
|
|
47
72
|
* (init, PUT, confirm), shows per-file progress and errors, and emits
|
|
48
73
|
* `onUploaded` as each file lands.
|
|
49
74
|
*
|
|
75
|
+
* With `offline` and `attach`, each file is kept on the device with
|
|
76
|
+
* `queueUpload` and shown from there at once; it uploads when there is a
|
|
77
|
+
* network and is then set on its record.
|
|
78
|
+
*
|
|
50
79
|
* Pure UI shell — no provider configuration of its own. Pylon's file
|
|
51
80
|
* router handles where bytes actually go (local disk for self-host,
|
|
52
81
|
* Stack0 / S3 for cloud).
|
|
@@ -58,6 +87,9 @@ export function FileUpload({
|
|
|
58
87
|
multiple,
|
|
59
88
|
maxSizeBytes,
|
|
60
89
|
visibility,
|
|
90
|
+
offline,
|
|
91
|
+
attach,
|
|
92
|
+
onQueued,
|
|
61
93
|
label = "Drop files to upload",
|
|
62
94
|
helperText = "or click to browse",
|
|
63
95
|
className,
|
|
@@ -86,6 +118,30 @@ export function FileUpload({
|
|
|
86
118
|
initial.map(async (entry) => {
|
|
87
119
|
if (entry.status === "error") return entry;
|
|
88
120
|
try {
|
|
121
|
+
if (offline) {
|
|
122
|
+
if (!attach) throw new Error("FileUpload offline needs attach");
|
|
123
|
+
const target =
|
|
124
|
+
typeof attach === "function" ? await attach(entry.file) : attach;
|
|
125
|
+
const queued = await queueUpload(entry.file, {
|
|
126
|
+
attach: target,
|
|
127
|
+
visibility,
|
|
128
|
+
});
|
|
129
|
+
onQueued?.(queued, entry.file);
|
|
130
|
+
const result: FileUploadResult = { ...entry, queued };
|
|
131
|
+
queued.done.then(
|
|
132
|
+
(uploaded) => {
|
|
133
|
+
// Also seen by the batch update below when the
|
|
134
|
+
// upload beats it.
|
|
135
|
+
result.uploaded = uploaded;
|
|
136
|
+
onUploaded?.(uploaded, entry.file);
|
|
137
|
+
setItems((prev) =>
|
|
138
|
+
prev.map((r) => (r.file === entry.file ? { ...r, uploaded } : r)),
|
|
139
|
+
);
|
|
140
|
+
},
|
|
141
|
+
() => {},
|
|
142
|
+
);
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
89
145
|
const uploaded = await uploadFile(entry.file, { visibility });
|
|
90
146
|
onUploaded?.(uploaded, entry.file);
|
|
91
147
|
return { ...entry, uploaded } satisfies FileUploadResult;
|
|
@@ -110,7 +166,7 @@ export function FileUpload({
|
|
|
110
166
|
});
|
|
111
167
|
onComplete?.(settled);
|
|
112
168
|
},
|
|
113
|
-
[maxSizeBytes, onComplete, onUploaded, visibility],
|
|
169
|
+
[attach, maxSizeBytes, offline, onComplete, onQueued, onUploaded, visibility],
|
|
114
170
|
);
|
|
115
171
|
|
|
116
172
|
function onDrop(e: DragEvent<HTMLDivElement>) {
|
|
@@ -170,7 +226,14 @@ export function FileUpload({
|
|
|
170
226
|
key={`${item.file.name}-${i}`}
|
|
171
227
|
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
|
|
172
228
|
>
|
|
173
|
-
|
|
229
|
+
{item.queued?.localUrl && item.file.type.startsWith("image/") ? (
|
|
230
|
+
<img
|
|
231
|
+
src={item.queued.localUrl}
|
|
232
|
+
alt=""
|
|
233
|
+
className="h-10 w-10 flex-none rounded object-cover"
|
|
234
|
+
/>
|
|
235
|
+
) : null}
|
|
236
|
+
<span className="min-w-0 flex-1">
|
|
174
237
|
<span className="block truncate font-medium text-[var(--pylon-ink,#0a0a0a)]">
|
|
175
238
|
{item.file.name}
|
|
176
239
|
</span>
|
|
@@ -188,7 +251,30 @@ export function FileUpload({
|
|
|
188
251
|
);
|
|
189
252
|
}
|
|
190
253
|
|
|
254
|
+
function QueuedStatus({ result }: { result: FileUploadResult }) {
|
|
255
|
+
const { failed } = useOutbox();
|
|
256
|
+
const failure = failed.find((f) => f.id === result.queued?.localId);
|
|
257
|
+
if (failure) {
|
|
258
|
+
return (
|
|
259
|
+
<span
|
|
260
|
+
title={failure.error}
|
|
261
|
+
className="rounded-md bg-[var(--pylon-error-bg,#fef2f2)] px-2 py-0.5 text-[11px] font-medium text-[var(--pylon-error-ink,#b91c1c)]"
|
|
262
|
+
>
|
|
263
|
+
Failed
|
|
264
|
+
</span>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return (
|
|
268
|
+
<span className="text-[11px] text-[var(--pylon-ink-3,#71717a)]">
|
|
269
|
+
Waiting to upload
|
|
270
|
+
</span>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
191
274
|
function UploadStatus({ result }: { result: FileUploadResult }) {
|
|
275
|
+
if (result.queued && !result.uploaded && result.status !== "error") {
|
|
276
|
+
return <QueuedStatus result={result} />;
|
|
277
|
+
}
|
|
192
278
|
if (result.uploaded) {
|
|
193
279
|
return (
|
|
194
280
|
<span className="rounded-md bg-[var(--pylon-paper-2,#f4f4f5)] px-2 py-0.5 text-[11px] font-medium text-[var(--pylon-ink-2,#52525b)]">
|