@kahitsan/ksui 0.19.0 → 0.21.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 +1 -1
- package/src/components/base/BadgeSelect.test.tsx +88 -0
- package/src/components/base/BadgeSelect.tsx +229 -0
- package/src/components/composite/CustomRenderer.test.tsx +86 -0
- package/src/components/composite/CustomRenderer.tsx +114 -0
- package/src/components/composite/FileField.test.tsx +112 -0
- package/src/components/composite/FileField.tsx +301 -0
- package/src/components/composite/FlowRunner.test.tsx +127 -0
- package/src/components/composite/FlowRunner.tsx +348 -0
- package/src/index.ts +53 -0
- package/src/utils/flow.test.ts +41 -0
- package/src/utils/flow.ts +149 -0
- package/src/utils/renderers.test.ts +87 -0
- package/src/utils/renderers.ts +153 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// U6 — FileField (Vision §11): a declarative file/media input for the spec-driven
|
|
2
|
+
// form runtime. Its value is an OPAQUE asset HANDLE (`{ id, name, mime, size }`),
|
|
3
|
+
// not a path or URL (§11 / Data Arch §4 opaque-id storage). The component is
|
|
4
|
+
// storage-agnostic: it knows nothing about S3/MinIO/the kernel. The HOST injects
|
|
5
|
+
// two callbacks:
|
|
6
|
+
// - onUpload(file) => Promise<handle> wires to the kernel asset service
|
|
7
|
+
// - presignUrl(handle) => Promise<url> resolves a handle to a time-limited URL
|
|
8
|
+
// ksui only models the field UX + state machine (pending / uploading / done /
|
|
9
|
+
// failed) and the preview. A missing/expired asset degrades gracefully — a
|
|
10
|
+
// rejected presign shows a broken-thumbnail fallback + warns, never throws (§11).
|
|
11
|
+
//
|
|
12
|
+
// Composite because it composes the upload/preview state machine and self-injects
|
|
13
|
+
// its CSS (ksui-ff-* unscoped classes + CSS custom props); no Tailwind, no
|
|
14
|
+
// host-brand classes (standalone-library rule).
|
|
15
|
+
|
|
16
|
+
import type { Component, JSX } from "solid-js";
|
|
17
|
+
import { Match, Show, Switch, createSignal } from "solid-js";
|
|
18
|
+
import UploadCloud from "lucide-solid/icons/cloud-upload";
|
|
19
|
+
import FileIcon from "lucide-solid/icons/file";
|
|
20
|
+
import ImageOff from "lucide-solid/icons/image-off";
|
|
21
|
+
import X from "lucide-solid/icons/x";
|
|
22
|
+
|
|
23
|
+
/** The opaque asset handle the field stores as its value (§11). */
|
|
24
|
+
export interface AssetHandle {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly mime: string;
|
|
28
|
+
readonly size: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Upload/preview lifecycle (§11: pending while queued, failed on hard error). */
|
|
32
|
+
export type FileFieldStatus = "empty" | "uploading" | "done" | "failed";
|
|
33
|
+
|
|
34
|
+
const STYLE_ID = "ksui-file-field-style";
|
|
35
|
+
|
|
36
|
+
function ensureStyle(): void {
|
|
37
|
+
if (typeof document === "undefined") return;
|
|
38
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
39
|
+
const style = document.createElement("style");
|
|
40
|
+
style.id = STYLE_ID;
|
|
41
|
+
style.textContent = `
|
|
42
|
+
.ksui-ff{display:flex;flex-direction:column;gap:0.5rem;}
|
|
43
|
+
.ksui-ff-drop{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0.375rem;padding:1.25rem;border-radius:0.625rem;border:1.5px dashed var(--ksui-ff-border,rgba(255,255,255,0.2));background:var(--ksui-ff-bg,rgba(255,255,255,0.03));color:var(--ksui-ff-fg,inherit);cursor:pointer;text-align:center;font-size:0.82rem;transition:border-color .15s,background .15s;}
|
|
44
|
+
.ksui-ff-drop.dragging{border-color:var(--ksui-ff-accent,#c9a961);background:var(--ksui-ff-accent-bg,rgba(201,169,97,0.08));}
|
|
45
|
+
.ksui-ff-drop:disabled{opacity:0.5;cursor:not-allowed;}
|
|
46
|
+
.ksui-ff-hint{font-size:0.72rem;opacity:0.6;}
|
|
47
|
+
.ksui-ff-card{display:flex;align-items:center;gap:0.625rem;padding:0.5rem 0.625rem;border-radius:0.5rem;border:1px solid var(--ksui-ff-border,rgba(255,255,255,0.15));background:var(--ksui-ff-bg,rgba(255,255,255,0.03));}
|
|
48
|
+
.ksui-ff-thumb{width:2.5rem;height:2.5rem;border-radius:0.375rem;object-fit:cover;flex:0 0 auto;background:rgba(255,255,255,0.06);display:flex;align-items:center;justify-content:center;}
|
|
49
|
+
.ksui-ff-meta{display:flex;flex-direction:column;min-width:0;flex:1;}
|
|
50
|
+
.ksui-ff-name{font-size:0.82rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
51
|
+
.ksui-ff-sub{font-size:0.72rem;opacity:0.6;}
|
|
52
|
+
.ksui-ff-sub.failed{color:var(--ksui-ff-danger,#f87171);}
|
|
53
|
+
.ksui-ff-remove{margin-left:auto;flex:0 0 auto;display:flex;align-items:center;justify-content:center;width:1.75rem;height:1.75rem;border-radius:0.375rem;border:1px solid var(--ksui-ff-border,rgba(255,255,255,0.15));background:transparent;color:inherit;cursor:pointer;}
|
|
54
|
+
.ksui-ff-retry{font-size:0.72rem;text-decoration:underline;cursor:pointer;color:var(--ksui-ff-accent,#c9a961);background:none;border:none;padding:0;}
|
|
55
|
+
`;
|
|
56
|
+
document.head.appendChild(style);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface FileFieldProps {
|
|
60
|
+
/** Field label. */
|
|
61
|
+
label?: string;
|
|
62
|
+
/** Current value — an asset handle, or null when empty. */
|
|
63
|
+
value?: AssetHandle | null;
|
|
64
|
+
/** Emitted when the handle changes (upload done, or cleared to null). */
|
|
65
|
+
onChange?: (handle: AssetHandle | null) => void;
|
|
66
|
+
/**
|
|
67
|
+
* HOST-injected uploader: takes the picked File, returns the asset handle once
|
|
68
|
+
* the kernel asset service has stored it. ksui stays storage-agnostic (§11).
|
|
69
|
+
*/
|
|
70
|
+
onUpload: (file: File) => Promise<AssetHandle>;
|
|
71
|
+
/**
|
|
72
|
+
* HOST-injected resolver: turns a handle into a time-limited URL for preview
|
|
73
|
+
* (§11). ksui never knows about S3/kernel. A rejection degrades to a broken-
|
|
74
|
+
* thumbnail fallback — never throws.
|
|
75
|
+
*/
|
|
76
|
+
presignUrl?: (handle: AssetHandle) => Promise<string>;
|
|
77
|
+
/** Accept hint (e.g. ["image/*","application/pdf"]) for the picker + filter. */
|
|
78
|
+
accept?: readonly string[];
|
|
79
|
+
/** Disable interaction. */
|
|
80
|
+
disabled?: boolean;
|
|
81
|
+
/** Optional test id prefix. */
|
|
82
|
+
testId?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatSize(bytes: number): string {
|
|
86
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
87
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
88
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const FileField: Component<FileFieldProps> = (props) => {
|
|
92
|
+
ensureStyle();
|
|
93
|
+
const [status, setStatus] = createSignal<FileFieldStatus>(props.value ? "done" : "empty");
|
|
94
|
+
// The handle currently shown. Seeded from props.value, and updated locally on a
|
|
95
|
+
// fresh upload so the done card shows even when the parent doesn't echo onChange
|
|
96
|
+
// back into `value` (controlled and uncontrolled both work).
|
|
97
|
+
const [current, setCurrent] = createSignal<AssetHandle | null>(props.value ?? null);
|
|
98
|
+
const [dragging, setDragging] = createSignal(false);
|
|
99
|
+
const [previewUrl, setPreviewUrl] = createSignal<string | null>(null);
|
|
100
|
+
const [previewFailed, setPreviewFailed] = createSignal(false);
|
|
101
|
+
const [errorMsg, setErrorMsg] = createSignal<string | null>(null);
|
|
102
|
+
let inputEl: HTMLInputElement | undefined;
|
|
103
|
+
|
|
104
|
+
const tid = (s: string) => (props.testId ? `${props.testId}-${s}` : undefined);
|
|
105
|
+
const acceptAttr = () => props.accept?.join(",");
|
|
106
|
+
|
|
107
|
+
const isImage = (h: AssetHandle | null | undefined) => !!h && h.mime.startsWith("image/");
|
|
108
|
+
|
|
109
|
+
// Resolve a preview URL for an image handle via the injected presigner. A
|
|
110
|
+
// rejected presign (missing/expired asset, §11) sets previewFailed instead of
|
|
111
|
+
// throwing — the card then shows the broken-thumbnail fallback + a warning.
|
|
112
|
+
const loadPreview = (handle: AssetHandle) => {
|
|
113
|
+
setPreviewUrl(null);
|
|
114
|
+
setPreviewFailed(false);
|
|
115
|
+
if (!isImage(handle) || !props.presignUrl) return;
|
|
116
|
+
props
|
|
117
|
+
.presignUrl(handle)
|
|
118
|
+
.then((url) => setPreviewUrl(url))
|
|
119
|
+
.catch((e) => {
|
|
120
|
+
setPreviewFailed(true);
|
|
121
|
+
console.warn(`[ksui] FileField: preview unavailable for "${handle.name}"`, e);
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Drive the upload state machine for a picked/dropped file.
|
|
126
|
+
const upload = (file: File) => {
|
|
127
|
+
setErrorMsg(null);
|
|
128
|
+
setStatus("uploading");
|
|
129
|
+
props
|
|
130
|
+
.onUpload(file)
|
|
131
|
+
.then((handle) => {
|
|
132
|
+
setStatus("done");
|
|
133
|
+
setCurrent(handle);
|
|
134
|
+
props.onChange?.(handle);
|
|
135
|
+
loadPreview(handle);
|
|
136
|
+
})
|
|
137
|
+
.catch((e) => {
|
|
138
|
+
setStatus("failed");
|
|
139
|
+
// §11: a hard failure tells the user it couldn't upload — never throws up.
|
|
140
|
+
setErrorMsg(e instanceof Error ? e.message : "Couldn't upload to the cloud");
|
|
141
|
+
console.warn("[ksui] FileField: upload failed", e);
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// If a value handle is supplied at mount, load its preview eagerly.
|
|
146
|
+
if (props.value) loadPreview(props.value);
|
|
147
|
+
|
|
148
|
+
const pick = () => {
|
|
149
|
+
if (props.disabled) return;
|
|
150
|
+
inputEl?.click();
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const onPicked = (e: Event & { currentTarget: HTMLInputElement }) => {
|
|
154
|
+
const file = e.currentTarget.files?.[0];
|
|
155
|
+
if (file) upload(file);
|
|
156
|
+
e.currentTarget.value = ""; // allow re-picking the same file
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const onDrop = (e: DragEvent) => {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
setDragging(false);
|
|
162
|
+
if (props.disabled) return;
|
|
163
|
+
const file = e.dataTransfer?.files?.[0];
|
|
164
|
+
if (file) upload(file);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const clear = () => {
|
|
168
|
+
setStatus("empty");
|
|
169
|
+
setCurrent(null);
|
|
170
|
+
setPreviewUrl(null);
|
|
171
|
+
setPreviewFailed(false);
|
|
172
|
+
setErrorMsg(null);
|
|
173
|
+
props.onChange?.(null);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Prefer a controlled value when the parent supplies one; else the locally
|
|
177
|
+
// tracked handle from the last upload.
|
|
178
|
+
const handle = () => props.value ?? current();
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div class="ksui-ff" data-testid={tid("root")}>
|
|
182
|
+
<Show when={props.label}>
|
|
183
|
+
<span class="ksui-ff-label">{props.label}</span>
|
|
184
|
+
</Show>
|
|
185
|
+
|
|
186
|
+
<input
|
|
187
|
+
ref={inputEl}
|
|
188
|
+
type="file"
|
|
189
|
+
accept={acceptAttr()}
|
|
190
|
+
style={{ display: "none" }}
|
|
191
|
+
data-testid={tid("input")}
|
|
192
|
+
onChange={onPicked}
|
|
193
|
+
/>
|
|
194
|
+
|
|
195
|
+
<Switch>
|
|
196
|
+
{/* empty → the drop zone / click-to-pick affordance */}
|
|
197
|
+
<Match when={status() === "empty"}>
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
class={`ksui-ff-drop${dragging() ? " dragging" : ""}`}
|
|
201
|
+
disabled={props.disabled}
|
|
202
|
+
data-testid={tid("drop")}
|
|
203
|
+
data-dragging={dragging() ? "true" : "false"}
|
|
204
|
+
onClick={pick}
|
|
205
|
+
onDragOver={(e) => {
|
|
206
|
+
e.preventDefault();
|
|
207
|
+
setDragging(true);
|
|
208
|
+
}}
|
|
209
|
+
onDragLeave={() => setDragging(false)}
|
|
210
|
+
onDrop={onDrop}
|
|
211
|
+
>
|
|
212
|
+
<UploadCloud size={22} />
|
|
213
|
+
<span>Drop a file or click to upload</span>
|
|
214
|
+
<Show when={props.accept?.length}>
|
|
215
|
+
<span class="ksui-ff-hint">{props.accept!.join(", ")}</span>
|
|
216
|
+
</Show>
|
|
217
|
+
</button>
|
|
218
|
+
</Match>
|
|
219
|
+
|
|
220
|
+
{/* uploading → pending state (§11 pending while queued) */}
|
|
221
|
+
<Match when={status() === "uploading"}>
|
|
222
|
+
<div class="ksui-ff-card" data-testid={tid("uploading")}>
|
|
223
|
+
<span class="ksui-ff-thumb">
|
|
224
|
+
<UploadCloud size={18} />
|
|
225
|
+
</span>
|
|
226
|
+
<div class="ksui-ff-meta">
|
|
227
|
+
<span class="ksui-ff-name">Uploading…</span>
|
|
228
|
+
<span class="ksui-ff-sub">Sending to the cloud</span>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
</Match>
|
|
232
|
+
|
|
233
|
+
{/* failed → hard-failure notice + retry (§11: tell the user, never throw) */}
|
|
234
|
+
<Match when={status() === "failed"}>
|
|
235
|
+
<div class="ksui-ff-card" data-testid={tid("failed")}>
|
|
236
|
+
<span class="ksui-ff-thumb">
|
|
237
|
+
<ImageOff size={18} />
|
|
238
|
+
</span>
|
|
239
|
+
<div class="ksui-ff-meta">
|
|
240
|
+
<span class="ksui-ff-name">Upload failed</span>
|
|
241
|
+
<span class="ksui-ff-sub failed">{errorMsg() ?? "Couldn't upload to the cloud"}</span>
|
|
242
|
+
</div>
|
|
243
|
+
<button type="button" class="ksui-ff-retry" data-testid={tid("retry")} onClick={pick}>
|
|
244
|
+
Retry
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
</Match>
|
|
248
|
+
|
|
249
|
+
{/* done → the asset card with image preview (graceful-degrade fallback) */}
|
|
250
|
+
<Match when={status() === "done" && !!handle()}>
|
|
251
|
+
{renderDoneCard(handle()!, isImage(handle()), previewUrl(), previewFailed(), () => setPreviewFailed(true), props.disabled, clear, tid)}
|
|
252
|
+
</Match>
|
|
253
|
+
</Switch>
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
function renderDoneCard(
|
|
259
|
+
h: AssetHandle,
|
|
260
|
+
image: boolean,
|
|
261
|
+
url: string | null,
|
|
262
|
+
failed: boolean,
|
|
263
|
+
onImgError: () => void,
|
|
264
|
+
disabled: boolean | undefined,
|
|
265
|
+
onClear: () => void,
|
|
266
|
+
tid: (s: string) => string | undefined,
|
|
267
|
+
): JSX.Element {
|
|
268
|
+
return (
|
|
269
|
+
<div class="ksui-ff-card" data-testid={tid("done")}>
|
|
270
|
+
<span class="ksui-ff-thumb" data-testid={tid("thumb")}>
|
|
271
|
+
<Switch fallback={<FileIcon size={18} />}>
|
|
272
|
+
{/* image + a presigned url that loaded → real thumbnail */}
|
|
273
|
+
<Match when={image && url && !failed}>
|
|
274
|
+
<img
|
|
275
|
+
src={url!}
|
|
276
|
+
alt={h.name}
|
|
277
|
+
class="ksui-ff-thumb"
|
|
278
|
+
data-testid={tid("preview")}
|
|
279
|
+
onError={onImgError}
|
|
280
|
+
/>
|
|
281
|
+
</Match>
|
|
282
|
+
{/* image but the presign rejected/expired → broken-thumbnail fallback (§11) */}
|
|
283
|
+
<Match when={image && failed}>
|
|
284
|
+
<ImageOff size={18} data-testid={tid("broken")} />
|
|
285
|
+
</Match>
|
|
286
|
+
</Switch>
|
|
287
|
+
</span>
|
|
288
|
+
<div class="ksui-ff-meta">
|
|
289
|
+
<span class="ksui-ff-name">{h.name}</span>
|
|
290
|
+
<span class="ksui-ff-sub">{formatSize(h.size)}</span>
|
|
291
|
+
</div>
|
|
292
|
+
<Show when={!disabled}>
|
|
293
|
+
<button type="button" class="ksui-ff-remove" aria-label="Remove file" data-testid={tid("remove")} onClick={onClear}>
|
|
294
|
+
<X size={14} />
|
|
295
|
+
</button>
|
|
296
|
+
</Show>
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export default FileField;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// U7 — FlowRunner component tests: renders each node kind, calls advance with the
|
|
2
|
+
// node's input, handles a terminal node + an error.
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { render, fireEvent, waitFor } from "@solidjs/testing-library";
|
|
5
|
+
import FlowRunner from "./FlowRunner";
|
|
6
|
+
import type { FlowNode } from "../../utils/flow";
|
|
7
|
+
|
|
8
|
+
const terminal: FlowNode = { kind: "terminal", id: "end", message: "Done" };
|
|
9
|
+
|
|
10
|
+
describe("FlowRunner", () => {
|
|
11
|
+
it("renders a form and calls advance with the form input on submit", async () => {
|
|
12
|
+
const advance = vi.fn(async () => terminal);
|
|
13
|
+
const onComplete = vi.fn();
|
|
14
|
+
const { getByTestId } = render(() => (
|
|
15
|
+
<FlowRunner
|
|
16
|
+
testId="fr"
|
|
17
|
+
initialNode={{
|
|
18
|
+
kind: "form",
|
|
19
|
+
id: "n1",
|
|
20
|
+
title: "Pay",
|
|
21
|
+
fields: [{ key: "amount", label: "Amount", type: "number", required: true }],
|
|
22
|
+
}}
|
|
23
|
+
advance={advance}
|
|
24
|
+
state={{ token: "abc" }}
|
|
25
|
+
onComplete={onComplete}
|
|
26
|
+
/>
|
|
27
|
+
));
|
|
28
|
+
fireEvent.input(getByTestId("fr-field-amount"), { target: { value: "50" } });
|
|
29
|
+
fireEvent.click(getByTestId("fr-submit"));
|
|
30
|
+
await waitFor(() => expect(advance).toHaveBeenCalledWith({ token: "abc" }, { amount: "50" }));
|
|
31
|
+
await waitFor(() => expect(getByTestId("fr-terminal")).toBeTruthy());
|
|
32
|
+
expect(onComplete).toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("blocks submit while a required field is empty", () => {
|
|
36
|
+
const advance = vi.fn(async () => terminal);
|
|
37
|
+
const { getByTestId } = render(() => (
|
|
38
|
+
<FlowRunner
|
|
39
|
+
testId="fr"
|
|
40
|
+
initialNode={{
|
|
41
|
+
kind: "form",
|
|
42
|
+
id: "n1",
|
|
43
|
+
fields: [{ key: "amount", label: "Amount", required: true }],
|
|
44
|
+
}}
|
|
45
|
+
advance={advance}
|
|
46
|
+
/>
|
|
47
|
+
));
|
|
48
|
+
fireEvent.click(getByTestId("fr-submit"));
|
|
49
|
+
expect(advance).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("renders a choice and submits the picked value", async () => {
|
|
53
|
+
const advance = vi.fn(async () => terminal);
|
|
54
|
+
const { getByTestId } = render(() => (
|
|
55
|
+
<FlowRunner
|
|
56
|
+
testId="fr"
|
|
57
|
+
initialNode={{
|
|
58
|
+
kind: "choice",
|
|
59
|
+
id: "c1",
|
|
60
|
+
options: [{ value: "yes", label: "Yes" }, { value: "no", label: "No" }],
|
|
61
|
+
}}
|
|
62
|
+
advance={advance}
|
|
63
|
+
/>
|
|
64
|
+
));
|
|
65
|
+
fireEvent.click(getByTestId("fr-choice-yes"));
|
|
66
|
+
await waitFor(() => expect(advance).toHaveBeenCalledWith(undefined, { value: "yes" }));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders a display node and continues with null input", async () => {
|
|
70
|
+
const advance = vi.fn(async () => terminal);
|
|
71
|
+
const { getByTestId } = render(() => (
|
|
72
|
+
<FlowRunner
|
|
73
|
+
testId="fr"
|
|
74
|
+
initialNode={{ kind: "display", id: "d1", body: "Review this" }}
|
|
75
|
+
advance={advance}
|
|
76
|
+
/>
|
|
77
|
+
));
|
|
78
|
+
expect(getByTestId("fr-display").textContent).toContain("Review this");
|
|
79
|
+
fireEvent.click(getByTestId("fr-continue"));
|
|
80
|
+
await waitFor(() => expect(advance).toHaveBeenCalledWith(undefined, null));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("renders a message node and acks", async () => {
|
|
84
|
+
const advance = vi.fn(async () => terminal);
|
|
85
|
+
const { getByTestId } = render(() => (
|
|
86
|
+
<FlowRunner
|
|
87
|
+
testId="fr"
|
|
88
|
+
initialNode={{ kind: "message", id: "m1", text: "Heads up", tone: "info" }}
|
|
89
|
+
advance={advance}
|
|
90
|
+
/>
|
|
91
|
+
));
|
|
92
|
+
expect(getByTestId("fr-message").textContent).toContain("Heads up");
|
|
93
|
+
fireEvent.click(getByTestId("fr-ack"));
|
|
94
|
+
await waitFor(() => expect(advance).toHaveBeenCalled());
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("surfaces an error when advance rejects, without inventing the next node", async () => {
|
|
98
|
+
const advance = vi.fn(async () => {
|
|
99
|
+
throw new Error("server said no");
|
|
100
|
+
});
|
|
101
|
+
const { getByTestId, queryByTestId } = render(() => (
|
|
102
|
+
<FlowRunner
|
|
103
|
+
testId="fr"
|
|
104
|
+
initialNode={{ kind: "choice", id: "c1", options: [{ value: "go", label: "Go" }] }}
|
|
105
|
+
advance={advance}
|
|
106
|
+
/>
|
|
107
|
+
));
|
|
108
|
+
fireEvent.click(getByTestId("fr-choice-go"));
|
|
109
|
+
await waitFor(() => expect(getByTestId("fr-error").textContent).toContain("server said no"));
|
|
110
|
+
// still on the choice node — the client never invents a next node
|
|
111
|
+
expect(queryByTestId("fr-choice")).toBeTruthy();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("takes the cancel path on a cancelable form", () => {
|
|
115
|
+
const onCancel = vi.fn();
|
|
116
|
+
const { getByTestId } = render(() => (
|
|
117
|
+
<FlowRunner
|
|
118
|
+
testId="fr"
|
|
119
|
+
initialNode={{ kind: "form", id: "n1", cancelable: true, fields: [] }}
|
|
120
|
+
advance={vi.fn(async () => terminal)}
|
|
121
|
+
onCancel={onCancel}
|
|
122
|
+
/>
|
|
123
|
+
));
|
|
124
|
+
fireEvent.click(getByTestId("fr-cancel"));
|
|
125
|
+
expect(onCancel).toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
});
|