@cosmicdrift/kumiko-renderer-web 0.183.2 → 0.185.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/package.json +4 -4
- package/src/__tests__/nav-tree.test.tsx +5 -3
- package/src/index.ts +2 -0
- package/src/layout/nav-tree.tsx +2 -0
- package/src/primitives/__tests__/button.test.tsx +27 -0
- package/src/primitives/__tests__/embedded-list-input.test.tsx +583 -0
- package/src/primitives/embedded-list-input.tsx +694 -0
- package/src/primitives/index.tsx +11 -5
- package/src/widgets/__tests__/infinity-list.test.tsx +238 -1
- package/src/widgets/__tests__/upload-zone.test.tsx +57 -0
- package/src/widgets/index.ts +1 -0
- package/src/widgets/infinity-list.tsx +63 -5
- package/src/widgets/upload-zone.tsx +158 -0
package/src/primitives/index.tsx
CHANGED
|
@@ -90,6 +90,7 @@ import {
|
|
|
90
90
|
DropdownMenuItem,
|
|
91
91
|
DropdownMenuTrigger,
|
|
92
92
|
} from "./dropdown-menu";
|
|
93
|
+
import { EmbeddedListInput } from "./embedded-list-input";
|
|
93
94
|
import { FileUploadInput } from "./file-upload";
|
|
94
95
|
import { DefaultLightbox } from "./lightbox";
|
|
95
96
|
import { LocatedTimestampInput } from "./located-timestamp-input";
|
|
@@ -143,15 +144,19 @@ function DefaultButton({
|
|
|
143
144
|
width = "auto",
|
|
144
145
|
children,
|
|
145
146
|
testId,
|
|
147
|
+
className,
|
|
148
|
+
ref,
|
|
146
149
|
}: ButtonProps): ReactNode {
|
|
147
150
|
// link-Variant rendert text-artig (Inline-Link im Fließtext/Banner), nicht als
|
|
148
151
|
// gepolsterte Fläche; width="full" streckt CTA-Buttons in Karten/Panels.
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
const resolvedClassName = cn(
|
|
153
|
+
variant === "link" ? "h-auto px-0 py-0" : "",
|
|
154
|
+
width === "full" ? "w-full" : "",
|
|
155
|
+
className,
|
|
156
|
+
);
|
|
153
157
|
return (
|
|
154
158
|
<UiButton
|
|
159
|
+
ref={ref}
|
|
155
160
|
type={type}
|
|
156
161
|
onClick={onClick}
|
|
157
162
|
disabled={disabled === true || loading === true}
|
|
@@ -160,7 +165,7 @@ function DefaultButton({
|
|
|
160
165
|
variant={BUTTON_VARIANT[variant]}
|
|
161
166
|
size={BUTTON_SIZE[size]}
|
|
162
167
|
aria-label={ariaLabel}
|
|
163
|
-
className={
|
|
168
|
+
className={resolvedClassName}
|
|
164
169
|
>
|
|
165
170
|
{loading === true ? <Loader2 className="size-4 animate-spin" aria-hidden="true" /> : children}
|
|
166
171
|
</UiButton>
|
|
@@ -1884,6 +1889,7 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
1884
1889
|
Field: DefaultField,
|
|
1885
1890
|
Input: DefaultInput,
|
|
1886
1891
|
DataTable: DefaultDataTable,
|
|
1892
|
+
EmbeddedListInput,
|
|
1887
1893
|
Form: DefaultForm,
|
|
1888
1894
|
Section: DefaultSection,
|
|
1889
1895
|
Card: DefaultCard,
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
DispatcherProvider,
|
|
5
|
+
type LiveEvent,
|
|
6
|
+
type LiveEventSubscriber,
|
|
7
|
+
LiveEventsProvider,
|
|
8
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
4
9
|
import type { ReactNode } from "react";
|
|
5
10
|
import {
|
|
11
|
+
act,
|
|
6
12
|
createMockDispatcher,
|
|
7
13
|
fireEvent,
|
|
8
14
|
render,
|
|
@@ -11,6 +17,30 @@ import {
|
|
|
11
17
|
} from "../../__tests__/test-utils";
|
|
12
18
|
import { InfinityList } from "../infinity-list";
|
|
13
19
|
|
|
20
|
+
// Fake LiveEventSubscriber for live-mode tests — collects subscribers,
|
|
21
|
+
// `inject(type, data)` fires the ones matching `data.aggregateType`.
|
|
22
|
+
// Same shape as production; mirrors use-query-live.test.tsx's helper.
|
|
23
|
+
function makeFakeLiveEvents(): {
|
|
24
|
+
subscriber: LiveEventSubscriber;
|
|
25
|
+
inject: (type: string, data: LiveEvent["data"]) => void;
|
|
26
|
+
} {
|
|
27
|
+
const listeners = new Set<{ entity: string; cb: (e: LiveEvent) => void }>();
|
|
28
|
+
return {
|
|
29
|
+
subscriber: (entity, cb) => {
|
|
30
|
+
const entry = { entity, cb };
|
|
31
|
+
listeners.add(entry);
|
|
32
|
+
return () => {
|
|
33
|
+
listeners.delete(entry);
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
inject: (type, data) => {
|
|
37
|
+
for (const l of listeners) {
|
|
38
|
+
if (l.entity === data.aggregateType) l.cb({ type, data });
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
14
44
|
// jsdom has no IntersectionObserver — stub stores the callback per observer
|
|
15
45
|
// so tests can fire the "sentinel became visible" event manually instead of
|
|
16
46
|
// simulating real scrolling.
|
|
@@ -32,6 +62,14 @@ function renderWithDispatcher(ui: ReactNode, dispatcher: Dispatcher) {
|
|
|
32
62
|
return render(<DispatcherProvider dispatcher={dispatcher}>{ui}</DispatcherProvider>);
|
|
33
63
|
}
|
|
34
64
|
|
|
65
|
+
function renderWithLive(ui: ReactNode, dispatcher: Dispatcher, liveEvents: LiveEventSubscriber) {
|
|
66
|
+
return render(
|
|
67
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
68
|
+
<LiveEventsProvider value={liveEvents}>{ui}</LiveEventsProvider>
|
|
69
|
+
</DispatcherProvider>,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
35
73
|
type Row = { readonly id: string; readonly subject: string };
|
|
36
74
|
type Page = { readonly rows: readonly Row[]; readonly nextCursor: string | null };
|
|
37
75
|
|
|
@@ -183,4 +221,203 @@ describe("InfinityList", () => {
|
|
|
183
221
|
expect(screen.queryByText("Bo-Treffer")).toBeNull();
|
|
184
222
|
expect(screen.getByText("Bob-Treffer")).toBeTruthy();
|
|
185
223
|
});
|
|
224
|
+
|
|
225
|
+
// fw#1827: InfinityList used dispatcher.query directly and never subscribed
|
|
226
|
+
// to live events, so a solon inbox stayed stale until the user reloaded.
|
|
227
|
+
describe("Live-Mode", () => {
|
|
228
|
+
test("SSE-Event mergt nur die erste Seite, bereits geladene Folgeseiten bleiben erhalten", async () => {
|
|
229
|
+
const calls: Array<Readonly<Record<string, unknown>>> = [];
|
|
230
|
+
const dispatcher = createMockDispatcher({
|
|
231
|
+
query: ((_type: string, payload: Readonly<Record<string, unknown>>) => {
|
|
232
|
+
calls.push(payload);
|
|
233
|
+
if (calls.length === 1) {
|
|
234
|
+
return Promise.resolve({
|
|
235
|
+
isSuccess: true,
|
|
236
|
+
data: { rows: [{ id: "m1", subject: "Alt-1" }], nextCursor: "c1" },
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (calls.length === 2) {
|
|
240
|
+
return Promise.resolve({
|
|
241
|
+
isSuccess: true,
|
|
242
|
+
data: { rows: [{ id: "m2", subject: "Alt-2" }], nextCursor: null },
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
// Live refresh of the first page: m1 stays, a new row lands on top.
|
|
246
|
+
return Promise.resolve({
|
|
247
|
+
isSuccess: true,
|
|
248
|
+
data: {
|
|
249
|
+
rows: [
|
|
250
|
+
{ id: "m3", subject: "Neu" },
|
|
251
|
+
{ id: "m1", subject: "Alt-1" },
|
|
252
|
+
],
|
|
253
|
+
nextCursor: "c1",
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}) as unknown as Dispatcher["query"],
|
|
257
|
+
});
|
|
258
|
+
const fake = makeFakeLiveEvents();
|
|
259
|
+
|
|
260
|
+
renderWithLive(
|
|
261
|
+
<InfinityList<Page, Row>
|
|
262
|
+
query="inbox:query:message:list"
|
|
263
|
+
pageSize={1}
|
|
264
|
+
rows={(data) => data.rows}
|
|
265
|
+
nextCursor={(data) => data.nextCursor}
|
|
266
|
+
rowId={(row) => row.id}
|
|
267
|
+
renderRow={(row) => <span>{row.subject}</span>}
|
|
268
|
+
testId="inbox"
|
|
269
|
+
/>,
|
|
270
|
+
dispatcher,
|
|
271
|
+
fake.subscriber,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
await waitFor(() => expect(screen.getByText("Alt-1")).toBeTruthy());
|
|
275
|
+
fireIntersect();
|
|
276
|
+
await waitFor(() => expect(screen.getByText("Alt-2")).toBeTruthy());
|
|
277
|
+
expect(calls.length).toBe(2);
|
|
278
|
+
|
|
279
|
+
act(() => {
|
|
280
|
+
fake.inject("message.created", {
|
|
281
|
+
id: "m3",
|
|
282
|
+
aggregateType: "message",
|
|
283
|
+
version: 1,
|
|
284
|
+
payload: {},
|
|
285
|
+
createdAt: "",
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
await waitFor(() => expect(screen.getByText("Neu")).toBeTruthy());
|
|
290
|
+
expect(screen.getByText("Alt-1")).toBeTruthy();
|
|
291
|
+
expect(screen.getByText("Alt-2")).toBeTruthy();
|
|
292
|
+
expect(screen.getAllByText("Alt-1").length).toBe(1);
|
|
293
|
+
|
|
294
|
+
// The live refresh only requests the first page — no cursor in the payload.
|
|
295
|
+
expect(calls[2]).toEqual({ limit: 1 });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("live=false: SSE-Event wird ignoriert, kein Refetch", async () => {
|
|
299
|
+
let calls = 0;
|
|
300
|
+
const dispatcher = createMockDispatcher({
|
|
301
|
+
query: (() => {
|
|
302
|
+
calls += 1;
|
|
303
|
+
return Promise.resolve({
|
|
304
|
+
isSuccess: true,
|
|
305
|
+
data: { rows: [{ id: "m1", subject: "Hallo" }], nextCursor: null },
|
|
306
|
+
});
|
|
307
|
+
}) as unknown as Dispatcher["query"],
|
|
308
|
+
});
|
|
309
|
+
const fake = makeFakeLiveEvents();
|
|
310
|
+
|
|
311
|
+
renderWithLive(
|
|
312
|
+
<InfinityList<Page, Row>
|
|
313
|
+
query="inbox:query:message:list"
|
|
314
|
+
live={false}
|
|
315
|
+
rows={(data) => data.rows}
|
|
316
|
+
nextCursor={(data) => data.nextCursor}
|
|
317
|
+
rowId={(row) => row.id}
|
|
318
|
+
renderRow={(row) => <span>{row.subject}</span>}
|
|
319
|
+
testId="inbox"
|
|
320
|
+
/>,
|
|
321
|
+
dispatcher,
|
|
322
|
+
fake.subscriber,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
await waitFor(() => expect(screen.getByText("Hallo")).toBeTruthy());
|
|
326
|
+
|
|
327
|
+
fake.inject("message.created", {
|
|
328
|
+
id: "m2",
|
|
329
|
+
aggregateType: "message",
|
|
330
|
+
version: 1,
|
|
331
|
+
payload: {},
|
|
332
|
+
createdAt: "",
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
336
|
+
expect(calls).toBe(1);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("nur Events für die Query-Entity triggern den Refresh", async () => {
|
|
340
|
+
let calls = 0;
|
|
341
|
+
const dispatcher = createMockDispatcher({
|
|
342
|
+
query: (() => {
|
|
343
|
+
calls += 1;
|
|
344
|
+
return Promise.resolve({
|
|
345
|
+
isSuccess: true,
|
|
346
|
+
data: { rows: [{ id: "m1", subject: "Hallo" }], nextCursor: null },
|
|
347
|
+
});
|
|
348
|
+
}) as unknown as Dispatcher["query"],
|
|
349
|
+
});
|
|
350
|
+
const fake = makeFakeLiveEvents();
|
|
351
|
+
|
|
352
|
+
renderWithLive(
|
|
353
|
+
<InfinityList<Page, Row>
|
|
354
|
+
query="inbox:query:message:list"
|
|
355
|
+
rows={(data) => data.rows}
|
|
356
|
+
nextCursor={(data) => data.nextCursor}
|
|
357
|
+
rowId={(row) => row.id}
|
|
358
|
+
renderRow={(row) => <span>{row.subject}</span>}
|
|
359
|
+
testId="inbox"
|
|
360
|
+
/>,
|
|
361
|
+
dispatcher,
|
|
362
|
+
fake.subscriber,
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
await waitFor(() => expect(screen.getByText("Hallo")).toBeTruthy());
|
|
366
|
+
|
|
367
|
+
fake.inject("note.created", {
|
|
368
|
+
id: "n1",
|
|
369
|
+
aggregateType: "note",
|
|
370
|
+
version: 1,
|
|
371
|
+
payload: {},
|
|
372
|
+
createdAt: "",
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
376
|
+
expect(calls).toBe(1);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// A live event arriving while the mount fetch is still in flight must not
|
|
380
|
+
// discard that fetch — it's the normal case for #1827's own scenario (a
|
|
381
|
+
// screen mounts while writes are already streaming in).
|
|
382
|
+
test("SSE-Event während der ersten Ladephase lässt die Mount-Anfrage trotzdem landen", async () => {
|
|
383
|
+
const resolvers: Array<(res: unknown) => void> = [];
|
|
384
|
+
const dispatcher = createMockDispatcher({
|
|
385
|
+
query: (() =>
|
|
386
|
+
new Promise((resolve) => {
|
|
387
|
+
resolvers.push(resolve);
|
|
388
|
+
})) as unknown as Dispatcher["query"],
|
|
389
|
+
});
|
|
390
|
+
const fake = makeFakeLiveEvents();
|
|
391
|
+
|
|
392
|
+
renderWithLive(list("inbox:query:message:list"), dispatcher, fake.subscriber);
|
|
393
|
+
|
|
394
|
+
await waitFor(() => expect(resolvers.length).toBe(1));
|
|
395
|
+
|
|
396
|
+
act(() => {
|
|
397
|
+
fake.inject("message.created", {
|
|
398
|
+
id: "m2",
|
|
399
|
+
aggregateType: "message",
|
|
400
|
+
version: 1,
|
|
401
|
+
payload: {},
|
|
402
|
+
createdAt: "",
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
await waitFor(() => expect(resolvers.length).toBe(2));
|
|
406
|
+
|
|
407
|
+
act(() => {
|
|
408
|
+
resolvers[0]?.({
|
|
409
|
+
isSuccess: true,
|
|
410
|
+
data: { rows: [{ id: "m1", subject: "Hallo" }], nextCursor: null },
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
act(() => {
|
|
414
|
+
resolvers[1]?.({
|
|
415
|
+
isSuccess: true,
|
|
416
|
+
data: { rows: [{ id: "m1", subject: "Hallo" }], nextCursor: null },
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
await waitFor(() => expect(screen.getByText("Hallo")).toBeTruthy());
|
|
421
|
+
});
|
|
422
|
+
});
|
|
186
423
|
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { fireEvent, render, screen, waitFor } from "../../__tests__/test-utils";
|
|
3
|
+
import { UploadZone } from "../upload-zone";
|
|
4
|
+
|
|
5
|
+
function pick(input: HTMLElement, files: readonly File[]): void {
|
|
6
|
+
Object.defineProperty(input, "files", { value: files, configurable: true });
|
|
7
|
+
fireEvent.change(input);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("UploadZone", () => {
|
|
11
|
+
test("lädt eine Datei hoch und zeigt den done-Status", async () => {
|
|
12
|
+
const onUpload = mock(async () => {});
|
|
13
|
+
render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
|
|
14
|
+
const file = new File(["hi"], "report.pdf", { type: "application/pdf" });
|
|
15
|
+
pick(screen.getByTestId("zone-input"), [file]);
|
|
16
|
+
|
|
17
|
+
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
|
|
18
|
+
expect(onUpload).toHaveBeenCalledWith(file);
|
|
19
|
+
await waitFor(() => expect(screen.getByText("report.pdf")).toBeTruthy());
|
|
20
|
+
await waitFor(() => expect(screen.getByText("Uploaded")).toBeTruthy());
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("zeigt die Fehlermeldung wenn onUpload wirft", async () => {
|
|
24
|
+
const onUpload = mock(async () => {
|
|
25
|
+
throw new Error("too_large");
|
|
26
|
+
});
|
|
27
|
+
render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
|
|
28
|
+
const file = new File(["hi"], "huge.pdf", { type: "application/pdf" });
|
|
29
|
+
pick(screen.getByTestId("zone-input"), [file]);
|
|
30
|
+
|
|
31
|
+
await waitFor(() => expect(screen.getByText("too_large")).toBeTruthy());
|
|
32
|
+
expect(screen.getByText("Failed")).toBeTruthy();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("multiple=false nimmt nur die erste Datei", async () => {
|
|
36
|
+
const onUpload = mock(async () => {});
|
|
37
|
+
render(
|
|
38
|
+
<UploadZone title="Datei hochladen" onUpload={onUpload} multiple={false} testId="zone" />,
|
|
39
|
+
);
|
|
40
|
+
const a = new File(["a"], "a.pdf");
|
|
41
|
+
const b = new File(["b"], "b.pdf");
|
|
42
|
+
pick(screen.getByTestId("zone-input"), [a, b]);
|
|
43
|
+
|
|
44
|
+
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
|
|
45
|
+
expect(onUpload).toHaveBeenCalledWith(a);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("resettet den Input nach dem Upload-Batch", async () => {
|
|
49
|
+
const onUpload = mock(async () => {});
|
|
50
|
+
render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
|
|
51
|
+
const input = screen.getByTestId("zone-input") as HTMLInputElement;
|
|
52
|
+
pick(input, [new File(["a"], "a.pdf")]);
|
|
53
|
+
|
|
54
|
+
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
|
|
55
|
+
expect(input.value).toBe("");
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/widgets/index.ts
CHANGED
|
@@ -56,4 +56,5 @@ export { SectionCard } from "./section-card";
|
|
|
56
56
|
export { MiniStat, Sparkline, StatCard, type StatDelta, type StatTone } from "./stat";
|
|
57
57
|
export { EmptyState, ErrorState, LoadingState } from "./states";
|
|
58
58
|
export { STATUS_TONE_TEXT, StatusBadge, type StatusTone } from "./status-badge";
|
|
59
|
+
export { UploadZone, type UploadZoneProps } from "./upload-zone";
|
|
59
60
|
export { useDraft } from "./use-draft";
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
entityFromQueryType,
|
|
4
|
+
useDispatcher,
|
|
5
|
+
useLiveEvents,
|
|
6
|
+
useTranslation,
|
|
7
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
3
8
|
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
4
9
|
import { EmptyState, ErrorState, LoadingState } from "./states";
|
|
5
10
|
|
|
@@ -12,11 +17,19 @@ export type InfinityListProps<TData = unknown, TRow = Readonly<Record<string, un
|
|
|
12
17
|
readonly rows: (data: TData) => readonly TRow[];
|
|
13
18
|
/** Pull the next-page cursor from the result; `null` means last page. */
|
|
14
19
|
readonly nextCursor: (data: TData) => string | null;
|
|
20
|
+
/** Must derive from row content (e.g. `row.id`), not from `index` — a
|
|
21
|
+
* live refresh reorders rows (new/changed rows move to the front). */
|
|
15
22
|
readonly rowId: (row: TRow, index: number) => string;
|
|
16
23
|
readonly renderRow: (row: TRow) => ReactNode;
|
|
17
24
|
readonly emptyState?: ReactNode;
|
|
18
25
|
readonly className?: string;
|
|
19
26
|
readonly testId?: string;
|
|
27
|
+
/** Subscribe to SSE events for the entity parsed from `query`
|
|
28
|
+
* (`<feature>:query:<entity>:<verb>`) and refetch the first page on
|
|
29
|
+
* any create/update/delete/restore event, merging it into the
|
|
30
|
+
* already-accumulated rows instead of collapsing them — see
|
|
31
|
+
* `useQuery`'s `live` option for the same convention. Default true. */
|
|
32
|
+
readonly live?: boolean;
|
|
20
33
|
};
|
|
21
34
|
|
|
22
35
|
type State<TRow> =
|
|
@@ -40,21 +53,26 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
40
53
|
emptyState,
|
|
41
54
|
className,
|
|
42
55
|
testId,
|
|
56
|
+
live = true,
|
|
43
57
|
}: InfinityListProps<TData, TRow>): ReactNode {
|
|
44
58
|
const dispatcher = useDispatcher();
|
|
45
59
|
const t = useTranslation();
|
|
60
|
+
const subscribeLive = useLiveEvents();
|
|
46
61
|
const [state, setState] = useState<State<TRow>>({ kind: "loading" });
|
|
47
62
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
|
48
63
|
const payloadKey = JSON.stringify(payload ?? {});
|
|
49
64
|
|
|
50
|
-
// rows/nextCursor are fresh closures on every caller render
|
|
51
|
-
// arrow props). As useCallback deps that would recreate
|
|
52
|
-
// render → the
|
|
53
|
-
//
|
|
65
|
+
// rows/nextCursor/rowId are fresh closures on every caller render
|
|
66
|
+
// (inline arrow props). As useCallback deps that would recreate
|
|
67
|
+
// `load`/`refreshFirstPage` every render → the effects below would
|
|
68
|
+
// refetch in a loop. Refs keep them stable while always reading the
|
|
69
|
+
// current selector.
|
|
54
70
|
const rowsRef = useRef(rows);
|
|
55
71
|
rowsRef.current = rows;
|
|
56
72
|
const nextCursorRef = useRef(nextCursor);
|
|
57
73
|
nextCursorRef.current = nextCursor;
|
|
74
|
+
const rowIdRef = useRef(rowId);
|
|
75
|
+
rowIdRef.current = rowId;
|
|
58
76
|
|
|
59
77
|
// Discards a response whose request was superseded by a newer one before
|
|
60
78
|
// it resolved (e.g. two searches fired in quick succession) — without
|
|
@@ -94,6 +112,46 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
94
112
|
};
|
|
95
113
|
}, [load]);
|
|
96
114
|
|
|
115
|
+
// Live-mode: on an SSE event for the query's entity, refetch only the
|
|
116
|
+
// first page and merge it in — rows the fresh page still contains move
|
|
117
|
+
// to the front (newest-first feeds), rows it dropped (edited/deleted
|
|
118
|
+
// elsewhere) are pruned, and everything beyond page 1 stays untouched.
|
|
119
|
+
// A full reload would collapse already-accumulated pages and jump the
|
|
120
|
+
// scroll position; see fw#1827.
|
|
121
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: payload goes through payloadKey
|
|
122
|
+
const refreshFirstPage = useCallback(async (): Promise<void> => {
|
|
123
|
+
// Read, don't bump: a concurrent load() (e.g. the mount fetch still
|
|
124
|
+
// in flight) must still land. Bumping here would make load()'s own
|
|
125
|
+
// sequence check discard it, and the refresh below then bails on
|
|
126
|
+
// `prev.kind !== "ready"` — the list gets stuck in loading forever.
|
|
127
|
+
const seqAtStart = requestSeq.current;
|
|
128
|
+
const res = await dispatcher.query<TData>(query, { ...payload, limit: pageSize });
|
|
129
|
+
if (seqAtStart !== requestSeq.current) return;
|
|
130
|
+
// skip: background live refresh failed, keep showing the current rows
|
|
131
|
+
if (!res.isSuccess) return;
|
|
132
|
+
const freshRows = rowsRef.current(res.data);
|
|
133
|
+
const freshIds = new Set(freshRows.map((row, index) => rowIdRef.current(row, index)));
|
|
134
|
+
setState((prev) => {
|
|
135
|
+
// skip: not showing an accumulated list yet, nothing to merge into
|
|
136
|
+
if (prev.kind !== "ready") return prev;
|
|
137
|
+
const staleRows = prev.rows.filter(
|
|
138
|
+
(row, index) => !freshIds.has(rowIdRef.current(row, index)),
|
|
139
|
+
);
|
|
140
|
+
return { kind: "ready", rows: [...freshRows, ...staleRows], cursor: prev.cursor };
|
|
141
|
+
});
|
|
142
|
+
}, [dispatcher, query, pageSize, payloadKey]);
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
// skip: live mode off, no SSE subscription needed
|
|
146
|
+
if (!live) return;
|
|
147
|
+
const entity = entityFromQueryType(query);
|
|
148
|
+
// skip: query type has no mapped entity, nothing to subscribe to
|
|
149
|
+
if (entity === undefined) return;
|
|
150
|
+
return subscribeLive(entity, () => {
|
|
151
|
+
void refreshFirstPage();
|
|
152
|
+
});
|
|
153
|
+
}, [live, query, refreshFirstPage, subscribeLive]);
|
|
154
|
+
|
|
97
155
|
useEffect(() => {
|
|
98
156
|
const sentinel = sentinelRef.current;
|
|
99
157
|
// skip: no further page or not ready yet — observer not needed
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import { CheckCircle2, FileUp, Loader2, TriangleAlert, Upload } from "lucide-react";
|
|
3
|
+
import { type DragEvent, type ReactNode, useId, useRef, useState } from "react";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
|
|
6
|
+
type UploadRowStatus = "uploading" | "done" | "error";
|
|
7
|
+
|
|
8
|
+
type UploadRow = {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly fileName: string;
|
|
11
|
+
readonly status: UploadRowStatus;
|
|
12
|
+
readonly error?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const STATUS_ICON: Record<UploadRowStatus, ReactNode> = {
|
|
16
|
+
uploading: <Loader2 className="size-3.5 animate-spin text-muted-foreground" aria-hidden="true" />,
|
|
17
|
+
done: <CheckCircle2 className="size-3.5 text-primary" aria-hidden="true" />,
|
|
18
|
+
error: <TriangleAlert className="size-3.5 text-destructive" aria-hidden="true" />,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const STATUS_LABEL_KEY: Record<UploadRowStatus, string> = {
|
|
22
|
+
uploading: "kumiko.widget.upload.uploading",
|
|
23
|
+
done: "kumiko.widget.upload.done",
|
|
24
|
+
error: "kumiko.widget.upload.error",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type UploadZoneProps = {
|
|
28
|
+
/** Uploads a single file (POST + optional follow-up mutation). Throws on
|
|
29
|
+
* failure — the message ends up in the row's error line. Runs in
|
|
30
|
+
* parallel per file within a batch (no sequential waiting). */
|
|
31
|
+
readonly onUpload: (file: File) => Promise<void>;
|
|
32
|
+
readonly title: ReactNode;
|
|
33
|
+
readonly hint?: ReactNode;
|
|
34
|
+
readonly accept?: readonly string[];
|
|
35
|
+
/** Allow multiple files per pick/drop. Defaults to true. */
|
|
36
|
+
readonly multiple?: boolean;
|
|
37
|
+
readonly disabled?: boolean;
|
|
38
|
+
readonly testId?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// "jpg" → ".jpg", "image/png" stays as-is. Empty list → no accept attribute.
|
|
42
|
+
function toAcceptAttr(accept?: readonly string[]): string | undefined {
|
|
43
|
+
if (accept === undefined || accept.length === 0) return undefined;
|
|
44
|
+
return accept.map((a) => (a.startsWith(".") || a.includes("/") ? a : `.${a}`)).join(",");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Drop zone + multi-file picker with a per-file status row (uploading/done/
|
|
48
|
+
* error). For screens that accept several files and upload them
|
|
49
|
+
* independently — unlike `FileField` (single file, FileRef value inside a
|
|
50
|
+
* form field). `onUpload` decides what "upload" means (storage POST, ingest
|
|
51
|
+
* mutation, or both); the zone only tracks uploading/done/error. */
|
|
52
|
+
export function UploadZone({
|
|
53
|
+
onUpload,
|
|
54
|
+
title,
|
|
55
|
+
hint,
|
|
56
|
+
accept,
|
|
57
|
+
multiple = true,
|
|
58
|
+
disabled,
|
|
59
|
+
testId,
|
|
60
|
+
}: UploadZoneProps): ReactNode {
|
|
61
|
+
const t = useTranslation();
|
|
62
|
+
const inputId = useId();
|
|
63
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
64
|
+
const [rows, setRows] = useState<readonly UploadRow[]>([]);
|
|
65
|
+
const [dragOver, setDragOver] = useState(false);
|
|
66
|
+
|
|
67
|
+
async function uploadOne(file: File): Promise<void> {
|
|
68
|
+
const rowId = crypto.randomUUID();
|
|
69
|
+
setRows((prev) => [...prev, { id: rowId, fileName: file.name, status: "uploading" }]);
|
|
70
|
+
try {
|
|
71
|
+
await onUpload(file);
|
|
72
|
+
setRows((prev) => prev.map((row) => (row.id === rowId ? { ...row, status: "done" } : row)));
|
|
73
|
+
} catch (cause) {
|
|
74
|
+
const message = cause instanceof Error ? cause.message : "upload_failed";
|
|
75
|
+
setRows((prev) =>
|
|
76
|
+
prev.map((row) => (row.id === rowId ? { ...row, status: "error", error: message } : row)),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function uploadFiles(files: FileList | null): Promise<void> {
|
|
82
|
+
if (files === null || files.length === 0) return;
|
|
83
|
+
const picked = multiple ? Array.from(files) : files[0] !== undefined ? [files[0]] : [];
|
|
84
|
+
await Promise.all(picked.map((file) => uploadOne(file)));
|
|
85
|
+
// Reset so re-picking the SAME file still fires change — the browser
|
|
86
|
+
// suppresses the event otherwise.
|
|
87
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function handleDrop(e: DragEvent<HTMLLabelElement>): void {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
setDragOver(false);
|
|
93
|
+
if (disabled === true) return;
|
|
94
|
+
void uploadFiles(e.dataTransfer.files);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const acceptAttr = toAcceptAttr(accept);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div data-testid={testId} className="flex flex-col gap-4">
|
|
101
|
+
<label
|
|
102
|
+
htmlFor={inputId}
|
|
103
|
+
onDragOver={(e) => {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
if (disabled !== true) setDragOver(true);
|
|
106
|
+
}}
|
|
107
|
+
onDragLeave={(e) => {
|
|
108
|
+
if (!(e.relatedTarget instanceof Node) || !e.currentTarget.contains(e.relatedTarget)) {
|
|
109
|
+
setDragOver(false);
|
|
110
|
+
}
|
|
111
|
+
}}
|
|
112
|
+
onDrop={handleDrop}
|
|
113
|
+
data-testid={testId !== undefined ? `${testId}-dropzone` : undefined}
|
|
114
|
+
className={cn(
|
|
115
|
+
"flex w-full cursor-pointer flex-col items-center gap-3 rounded-lg border-2 border-dashed p-10 text-center transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2",
|
|
116
|
+
dragOver ? "border-primary bg-muted/40" : "border-border",
|
|
117
|
+
disabled === true && "cursor-not-allowed opacity-50",
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
<Upload className="size-8 text-muted-foreground" aria-hidden="true" />
|
|
121
|
+
<span className="text-sm">{title}</span>
|
|
122
|
+
{hint !== undefined && <span className="text-sm text-muted-foreground">{hint}</span>}
|
|
123
|
+
<input
|
|
124
|
+
ref={inputRef}
|
|
125
|
+
id={inputId}
|
|
126
|
+
type="file"
|
|
127
|
+
multiple={multiple}
|
|
128
|
+
className="sr-only"
|
|
129
|
+
data-testid={testId !== undefined ? `${testId}-input` : undefined}
|
|
130
|
+
disabled={disabled}
|
|
131
|
+
{...(acceptAttr !== undefined && { accept: acceptAttr })}
|
|
132
|
+
onChange={(e) => void uploadFiles(e.target.files)}
|
|
133
|
+
/>
|
|
134
|
+
</label>
|
|
135
|
+
{rows.length > 0 && (
|
|
136
|
+
<ul className="flex flex-col gap-2">
|
|
137
|
+
{rows.map((row) => (
|
|
138
|
+
<li
|
|
139
|
+
key={row.id}
|
|
140
|
+
data-testid={testId !== undefined ? `${testId}-row` : undefined}
|
|
141
|
+
className="flex items-center justify-between gap-3 rounded-md border border-border px-4 py-2"
|
|
142
|
+
>
|
|
143
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
144
|
+
<FileUp className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
145
|
+
<span className="truncate text-sm">{row.fileName}</span>
|
|
146
|
+
</div>
|
|
147
|
+
<output className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
|
|
148
|
+
{STATUS_ICON[row.status]}
|
|
149
|
+
<span className="sr-only">{t(STATUS_LABEL_KEY[row.status])}</span>
|
|
150
|
+
{row.status === "error" && row.error !== undefined && <span>{row.error}</span>}
|
|
151
|
+
</output>
|
|
152
|
+
</li>
|
|
153
|
+
))}
|
|
154
|
+
</ul>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|