@kahitsan/ksui 0.24.0 → 0.26.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 +53 -0
- package/src/utils/flow-builder.ts +120 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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,53 @@
|
|
|
1
|
+
// flow-builder: node-step authoring → FlowDefinition. Tests linear chaining and
|
|
2
|
+
// a real graph (branch + loop + join), which the builder must express 1:1.
|
|
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
|
+
const t = f.trigger("Add Item button");
|
|
11
|
+
const m = f.modal("Item form");
|
|
12
|
+
const c = f.commit("Create", "POST /api/items");
|
|
13
|
+
t.to(m).to(c);
|
|
14
|
+
});
|
|
15
|
+
expect(def.nodes.map((n) => n.kind)).toEqual(["trigger", "modal", "commit"]);
|
|
16
|
+
const { edges } = flowToGraph(def);
|
|
17
|
+
expect(edges.map((e) => [e.from, e.to])).toEqual([
|
|
18
|
+
[def.nodes[0].id, def.nodes[1].id],
|
|
19
|
+
[def.nodes[1].id, def.nodes[2].id],
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("expresses a branch, a loop, and a join (a real graph, not a tree)", () => {
|
|
24
|
+
const def = buildFlow("cart.checkout", "Checkout", (f) => {
|
|
25
|
+
const list = f.data("Items");
|
|
26
|
+
const open = f.trigger("Checkout button");
|
|
27
|
+
const form = f.modal("Coupon entry");
|
|
28
|
+
const valid = f.condition("Coupon valid?");
|
|
29
|
+
const apply = f.compute("Apply discount");
|
|
30
|
+
const place = f.commit("Place order");
|
|
31
|
+
list.to(open).to(form).to(valid);
|
|
32
|
+
valid.to(apply, "yes").to(place); // yes arm
|
|
33
|
+
valid.to(form, "no"); // LOOP back to the form
|
|
34
|
+
apply.to(place); // (place already reached from apply) — keep single
|
|
35
|
+
place.to(list, "done"); // JOIN/loop back to the list
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const cond = def.nodes.find((n) => n.kind === "condition")!;
|
|
39
|
+
expect(cond.out!.map((p) => p.label).sort()).toEqual(["no", "yes"]);
|
|
40
|
+
|
|
41
|
+
// a back-edge exists (loop): some edge points to an earlier node
|
|
42
|
+
const order = new Map(def.nodes.map((n, i) => [n.id, i] as const));
|
|
43
|
+
const { nodes, edges } = flowToGraph(def);
|
|
44
|
+
expect(edges.some((e) => order.get(e.to)! <= order.get(e.from)!)).toBe(true);
|
|
45
|
+
// a join exists: some node has in-degree > 1
|
|
46
|
+
const indeg: Record<string, number> = {};
|
|
47
|
+
edges.forEach((e) => (indeg[e.to] = (indeg[e.to] ?? 0) + 1));
|
|
48
|
+
expect(Object.values(indeg).some((d) => d > 1)).toBe(true);
|
|
49
|
+
// every edge resolves (defineFlow would have thrown on a dangling one)
|
|
50
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
51
|
+
expect(edges.every((e) => ids.has(e.from) && ids.has(e.to))).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Node-step authoring DSL — a small, optional graph builder for composing a
|
|
2
|
+
// `FlowDefinition`. Each step creates a node and returns a handle; you wire
|
|
3
|
+
// handles together with `.to(target, label?)`, which returns the target so
|
|
4
|
+
// linear paths chain (`a.to(b).to(c)`) while joins, loops and multi-way
|
|
5
|
+
// branches fall out of connecting the same handle more than once:
|
|
6
|
+
//
|
|
7
|
+
// buildFlow("checkout", "Checkout", (f) => {
|
|
8
|
+
// const list = f.data("Items");
|
|
9
|
+
// const add = f.trigger("Add button");
|
|
10
|
+
// const form = f.modal("Item form");
|
|
11
|
+
// const valid = f.condition("Valid?");
|
|
12
|
+
// const save = f.commit("Save", "POST /api/items");
|
|
13
|
+
// list.to(add).to(form).to(valid);
|
|
14
|
+
// valid.to(save, "yes"); // branch
|
|
15
|
+
// valid.to(form, "no"); // loop back to the form
|
|
16
|
+
// save.to(list, "done"); // and back to the list (join)
|
|
17
|
+
// });
|
|
18
|
+
//
|
|
19
|
+
// It lowers to the same `FlowDefinition` the FlowGraph renders, so it is purely
|
|
20
|
+
// additive sugar over `defineFlow` — consumers can still build flows from
|
|
21
|
+
// `node`/`edge` directly. Domain-free: every step is a generic node kind, no app
|
|
22
|
+
// or transport assumptions.
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
defineFlow,
|
|
26
|
+
type FlowDefinition,
|
|
27
|
+
type FlowNodeDef,
|
|
28
|
+
type FlowNodeKind,
|
|
29
|
+
} from "./flow-spec";
|
|
30
|
+
|
|
31
|
+
/** A handle to a created node. `.to(target)` connects this node → target and
|
|
32
|
+
* returns target, so paths chain; call it again (or from another handle) to
|
|
33
|
+
* form branches, joins and loops. */
|
|
34
|
+
export interface FlowNode {
|
|
35
|
+
readonly id: string;
|
|
36
|
+
to(target: FlowNode, label?: string): FlowNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Graph builder: one method per node kind, each returning a connectable handle. */
|
|
40
|
+
export class FlowSteps {
|
|
41
|
+
readonly nodes: FlowNodeDef[] = [];
|
|
42
|
+
constructor(readonly prefix: string) {}
|
|
43
|
+
|
|
44
|
+
private make(kind: FlowNodeKind, label: string, detail?: string): FlowNode {
|
|
45
|
+
const def: FlowNodeDef = {
|
|
46
|
+
id: `${this.prefix}_${kind}_${this.nodes.length}`,
|
|
47
|
+
kind,
|
|
48
|
+
label,
|
|
49
|
+
...(detail ? { detail } : {}),
|
|
50
|
+
};
|
|
51
|
+
this.nodes.push(def);
|
|
52
|
+
const handle: FlowNode = {
|
|
53
|
+
id: def.id,
|
|
54
|
+
to(target: FlowNode, label?: string): FlowNode {
|
|
55
|
+
def.out = def.out ?? [];
|
|
56
|
+
def.out.push(label ? { id: label, to: target.id, label } : { id: "out", to: target.id });
|
|
57
|
+
return target;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
return handle;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A UI event that starts/continues the flow (a button, a selection). */
|
|
64
|
+
trigger(label: string): FlowNode {
|
|
65
|
+
return this.make("trigger", label);
|
|
66
|
+
}
|
|
67
|
+
/** A data source / list the screen shows. */
|
|
68
|
+
data(label: string, detail?: string): FlowNode {
|
|
69
|
+
return this.make("data", label, detail);
|
|
70
|
+
}
|
|
71
|
+
/** A fetch/load into the current screen. */
|
|
72
|
+
load(label: string, detail?: string): FlowNode {
|
|
73
|
+
return this.make("load", label, detail);
|
|
74
|
+
}
|
|
75
|
+
/** Opens an overlay / form. */
|
|
76
|
+
modal(label: string): FlowNode {
|
|
77
|
+
return this.make("modal", label);
|
|
78
|
+
}
|
|
79
|
+
/** A call out to another service/capability; `target` is its identifier. */
|
|
80
|
+
call(target: string, label?: string): FlowNode {
|
|
81
|
+
return this.make("call", label ?? target, target);
|
|
82
|
+
}
|
|
83
|
+
/** A pure computation (apply a discount, total a cart). */
|
|
84
|
+
compute(label: string): FlowNode {
|
|
85
|
+
return this.make("compute", label);
|
|
86
|
+
}
|
|
87
|
+
/** A branch — wire its outcomes with `.to(target, "yes")` / `.to(target, "no")`. */
|
|
88
|
+
condition(label: string): FlowNode {
|
|
89
|
+
return this.make("condition", label);
|
|
90
|
+
}
|
|
91
|
+
/** A write / command. */
|
|
92
|
+
commit(label: string, detail?: string): FlowNode {
|
|
93
|
+
return this.make("commit", label, detail);
|
|
94
|
+
}
|
|
95
|
+
/** Emits a domain event. */
|
|
96
|
+
emit(event: string): FlowNode {
|
|
97
|
+
return this.make("emit", event);
|
|
98
|
+
}
|
|
99
|
+
/** A UI effect — refresh / toast / navigate / close. */
|
|
100
|
+
effect(label: string): FlowNode {
|
|
101
|
+
return this.make("effect", label);
|
|
102
|
+
}
|
|
103
|
+
/** An end state. */
|
|
104
|
+
terminal(label: string): FlowNode {
|
|
105
|
+
return this.make("terminal", label);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Author one flow by connecting node steps. The returned definition is
|
|
110
|
+
* identity-checked (`defineFlow` throws on a dangling edge) and renders on the
|
|
111
|
+
* FlowGraph canvas. */
|
|
112
|
+
export function buildFlow(
|
|
113
|
+
id: string,
|
|
114
|
+
title: string,
|
|
115
|
+
build: (f: FlowSteps) => void,
|
|
116
|
+
): FlowDefinition {
|
|
117
|
+
const f = new FlowSteps(id.replace(/[^a-zA-Z0-9]+/g, "_"));
|
|
118
|
+
build(f);
|
|
119
|
+
return defineFlow({ id, title, nodes: f.nodes });
|
|
120
|
+
}
|