@signalsafe/tree-spec-editor-react 0.1.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/README.md +43 -0
- package/dist/TreeSpecGraphEditor.d.ts +16 -0
- package/dist/TreeSpecGraphEditor.d.ts.map +1 -0
- package/dist/TreeSpecGraphEditor.js +274 -0
- package/dist/hooks/types.d.ts +337 -0
- package/dist/hooks/types.d.ts.map +1 -0
- package/dist/hooks/types.js +1 -0
- package/dist/hooks/useTreeSpecEditor.d.ts +18 -0
- package/dist/hooks/useTreeSpecEditor.d.ts.map +1 -0
- package/dist/hooks/useTreeSpecEditor.js +663 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/package.json +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# @signalsafe/tree-spec-editor-react
|
|
2
|
+
|
|
3
|
+
Headless React layer for the SignalSafe TreeSpec graph editor. Renders the
|
|
4
|
+
React Flow canvas, owns React state plumbing, and exposes the editor as a
|
|
5
|
+
single React component — without depending on any specific UI library.
|
|
6
|
+
|
|
7
|
+
This is the React-specific sibling to **[`@signalsafe/tree-spec-editor-core`](../tree-spec-editor-core/README.md)**. The core package owns model + helpers (zero UI deps); this package owns React rendering. UI shells (e.g. `@signalsafe/tree-spec-editor` for React + Bootstrap, planned `@signalsafe/tree-spec-editor-react-mui` for React + Material) layer on top.
|
|
8
|
+
|
|
9
|
+
## What this package owns
|
|
10
|
+
|
|
11
|
+
- **`TreeSpecGraphEditor`** — the React Flow canvas (Background, Controls, MiniMap, custom node renderer, transition edges, selection wiring, focus/fit-view).
|
|
12
|
+
- **`TreeSpecGraphEditorProps`** — props type.
|
|
13
|
+
- Future: framework-shaped headless hooks (e.g. `useTreeSpecEditorState`,
|
|
14
|
+
`useGraphSelection`) that wrap the framework-agnostic helpers from
|
|
15
|
+
`tree-spec-editor-core`.
|
|
16
|
+
|
|
17
|
+
## What lives elsewhere
|
|
18
|
+
|
|
19
|
+
| Concern | Package |
|
|
20
|
+
|--------|---------|
|
|
21
|
+
| Editor model, tree operations, layout, autosave/keyboard helpers, constants | `@signalsafe/tree-spec-editor-core` |
|
|
22
|
+
| Sidebar panels, inspector, modals, toolbar (Bootstrap-styled) | `@signalsafe/tree-spec-editor` |
|
|
23
|
+
| Material-styled UI shells (planned) | `@signalsafe/tree-spec-editor-react-mui` |
|
|
24
|
+
| Angular implementation (planned) | `@signalsafe/tree-spec-editor-angular` |
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install @signalsafe/tree-spec-editor-react react react-dom reactflow
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`reactflow` is a peer dependency; you must install it (and ship its CSS,
|
|
33
|
+
e.g. `import 'reactflow/dist/style.css';`) in the consuming app. The
|
|
34
|
+
package itself imports the CSS file from its source, so bundlers that
|
|
35
|
+
resolve module references will pick it up automatically.
|
|
36
|
+
|
|
37
|
+
## Why a separate package?
|
|
38
|
+
|
|
39
|
+
This layer is React-specific but **UI-library-agnostic**. Hosts that want
|
|
40
|
+
to ship a Material-styled editor only need to publish their own UI shell
|
|
41
|
+
(panels, modals, toolbar) — they reuse the canvas and the editor model
|
|
42
|
+
unchanged. This also keeps `@signalsafe/tree-spec-editor` (the
|
|
43
|
+
Bootstrap variant) from being the sole React entry point.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import 'reactflow/dist/style.css';
|
|
2
|
+
import { type EditorTree, type GraphSelection, type GraphEditorIssue } from '@signalsafe/tree-spec-editor-core';
|
|
3
|
+
export type TreeSpecGraphEditorProps = {
|
|
4
|
+
tree: EditorTree;
|
|
5
|
+
onChange: (next: EditorTree) => void;
|
|
6
|
+
issues?: GraphEditorIssue[];
|
|
7
|
+
showMiniMap?: boolean;
|
|
8
|
+
selected?: GraphSelection;
|
|
9
|
+
onSelect?: (sel: GraphSelection) => void;
|
|
10
|
+
focusNodeId?: string | null;
|
|
11
|
+
fitViewNonce?: number;
|
|
12
|
+
/** Optional class for the outer container (default includes h-70vh border rounded). */
|
|
13
|
+
className?: string;
|
|
14
|
+
};
|
|
15
|
+
export default function TreeSpecGraphEditor(props: Readonly<TreeSpecGraphEditorProps>): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
//# sourceMappingURL=TreeSpecGraphEditor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TreeSpecGraphEditor.d.ts","sourceRoot":"","sources":["../src/TreeSpecGraphEditor.tsx"],"names":[],"mappings":"AAkBA,OAAO,0BAA0B,CAAC;AAIlC,OAAO,EAKH,KAAK,UAAU,EAEf,KAAK,cAAc,EACnB,KAAK,gBAAgB,EAGxB,MAAM,mCAAmC,CAAC;AAE3C,MAAM,MAAM,wBAAwB,GAAG;IACnC,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,CAAC;IACrC,MAAM,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC5B,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,IAAI,CAAC;IACzC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uFAAuF;IACvF,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAkZF,MAAM,CAAC,OAAO,UAAU,mBAAmB,CAAC,KAAK,EAAE,QAAQ,CAAC,wBAAwB,CAAC,2CAMpF"}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
3
|
+
import ReactFlow, { Background, Controls, Handle, MiniMap, Position, ReactFlowProvider, addEdge, useEdgesState, useNodesState, useReactFlow, } from 'reactflow';
|
|
4
|
+
import 'reactflow/dist/style.css';
|
|
5
|
+
import { TERMINAL_OUTCOME, TREE_SPEC_ISSUE_SEVERITY } from '@signalsafe/tree-spec';
|
|
6
|
+
import { END_NODE_ID, GRAPH_SELECTION_KIND, safeUUID, } from '@signalsafe/tree-spec-editor-core';
|
|
7
|
+
/** Background highlight when the node matches editor selection (sidebar / issues / canvas). */
|
|
8
|
+
const CANVAS_NODE_SELECTED_CLASS = 'bg-primary-subtle';
|
|
9
|
+
function getPromptNodeBorderClass(hasErrors, warningCount) {
|
|
10
|
+
if (hasErrors) {
|
|
11
|
+
return 'border-danger';
|
|
12
|
+
}
|
|
13
|
+
if (warningCount > 0) {
|
|
14
|
+
return 'border-warning';
|
|
15
|
+
}
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
function getIssueEdgeStyle(style, hasIssue) {
|
|
19
|
+
if (!hasIssue) {
|
|
20
|
+
return style;
|
|
21
|
+
}
|
|
22
|
+
return style ? { ...style, strokeWidth: 2, strokeDasharray: '6 4' } : { strokeWidth: 2, strokeDasharray: '6 4' };
|
|
23
|
+
}
|
|
24
|
+
function PromptNode({ data, selected }) {
|
|
25
|
+
const n = data.node;
|
|
26
|
+
const choices = n.choices ?? [];
|
|
27
|
+
const hasErrors = data.issuesErrors > 0;
|
|
28
|
+
const borderClass = getPromptNodeBorderClass(hasErrors, data.issuesWarnings);
|
|
29
|
+
return (_jsxs("div", { className: `card card-min-width-280 ${borderClass}${selected ? ` ${CANVAS_NODE_SELECTED_CLASS}` : ''}`, children: [_jsx(Handle, { type: "target", position: Position.Left, id: "in", className: "handle-bg-default" }), _jsxs("div", { className: "card-body p-2", children: [_jsxs("div", { className: "d-flex justify-content-between align-items-center", children: [_jsxs("div", { children: [_jsxs("div", { className: "fw-bold font-size-13", children: [data.isStart ? '▶ ' : '', n.type, data.issuesTotal > 0 ? (_jsxs("span", { className: "ms-2", title: `${data.issuesErrors} errors, ${data.issuesWarnings} warnings, ${data.issuesInfo} info`, children: [data.issuesErrors > 0 ? _jsx("span", { className: "badge bg-danger me-1", children: data.issuesErrors }) : null, data.issuesWarnings > 0 ? (_jsx("span", { className: "badge bg-warning text-dark me-1", children: data.issuesWarnings })) : null, data.issuesInfo > 0 ? _jsx("span", { className: "badge bg-info text-dark", children: data.issuesInfo }) : null] })) : null] }), _jsx("div", { className: "font-size-12 opacity-90 text-truncate", children: n.prompt || _jsx("em", { className: "text-muted", children: "(empty prompt)" }) })] }), _jsx("div", { className: "text-muted font-size-11", children: n.id.slice(0, 8) })] }), _jsx("hr", { className: "my-2" }), _jsx("div", { className: "font-size-12", children: choices.length === 0 ? (_jsx("div", { className: "text-muted", children: _jsx("em", { children: "No choices" }) })) : (_jsx("ul", { className: "list-unstyled mb-0", children: choices.map((c) => (_jsxs("li", { className: "d-flex justify-content-between align-items-center position-relative pr-18", children: [_jsx("span", { className: "text-truncate max-w-210", children: c.label }), _jsx("span", { className: "badge bg-light text-dark", children: c.id }), _jsx(Handle, { type: "source", position: Position.Right, id: `choice:${c.id}`, className: "graph-editor-handle", style: {
|
|
30
|
+
position: 'absolute',
|
|
31
|
+
right: -6,
|
|
32
|
+
top: '50%',
|
|
33
|
+
transform: 'translateY(-50%)',
|
|
34
|
+
} })] }, c.id))) })) })] })] }));
|
|
35
|
+
}
|
|
36
|
+
function EndNode({ selected }) {
|
|
37
|
+
return (_jsxs("div", { className: `card border-danger w-180${selected ? ` ${CANVAS_NODE_SELECTED_CLASS}` : ''}`, children: [_jsx(Handle, { type: "target", position: Position.Left, id: "in", className: "handle-bg-danger" }), _jsxs("div", { className: "card-body p-2 text-center", children: [_jsx("div", { className: "fw-bold text-danger", children: "END" }), _jsx("div", { className: "text-muted font-size-12", children: "Outcome required" })] })] }));
|
|
38
|
+
}
|
|
39
|
+
function choiceIdFromHandle(h) {
|
|
40
|
+
if (!h)
|
|
41
|
+
return '';
|
|
42
|
+
if (h.startsWith('choice:'))
|
|
43
|
+
return h.slice('choice:'.length);
|
|
44
|
+
return h;
|
|
45
|
+
}
|
|
46
|
+
function edgeLabelForTransition(tree, t) {
|
|
47
|
+
const node = tree.nodes[t.fromNodeId];
|
|
48
|
+
const choice = node?.choices?.find((c) => c.id === t.fromChoiceId);
|
|
49
|
+
const base = choice?.label || t.fromChoiceId;
|
|
50
|
+
if (t.toNodeId === END_NODE_ID) {
|
|
51
|
+
const oc = t.outcome ?? TERMINAL_OUTCOME.AT_RISK;
|
|
52
|
+
return `${base} → END (${oc})`;
|
|
53
|
+
}
|
|
54
|
+
return base;
|
|
55
|
+
}
|
|
56
|
+
function buildEdgesFromTransitions(tree) {
|
|
57
|
+
return tree.transitions.map((t) => ({
|
|
58
|
+
id: t.id,
|
|
59
|
+
source: t.fromNodeId,
|
|
60
|
+
target: t.toNodeId,
|
|
61
|
+
sourceHandle: `choice:${t.fromChoiceId}`,
|
|
62
|
+
label: edgeLabelForTransition(tree, t),
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
function buildTransitionsFromEdges(edges, existing) {
|
|
66
|
+
const existingById = new Map(existing.map((t) => [t.id, t]));
|
|
67
|
+
return edges
|
|
68
|
+
.filter((e) => e.source && e.target && e.source !== END_NODE_ID)
|
|
69
|
+
.map((e) => {
|
|
70
|
+
const fromChoiceId = choiceIdFromHandle(e.sourceHandle);
|
|
71
|
+
const prior = existingById.get(String(e.id));
|
|
72
|
+
return {
|
|
73
|
+
id: String(e.id),
|
|
74
|
+
fromNodeId: String(e.source),
|
|
75
|
+
fromChoiceId: fromChoiceId,
|
|
76
|
+
toNodeId: String(e.target),
|
|
77
|
+
outcome: String(e.target) === END_NODE_ID ? (prior?.outcome ?? TERMINAL_OUTCOME.AT_RISK) : undefined,
|
|
78
|
+
};
|
|
79
|
+
})
|
|
80
|
+
.filter((t) => t.fromChoiceId.length > 0);
|
|
81
|
+
}
|
|
82
|
+
function TreeSpecGraphInner({ tree, onChange, issues = [], selected, onSelect, focusNodeId, showMiniMap = true, fitViewNonce, className = 'h-70vh border rounded', }) {
|
|
83
|
+
const rf = useReactFlow();
|
|
84
|
+
const isDraggingRef = useRef(false);
|
|
85
|
+
const nodesRef = useRef([]);
|
|
86
|
+
const edgesRef = useRef([]);
|
|
87
|
+
const issuesByNode = useMemo(() => {
|
|
88
|
+
const m = new Map();
|
|
89
|
+
for (const i of issues) {
|
|
90
|
+
if (!i.node_id)
|
|
91
|
+
continue;
|
|
92
|
+
const cur = m.get(i.node_id) ?? { total: 0, errors: 0, warnings: 0, info: 0 };
|
|
93
|
+
cur.total += 1;
|
|
94
|
+
const sev = String(i.severity ?? '').toLowerCase();
|
|
95
|
+
if (sev === TREE_SPEC_ISSUE_SEVERITY.WARNING)
|
|
96
|
+
cur.warnings += 1;
|
|
97
|
+
else if (sev === TREE_SPEC_ISSUE_SEVERITY.INFO)
|
|
98
|
+
cur.info += 1;
|
|
99
|
+
else
|
|
100
|
+
cur.errors += 1;
|
|
101
|
+
m.set(i.node_id, cur);
|
|
102
|
+
}
|
|
103
|
+
return m;
|
|
104
|
+
}, [issues]);
|
|
105
|
+
const issueKeySet = useMemo(() => {
|
|
106
|
+
const s = new Set();
|
|
107
|
+
for (const i of issues) {
|
|
108
|
+
if (!i.node_id || !i.choice_id)
|
|
109
|
+
continue;
|
|
110
|
+
s.add(`${i.node_id}::${i.choice_id}`);
|
|
111
|
+
}
|
|
112
|
+
return s;
|
|
113
|
+
}, [issues]);
|
|
114
|
+
const initialNodes = useMemo(() => {
|
|
115
|
+
const arr = [];
|
|
116
|
+
for (const n of Object.values(tree.nodes)) {
|
|
117
|
+
arr.push({
|
|
118
|
+
id: n.id,
|
|
119
|
+
type: 'promptNode',
|
|
120
|
+
position: n.position ?? { x: 0, y: 0 },
|
|
121
|
+
data: {
|
|
122
|
+
node: n,
|
|
123
|
+
isStart: tree.start_node === n.id,
|
|
124
|
+
issuesTotal: issuesByNode.get(n.id)?.total ?? 0,
|
|
125
|
+
issuesErrors: issuesByNode.get(n.id)?.errors ?? 0,
|
|
126
|
+
issuesWarnings: issuesByNode.get(n.id)?.warnings ?? 0,
|
|
127
|
+
issuesInfo: issuesByNode.get(n.id)?.info ?? 0,
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
arr.push({
|
|
132
|
+
id: END_NODE_ID,
|
|
133
|
+
type: 'endNode',
|
|
134
|
+
position: { x: 700, y: 0 },
|
|
135
|
+
data: {},
|
|
136
|
+
selectable: true,
|
|
137
|
+
draggable: true,
|
|
138
|
+
});
|
|
139
|
+
return arr;
|
|
140
|
+
}, [tree.nodes, tree.start_node, issuesByNode]);
|
|
141
|
+
const initialEdges = useMemo(() => buildEdgesFromTransitions(tree), [tree]);
|
|
142
|
+
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
|
143
|
+
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
nodesRef.current = nodes;
|
|
146
|
+
}, [nodes]);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
edgesRef.current = edges;
|
|
149
|
+
}, [edges]);
|
|
150
|
+
const lastTreeRef = useRef('');
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
const nextKey = JSON.stringify({ tree, issues: issues.length });
|
|
153
|
+
if (nextKey === lastTreeRef.current)
|
|
154
|
+
return;
|
|
155
|
+
lastTreeRef.current = nextKey;
|
|
156
|
+
setNodes(initialNodes);
|
|
157
|
+
setEdges(initialEdges);
|
|
158
|
+
}, [tree, issues.length, initialNodes, initialEdges, setNodes, setEdges]);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (!focusNodeId)
|
|
161
|
+
return;
|
|
162
|
+
try {
|
|
163
|
+
const n = rf.getNode(focusNodeId);
|
|
164
|
+
if (!n)
|
|
165
|
+
return;
|
|
166
|
+
rf.setCenter(n.position.x + 150, n.position.y + 60, { zoom: 1, duration: 300 });
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// ignore
|
|
170
|
+
}
|
|
171
|
+
}, [focusNodeId, rf]);
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!fitViewNonce)
|
|
174
|
+
return;
|
|
175
|
+
const id = setTimeout(() => {
|
|
176
|
+
try {
|
|
177
|
+
rf.fitView({ padding: 0.2, duration: 300 });
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// ignore
|
|
181
|
+
}
|
|
182
|
+
}, 100);
|
|
183
|
+
return () => clearTimeout(id);
|
|
184
|
+
}, [fitViewNonce, rf]);
|
|
185
|
+
const nodeTypes = useMemo(() => ({
|
|
186
|
+
promptNode: PromptNode,
|
|
187
|
+
endNode: EndNode,
|
|
188
|
+
}), []);
|
|
189
|
+
const commit = useCallback((nextNodes, nextEdges) => {
|
|
190
|
+
if (isDraggingRef.current)
|
|
191
|
+
return;
|
|
192
|
+
const updatedNodes = { ...tree.nodes };
|
|
193
|
+
for (const n of nextNodes) {
|
|
194
|
+
if (n.id === END_NODE_ID)
|
|
195
|
+
continue;
|
|
196
|
+
const existing = updatedNodes[n.id];
|
|
197
|
+
if (!existing)
|
|
198
|
+
continue;
|
|
199
|
+
updatedNodes[n.id] = {
|
|
200
|
+
...existing,
|
|
201
|
+
position: { x: n.position.x, y: n.position.y },
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const transitions = buildTransitionsFromEdges(nextEdges, tree.transitions);
|
|
205
|
+
onChange({
|
|
206
|
+
...tree,
|
|
207
|
+
nodes: updatedNodes,
|
|
208
|
+
transitions,
|
|
209
|
+
});
|
|
210
|
+
}, [tree, onChange]);
|
|
211
|
+
const onConnect = useCallback((conn) => {
|
|
212
|
+
const choiceId = choiceIdFromHandle(conn.sourceHandle);
|
|
213
|
+
if (!conn.source || !conn.target || !choiceId)
|
|
214
|
+
return;
|
|
215
|
+
const newEdge = {
|
|
216
|
+
id: safeUUID(),
|
|
217
|
+
source: conn.source,
|
|
218
|
+
target: conn.target,
|
|
219
|
+
sourceHandle: conn.sourceHandle,
|
|
220
|
+
label: choiceId,
|
|
221
|
+
};
|
|
222
|
+
setEdges((eds) => {
|
|
223
|
+
const next = addEdge(newEdge, eds);
|
|
224
|
+
commit(nodesRef.current, next);
|
|
225
|
+
return next;
|
|
226
|
+
});
|
|
227
|
+
}, [setEdges, commit]);
|
|
228
|
+
const onNodesChangeWrapped = useCallback((changes) => {
|
|
229
|
+
onNodesChange(changes);
|
|
230
|
+
const shouldCommit = changes.some((c) => {
|
|
231
|
+
if (c?.type === 'select' || c?.type === 'dimensions')
|
|
232
|
+
return false;
|
|
233
|
+
if (c?.type === 'position' && c?.dragging)
|
|
234
|
+
return false;
|
|
235
|
+
return true;
|
|
236
|
+
});
|
|
237
|
+
if (shouldCommit)
|
|
238
|
+
queueMicrotask(() => commit(nodesRef.current, edgesRef.current));
|
|
239
|
+
}, [onNodesChange, commit]);
|
|
240
|
+
const onEdgesChangeWrapped = useCallback((changes) => {
|
|
241
|
+
onEdgesChange(changes);
|
|
242
|
+
const shouldCommit = changes.some((c) => c?.type !== 'select');
|
|
243
|
+
if (shouldCommit)
|
|
244
|
+
queueMicrotask(() => commit(nodesRef.current, edgesRef.current));
|
|
245
|
+
}, [onEdgesChange, commit]);
|
|
246
|
+
const onNodeDragStart = useCallback(() => {
|
|
247
|
+
isDraggingRef.current = true;
|
|
248
|
+
}, []);
|
|
249
|
+
const onNodeDragStop = useCallback(() => {
|
|
250
|
+
isDraggingRef.current = false;
|
|
251
|
+
commit(nodesRef.current, edgesRef.current);
|
|
252
|
+
}, [commit]);
|
|
253
|
+
const onNodeClick = useCallback((_evt, node) => {
|
|
254
|
+
onSelect?.({ kind: GRAPH_SELECTION_KIND.NODE, id: node.id });
|
|
255
|
+
}, [onSelect]);
|
|
256
|
+
const onEdgeClick = useCallback((_evt, edge) => {
|
|
257
|
+
onSelect?.({ kind: GRAPH_SELECTION_KIND.EDGE, id: edge.id });
|
|
258
|
+
}, [onSelect]);
|
|
259
|
+
return (_jsx("div", { className: className, children: _jsxs(ReactFlow, { nodes: nodes.map((n) => ({
|
|
260
|
+
...n,
|
|
261
|
+
selected: selected?.kind === GRAPH_SELECTION_KIND.NODE && selected.id === n.id,
|
|
262
|
+
})), edges: edges.map((e) => {
|
|
263
|
+
const fromChoiceId = choiceIdFromHandle(e.sourceHandle);
|
|
264
|
+
const hasIssue = fromChoiceId ? issueKeySet.has(`${e.source}::${fromChoiceId}`) : false;
|
|
265
|
+
return {
|
|
266
|
+
...e,
|
|
267
|
+
selected: selected?.kind === GRAPH_SELECTION_KIND.EDGE && selected.id === e.id,
|
|
268
|
+
style: getIssueEdgeStyle(e.style, hasIssue),
|
|
269
|
+
};
|
|
270
|
+
}), onNodesChange: onNodesChangeWrapped, onEdgesChange: onEdgesChangeWrapped, onConnect: onConnect, onNodeDragStart: onNodeDragStart, onNodeDragStop: onNodeDragStop, onNodeClick: onNodeClick, onEdgeClick: onEdgeClick, nodeTypes: nodeTypes, children: [showMiniMap ? _jsx(MiniMap, {}) : null, _jsx(Controls, {}), _jsx(Background, {})] }) }));
|
|
271
|
+
}
|
|
272
|
+
export default function TreeSpecGraphEditor(props) {
|
|
273
|
+
return (_jsx(ReactFlowProvider, { children: _jsx(TreeSpecGraphInner, { ...props }) }));
|
|
274
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import type { AutosaveStatus, EditorChoice, EditorNode, EditorTransition, EditorTree, GraphSelection, KeyboardShortcutAction, TreeSpecAuditEventItem, TreeSpecIssue, TreeSpecSnapshotItem, TreeSpecWire, TreeTemplateSpec } from '@signalsafe/tree-spec-editor-core';
|
|
2
|
+
/**
|
|
3
|
+
* Optional metadata returned with {@link TreeSpecEditorAdapter.getVersion} for
|
|
4
|
+
* host info panels (e.g. scenario id, version label, timestamps).
|
|
5
|
+
*/
|
|
6
|
+
export interface GraphEditorVersionInfo {
|
|
7
|
+
scenarioId: string;
|
|
8
|
+
versionId: string;
|
|
9
|
+
/** Display name (e.g. scenario title or version label). */
|
|
10
|
+
name: string;
|
|
11
|
+
createdAt: string | null;
|
|
12
|
+
updatedAt: string | null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Data-only adapter consumed by {@link useTreeSpecEditor}. Hosts pass an
|
|
16
|
+
* object satisfying this contract; the hook never reads or writes routing,
|
|
17
|
+
* URLs, or page chrome through it. Host pages may extend this adapter with
|
|
18
|
+
* extra methods (e.g. `getPreviewUrl`, `getBackUrl`) for their own use —
|
|
19
|
+
* those methods are not part of the hook contract.
|
|
20
|
+
*
|
|
21
|
+
* Every method except `getVersion` and `updateVersion` is optional; the hook
|
|
22
|
+
* gates the corresponding feature (Validate / Publish / Snapshots / Audit /
|
|
23
|
+
* Clone) on its presence.
|
|
24
|
+
*/
|
|
25
|
+
export interface TreeSpecEditorAdapter {
|
|
26
|
+
/**
|
|
27
|
+
* Load the current TreeSpec wire and published flag for an entity.
|
|
28
|
+
* Returning `null` makes the hook surface a "failed to load" state.
|
|
29
|
+
*/
|
|
30
|
+
getVersion: (entityId: string) => Promise<{
|
|
31
|
+
tree_spec: Record<string, unknown>;
|
|
32
|
+
is_published: boolean;
|
|
33
|
+
info?: GraphEditorVersionInfo;
|
|
34
|
+
} | null>;
|
|
35
|
+
/** Persist the current TreeSpec wire (called by Save Draft + autosave). */
|
|
36
|
+
updateVersion: (entityId: string, payload: {
|
|
37
|
+
tree_spec: Record<string, unknown>;
|
|
38
|
+
}) => Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Optional server-side validation. When absent, Validate becomes a local
|
|
41
|
+
* (lint-only) operation and the toolbar should hide the Validate button.
|
|
42
|
+
*/
|
|
43
|
+
validate?: (entityId: string, tree_spec: Record<string, unknown>) => Promise<{
|
|
44
|
+
valid: boolean;
|
|
45
|
+
issues?: AdapterValidationIssue[];
|
|
46
|
+
}>;
|
|
47
|
+
/** Optional publish. When absent, Publish is unavailable. */
|
|
48
|
+
publish?: (entityId: string) => Promise<void>;
|
|
49
|
+
/** Optional snapshot listing. When absent, Draft history is unavailable. */
|
|
50
|
+
listSnapshots?: (entityId: string) => Promise<TreeSpecSnapshotItem[]>;
|
|
51
|
+
/** Optional snapshot restore. */
|
|
52
|
+
restoreSnapshot?: (entityId: string, snapshotId: string) => Promise<{
|
|
53
|
+
tree_spec: Record<string, unknown>;
|
|
54
|
+
}>;
|
|
55
|
+
/** Optional manual snapshot creation. */
|
|
56
|
+
createSnapshot?: (entityId: string, payload: {
|
|
57
|
+
label?: string;
|
|
58
|
+
tree_spec: Record<string, unknown>;
|
|
59
|
+
}) => Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Optional clone-to-draft. Returns the id of the newly created draft.
|
|
62
|
+
* When absent, Clone is unavailable.
|
|
63
|
+
*/
|
|
64
|
+
cloneToDraft?: (entityId: string) => Promise<{
|
|
65
|
+
id: string;
|
|
66
|
+
}>;
|
|
67
|
+
/** Optional audit listing. When absent, Audit is unavailable. */
|
|
68
|
+
listAudit?: (entityId: string) => Promise<TreeSpecAuditEventItem[]>;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Adapter-shaped validation issue. Hosts that surface server validation should
|
|
72
|
+
* normalize their backend payload to this shape inside the adapter — the hook
|
|
73
|
+
* then maps it into the standard {@link TreeSpecIssue}.
|
|
74
|
+
*/
|
|
75
|
+
export interface AdapterValidationIssue {
|
|
76
|
+
severity?: 'error' | 'warning' | 'info';
|
|
77
|
+
level?: string;
|
|
78
|
+
message?: string;
|
|
79
|
+
node_id?: string;
|
|
80
|
+
choice_id?: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Input options for {@link useTreeSpecEditor}. Only `adapter` and `entityId`
|
|
84
|
+
* are required; everything else is opt-in.
|
|
85
|
+
*/
|
|
86
|
+
export interface UseTreeSpecEditorOptions {
|
|
87
|
+
/** Data adapter for the editor (see {@link TreeSpecEditorAdapter}). */
|
|
88
|
+
adapter: TreeSpecEditorAdapter;
|
|
89
|
+
/**
|
|
90
|
+
* Stable identifier for the entity being edited (host extracts from its
|
|
91
|
+
* router, e.g. `useParams().scenarioVersionId`). `undefined` puts the
|
|
92
|
+
* hook in a loading-without-target state until a value is supplied.
|
|
93
|
+
*/
|
|
94
|
+
entityId: string | undefined;
|
|
95
|
+
/**
|
|
96
|
+
* Autosave debounce delay in milliseconds. Default `2500`.
|
|
97
|
+
* Ignored when {@link enableAutosave} is `false`.
|
|
98
|
+
*/
|
|
99
|
+
autosaveDebounceMs?: number;
|
|
100
|
+
/** Enable/disable autosave entirely. Default `true`. */
|
|
101
|
+
enableAutosave?: boolean;
|
|
102
|
+
/** Enable/disable the global keyboard shortcut listener. Default `true`. */
|
|
103
|
+
enableKeyboardShortcuts?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Coerce the raw `tree_spec` payload returned by the adapter into a
|
|
106
|
+
* `TreeSpecWire`. Defaults to {@link coerceTreeSpecWireForEditor} from
|
|
107
|
+
* `@signalsafe/tree-spec-editor-core`, which passes valid wires through
|
|
108
|
+
* and bootstraps a starter graph for `null`/`undefined`/empty payloads.
|
|
109
|
+
* Override to change the bootstrap behavior (e.g. ship a richer template,
|
|
110
|
+
* or refuse to bootstrap).
|
|
111
|
+
*/
|
|
112
|
+
coerceRawSpec?: (raw: unknown) => TreeSpecWire | null;
|
|
113
|
+
/**
|
|
114
|
+
* Optional computation of runtime issues from the compiled TreeSpec.
|
|
115
|
+
* Hosts that integrate with the simulator pass
|
|
116
|
+
* `treeSpecRuntimeIssues` from `@signalsafe/simulator-core`. Hosts that
|
|
117
|
+
* don't can omit this; runtime issues will be an empty array.
|
|
118
|
+
* The hook itself never imports the simulator package.
|
|
119
|
+
*/
|
|
120
|
+
computeRuntimeIssues?: (compiled: Record<string, unknown>) => TreeSpecIssue[];
|
|
121
|
+
/**
|
|
122
|
+
* When `true`, the runtime-issue category is folded into the developer
|
|
123
|
+
* tools surface rather than appearing in the dedup'd `issues` list. Host
|
|
124
|
+
* supplies this from its debug-mode toggle. Default `false`.
|
|
125
|
+
*/
|
|
126
|
+
debugMode?: boolean;
|
|
127
|
+
/**
|
|
128
|
+
* Invoked when the user triggers the Preview action (toolbar button or
|
|
129
|
+
* `Ctrl/Cmd+P` keyboard shortcut). Hosts wire their router here.
|
|
130
|
+
*/
|
|
131
|
+
onPreview?: () => void;
|
|
132
|
+
/**
|
|
133
|
+
* Invoked after a successful `cloneToDraft` with the new draft id. Hosts
|
|
134
|
+
* wire their router here.
|
|
135
|
+
*/
|
|
136
|
+
onCloneNavigate?: (newDraftId: string) => void;
|
|
137
|
+
/**
|
|
138
|
+
* Optional parser for backend validation errors that arrive as a single
|
|
139
|
+
* detail string (e.g. Pydantic outcome errors). Defaults to
|
|
140
|
+
* {@link parsePydanticOutcomeErrors} from
|
|
141
|
+
* `@signalsafe/tree-spec-editor-core`.
|
|
142
|
+
*/
|
|
143
|
+
parseServerErrorMessage?: (message: string) => TreeSpecIssue[] | null;
|
|
144
|
+
/**
|
|
145
|
+
* Decide whether the hook should auto-validate on initial load. Defaults
|
|
146
|
+
* to {@link shouldQueueInitialValidation} from
|
|
147
|
+
* `@signalsafe/tree-spec-editor-core` (validates drafts, skips published).
|
|
148
|
+
*/
|
|
149
|
+
shouldQueueInitialValidation?: (isPublished: boolean | undefined) => boolean;
|
|
150
|
+
}
|
|
151
|
+
/** Mutable editor state surfaced by {@link useTreeSpecEditor}. */
|
|
152
|
+
export interface UseTreeSpecEditorState {
|
|
153
|
+
/** Loading the entity from the adapter. `true` until first load resolves or rejects. */
|
|
154
|
+
loading: boolean;
|
|
155
|
+
/** A `Save Draft` round-trip is in flight. */
|
|
156
|
+
saving: boolean;
|
|
157
|
+
/** A `Publish` round-trip is in flight. */
|
|
158
|
+
publishing: boolean;
|
|
159
|
+
/** A `Snapshot` round-trip is in flight. */
|
|
160
|
+
creatingSnapshot: boolean;
|
|
161
|
+
/** A `Clone to Draft` round-trip is in flight. */
|
|
162
|
+
cloning: boolean;
|
|
163
|
+
/** Id of the snapshot currently being restored, or `null`. */
|
|
164
|
+
restoringSnapshotId: string | null;
|
|
165
|
+
/** Autosave state machine snapshot. */
|
|
166
|
+
autosaveStatus: AutosaveStatus;
|
|
167
|
+
/** ISO timestamp of the most recent validation, or `null`. */
|
|
168
|
+
lastValidatedAt: string | null;
|
|
169
|
+
/** The most recent raw wire from the adapter (or the last successful save). */
|
|
170
|
+
rawTreeSpec: TreeSpecWire | null;
|
|
171
|
+
/** Editor-shaped tree derived from `rawTreeSpec` plus user edits. */
|
|
172
|
+
tree: EditorTree | null;
|
|
173
|
+
/**
|
|
174
|
+
* Decompiled `rawTreeSpec` — used by the Publish modal to diff against the
|
|
175
|
+
* current `tree`. `null` until the initial load resolves.
|
|
176
|
+
*/
|
|
177
|
+
baselineTree: EditorTree | null;
|
|
178
|
+
/** Compiled `tree`, or `null` when `tree` is `null`. Memoized. */
|
|
179
|
+
compiledTreeSpec: Record<string, unknown> | null;
|
|
180
|
+
/** Whether the loaded entity is published (read-only). */
|
|
181
|
+
isPublished: boolean;
|
|
182
|
+
/**
|
|
183
|
+
* Optional version/scenario metadata from {@link TreeSpecEditorAdapter.getVersion}
|
|
184
|
+
* (e.g. for an info panel). `null` when absent or not yet loaded.
|
|
185
|
+
*/
|
|
186
|
+
versionInfo: GraphEditorVersionInfo | null;
|
|
187
|
+
/** Convenience: `Boolean(tree)`. */
|
|
188
|
+
hasTree: boolean;
|
|
189
|
+
/** Local lint issues (editor + wire) — recomputed on every tree change. */
|
|
190
|
+
localIssues: TreeSpecIssue[];
|
|
191
|
+
/** Server validation issues — populated by `actions.validate`. */
|
|
192
|
+
serverIssues: TreeSpecIssue[];
|
|
193
|
+
/**
|
|
194
|
+
* Runtime issues from {@link UseTreeSpecEditorOptions.computeRuntimeIssues}
|
|
195
|
+
* (empty when no callback is wired).
|
|
196
|
+
*/
|
|
197
|
+
runtimeIssues: TreeSpecIssue[];
|
|
198
|
+
/** Dedup'd union of `localIssues`, `serverIssues`, and `runtimeIssues`. */
|
|
199
|
+
issues: TreeSpecIssue[];
|
|
200
|
+
/** `true` when `issues` contains no error-severity entries. */
|
|
201
|
+
canPublish: boolean;
|
|
202
|
+
/** Current graph selection (node, edge, or none). */
|
|
203
|
+
selection: GraphSelection;
|
|
204
|
+
/** Node the canvas should focus into view (e.g. after add/duplicate). */
|
|
205
|
+
focusNodeId: string | null;
|
|
206
|
+
/** Choice within the focused node to scroll into view. */
|
|
207
|
+
focusChoiceId: string | null;
|
|
208
|
+
/** Bump-counter the canvas watches to trigger `fitView`. */
|
|
209
|
+
fitViewNonce: number;
|
|
210
|
+
/** Node object for `selection` when it's a NODE selection; `null` otherwise. */
|
|
211
|
+
selectedNode: EditorNode | null;
|
|
212
|
+
/** Transition object for `selection` when it's an EDGE selection; `null` otherwise. */
|
|
213
|
+
selectedEdge: EditorTransition | null;
|
|
214
|
+
/** Search filter for the Nodes panel. */
|
|
215
|
+
nodeSearch: string;
|
|
216
|
+
/** Search filter for the Issues panel. */
|
|
217
|
+
issueSearch: string;
|
|
218
|
+
/** Whether the canvas mini-map is visible. */
|
|
219
|
+
showMiniMap: boolean;
|
|
220
|
+
/** Snapshot list (loaded when `showDraftHistory` opens). */
|
|
221
|
+
snapshots: TreeSpecSnapshotItem[];
|
|
222
|
+
/** Audit event list (loaded when `showAudit` opens). */
|
|
223
|
+
auditEvents: TreeSpecAuditEventItem[];
|
|
224
|
+
/** Fetching the snapshot list. */
|
|
225
|
+
loadingSnapshots: boolean;
|
|
226
|
+
/** Fetching the audit event list. */
|
|
227
|
+
loadingAudit: boolean;
|
|
228
|
+
/** Draft-history modal visibility. */
|
|
229
|
+
showDraftHistory: boolean;
|
|
230
|
+
/** Audit modal visibility. */
|
|
231
|
+
showAudit: boolean;
|
|
232
|
+
/** Publish-review modal visibility. */
|
|
233
|
+
showPublishModal: boolean;
|
|
234
|
+
}
|
|
235
|
+
/** Actions surfaced by {@link useTreeSpecEditor}. */
|
|
236
|
+
export interface UseTreeSpecEditorActions {
|
|
237
|
+
/** Replace the entire tree (e.g. after the canvas emits a change). */
|
|
238
|
+
setTree: (next: EditorTree | null) => void;
|
|
239
|
+
/** Replace the current selection. */
|
|
240
|
+
setSelection: (next: GraphSelection) => void;
|
|
241
|
+
/** Replace the node-focus marker. */
|
|
242
|
+
setFocusNodeId: (id: string | null) => void;
|
|
243
|
+
/** Replace the choice-focus marker. */
|
|
244
|
+
setFocusChoiceId: (id: string | null) => void;
|
|
245
|
+
/** Bump the fitView nonce (e.g. for a "Reset view" button). */
|
|
246
|
+
triggerResetView: () => void;
|
|
247
|
+
/** Update the Nodes-panel search filter. */
|
|
248
|
+
setNodeSearch: (next: string) => void;
|
|
249
|
+
/** Update the Issues-panel search filter. */
|
|
250
|
+
setIssueSearch: (next: string) => void;
|
|
251
|
+
/** Toggle the canvas mini-map. */
|
|
252
|
+
setShowMiniMap: (next: boolean) => void;
|
|
253
|
+
/** Show/hide the draft-history modal. */
|
|
254
|
+
setShowDraftHistory: (next: boolean) => void;
|
|
255
|
+
/** Show/hide the audit modal. */
|
|
256
|
+
setShowAudit: (next: boolean) => void;
|
|
257
|
+
/** Show/hide the publish-review modal. */
|
|
258
|
+
setShowPublishModal: (next: boolean) => void;
|
|
259
|
+
/**
|
|
260
|
+
* Spawn a new node of the supplied type (host owns the type vocabulary).
|
|
261
|
+
* Returns the id of the spawned node, or `undefined` when no tree is loaded.
|
|
262
|
+
*/
|
|
263
|
+
addNodeOfType: (type: string, patch?: Partial<EditorNode>) => string | undefined;
|
|
264
|
+
/**
|
|
265
|
+
* Remove the currently selected node (same semantics as the Delete keyboard shortcut).
|
|
266
|
+
* Returns `true` when a node was removed. No-op (returns `false`) when there is no tree,
|
|
267
|
+
* nothing is selected, the selection is not a node, the version is published, or delete is
|
|
268
|
+
* not allowed for that node.
|
|
269
|
+
*/
|
|
270
|
+
deleteSelectedNode: () => boolean;
|
|
271
|
+
/**
|
|
272
|
+
* Remove a node by id (clears selection / focus when they pointed at that node).
|
|
273
|
+
* Returns `true` when the node was removed. No-op when there is no tree, the version is
|
|
274
|
+
* published, or `deleteNode` refuses (e.g. invalid id).
|
|
275
|
+
*/
|
|
276
|
+
deleteNodeById: (nodeId: string) => boolean;
|
|
277
|
+
/** Run the auto-layout helper and bump the fitView nonce. */
|
|
278
|
+
autoLayout: () => void;
|
|
279
|
+
/** Apply a starter template (host owns the template vocabulary). */
|
|
280
|
+
insertTemplate: (spec: TreeTemplateSpec) => void;
|
|
281
|
+
/**
|
|
282
|
+
* Run server-side validation if the adapter supports it; otherwise just
|
|
283
|
+
* stamp `lastValidatedAt`. Pass an explicit wire to validate something
|
|
284
|
+
* other than the current `tree`. Returns the adapter's payload, or
|
|
285
|
+
* `{ valid: false, issues }` when the call rejected.
|
|
286
|
+
*/
|
|
287
|
+
validate: (specOverride?: TreeSpecWire) => Promise<{
|
|
288
|
+
valid: boolean;
|
|
289
|
+
issues?: AdapterValidationIssue[];
|
|
290
|
+
} | undefined>;
|
|
291
|
+
/** Save the current draft via the adapter. No-op when published or loading. */
|
|
292
|
+
saveDraft: () => Promise<void>;
|
|
293
|
+
/**
|
|
294
|
+
* Validate then publish. Surfaces validation errors via `serverIssues`
|
|
295
|
+
* and refuses to publish when `canPublish` is `false`.
|
|
296
|
+
*/
|
|
297
|
+
publish: () => Promise<void>;
|
|
298
|
+
/** Create a snapshot of the current tree via the adapter. */
|
|
299
|
+
createSnapshot: () => Promise<void>;
|
|
300
|
+
/** Restore the tree to a snapshot via the adapter; clears local edits. */
|
|
301
|
+
restoreSnapshot: (snapshotId: string) => Promise<void>;
|
|
302
|
+
/**
|
|
303
|
+
* Clone the current entity to a new draft via the adapter and invoke
|
|
304
|
+
* {@link UseTreeSpecEditorOptions.onCloneNavigate} when the new id arrives.
|
|
305
|
+
*/
|
|
306
|
+
cloneToDraft: () => Promise<void>;
|
|
307
|
+
/** Patch the currently-selected node. No-op when no node is selected. */
|
|
308
|
+
updateSelectedNode: (patch: Partial<EditorNode>) => void;
|
|
309
|
+
/** Append a new (empty) choice to the currently-selected node. */
|
|
310
|
+
addChoice: () => void;
|
|
311
|
+
/** Remove a choice from the selected node (and its outgoing transitions). */
|
|
312
|
+
deleteChoice: (choiceId: string) => void;
|
|
313
|
+
/** Re-target a choice's transition (creates the transition if missing). */
|
|
314
|
+
setChoiceTarget: (choiceId: string, targetNodeId: string) => void;
|
|
315
|
+
/** Update the terminal outcome of a choice targeting the END node. */
|
|
316
|
+
setChoiceOutcome: (choiceId: string, outcome: string) => void;
|
|
317
|
+
/**
|
|
318
|
+
* Click an issue from the Issues panel: focuses the issue's node + choice,
|
|
319
|
+
* selects the node, and bumps the fitView nonce.
|
|
320
|
+
*/
|
|
321
|
+
selectIssue: (issue: {
|
|
322
|
+
node_id?: string;
|
|
323
|
+
choice_id?: string;
|
|
324
|
+
}) => void;
|
|
325
|
+
}
|
|
326
|
+
/** Return value of {@link useTreeSpecEditor}. */
|
|
327
|
+
export interface UseTreeSpecEditorResult extends UseTreeSpecEditorState {
|
|
328
|
+
actions: UseTreeSpecEditorActions;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Map of keyboard shortcut actions the hook handled most recently. Test-only
|
|
332
|
+
* surface; not part of the public API.
|
|
333
|
+
* @internal
|
|
334
|
+
*/
|
|
335
|
+
export type DispatchedKeyboardAction = KeyboardShortcutAction | null;
|
|
336
|
+
export type { EditorChoice, EditorNode, EditorTransition, EditorTree };
|
|
337
|
+
//# sourceMappingURL=types.d.ts.map
|