@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,348 @@
|
|
|
1
|
+
// U7 — FlowRunner (Vision §9): a client renderer for a SERVER-DRIVEN node graph.
|
|
2
|
+
// It renders the current UI-effect node (form / display / choice / message /
|
|
3
|
+
// terminal) and POSTs each step to the host-injected `advance(state, input)`
|
|
4
|
+
// resolver — which owns ALL authority, data and branching. The client never
|
|
5
|
+
// decides the graph: it shows the node, collects input, calls `advance`, and
|
|
6
|
+
// renders whatever node comes back, stopping at a terminal node.
|
|
7
|
+
//
|
|
8
|
+
// Composite because it composes the node model (utils/flow) with loading/terminal/
|
|
9
|
+
// error UI. Self-contained CSS (ksui-fr-* unscoped classes + CSS custom props);
|
|
10
|
+
// no Tailwind, no host-brand classes (standalone-library rule).
|
|
11
|
+
|
|
12
|
+
import type { Component, JSX } from "solid-js";
|
|
13
|
+
import { For, Match, Show, Switch, createSignal } from "solid-js";
|
|
14
|
+
import {
|
|
15
|
+
collectsInput,
|
|
16
|
+
formNodeInput,
|
|
17
|
+
isTerminal,
|
|
18
|
+
missingRequired,
|
|
19
|
+
type FlowAdvance,
|
|
20
|
+
type FlowChoiceNode,
|
|
21
|
+
type FlowDisplayNode,
|
|
22
|
+
type FlowFormNode,
|
|
23
|
+
type FlowInput,
|
|
24
|
+
type FlowMessageNode,
|
|
25
|
+
type FlowNode,
|
|
26
|
+
type FlowState,
|
|
27
|
+
} from "../../utils/flow";
|
|
28
|
+
|
|
29
|
+
const STYLE_ID = "ksui-flow-runner-style";
|
|
30
|
+
|
|
31
|
+
function ensureStyle(): void {
|
|
32
|
+
if (typeof document === "undefined") return;
|
|
33
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
34
|
+
const style = document.createElement("style");
|
|
35
|
+
style.id = STYLE_ID;
|
|
36
|
+
style.textContent = `
|
|
37
|
+
.ksui-fr{display:flex;flex-direction:column;gap:0.875rem;color:var(--ksui-fr-fg,inherit);}
|
|
38
|
+
.ksui-fr-title{font-size:0.95rem;font-weight:600;margin:0;}
|
|
39
|
+
.ksui-fr-prompt,.ksui-fr-body{font-size:0.85rem;opacity:0.85;margin:0;}
|
|
40
|
+
.ksui-fr-field{display:flex;flex-direction:column;gap:0.25rem;}
|
|
41
|
+
.ksui-fr-label{font-size:0.78rem;font-weight:500;}
|
|
42
|
+
.ksui-fr-input{padding:0.5rem 0.625rem;border-radius:0.5rem;border:1px solid var(--ksui-fr-border,rgba(255,255,255,0.15));background:var(--ksui-fr-input-bg,rgba(255,255,255,0.04));color:inherit;font-size:0.85rem;}
|
|
43
|
+
.ksui-fr-actions{display:flex;gap:0.5rem;flex-wrap:wrap;}
|
|
44
|
+
.ksui-fr-btn{padding:0.5rem 0.875rem;border-radius:0.5rem;border:1px solid var(--ksui-fr-border,rgba(255,255,255,0.15));background:var(--ksui-fr-btn-bg,rgba(255,255,255,0.06));color:inherit;font-size:0.82rem;cursor:pointer;}
|
|
45
|
+
.ksui-fr-btn:disabled{opacity:0.5;cursor:not-allowed;}
|
|
46
|
+
.ksui-fr-btn.primary{background:var(--ksui-fr-primary,#c9a961);color:var(--ksui-fr-primary-fg,#18181b);border-color:transparent;}
|
|
47
|
+
.ksui-fr-btn.danger{background:var(--ksui-fr-danger,#ef4444);color:#fff;border-color:transparent;}
|
|
48
|
+
.ksui-fr-msg{padding:0.625rem 0.75rem;border-radius:0.5rem;font-size:0.85rem;}
|
|
49
|
+
.ksui-fr-msg.info{background:rgba(59,130,246,0.1);}
|
|
50
|
+
.ksui-fr-msg.success{background:rgba(34,197,94,0.12);}
|
|
51
|
+
.ksui-fr-msg.error{background:rgba(239,68,68,0.12);}
|
|
52
|
+
.ksui-fr-error{padding:0.625rem 0.75rem;border-radius:0.5rem;font-size:0.82rem;background:rgba(239,68,68,0.12);border:1px solid rgba(239,68,68,0.3);}
|
|
53
|
+
.ksui-fr-loading{font-size:0.82rem;opacity:0.7;}
|
|
54
|
+
`;
|
|
55
|
+
document.head.appendChild(style);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface FlowRunnerProps {
|
|
59
|
+
/** The first node the server handed the client (the flow entry). */
|
|
60
|
+
initialNode: FlowNode;
|
|
61
|
+
/**
|
|
62
|
+
* Host-injected resolver — owns authority, data and branching (§9). Given the
|
|
63
|
+
* opaque state and the step input it returns the next node. ksui never decides.
|
|
64
|
+
*/
|
|
65
|
+
advance: FlowAdvance;
|
|
66
|
+
/**
|
|
67
|
+
* Opaque flow state threaded into each `advance` call. The client never reads
|
|
68
|
+
* into it; the server correlates the step from it.
|
|
69
|
+
*/
|
|
70
|
+
state?: FlowState;
|
|
71
|
+
/** Fired when a terminal node is reached (host closes the flow / refreshes). */
|
|
72
|
+
onComplete?: (node: FlowNode) => void;
|
|
73
|
+
/** Fired when the user cancels a cancelable form (host takes the onCancel edge). */
|
|
74
|
+
onCancel?: () => void;
|
|
75
|
+
/** Optional test id prefix. */
|
|
76
|
+
testId?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const FlowRunner: Component<FlowRunnerProps> = (props) => {
|
|
80
|
+
ensureStyle();
|
|
81
|
+
const [node, setNode] = createSignal<FlowNode>(props.initialNode);
|
|
82
|
+
const [busy, setBusy] = createSignal(false);
|
|
83
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
84
|
+
// Form field values for the current form node (reset whenever the node changes).
|
|
85
|
+
const [values, setValues] = createSignal<Record<string, unknown>>({});
|
|
86
|
+
|
|
87
|
+
const tid = (s: string) => (props.testId ? `${props.testId}-${s}` : undefined);
|
|
88
|
+
|
|
89
|
+
// Submit `input` to the server resolver and swap in the node it returns. A
|
|
90
|
+
// rejected `advance` surfaces as an error state — the client never invents the
|
|
91
|
+
// next node, so a failure halts on the current step (re-submittable).
|
|
92
|
+
const submit = async (input: FlowInput) => {
|
|
93
|
+
setError(null);
|
|
94
|
+
setBusy(true);
|
|
95
|
+
try {
|
|
96
|
+
const next = await props.advance(props.state, input);
|
|
97
|
+
setNode(next);
|
|
98
|
+
setValues({});
|
|
99
|
+
if (isTerminal(next)) props.onComplete?.(next);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
setError(e instanceof Error ? e.message : "Something went wrong");
|
|
102
|
+
} finally {
|
|
103
|
+
setBusy(false);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const setField = (key: string, v: unknown) =>
|
|
108
|
+
setValues((prev) => ({ ...prev, [key]: v }));
|
|
109
|
+
|
|
110
|
+
const submitForm = (n: FlowFormNode) => {
|
|
111
|
+
if (missingRequired(n, values()).length > 0) return; // UX gate; server re-validates
|
|
112
|
+
void submit(formNodeInput(n, values()));
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div class="ksui-fr" data-testid={tid("root")}>
|
|
117
|
+
<Show when={error()}>
|
|
118
|
+
<div class="ksui-fr-error" role="alert" data-testid={tid("error")}>
|
|
119
|
+
{error()}
|
|
120
|
+
</div>
|
|
121
|
+
</Show>
|
|
122
|
+
|
|
123
|
+
<Switch>
|
|
124
|
+
<Match when={node().kind === "form"}>
|
|
125
|
+
{renderForm(node() as FlowFormNode, values(), setField, () => submitForm(node() as FlowFormNode), () => props.onCancel?.(), busy(), tid)}
|
|
126
|
+
</Match>
|
|
127
|
+
<Match when={node().kind === "display"}>
|
|
128
|
+
{renderDisplay(node() as FlowDisplayNode, () => void submit(null), busy(), tid)}
|
|
129
|
+
</Match>
|
|
130
|
+
<Match when={node().kind === "choice"}>
|
|
131
|
+
{renderChoice(node() as FlowChoiceNode, (value) => void submit({ value }), busy(), tid)}
|
|
132
|
+
</Match>
|
|
133
|
+
<Match when={node().kind === "message"}>
|
|
134
|
+
{renderMessage(node() as FlowMessageNode, () => void submit(null), busy(), tid)}
|
|
135
|
+
</Match>
|
|
136
|
+
<Match when={node().kind === "terminal"}>
|
|
137
|
+
{renderTerminal(node(), tid)}
|
|
138
|
+
</Match>
|
|
139
|
+
</Switch>
|
|
140
|
+
|
|
141
|
+
<Show when={busy()}>
|
|
142
|
+
<span class="ksui-fr-loading" data-testid={tid("loading")}>Working…</span>
|
|
143
|
+
</Show>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// ---- node renderers (plain functions returning JSX; no per-node reactivity needed) ----
|
|
149
|
+
|
|
150
|
+
function renderForm(
|
|
151
|
+
n: FlowFormNode,
|
|
152
|
+
values: Record<string, unknown>,
|
|
153
|
+
setField: (k: string, v: unknown) => void,
|
|
154
|
+
onSubmit: () => void,
|
|
155
|
+
onCancel: () => void,
|
|
156
|
+
busy: boolean,
|
|
157
|
+
tid: (s: string) => string | undefined,
|
|
158
|
+
): JSX.Element {
|
|
159
|
+
const blocked = missingRequired(n, values).length > 0;
|
|
160
|
+
return (
|
|
161
|
+
<div class="ksui-fr" data-testid={tid("form")}>
|
|
162
|
+
<Show when={n.title}>
|
|
163
|
+
<h3 class="ksui-fr-title">{n.title}</h3>
|
|
164
|
+
</Show>
|
|
165
|
+
<For each={n.fields}>
|
|
166
|
+
{(f) => (
|
|
167
|
+
<div class="ksui-fr-field">
|
|
168
|
+
<label class="ksui-fr-label" for={`ksui-fr-${f.key}`}>
|
|
169
|
+
{f.label}
|
|
170
|
+
{f.required ? " *" : ""}
|
|
171
|
+
</label>
|
|
172
|
+
<Switch
|
|
173
|
+
fallback={
|
|
174
|
+
<input
|
|
175
|
+
id={`ksui-fr-${f.key}`}
|
|
176
|
+
class="ksui-fr-input"
|
|
177
|
+
type={f.type === "number" ? "number" : "text"}
|
|
178
|
+
placeholder={f.placeholder}
|
|
179
|
+
data-testid={tid(`field-${f.key}`)}
|
|
180
|
+
onInput={(e) => setField(f.key, e.currentTarget.value)}
|
|
181
|
+
/>
|
|
182
|
+
}
|
|
183
|
+
>
|
|
184
|
+
<Match when={f.type === "textarea"}>
|
|
185
|
+
<textarea
|
|
186
|
+
id={`ksui-fr-${f.key}`}
|
|
187
|
+
class="ksui-fr-input"
|
|
188
|
+
placeholder={f.placeholder}
|
|
189
|
+
data-testid={tid(`field-${f.key}`)}
|
|
190
|
+
onInput={(e) => setField(f.key, e.currentTarget.value)}
|
|
191
|
+
/>
|
|
192
|
+
</Match>
|
|
193
|
+
<Match when={f.type === "select"}>
|
|
194
|
+
<select
|
|
195
|
+
id={`ksui-fr-${f.key}`}
|
|
196
|
+
class="ksui-fr-input"
|
|
197
|
+
data-testid={tid(`field-${f.key}`)}
|
|
198
|
+
onChange={(e) => setField(f.key, e.currentTarget.value)}
|
|
199
|
+
>
|
|
200
|
+
<option value="" />
|
|
201
|
+
<For each={f.options ?? []}>
|
|
202
|
+
{(o) => <option value={o.value}>{o.label}</option>}
|
|
203
|
+
</For>
|
|
204
|
+
</select>
|
|
205
|
+
</Match>
|
|
206
|
+
<Match when={f.type === "toggle"}>
|
|
207
|
+
<input
|
|
208
|
+
id={`ksui-fr-${f.key}`}
|
|
209
|
+
type="checkbox"
|
|
210
|
+
data-testid={tid(`field-${f.key}`)}
|
|
211
|
+
onChange={(e) => setField(f.key, e.currentTarget.checked)}
|
|
212
|
+
/>
|
|
213
|
+
</Match>
|
|
214
|
+
</Switch>
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
</For>
|
|
218
|
+
<div class="ksui-fr-actions">
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
class="ksui-fr-btn primary"
|
|
222
|
+
disabled={busy || blocked}
|
|
223
|
+
data-testid={tid("submit")}
|
|
224
|
+
onClick={onSubmit}
|
|
225
|
+
>
|
|
226
|
+
{n.submitLabel ?? "Submit"}
|
|
227
|
+
</button>
|
|
228
|
+
<Show when={n.cancelable}>
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
class="ksui-fr-btn"
|
|
232
|
+
disabled={busy}
|
|
233
|
+
data-testid={tid("cancel")}
|
|
234
|
+
onClick={onCancel}
|
|
235
|
+
>
|
|
236
|
+
Cancel
|
|
237
|
+
</button>
|
|
238
|
+
</Show>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function renderDisplay(
|
|
245
|
+
n: FlowDisplayNode,
|
|
246
|
+
onContinue: () => void,
|
|
247
|
+
busy: boolean,
|
|
248
|
+
tid: (s: string) => string | undefined,
|
|
249
|
+
): JSX.Element {
|
|
250
|
+
return (
|
|
251
|
+
<div class="ksui-fr" data-testid={tid("display")}>
|
|
252
|
+
<Show when={n.title}>
|
|
253
|
+
<h3 class="ksui-fr-title">{n.title}</h3>
|
|
254
|
+
</Show>
|
|
255
|
+
{/* Server-provided, already-sanitized read-only text. Rendered as text, not
|
|
256
|
+
innerHTML, so the client never trusts markup it didn't sanitize. */}
|
|
257
|
+
<p class="ksui-fr-body">{n.body}</p>
|
|
258
|
+
<div class="ksui-fr-actions">
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
class="ksui-fr-btn primary"
|
|
262
|
+
disabled={busy}
|
|
263
|
+
data-testid={tid("continue")}
|
|
264
|
+
onClick={onContinue}
|
|
265
|
+
>
|
|
266
|
+
{n.continueLabel ?? "Continue"}
|
|
267
|
+
</button>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function renderChoice(
|
|
274
|
+
n: FlowChoiceNode,
|
|
275
|
+
onPick: (value: string) => void,
|
|
276
|
+
busy: boolean,
|
|
277
|
+
tid: (s: string) => string | undefined,
|
|
278
|
+
): JSX.Element {
|
|
279
|
+
return (
|
|
280
|
+
<div class="ksui-fr" data-testid={tid("choice")}>
|
|
281
|
+
<Show when={n.title}>
|
|
282
|
+
<h3 class="ksui-fr-title">{n.title}</h3>
|
|
283
|
+
</Show>
|
|
284
|
+
<Show when={n.prompt}>
|
|
285
|
+
<p class="ksui-fr-prompt">{n.prompt}</p>
|
|
286
|
+
</Show>
|
|
287
|
+
<div class="ksui-fr-actions">
|
|
288
|
+
<For each={n.options}>
|
|
289
|
+
{(o) => (
|
|
290
|
+
<button
|
|
291
|
+
type="button"
|
|
292
|
+
class={`ksui-fr-btn ${o.intent ?? ""}`}
|
|
293
|
+
disabled={busy}
|
|
294
|
+
data-testid={tid(`choice-${o.value}`)}
|
|
295
|
+
onClick={() => onPick(o.value)}
|
|
296
|
+
>
|
|
297
|
+
{o.label}
|
|
298
|
+
</button>
|
|
299
|
+
)}
|
|
300
|
+
</For>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function renderMessage(
|
|
307
|
+
n: FlowMessageNode,
|
|
308
|
+
onAck: () => void,
|
|
309
|
+
busy: boolean,
|
|
310
|
+
tid: (s: string) => string | undefined,
|
|
311
|
+
): JSX.Element {
|
|
312
|
+
return (
|
|
313
|
+
<div class="ksui-fr" data-testid={tid("message")}>
|
|
314
|
+
<div class={`ksui-fr-msg ${n.tone ?? "info"}`} role="status">
|
|
315
|
+
{n.text}
|
|
316
|
+
</div>
|
|
317
|
+
<div class="ksui-fr-actions">
|
|
318
|
+
<button
|
|
319
|
+
type="button"
|
|
320
|
+
class="ksui-fr-btn"
|
|
321
|
+
disabled={busy}
|
|
322
|
+
data-testid={tid("ack")}
|
|
323
|
+
onClick={onAck}
|
|
324
|
+
>
|
|
325
|
+
{n.ackLabel ?? "OK"}
|
|
326
|
+
</button>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function renderTerminal(n: FlowNode, tid: (s: string) => string | undefined): JSX.Element {
|
|
333
|
+
if (!isTerminal(n)) return <></>;
|
|
334
|
+
return (
|
|
335
|
+
<div class="ksui-fr" data-testid={tid("terminal")}>
|
|
336
|
+
<Show when={n.title}>
|
|
337
|
+
<h3 class="ksui-fr-title">{n.title}</h3>
|
|
338
|
+
</Show>
|
|
339
|
+
<Show when={n.message}>
|
|
340
|
+
<div class={`ksui-fr-msg ${n.tone ?? "success"}`} role="status">
|
|
341
|
+
{n.message}
|
|
342
|
+
</div>
|
|
343
|
+
</Show>
|
|
344
|
+
</div>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export default FlowRunner;
|
package/src/index.ts
CHANGED
|
@@ -64,6 +64,7 @@ export { default as KpiCard, type KpiCardProps, type KpiTone } from "./component
|
|
|
64
64
|
export { default as RadioCardGroup } from "./components/base/RadioCardGroup";
|
|
65
65
|
export { default as FormErrorBanner } from "./components/base/FormErrorBanner";
|
|
66
66
|
export { default as TagPill } from "./components/base/TagPill";
|
|
67
|
+
export { default as BadgeSelect, type BadgeSelectProps, type BadgeSelectOption } from "./components/base/BadgeSelect";
|
|
67
68
|
export { default as DateTile, type DateTileProps } from "./components/base/DateTile";
|
|
68
69
|
export { default as Button, type ButtonProps, type ButtonIntent, type ButtonVariant } from "./components/base/Button";
|
|
69
70
|
export { default as ThemeToggle, type ThemeToggleProps, type ThemeToggleValue } from "./components/base/ThemeToggle";
|
|
@@ -160,6 +161,18 @@ export type {
|
|
|
160
161
|
} from "./components/composite/resource/ResourcePage";
|
|
161
162
|
export * from "./components/composite/resource/spec";
|
|
162
163
|
|
|
164
|
+
// U6 — declarative file/media field for the spec-driven form runtime. Value is an
|
|
165
|
+
// opaque asset handle; host injects onUpload + presignUrl (storage-agnostic).
|
|
166
|
+
export { default as FileField, type FileFieldProps, type AssetHandle, type FileFieldStatus } from "./components/composite/FileField";
|
|
167
|
+
|
|
168
|
+
// U7 — client renderer for a server-driven flow node graph. Host injects `advance`;
|
|
169
|
+
// the client only renders UI-effect nodes and POSTs each step (authority is server-side).
|
|
170
|
+
export { default as FlowRunner, type FlowRunnerProps } from "./components/composite/FlowRunner";
|
|
171
|
+
|
|
172
|
+
// U8 — schema-bound custom renderer: looks up an id in the in-process registry and
|
|
173
|
+
// renders it with validated props, falling back safely on miss/mismatch.
|
|
174
|
+
export { default as CustomRenderer, type CustomRendererProps } from "./components/composite/CustomRenderer";
|
|
175
|
+
|
|
163
176
|
// ---------------------------------------------------------------------------
|
|
164
177
|
// Utils (not components)
|
|
165
178
|
// ---------------------------------------------------------------------------
|
|
@@ -199,6 +212,46 @@ export { highlightMatch, HighlightedText, matchesQuery, matchesAny } from "./uti
|
|
|
199
212
|
export { confirm, type ConfirmOptions } from "./utils/confirm";
|
|
200
213
|
export { useFocusTrap, autoFocusOnMount, lockPullToRefresh, unlockPullToRefresh } from "./utils/dom";
|
|
201
214
|
|
|
215
|
+
// U7 — flow node model (pure): the discriminated union of client-renderable
|
|
216
|
+
// UI-effect nodes + the host-injected `advance` resolver type + pure step helpers.
|
|
217
|
+
export {
|
|
218
|
+
isTerminal,
|
|
219
|
+
collectsInput,
|
|
220
|
+
formNodeInput,
|
|
221
|
+
missingRequired,
|
|
222
|
+
} from "./utils/flow";
|
|
223
|
+
export type {
|
|
224
|
+
FlowNode,
|
|
225
|
+
FlowFormNode,
|
|
226
|
+
FlowDisplayNode,
|
|
227
|
+
FlowChoiceNode,
|
|
228
|
+
FlowMessageNode,
|
|
229
|
+
FlowTerminalNode,
|
|
230
|
+
FlowFormField,
|
|
231
|
+
FlowChoiceOption,
|
|
232
|
+
FlowAdvance,
|
|
233
|
+
FlowState,
|
|
234
|
+
FlowInput,
|
|
235
|
+
} from "./utils/flow";
|
|
236
|
+
|
|
237
|
+
// U8 — in-process, build-time custom renderer registry (no eval/remote code) and
|
|
238
|
+
// its consumes-schema validator. Hosts register renderers at startup.
|
|
239
|
+
export {
|
|
240
|
+
registerRenderer,
|
|
241
|
+
getRenderer,
|
|
242
|
+
hasRenderer,
|
|
243
|
+
unregisterRenderer,
|
|
244
|
+
clearRenderers,
|
|
245
|
+
validateConsumes,
|
|
246
|
+
} from "./utils/renderers";
|
|
247
|
+
export type {
|
|
248
|
+
RendererDefinition,
|
|
249
|
+
RendererProps,
|
|
250
|
+
ConsumesSchema,
|
|
251
|
+
ConsumeKind,
|
|
252
|
+
ValidationResult,
|
|
253
|
+
} from "./utils/renderers";
|
|
254
|
+
|
|
202
255
|
// Optional host integrations. Components degrade gracefully when these are not
|
|
203
256
|
// configured; a host app opts in once at startup.
|
|
204
257
|
export {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// U7 — flow node-model pure helpers (no DOM).
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
collectsInput,
|
|
5
|
+
formNodeInput,
|
|
6
|
+
isTerminal,
|
|
7
|
+
missingRequired,
|
|
8
|
+
type FlowFormNode,
|
|
9
|
+
} from "./flow";
|
|
10
|
+
|
|
11
|
+
const form: FlowFormNode = {
|
|
12
|
+
kind: "form",
|
|
13
|
+
id: "n1",
|
|
14
|
+
fields: [
|
|
15
|
+
{ key: "amount", label: "Amount", type: "number", required: true },
|
|
16
|
+
{ key: "note", label: "Note", type: "text" },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("flow helpers", () => {
|
|
21
|
+
it("isTerminal flags only terminal nodes", () => {
|
|
22
|
+
expect(isTerminal({ kind: "terminal", id: "t" })).toBe(true);
|
|
23
|
+
expect(isTerminal(form)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("collectsInput flags form + choice", () => {
|
|
27
|
+
expect(collectsInput(form)).toBe(true);
|
|
28
|
+
expect(collectsInput({ kind: "choice", id: "c", options: [] })).toBe(true);
|
|
29
|
+
expect(collectsInput({ kind: "display", id: "d", body: "" })).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("formNodeInput keeps declared fields, drops undefined", () => {
|
|
33
|
+
expect(formNodeInput(form, { amount: 5, stray: "x" })).toEqual({ amount: 5 });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("missingRequired reports empty required fields only", () => {
|
|
37
|
+
expect(missingRequired(form, {})).toEqual(["amount"]);
|
|
38
|
+
expect(missingRequired(form, { amount: 5 })).toEqual([]);
|
|
39
|
+
expect(missingRequired(form, { amount: "" })).toEqual(["amount"]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// U7 — flow node model (Vision §9). The CLIENT renders only UI-EFFECT nodes;
|
|
2
|
+
// ALL authority/data/branching lives server-side. The client never decides the
|
|
3
|
+
// graph — it renders the current node and POSTs each step to a host-injected
|
|
4
|
+
// `advance(state, input) => Promise<nextNode>` resolver. ksui ships NO server
|
|
5
|
+
// logic and NO `command`/`call(peer)` node kinds: those carry authority and run
|
|
6
|
+
// on the kernel (§9 "authority is server-side, presentation is client-side").
|
|
7
|
+
//
|
|
8
|
+
// This file is PURE (no solid-js, no component imports) so the node model + its
|
|
9
|
+
// type guards unit-test under plain node, mirroring the resource `spec.ts` split.
|
|
10
|
+
|
|
11
|
+
/** A single field a flow `form` node asks the user to fill (§10, UI subset). */
|
|
12
|
+
export interface FlowFormField {
|
|
13
|
+
readonly key: string;
|
|
14
|
+
readonly label: string;
|
|
15
|
+
/** Widget hint the runner maps to an input; defaults to "text". */
|
|
16
|
+
readonly type?: "text" | "textarea" | "number" | "select" | "toggle";
|
|
17
|
+
readonly required?: boolean;
|
|
18
|
+
readonly placeholder?: string;
|
|
19
|
+
/** For "select": the choices. */
|
|
20
|
+
readonly options?: ReadonlyArray<{ readonly value: string; readonly label: string }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A choice the user picks; its `value` is the input POSTed to `advance` (§9). */
|
|
24
|
+
export interface FlowChoiceOption {
|
|
25
|
+
readonly value: string;
|
|
26
|
+
readonly label: string;
|
|
27
|
+
/** Visual emphasis hint; the runner styles primary distinctly. */
|
|
28
|
+
readonly intent?: "primary" | "neutral" | "danger";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---- The UI-effect node kinds the CLIENT renders (and ONLY these) ----------
|
|
32
|
+
|
|
33
|
+
/** A form node: collect typed values, submit continues the flow (§10). */
|
|
34
|
+
export interface FlowFormNode {
|
|
35
|
+
readonly kind: "form";
|
|
36
|
+
/** Server-assigned node id, echoed back so the server correlates the step. */
|
|
37
|
+
readonly id: string;
|
|
38
|
+
readonly title?: string;
|
|
39
|
+
readonly fields: readonly FlowFormField[];
|
|
40
|
+
readonly submitLabel?: string;
|
|
41
|
+
/** When true the runner offers a cancel affordance taking the onCancel edge. */
|
|
42
|
+
readonly cancelable?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A display node: render server-provided read-only content, then continue. */
|
|
46
|
+
export interface FlowDisplayNode {
|
|
47
|
+
readonly kind: "display";
|
|
48
|
+
readonly id: string;
|
|
49
|
+
readonly title?: string;
|
|
50
|
+
/** Read-only text/markup string the server already rendered/sanitized. */
|
|
51
|
+
readonly body: string;
|
|
52
|
+
readonly continueLabel?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A choice node: the user picks one option; its value is the step input. */
|
|
56
|
+
export interface FlowChoiceNode {
|
|
57
|
+
readonly kind: "choice";
|
|
58
|
+
readonly id: string;
|
|
59
|
+
readonly title?: string;
|
|
60
|
+
readonly prompt?: string;
|
|
61
|
+
readonly options: readonly FlowChoiceOption[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A message node: a transient toast-like notice; continues automatically or on ack. */
|
|
65
|
+
export interface FlowMessageNode {
|
|
66
|
+
readonly kind: "message";
|
|
67
|
+
readonly id: string;
|
|
68
|
+
readonly text: string;
|
|
69
|
+
readonly tone?: "info" | "success" | "error";
|
|
70
|
+
readonly ackLabel?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A terminal node: the flow is done. The client stops here and renders the
|
|
75
|
+
* outcome; it never calls `advance` from a terminal node. The server marks
|
|
76
|
+
* terminality — the client does not infer it.
|
|
77
|
+
*/
|
|
78
|
+
export interface FlowTerminalNode {
|
|
79
|
+
readonly kind: "terminal";
|
|
80
|
+
readonly id: string;
|
|
81
|
+
readonly title?: string;
|
|
82
|
+
readonly message?: string;
|
|
83
|
+
readonly tone?: "success" | "error" | "info";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The discriminated union of client-renderable nodes (UI-effect + terminal). */
|
|
87
|
+
export type FlowNode =
|
|
88
|
+
| FlowFormNode
|
|
89
|
+
| FlowDisplayNode
|
|
90
|
+
| FlowChoiceNode
|
|
91
|
+
| FlowMessageNode
|
|
92
|
+
| FlowTerminalNode;
|
|
93
|
+
|
|
94
|
+
/** Opaque server state threaded through each step; the client never reads into it. */
|
|
95
|
+
export type FlowState = unknown;
|
|
96
|
+
|
|
97
|
+
/** The input a step submits back to the server (form values / choice value / ack). */
|
|
98
|
+
export type FlowInput = Record<string, unknown> | null;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The host-injected resolver that owns ALL authority + branching. Given the
|
|
102
|
+
* current opaque state and the step's input, it returns the next node (which may
|
|
103
|
+
* be terminal). ksui never decides what comes next — it only renders + POSTs.
|
|
104
|
+
*/
|
|
105
|
+
export type FlowAdvance = (state: FlowState, input: FlowInput) => Promise<FlowNode>;
|
|
106
|
+
|
|
107
|
+
/** True for the terminal node kind (the runner stops calling `advance`). */
|
|
108
|
+
export function isTerminal(node: FlowNode): node is FlowTerminalNode {
|
|
109
|
+
return node.kind === "terminal";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** True for a node the runner submits user input from (form/choice/message). */
|
|
113
|
+
export function collectsInput(node: FlowNode): node is FlowFormNode | FlowChoiceNode {
|
|
114
|
+
return node.kind === "form" || node.kind === "choice";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the input payload for a form node from its current field values,
|
|
119
|
+
* dropping undefined and coercing nothing (the server re-validates — §10.5).
|
|
120
|
+
* Pure helper so submission shaping is testable without a DOM.
|
|
121
|
+
*/
|
|
122
|
+
export function formNodeInput(
|
|
123
|
+
node: FlowFormNode,
|
|
124
|
+
values: Record<string, unknown>,
|
|
125
|
+
): Record<string, unknown> {
|
|
126
|
+
const out: Record<string, unknown> = {};
|
|
127
|
+
for (const f of node.fields) {
|
|
128
|
+
const v = values[f.key];
|
|
129
|
+
if (v !== undefined) out[f.key] = v;
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Which required form fields are still empty. Client-side gating is UX only;
|
|
136
|
+
* the kernel re-validates every rule server-side (§10.5) — this never authorizes.
|
|
137
|
+
*/
|
|
138
|
+
export function missingRequired(
|
|
139
|
+
node: FlowFormNode,
|
|
140
|
+
values: Record<string, unknown>,
|
|
141
|
+
): string[] {
|
|
142
|
+
const missing: string[] = [];
|
|
143
|
+
for (const f of node.fields) {
|
|
144
|
+
if (!f.required) continue;
|
|
145
|
+
const v = values[f.key];
|
|
146
|
+
if (v === undefined || v === null || v === "") missing.push(f.key);
|
|
147
|
+
}
|
|
148
|
+
return missing;
|
|
149
|
+
}
|