@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.47",
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.47",
24
- "@pylonsync/sync": "0.22.47",
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
- "@types/react": "^19.0.0"
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 { uploadFile, type FileVisibility, type UploadedFile } from "@pylonsync/react";
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
- <span className="min-w-0">
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)]">