@kahitsan/ksui 0.24.0 → 0.25.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/index.ts +1 -0
- package/src/utils/flow-builder.test.ts +44 -0
- package/src/utils/flow-builder.ts +121 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/index.ts
CHANGED
|
@@ -245,6 +245,7 @@ export type {
|
|
|
245
245
|
// FlowGraph canvas draws. This is the "authored in SDK code → parsed to a
|
|
246
246
|
// diagram" seam.
|
|
247
247
|
export { defineFlow, node, edge, flowToGraph } from "./utils/flow-spec";
|
|
248
|
+
export { buildFlow, FlowSteps } from "./utils/flow-builder";
|
|
248
249
|
export type { FlowDefinition, FlowNodeDef, FlowNodeKind, FlowPort } from "./utils/flow-spec";
|
|
249
250
|
|
|
250
251
|
// FlowGraph model (pure): the node/edge types + the dependency-free layout the
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// flow-builder: node-step authoring → FlowDefinition. Tests linear chaining and
|
|
2
|
+
// the condition fork (both arms captured).
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { buildFlow } from "./flow-builder";
|
|
5
|
+
import { flowToGraph } from "./flow-spec";
|
|
6
|
+
|
|
7
|
+
describe("buildFlow", () => {
|
|
8
|
+
it("chains linear steps into a connected path", () => {
|
|
9
|
+
const def = buildFlow("item.create", "Add Item", (f) => {
|
|
10
|
+
f.trigger("Add Item button").modal("Item form").commit("Create", "POST /api/items");
|
|
11
|
+
});
|
|
12
|
+
expect(def.nodes.map((n) => n.kind)).toEqual(["trigger", "modal", "commit"]);
|
|
13
|
+
const { edges } = flowToGraph(def);
|
|
14
|
+
// trigger → modal → commit
|
|
15
|
+
expect(edges).toHaveLength(2);
|
|
16
|
+
expect(edges[0].from).toBe(def.nodes[0].id);
|
|
17
|
+
expect(edges[0].to).toBe(def.nodes[1].id);
|
|
18
|
+
expect(edges[1].to).toBe(def.nodes[2].id);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("forks a condition into two labelled branches, both rendered", () => {
|
|
22
|
+
const def = buildFlow("cart.checkout", "Checkout", (f) => {
|
|
23
|
+
f.trigger("Checkout button")
|
|
24
|
+
.modal("Coupon entry")
|
|
25
|
+
.condition(
|
|
26
|
+
"Coupon valid?",
|
|
27
|
+
(yes) => yes.call("pricing:validate").compute("Apply discount").commit("Place order"),
|
|
28
|
+
(no) => no.commit("Place order"),
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
const cond = def.nodes.find((n) => n.kind === "condition")!;
|
|
32
|
+
// exactly two outgoing branches, to DIFFERENT nodes, each labelled
|
|
33
|
+
expect(cond.out).toHaveLength(2);
|
|
34
|
+
expect(cond.out![0].to).not.toBe(cond.out![1].to);
|
|
35
|
+
expect(cond.out!.map((p) => p.label)).toEqual(["yes", "no"]);
|
|
36
|
+
// the "yes" arm carries the validate→compute→commit chain
|
|
37
|
+
expect(def.nodes.some((n) => n.kind === "call" && n.detail === "pricing:validate")).toBe(true);
|
|
38
|
+
expect(def.nodes.filter((n) => n.kind === "commit")).toHaveLength(2);
|
|
39
|
+
// every edge resolves (defineFlow would have thrown otherwise)
|
|
40
|
+
const { nodes, edges } = flowToGraph(def);
|
|
41
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
42
|
+
expect(edges.every((e) => ids.has(e.from) && ids.has(e.to))).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Node-step authoring DSL — a fluent builder for a flow/node-graph definition.
|
|
2
|
+
// An author wraps steps in calls (`f.trigger(...)`, `f.condition(label, onYes,
|
|
3
|
+
// onNo)`, `f.call(...)`, …); each call appends a node wired from the previous
|
|
4
|
+
// step, and a condition forks into two labelled branches. The whole thing lowers
|
|
5
|
+
// to a `FlowDefinition` the FlowGraph renders.
|
|
6
|
+
//
|
|
7
|
+
// Authoring with steps — instead of hand-building node/edge data — is what keeps
|
|
8
|
+
// the diagram parseable and in lockstep with the code that declares it: the same
|
|
9
|
+
// call tree that (in a step-running runtime) drives the behaviour is what gets
|
|
10
|
+
// rendered. This is optional, additive sugar over `defineFlow`; consumers can
|
|
11
|
+
// still build a `FlowDefinition` directly with `node`/`edge` if they prefer.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
defineFlow,
|
|
15
|
+
type FlowDefinition,
|
|
16
|
+
type FlowNodeDef,
|
|
17
|
+
type FlowNodeKind,
|
|
18
|
+
} from "./flow-spec";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A node-step recorder. Linear steps chain (`f.trigger(...).load(...).commit(...)`);
|
|
22
|
+
* `condition` forks into two labelled branches that recurse with the SAME node
|
|
23
|
+
* list, so the full tree — both arms, not just the one a runtime would take — is
|
|
24
|
+
* captured for the diagram. Branch builders share `nodes` (and thus the running
|
|
25
|
+
* id sequence via `nodes.length`), so ids never collide across arms.
|
|
26
|
+
*/
|
|
27
|
+
export class FlowSteps {
|
|
28
|
+
constructor(
|
|
29
|
+
readonly prefix: string,
|
|
30
|
+
readonly nodes: FlowNodeDef[] = [],
|
|
31
|
+
private tail: string | null = null,
|
|
32
|
+
private pendingLabel?: string,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
private step(kind: FlowNodeKind, label: string, detail?: string): this {
|
|
36
|
+
const id = `${this.prefix}_${kind}_${this.nodes.length}`;
|
|
37
|
+
this.nodes.push({ id, kind, label, ...(detail ? { detail } : {}) });
|
|
38
|
+
if (this.tail) {
|
|
39
|
+
const prev = this.nodes.find((n) => n.id === this.tail);
|
|
40
|
+
if (prev) {
|
|
41
|
+
prev.out = prev.out ?? [];
|
|
42
|
+
const bl = this.pendingLabel;
|
|
43
|
+
prev.out.push(bl ? { id: bl, to: id, label: bl } : { id: "out", to: id });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
this.tail = id;
|
|
47
|
+
this.pendingLabel = undefined; // a fork label applies only to the first step of the arm
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A UI event that starts/continues the flow (a button, a selection). */
|
|
52
|
+
trigger(label: string): this {
|
|
53
|
+
return this.step("trigger", label);
|
|
54
|
+
}
|
|
55
|
+
/** A data source / list the screen shows. */
|
|
56
|
+
data(label: string, detail?: string): this {
|
|
57
|
+
return this.step("data", label, detail);
|
|
58
|
+
}
|
|
59
|
+
/** A fetch/load into the current screen. */
|
|
60
|
+
load(label: string, detail?: string): this {
|
|
61
|
+
return this.step("load", label, detail);
|
|
62
|
+
}
|
|
63
|
+
/** Opens an overlay / form. */
|
|
64
|
+
modal(label: string): this {
|
|
65
|
+
return this.step("modal", label);
|
|
66
|
+
}
|
|
67
|
+
/** A call out to another service/capability; `target` is its identifier. */
|
|
68
|
+
call(target: string, label?: string): this {
|
|
69
|
+
return this.step("call", label ?? target, target);
|
|
70
|
+
}
|
|
71
|
+
/** A pure computation (apply a discount, total a cart). */
|
|
72
|
+
compute(label: string): this {
|
|
73
|
+
return this.step("compute", label);
|
|
74
|
+
}
|
|
75
|
+
/** A write / command. */
|
|
76
|
+
commit(label: string, detail?: string): this {
|
|
77
|
+
return this.step("commit", label, detail);
|
|
78
|
+
}
|
|
79
|
+
/** Emits a domain event. */
|
|
80
|
+
emit(event: string): this {
|
|
81
|
+
return this.step("emit", event);
|
|
82
|
+
}
|
|
83
|
+
/** A UI effect — refresh / toast / navigate / close. */
|
|
84
|
+
effect(label: string): this {
|
|
85
|
+
return this.step("effect", label);
|
|
86
|
+
}
|
|
87
|
+
/** An end state. */
|
|
88
|
+
terminal(label: string): this {
|
|
89
|
+
return this.step("terminal", label);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A two-way branch. `onYes`/`onNo` each receive a builder rooted at the
|
|
94
|
+
* condition so both arms render; `labels` annotates the two out-edges
|
|
95
|
+
* (default "yes"/"no").
|
|
96
|
+
*/
|
|
97
|
+
condition(
|
|
98
|
+
label: string,
|
|
99
|
+
onYes: (yes: FlowSteps) => void,
|
|
100
|
+
onNo: (no: FlowSteps) => void,
|
|
101
|
+
labels: readonly [string, string] = ["yes", "no"],
|
|
102
|
+
): this {
|
|
103
|
+
this.step("condition", label);
|
|
104
|
+
const cond = this.tail as string;
|
|
105
|
+
onYes(new FlowSteps(this.prefix, this.nodes, cond, labels[0]));
|
|
106
|
+
onNo(new FlowSteps(this.prefix, this.nodes, cond, labels[1]));
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Author one flow from node steps. The returned definition is identity-checked
|
|
112
|
+
* (`defineFlow` throws on a dangling edge) and renders on the FlowGraph canvas. */
|
|
113
|
+
export function buildFlow(
|
|
114
|
+
id: string,
|
|
115
|
+
title: string,
|
|
116
|
+
build: (f: FlowSteps) => void,
|
|
117
|
+
): FlowDefinition {
|
|
118
|
+
const f = new FlowSteps(id.replace(/[^a-zA-Z0-9]+/g, "_"));
|
|
119
|
+
build(f);
|
|
120
|
+
return defineFlow({ id, title, nodes: f.nodes });
|
|
121
|
+
}
|