@kahitsan/ksui 0.25.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/utils/flow-builder.test.ts +32 -23
- package/src/utils/flow-builder.ts +76 -77
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",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// flow-builder: node-step authoring → FlowDefinition. Tests linear chaining and
|
|
2
|
-
//
|
|
2
|
+
// a real graph (branch + loop + join), which the builder must express 1:1.
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
4
|
import { buildFlow } from "./flow-builder";
|
|
5
5
|
import { flowToGraph } from "./flow-spec";
|
|
@@ -7,37 +7,46 @@ import { flowToGraph } from "./flow-spec";
|
|
|
7
7
|
describe("buildFlow", () => {
|
|
8
8
|
it("chains linear steps into a connected path", () => {
|
|
9
9
|
const def = buildFlow("item.create", "Add Item", (f) => {
|
|
10
|
-
f.trigger("Add Item button")
|
|
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);
|
|
11
14
|
});
|
|
12
15
|
expect(def.nodes.map((n) => n.kind)).toEqual(["trigger", "modal", "commit"]);
|
|
13
16
|
const { edges } = flowToGraph(def);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
expect(edges[1].to).toBe(def.nodes[2].id);
|
|
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
|
+
]);
|
|
19
21
|
});
|
|
20
22
|
|
|
21
|
-
it("
|
|
23
|
+
it("expresses a branch, a loop, and a join (a real graph, not a tree)", () => {
|
|
22
24
|
const def = buildFlow("cart.checkout", "Checkout", (f) => {
|
|
23
|
-
f.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
30
36
|
});
|
|
37
|
+
|
|
31
38
|
const cond = def.nodes.find((n) => n.kind === "condition")!;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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)
|
|
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));
|
|
40
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)
|
|
41
50
|
const ids = new Set(nodes.map((n) => n.id));
|
|
42
51
|
expect(edges.every((e) => ids.has(e.from) && ids.has(e.to))).toBe(true);
|
|
43
52
|
});
|
|
@@ -1,14 +1,25 @@
|
|
|
1
|
-
// Node-step authoring DSL — a
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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.
|
|
12
23
|
|
|
13
24
|
import {
|
|
14
25
|
defineFlow,
|
|
@@ -17,99 +28,87 @@ import {
|
|
|
17
28
|
type FlowNodeKind,
|
|
18
29
|
} from "./flow-spec";
|
|
19
30
|
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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. */
|
|
27
40
|
export class FlowSteps {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
readonly nodes: FlowNodeDef[] = [],
|
|
31
|
-
private tail: string | null = null,
|
|
32
|
-
private pendingLabel?: string,
|
|
33
|
-
) {}
|
|
41
|
+
readonly nodes: FlowNodeDef[] = [];
|
|
42
|
+
constructor(readonly prefix: string) {}
|
|
34
43
|
|
|
35
|
-
private
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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;
|
|
49
61
|
}
|
|
50
62
|
|
|
51
63
|
/** A UI event that starts/continues the flow (a button, a selection). */
|
|
52
|
-
trigger(label: string):
|
|
53
|
-
return this.
|
|
64
|
+
trigger(label: string): FlowNode {
|
|
65
|
+
return this.make("trigger", label);
|
|
54
66
|
}
|
|
55
67
|
/** A data source / list the screen shows. */
|
|
56
|
-
data(label: string, detail?: string):
|
|
57
|
-
return this.
|
|
68
|
+
data(label: string, detail?: string): FlowNode {
|
|
69
|
+
return this.make("data", label, detail);
|
|
58
70
|
}
|
|
59
71
|
/** A fetch/load into the current screen. */
|
|
60
|
-
load(label: string, detail?: string):
|
|
61
|
-
return this.
|
|
72
|
+
load(label: string, detail?: string): FlowNode {
|
|
73
|
+
return this.make("load", label, detail);
|
|
62
74
|
}
|
|
63
75
|
/** Opens an overlay / form. */
|
|
64
|
-
modal(label: string):
|
|
65
|
-
return this.
|
|
76
|
+
modal(label: string): FlowNode {
|
|
77
|
+
return this.make("modal", label);
|
|
66
78
|
}
|
|
67
79
|
/** A call out to another service/capability; `target` is its identifier. */
|
|
68
|
-
call(target: string, label?: string):
|
|
69
|
-
return this.
|
|
80
|
+
call(target: string, label?: string): FlowNode {
|
|
81
|
+
return this.make("call", label ?? target, target);
|
|
70
82
|
}
|
|
71
83
|
/** A pure computation (apply a discount, total a cart). */
|
|
72
|
-
compute(label: string):
|
|
73
|
-
return this.
|
|
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);
|
|
74
90
|
}
|
|
75
91
|
/** A write / command. */
|
|
76
|
-
commit(label: string, detail?: string):
|
|
77
|
-
return this.
|
|
92
|
+
commit(label: string, detail?: string): FlowNode {
|
|
93
|
+
return this.make("commit", label, detail);
|
|
78
94
|
}
|
|
79
95
|
/** Emits a domain event. */
|
|
80
|
-
emit(event: string):
|
|
81
|
-
return this.
|
|
96
|
+
emit(event: string): FlowNode {
|
|
97
|
+
return this.make("emit", event);
|
|
82
98
|
}
|
|
83
99
|
/** A UI effect — refresh / toast / navigate / close. */
|
|
84
|
-
effect(label: string):
|
|
85
|
-
return this.
|
|
100
|
+
effect(label: string): FlowNode {
|
|
101
|
+
return this.make("effect", label);
|
|
86
102
|
}
|
|
87
103
|
/** An end state. */
|
|
88
|
-
terminal(label: string):
|
|
89
|
-
return this.
|
|
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;
|
|
104
|
+
terminal(label: string): FlowNode {
|
|
105
|
+
return this.make("terminal", label);
|
|
108
106
|
}
|
|
109
107
|
}
|
|
110
108
|
|
|
111
|
-
/** Author one flow
|
|
112
|
-
* (`defineFlow` throws on a dangling edge) and renders on the
|
|
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. */
|
|
113
112
|
export function buildFlow(
|
|
114
113
|
id: string,
|
|
115
114
|
title: string,
|