Checkout state editor
Loading…
Source code
$ npx @flitterjs/cli@latest add flow-diagramimport { defaultEdgePath } from "@/components/diagram/flow-diagram";
import { defaultNode } from "@/components/diagram/flow-diagram";
import {
SizedBox,
LayoutBuilder,
Alignment,
Border,
BorderRadius,
BoxDecoration,
Column,
Container,
CrossAxisAlignment,
EdgeInsets,
GestureDetector,
MainAxisSize,
Positioned,
Row,
Stack,
StackFit,
State,
StatefulWidget,
Text,
TextStyle,
type Widget,
} from "flitterjs";
import {
FlowDiagramController,
getEdgePathByType,
layeredLayout,
type FlowNode,
type FlowEdge,
} from "@flitterjs/diagram";
import FlowDiagram, {
defaultHandle,
defaultEdge,
} from "@/components/diagram/flow-diagram";
type Theme = "light" | "dark";
class Machine extends StatefulWidget {
constructor(readonly theme: Theme) {
super(theme);
}
createState() {
return new MachineState();
}
}
class MachineState extends State<Machine> {
flow = new FlowDiagramController();
diagram?: Widget;
selected = "authorizing";
serial = 1;
nodes: FlowNode[] = [
{
id: "checkout",
type: "compound",
position: { x: 0, y: 0 },
width: 1090,
height: 390,
data: { label: "checkout", note: "Compound state" },
},
{
id: "payment",
type: "compound",
parentId: "checkout",
position: { x: 270, y: 55 },
width: 540,
height: 285,
data: {
label: "payment",
note: "Nested state · double-click empty space",
},
},
{
id: "initial",
type: "initial",
parentId: "checkout",
position: { x: 25, y: 120 },
width: 16,
height: 16,
data: {},
},
{
id: "draft",
type: "state",
parentId: "checkout",
position: { x: 75, y: 95 },
width: 150,
height: 64,
data: { label: "draft", action: "Create cart" },
},
{
id: "authorizing",
type: "state",
parentId: "payment",
position: { x: 35, y: 65 },
width: 175,
height: 64,
data: { label: "authorizing", action: "Authorize card" },
selected: true,
},
{
id: "capturing",
type: "state",
parentId: "payment",
position: { x: 320, y: 65 },
width: 175,
height: 64,
data: { label: "capturing", action: "Capture funds" },
},
{
id: "failed",
type: "state",
parentId: "payment",
position: { x: 175, y: 195 },
width: 175,
height: 64,
data: { label: "failed", action: "Report error" },
},
{
id: "complete",
type: "state",
parentId: "checkout",
position: { x: 880, y: 120 },
width: 175,
height: 64,
data: { label: "complete", action: "Order confirmed" },
},
];
edges: FlowEdge[] = [
{ id: "initial-draft", source: "initial", target: "draft", label: "" },
{ id: "submit", source: "draft", target: "authorizing", label: "SUBMIT" },
{
id: "authorized",
source: "authorizing",
target: "capturing",
label: "AUTHORIZED",
},
{
id: "declined",
source: "authorizing",
sourceHandle: "decline",
target: "failed",
targetHandle: "incoming",
label: "DECLINED",
waypoints: [
{ x: 392.5, y: 215 },
{ x: 532.5, y: 215 },
],
},
{
id: "retry",
source: "failed",
sourceHandle: "retry",
target: "authorizing",
label: "RETRY",
waypoints: [
{ x: 280, y: 282 },
{ x: 280, y: 152 },
],
},
{
id: "captured",
source: "capturing",
target: "complete",
label: "CAPTURED",
},
].map((e) => ({ ...e, type: "smoothstep", markerEnd: "arrowclosed" }));
get c() {
return this.widget.theme === "dark"
? {
bg: "#181c2c",
card: "#252d46",
ink: "#e5eafa",
muted: "#99a6ca",
line: "#48577f",
}
: {
bg: "#f5f7ff",
card: "#fff",
ink: "#2e416b",
muted: "#8393b5",
line: "#ccd6eb",
};
}
text(value: string, size = 11, color = this.c.ink) {
return Text(value, {
style: new TextStyle({
fontFamily: "system-ui",
fontSize: Math.max(13, size),
color,
}),
});
}
button(label: string, fn: () => void) {
return GestureDetector({
onMouseDown: (e) => e.stopPropagation(),
onClick: (e) => {
e.stopPropagation();
fn();
},
child: Container({
padding: EdgeInsets.all(9),
color: this.c.card,
child: this.text(label, 10, "#7c8fe2"),
}),
});
}
add(
parentId: string | undefined,
at: { x: number; y: number },
source?: string,
) {
const id = `state-${this.serial}`;
this.setState(() => {
this.serial++;
this.selected = id;
});
this.flow.history.mark("Add state");
this.flow.run(
() => {
this.flow.addNodes({
id,
type: "state",
parentId,
position: at,
width: 175,
height: 64,
data: { label: id, action: "entry: assignContext" },
});
// Grow enclosing frames instead of letting a newly appended state escape them.
let child = this.flow.getNode(id);
while (child?.parentId) {
const parent = this.flow.getNode(child.parentId);
if (!parent) break;
this.flow.updateNode(parent.id, {
width: Math.max(
parent.width ?? 0,
child.position.x + (child.width ?? 175) + 20,
),
height: Math.max(
parent.height ?? 0,
child.position.y + (child.height ?? 64) + 20,
),
});
child = this.flow.getNode(parent.id);
}
this.flow.selectNodes([id]);
if (source)
this.flow.addEdges({
id: `transition-${id}`,
source,
target: id,
label: "NEXT",
type: "smoothstep",
markerEnd: "arrowclosed",
});
},
{ history: "merge" },
);
}
createDiagram(): Widget {
const c = this.c;
return FlowDiagram({
controller: this.flow,
data: { nodes: this.nodes, edges: this.edges },
viewport: { minZoom: 0.15, fitView: { padding: 0.12, maxZoom: 1 } },
config: {
edgeLabel: { fontSize: 13 },
colorMode: this.widget.theme,
colors: { background: c.bg, edgeStroke: "#8899d1" },
minimap: { visible: false },
},
on: {
nodeClick: (_e, n) =>
this.setState(() => {
this.selected = n.id;
}),
nodeDoubleClick: (event, n) => {
if (n.type !== "compound") return;
const p = this.flow.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
}),
origin = this.flow.getNodePositionAbsolute(n);
this.add(n.id, { x: p.x - origin.x, y: p.y - origin.y });
},
connectStart: (state) => {
// Click on + is quick-add; dragging the same handle remains a normal connection.
if (state.mode === "click" && state.fromHandle.id === "add") {
this.flow.cancelConnection();
const n = state.fromNode;
this.add(
n.parentId,
{ x: n.position.x + (n.width ?? 175) + 70, y: n.position.y + 90 },
n.id,
);
}
},
},
custom: {
handle: (args, ctx) =>
args.handle.id === "add"
? Container({
width: 18,
height: 18,
alignment: Alignment.center,
decoration: new BoxDecoration({
shape: "circle",
color: "#7c8fe2",
}),
child: this.text("+", 13, "#fff"),
})
: defaultHandle(args, ctx),
node: (args, ctx) => {
const views: Record<string, typeof defaultNode> = {
compound: ({ node, isSelected: selected }) =>
Container({
padding: EdgeInsets.all(14),
decoration: new BoxDecoration({
color: c.bg,
border: Border.all({
color: selected ? "#8498ef" : c.line,
width: 1,
}),
borderRadius: BorderRadius.circular(12),
}),
child: Column({
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
this.text(String(node.data.label), 15),
this.text(String(node.data.note), 9, c.muted),
],
}),
}),
initial: () =>
Container({
decoration: new BoxDecoration({
shape: "circle",
color: c.ink,
}),
}),
state: ({ node, isSelected: selected }) =>
Container({
alignment: Alignment.center,
padding: EdgeInsets.all(8),
decoration: new BoxDecoration({
color: c.card,
border: Border.all({
color: selected ? "#7c8fe2" : c.line,
width: 2,
}),
borderRadius: BorderRadius.circular(9),
}),
child: Column({
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
this.text(String(node.data.label), 13),
this.text(String(node.data.action), 9, c.muted),
],
}),
}),
};
return (
views[args.node.type ?? "default"] ??
views.default ??
defaultNode
)(args, ctx);
},
edge: (args, ctx) => {
const views: Record<string, typeof defaultEdge> = {
smoothstep: defaultEdge,
};
return (
views[args.edge.type ?? "default"] ??
views.default ??
defaultEdge
)(args, ctx);
},
},
nodeOptions: {
compound: { group: true, handles: [] },
initial: { handles: [{ type: "source", position: "right" }] },
state: {
handles: (node) => [
{ type: "target", position: "left" },
{
id: "add",
type: "source",
position: "right",
width: 18,
height: 18,
},
...(node.id === "authorizing"
? [
{
id: "decline",
type: "source" as const,
position: "bottom" as const,
},
]
: []),
...(node.id === "failed"
? [
{
id: "retry",
type: "source" as const,
position: "left" as const,
},
{
id: "incoming",
type: "target" as const,
position: "top" as const,
},
]
: []),
],
},
},
getEdgePath: (args, ctx) => {
const paths: Record<
string,
(
params: Parameters<typeof defaultEdgePath>[0]["params"],
edge: Parameters<typeof defaultEdgePath>[0]["edge"],
) => ReturnType<typeof defaultEdgePath>
> = {
smoothstep: (params, edge) => {
const path = getEdgePathByType(
"smoothstep",
params,
edge?.pathOptions,
);
if (edge?.id === "retry")
return {
...path,
labelX: params.targetX - 60,
labelY: params.sourceY - 35,
};
if (edge?.id === "submit")
return { ...path, labelY: path.labelY - 20 };
return path;
},
};
const getPath = paths[args.type];
return getPath
? getPath(args.params, args.edge)
: defaultEdgePath(args, ctx);
},
});
}
build(): Widget {
const n = this.flow.getNode(this.selected) ?? this.nodes[4];
return Stack({
fit: StackFit.expand,
children: [
(this.diagram ??= this.createDiagram()),
Positioned({
top: 12,
left: 12,
child: Container({
padding: EdgeInsets.all(10),
color: this.c.card,
child: Row({
mainAxisSize: MainAxisSize.min,
children: [
this.text("Checkout", 16),
this.button(
"Arrange",
() =>
void this.flow.layout(layeredLayout, {
direction: "LR",
rankSpacing: 100,
childPadding: 40,
}),
),
this.button("Undo", () => this.flow.history.undo()),
],
}),
}),
}),
],
});
}
dispose() {
this.flow.dispose();
super.dispose();
}
}
export default function StateMachine(theme: Theme = "light"): Widget {
return new Machine(theme);
}Custom state shapes, nested frames, transition handles, and routed labels form a domain-specific editor.
Double-click inside a state group to add a child. Drag from + to create a transition, then undo.
Demo data; execution and deployment are simulated.