@principal-ai/subsystems-react 0.37.5 → 0.37.7
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/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +3 -0
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +1 -0
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js +2 -0
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +1 -0
- package/dist/subsystem/C4Graph.d.ts +22 -0
- package/dist/subsystem/C4Graph.d.ts.map +1 -0
- package/dist/subsystem/C4Graph.js +305 -0
- package/dist/subsystem/C4Graph.js.map +1 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +7 -3
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/WalkthroughsPanel.d.ts +2 -0
- package/dist/subsystem/WalkthroughsPanel.d.ts.map +1 -1
- package/dist/subsystem/WalkthroughsPanel.js +114 -48
- package/dist/subsystem/WalkthroughsPanel.js.map +1 -1
- package/dist/subsystem/model.d.ts +2 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +6 -6
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +2 -1
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/toC4.d.ts +95 -0
- package/dist/subsystem/toC4.d.ts.map +1 -0
- package/dist/subsystem/toC4.js +196 -0
- package/dist/subsystem/toC4.js.map +1 -0
- package/dist/subsystem/walkthroughBrief.d.ts +18 -0
- package/dist/subsystem/walkthroughBrief.d.ts.map +1 -0
- package/dist/subsystem/walkthroughBrief.js +34 -0
- package/dist/subsystem/walkthroughBrief.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +6 -1
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +104 -0
- package/src/stories/Subsystem/C4Graph/c4Fixture.ts +6 -0
- package/src/subsystem/C4Graph.tsx +465 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +15 -3
- package/src/subsystem/WalkthroughsPanel.test.tsx +146 -0
- package/src/subsystem/WalkthroughsPanel.tsx +163 -60
- package/src/subsystem/model.ts +8 -6
- package/src/subsystem/nodes.tsx +2 -1
- package/src/subsystem/toC4.test.ts +121 -0
- package/src/subsystem/toC4.ts +277 -0
- package/src/subsystem/walkthroughBrief.test.ts +57 -0
- package/src/subsystem/walkthroughBrief.ts +40 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C4Graph — a C4 projection of a (composed) subsystem model on React Flow.
|
|
3
|
+
*
|
|
4
|
+
* Prototype test case: feed it a `C4Model` from `toC4()` and it draws the
|
|
5
|
+
* system as a compound frame, its containers (or components) as boxes, and
|
|
6
|
+
* externals/actors outside the system. Static `relations` render as solid
|
|
7
|
+
* edges; `walkthrough` hops render as dashed flow edges. Click a box to list
|
|
8
|
+
* the source components it rolled up.
|
|
9
|
+
*
|
|
10
|
+
* Same interaction language as the other graphs: React Flow + ELK + the shared
|
|
11
|
+
* `GRAPH_NAV_PROPS` chrome. Deliberately small — no measurement passes, no
|
|
12
|
+
* drawers, no walkthrough playback.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
16
|
+
import {
|
|
17
|
+
Edge,
|
|
18
|
+
Handle,
|
|
19
|
+
MarkerType,
|
|
20
|
+
Node,
|
|
21
|
+
NodeProps,
|
|
22
|
+
Position,
|
|
23
|
+
ReactFlow,
|
|
24
|
+
ReactFlowProvider,
|
|
25
|
+
useReactFlow,
|
|
26
|
+
} from '@xyflow/react';
|
|
27
|
+
import { useTheme } from '@principal-ade/industry-theme';
|
|
28
|
+
import { computeElkLayout } from '../utils/elkLayout';
|
|
29
|
+
import { describeConstructBreakdown, MECHANISM_COLOR } from './model';
|
|
30
|
+
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
31
|
+
import type { C4Model, C4Node } from './toC4';
|
|
32
|
+
|
|
33
|
+
export interface C4GraphProps {
|
|
34
|
+
model: C4Model;
|
|
35
|
+
title?: string;
|
|
36
|
+
onSelectNode?: (id: string | null) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const NODE_W = 230;
|
|
40
|
+
const NODE_H = 80;
|
|
41
|
+
|
|
42
|
+
function baseName(path: string): string {
|
|
43
|
+
const parts = path.split('/').filter(Boolean);
|
|
44
|
+
return parts[parts.length - 1] ?? path;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function edgeColor(mechanism: string | undefined, fallback: string): string {
|
|
48
|
+
if (!mechanism) return fallback;
|
|
49
|
+
return (MECHANISM_COLOR as Record<string, string>)[mechanism] ?? fallback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>) {
|
|
53
|
+
const { theme } = useTheme();
|
|
54
|
+
const { node, selected } = props.data;
|
|
55
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
56
|
+
|
|
57
|
+
const kindColor =
|
|
58
|
+
node.kind === 'external'
|
|
59
|
+
? (theme.colors.warning ?? theme.colors.accent ?? theme.colors.info)
|
|
60
|
+
: node.kind === 'actor'
|
|
61
|
+
? (theme.colors.accent ?? theme.colors.info)
|
|
62
|
+
: (theme.colors.border ?? '#333');
|
|
63
|
+
|
|
64
|
+
const tag = node.kind === 'container' ? 'container' : node.kind === 'external' ? 'external system' : node.kind === 'actor' ? 'actor' : 'component';
|
|
65
|
+
|
|
66
|
+
const subtitle =
|
|
67
|
+
node.kind === 'component'
|
|
68
|
+
? `${node.component?.construct ?? 'code'}${node.component?.file ? ` · ${baseName(node.component.file)}` : ''}`
|
|
69
|
+
: `${describeConstructBreakdown(node.constructs)}${
|
|
70
|
+
node.members.length > 0 ? ` · ${node.members.length} component${node.members.length === 1 ? '' : 's'}` : ''
|
|
71
|
+
}`;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
style={{
|
|
76
|
+
width: NODE_W,
|
|
77
|
+
height: NODE_H,
|
|
78
|
+
boxSizing: 'border-box',
|
|
79
|
+
display: 'flex',
|
|
80
|
+
flexDirection: 'column',
|
|
81
|
+
justifyContent: 'center',
|
|
82
|
+
gap: 2,
|
|
83
|
+
padding: '8px 12px',
|
|
84
|
+
borderRadius: node.kind === 'actor' ? 20 : 8,
|
|
85
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
86
|
+
border: `2px ${node.kind === 'component' ? 'solid' : node.kind === 'container' ? 'solid' : 'dashed'} ${
|
|
87
|
+
selected ? theme.colors.primary : kindColor
|
|
88
|
+
}`,
|
|
89
|
+
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
|
|
90
|
+
cursor: 'pointer',
|
|
91
|
+
fontFamily: theme.fonts.body,
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<span
|
|
95
|
+
style={{
|
|
96
|
+
fontFamily: theme.fonts.monospace,
|
|
97
|
+
fontSize: theme.fontSizes[0],
|
|
98
|
+
letterSpacing: 0.5,
|
|
99
|
+
textTransform: 'uppercase',
|
|
100
|
+
color: kindColor,
|
|
101
|
+
whiteSpace: 'nowrap',
|
|
102
|
+
overflow: 'hidden',
|
|
103
|
+
textOverflow: 'ellipsis',
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{tag}
|
|
107
|
+
</span>
|
|
108
|
+
<span
|
|
109
|
+
style={{
|
|
110
|
+
fontWeight: 600,
|
|
111
|
+
fontSize: theme.fontSizes[2],
|
|
112
|
+
color: theme.colors.text,
|
|
113
|
+
whiteSpace: 'nowrap',
|
|
114
|
+
overflow: 'hidden',
|
|
115
|
+
textOverflow: 'ellipsis',
|
|
116
|
+
}}
|
|
117
|
+
title={node.label}
|
|
118
|
+
>
|
|
119
|
+
{node.label}
|
|
120
|
+
</span>
|
|
121
|
+
<span
|
|
122
|
+
style={{
|
|
123
|
+
fontFamily: theme.fonts.monospace,
|
|
124
|
+
fontSize: theme.fontSizes[0],
|
|
125
|
+
color: muted,
|
|
126
|
+
whiteSpace: 'nowrap',
|
|
127
|
+
overflow: 'hidden',
|
|
128
|
+
textOverflow: 'ellipsis',
|
|
129
|
+
}}
|
|
130
|
+
title={node.members.join(', ')}
|
|
131
|
+
>
|
|
132
|
+
{subtitle}
|
|
133
|
+
</span>
|
|
134
|
+
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
|
|
135
|
+
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'container'; color: string }>>) {
|
|
141
|
+
const { theme } = useTheme();
|
|
142
|
+
const color = props.data.color;
|
|
143
|
+
return (
|
|
144
|
+
<div
|
|
145
|
+
style={{
|
|
146
|
+
width: '100%',
|
|
147
|
+
height: '100%',
|
|
148
|
+
boxSizing: 'border-box',
|
|
149
|
+
borderRadius: 12,
|
|
150
|
+
border: `2px dashed ${color}`,
|
|
151
|
+
background: 'transparent',
|
|
152
|
+
pointerEvents: 'none',
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
<span
|
|
156
|
+
style={{
|
|
157
|
+
position: 'absolute',
|
|
158
|
+
top: 0,
|
|
159
|
+
transform: 'translateY(-50%)',
|
|
160
|
+
left: 12,
|
|
161
|
+
zIndex: 1,
|
|
162
|
+
fontFamily: theme.fonts.monospace,
|
|
163
|
+
fontSize: theme.fontSizes[2],
|
|
164
|
+
fontWeight: 600,
|
|
165
|
+
letterSpacing: 0.5,
|
|
166
|
+
textTransform: 'uppercase',
|
|
167
|
+
lineHeight: '18px',
|
|
168
|
+
color,
|
|
169
|
+
background: theme.colors.background,
|
|
170
|
+
boxShadow: `0 0 0 1.5px ${theme.colors.background}`,
|
|
171
|
+
border: `2px solid ${color}`,
|
|
172
|
+
borderRadius: 4,
|
|
173
|
+
padding: '4px 10px',
|
|
174
|
+
boxSizing: 'border-box',
|
|
175
|
+
whiteSpace: 'nowrap',
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
{props.data.label}
|
|
179
|
+
</span>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const nodeTypes = {
|
|
185
|
+
'c4-node': C4NodeView,
|
|
186
|
+
'c4-group': C4GroupView,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
190
|
+
const { theme } = useTheme();
|
|
191
|
+
const { fitView } = useReactFlow();
|
|
192
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
193
|
+
const [nodes, setNodes] = useState<Node[]>([]);
|
|
194
|
+
const [rfEdges, setRfEdges] = useState<Edge[]>([]);
|
|
195
|
+
const [ready, setReady] = useState(false);
|
|
196
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
197
|
+
|
|
198
|
+
const layoutKey = useMemo(
|
|
199
|
+
() =>
|
|
200
|
+
[
|
|
201
|
+
...model.nodes.map((n) => `${n.id}\0${n.parentId ?? ''}\0${n.label}`).sort(),
|
|
202
|
+
...model.groups.map((g) => `${g.id}\0${g.parentId ?? ''}\0${g.memberIds.length}`).sort(),
|
|
203
|
+
...model.edges.map((e) => `${e.id}\0${e.count}`).sort(),
|
|
204
|
+
].join('\n'),
|
|
205
|
+
[model],
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
let alive = true;
|
|
210
|
+
setReady(false);
|
|
211
|
+
setSelectedId(null);
|
|
212
|
+
|
|
213
|
+
const rfNodes: Node[] = model.nodes.map((n, i) => ({
|
|
214
|
+
id: n.id,
|
|
215
|
+
type: 'c4-node',
|
|
216
|
+
position: { x: (i % 4) * (NODE_W + 60), y: Math.floor(i / 4) * (NODE_H + 40) },
|
|
217
|
+
width: NODE_W,
|
|
218
|
+
height: NODE_H,
|
|
219
|
+
...(n.parentId ? { parentId: n.parentId } : {}),
|
|
220
|
+
data: { node: n, selected: false },
|
|
221
|
+
}));
|
|
222
|
+
|
|
223
|
+
const groups = model.groups.map((g) => ({
|
|
224
|
+
id: g.id,
|
|
225
|
+
memberIds: g.memberIds,
|
|
226
|
+
...(g.parentId ? { parentId: g.parentId } : {}),
|
|
227
|
+
}));
|
|
228
|
+
|
|
229
|
+
const rfEdges: Edge[] = model.edges.map((e) => {
|
|
230
|
+
const flow = e.kind === 'flow';
|
|
231
|
+
const color = flow ? edgeColor(e.mechanisms[0], muted) : (theme.colors.border ?? '#666');
|
|
232
|
+
return {
|
|
233
|
+
id: e.id,
|
|
234
|
+
source: e.source,
|
|
235
|
+
target: e.target,
|
|
236
|
+
label: e.label,
|
|
237
|
+
labelShowBg: false,
|
|
238
|
+
labelStyle: { fill: muted, fontSize: theme.fontSizes[0], fontFamily: theme.fonts.monospace },
|
|
239
|
+
style: {
|
|
240
|
+
stroke: color,
|
|
241
|
+
strokeWidth: 1 + Math.min(e.count, 6) * 0.4,
|
|
242
|
+
...(flow ? { strokeDasharray: '6 4' } : {}),
|
|
243
|
+
},
|
|
244
|
+
markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
|
|
245
|
+
data: { edge: e },
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
void computeElkLayout(rfNodes, rfEdges, {
|
|
250
|
+
direction: 'RIGHT',
|
|
251
|
+
preserveNodePositions: false,
|
|
252
|
+
groups,
|
|
253
|
+
keepSingletonGroups: true,
|
|
254
|
+
})
|
|
255
|
+
.then((result) => {
|
|
256
|
+
if (!alive) return;
|
|
257
|
+
const positioned = new Map(result.nodes.map((n) => [n.id, n]));
|
|
258
|
+
const builtGroups = new Set(result.groupBounds.keys());
|
|
259
|
+
const groupById = new Map(model.groups.map((g) => [g.id, g]));
|
|
260
|
+
|
|
261
|
+
const depthOf = (id: string): number => {
|
|
262
|
+
let d = 0;
|
|
263
|
+
let g = groupById.get(id);
|
|
264
|
+
while (g?.parentId) {
|
|
265
|
+
d += 1;
|
|
266
|
+
g = groupById.get(g.parentId);
|
|
267
|
+
}
|
|
268
|
+
return d;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const shells: Node[] = [...result.groupBounds.entries()]
|
|
272
|
+
.sort((a, b) => depthOf(a[0]) - depthOf(b[0]))
|
|
273
|
+
.map(([id, b]) => {
|
|
274
|
+
const def = groupById.get(id);
|
|
275
|
+
const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
|
|
276
|
+
return {
|
|
277
|
+
id,
|
|
278
|
+
type: 'c4-group',
|
|
279
|
+
position: { x: b.x, y: b.y },
|
|
280
|
+
width: Math.max(200, b.width),
|
|
281
|
+
height: Math.max(120, b.height),
|
|
282
|
+
draggable: false,
|
|
283
|
+
selectable: false,
|
|
284
|
+
...(parentId ? { parentId } : {}),
|
|
285
|
+
data: {
|
|
286
|
+
label: def?.label ?? id,
|
|
287
|
+
kind: def?.kind ?? 'container',
|
|
288
|
+
color: def?.kind === 'system' ? (theme.colors.text ?? '#888') : (theme.colors.accent ?? theme.colors.info),
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const leaves = rfNodes.map((n) => {
|
|
294
|
+
const p = positioned.get(n.id);
|
|
295
|
+
const node = p ? { ...n, position: { x: p.position.x, y: p.position.y } } : n;
|
|
296
|
+
const parentId = (node as { parentId?: string }).parentId;
|
|
297
|
+
if (parentId && !builtGroups.has(parentId)) {
|
|
298
|
+
const { parentId: _drop, ...rest } = node as { parentId?: string } & Node;
|
|
299
|
+
return rest as Node;
|
|
300
|
+
}
|
|
301
|
+
return node;
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
setNodes([...shells, ...leaves]);
|
|
305
|
+
setRfEdges(rfEdges);
|
|
306
|
+
setReady(true);
|
|
307
|
+
requestAnimationFrame(() => fitView({ padding: 0.15 }));
|
|
308
|
+
})
|
|
309
|
+
.catch((err) => {
|
|
310
|
+
if (!alive) return;
|
|
311
|
+
console.warn('[c4-graph] ELK layout failed, using grid:', err);
|
|
312
|
+
setNodes(rfNodes);
|
|
313
|
+
setRfEdges(rfEdges);
|
|
314
|
+
setReady(true);
|
|
315
|
+
requestAnimationFrame(() => fitView({ padding: 0.15 }));
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
return () => {
|
|
319
|
+
alive = false;
|
|
320
|
+
};
|
|
321
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
322
|
+
}, [layoutKey]);
|
|
323
|
+
|
|
324
|
+
const selected = useMemo(() => model.nodes.find((n) => n.id === selectedId) ?? null, [model, selectedId]);
|
|
325
|
+
|
|
326
|
+
const displayNodes = useMemo(
|
|
327
|
+
() =>
|
|
328
|
+
nodes.map((n) =>
|
|
329
|
+
n.type === 'c4-node'
|
|
330
|
+
? { ...n, data: { ...n.data, selected: n.id === selectedId } }
|
|
331
|
+
: n,
|
|
332
|
+
),
|
|
333
|
+
[nodes, selectedId],
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const runtime = model.nodes.filter((n) => n.kind === 'container' || n.kind === 'component').length;
|
|
337
|
+
const outside = model.nodes.filter((n) => n.kind === 'external' || n.kind === 'actor').length;
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<div style={{ position: 'relative', width: '100%', height: '100%', background: theme.colors.background }}>
|
|
341
|
+
<GraphLayerStyle />
|
|
342
|
+
<ReactFlow
|
|
343
|
+
nodes={displayNodes}
|
|
344
|
+
edges={rfEdges}
|
|
345
|
+
nodeTypes={nodeTypes}
|
|
346
|
+
className={GRAPH_CANVAS_CLASS}
|
|
347
|
+
{...GRAPH_NAV_PROPS}
|
|
348
|
+
onNodeClick={(_e, node) => {
|
|
349
|
+
if (node.type !== 'c4-node') return;
|
|
350
|
+
const next = node.id === selectedId ? null : node.id;
|
|
351
|
+
setSelectedId(next);
|
|
352
|
+
onSelectNode?.(next);
|
|
353
|
+
}}
|
|
354
|
+
onPaneClick={() => {
|
|
355
|
+
setSelectedId(null);
|
|
356
|
+
onSelectNode?.(null);
|
|
357
|
+
}}
|
|
358
|
+
proOptions={{ hideAttribution: true }}
|
|
359
|
+
>
|
|
360
|
+
<GraphChrome />
|
|
361
|
+
</ReactFlow>
|
|
362
|
+
{!ready && (
|
|
363
|
+
<div
|
|
364
|
+
style={{
|
|
365
|
+
position: 'absolute',
|
|
366
|
+
inset: 0,
|
|
367
|
+
display: 'flex',
|
|
368
|
+
alignItems: 'center',
|
|
369
|
+
justifyContent: 'center',
|
|
370
|
+
background: theme.colors.background,
|
|
371
|
+
color: muted,
|
|
372
|
+
fontFamily: theme.fonts.monospace,
|
|
373
|
+
fontSize: theme.fontSizes[0],
|
|
374
|
+
}}
|
|
375
|
+
>
|
|
376
|
+
Laying out C4 view…
|
|
377
|
+
</div>
|
|
378
|
+
)}
|
|
379
|
+
{title && (
|
|
380
|
+
<div
|
|
381
|
+
style={{
|
|
382
|
+
position: 'absolute',
|
|
383
|
+
top: 12,
|
|
384
|
+
left: 12,
|
|
385
|
+
fontSize: theme.fontSizes[2],
|
|
386
|
+
fontWeight: 600,
|
|
387
|
+
color: theme.colors.text,
|
|
388
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
389
|
+
border: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
390
|
+
borderRadius: 6,
|
|
391
|
+
padding: '4px 10px',
|
|
392
|
+
}}
|
|
393
|
+
>
|
|
394
|
+
{title}
|
|
395
|
+
<span style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted, marginLeft: 8 }}>
|
|
396
|
+
{model.view} view · {runtime} {model.view === 'container' ? 'containers' : 'components'} · {outside} outside
|
|
397
|
+
</span>
|
|
398
|
+
</div>
|
|
399
|
+
)}
|
|
400
|
+
{selected && (
|
|
401
|
+
<div
|
|
402
|
+
style={{
|
|
403
|
+
position: 'absolute',
|
|
404
|
+
top: 12,
|
|
405
|
+
right: 12,
|
|
406
|
+
width: 280,
|
|
407
|
+
maxHeight: 'calc(100% - 24px)',
|
|
408
|
+
overflowY: 'auto',
|
|
409
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
410
|
+
border: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
411
|
+
borderRadius: 8,
|
|
412
|
+
padding: '10px 12px',
|
|
413
|
+
}}
|
|
414
|
+
>
|
|
415
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
416
|
+
<span style={{ fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
417
|
+
{selected.label}
|
|
418
|
+
</span>
|
|
419
|
+
<button
|
|
420
|
+
type="button"
|
|
421
|
+
onClick={() => {
|
|
422
|
+
setSelectedId(null);
|
|
423
|
+
onSelectNode?.(null);
|
|
424
|
+
}}
|
|
425
|
+
aria-label="Clear selection"
|
|
426
|
+
style={{ border: 'none', background: 'transparent', color: muted, cursor: 'pointer', fontSize: theme.fontSizes[2], lineHeight: 1 }}
|
|
427
|
+
>
|
|
428
|
+
×
|
|
429
|
+
</button>
|
|
430
|
+
</div>
|
|
431
|
+
<div style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted, margin: '4px 0 8px' }}>
|
|
432
|
+
{selected.kind} · {selected.members.length} component{selected.members.length === 1 ? '' : 's'}
|
|
433
|
+
{selected.models && selected.models.length > 0 ? ` · ${selected.models.length} model${selected.models.length === 1 ? '' : 's'}` : ''}
|
|
434
|
+
</div>
|
|
435
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
436
|
+
{selected.members.map((alias) => (
|
|
437
|
+
<span
|
|
438
|
+
key={alias}
|
|
439
|
+
title={alias}
|
|
440
|
+
style={{
|
|
441
|
+
fontFamily: theme.fonts.monospace,
|
|
442
|
+
fontSize: theme.fontSizes[0],
|
|
443
|
+
color: theme.colors.text,
|
|
444
|
+
overflow: 'hidden',
|
|
445
|
+
textOverflow: 'ellipsis',
|
|
446
|
+
whiteSpace: 'nowrap',
|
|
447
|
+
}}
|
|
448
|
+
>
|
|
449
|
+
{alias}
|
|
450
|
+
</span>
|
|
451
|
+
))}
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
)}
|
|
455
|
+
</div>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function C4Graph(props: C4GraphProps) {
|
|
460
|
+
return (
|
|
461
|
+
<ReactFlowProvider>
|
|
462
|
+
<Inner {...props} />
|
|
463
|
+
</ReactFlowProvider>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
@@ -345,6 +345,14 @@ export interface SubsystemComponentGraphProps {
|
|
|
345
345
|
* where the user left off. Omit to keep the state purely in-memory.
|
|
346
346
|
*/
|
|
347
347
|
persistKey?: string;
|
|
348
|
+
/**
|
|
349
|
+
* Explicit frame colors for boundary regions, keyed by region key (the
|
|
350
|
+
* component's `process` / `module` value, or the package purl key). A key
|
|
351
|
+
* present here overrides the library's derived `packageColor`. Hosts use this
|
|
352
|
+
* to pin the colors a given surface cares about (e.g. a marketing hero)
|
|
353
|
+
* instead of accepting the hash. Unmapped regions keep the derived color.
|
|
354
|
+
*/
|
|
355
|
+
boundaryColors?: Record<string, string>;
|
|
348
356
|
}
|
|
349
357
|
|
|
350
358
|
const nodeTypes: NodeTypes = {
|
|
@@ -449,7 +457,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
|
|
|
449
457
|
measured: { w: number; h: number } | null;
|
|
450
458
|
}
|
|
451
459
|
|
|
452
|
-
function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, onInspectSymbol, persistKey }: InnerProps) {
|
|
460
|
+
function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, persistKey }: InnerProps) {
|
|
453
461
|
const { theme } = useTheme();
|
|
454
462
|
const { fitView } = useReactFlow();
|
|
455
463
|
const viewport = useViewport();
|
|
@@ -984,7 +992,8 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
984
992
|
// Boundary frames follow their members: hidden when no member is
|
|
985
993
|
// visible, dimmed when members are dimmed. Never selectable.
|
|
986
994
|
if (n.type === 'subsystem-group') {
|
|
987
|
-
const
|
|
995
|
+
const region = (n.data as { region?: { key?: string; memberAliases?: string[] } } | undefined)?.region;
|
|
996
|
+
const memberAliases = region?.memberAliases ?? [];
|
|
988
997
|
const vis = flowElementVisibility({
|
|
989
998
|
inOpened: memberAliases.some((alias) => openedNodeIds?.has(alias) === true),
|
|
990
999
|
inSelected: memberAliases.some((alias) => brightNodeIds?.has(alias) === true),
|
|
@@ -995,12 +1004,15 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
995
1004
|
? vis.hidden || !memberAliases.some((alias) => previewNodeIds.has(alias))
|
|
996
1005
|
: vis.dimmed;
|
|
997
1006
|
const hidden = vis.hidden;
|
|
1007
|
+
// Host override wins over the library's derived frame color.
|
|
1008
|
+
const color = region?.key != null ? boundaryColors?.[region.key] : undefined;
|
|
998
1009
|
return {
|
|
999
1010
|
...n,
|
|
1000
1011
|
hidden,
|
|
1001
1012
|
selectable: false,
|
|
1002
1013
|
data: {
|
|
1003
1014
|
...(n.data as object),
|
|
1015
|
+
...(color != null && { color }),
|
|
1004
1016
|
...(dimmed && { dimmed: true }),
|
|
1005
1017
|
},
|
|
1006
1018
|
};
|
|
@@ -1031,7 +1043,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1031
1043
|
},
|
|
1032
1044
|
};
|
|
1033
1045
|
});
|
|
1034
|
-
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, previewNodeIds]);
|
|
1046
|
+
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, previewNodeIds, boundaryColors]);
|
|
1035
1047
|
|
|
1036
1048
|
const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
|
|
1037
1049
|
const baseEdgesKey = useMemo(
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { Window } from 'happy-dom';
|
|
3
|
+
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react/pure';
|
|
4
|
+
import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
|
|
5
|
+
import {
|
|
6
|
+
STEP_COPY_FEEDBACK_MS,
|
|
7
|
+
WalkthroughsPanel,
|
|
8
|
+
type WalkthroughsPanelProps,
|
|
9
|
+
} from './WalkthroughsPanel';
|
|
10
|
+
import type { SubsystemWalkthrough } from './model';
|
|
11
|
+
import { buildStepBrief } from './walkthroughBrief';
|
|
12
|
+
|
|
13
|
+
// Bun's happy-dom test environment needs `@happy-dom/global-registrator`, which
|
|
14
|
+
// isn't installed here, so wire the DOM primitives React DOM + Testing Library
|
|
15
|
+
// need before any test renders.
|
|
16
|
+
const dom = new Window();
|
|
17
|
+
const globals = globalThis as unknown as Record<string, unknown>;
|
|
18
|
+
globals.window = dom;
|
|
19
|
+
globals.document = dom.document;
|
|
20
|
+
globals.navigator = dom.navigator;
|
|
21
|
+
globals.HTMLElement = dom.HTMLElement;
|
|
22
|
+
globals.Node = dom.Node;
|
|
23
|
+
globals.Element = dom.Element;
|
|
24
|
+
globals.Event = dom.Event;
|
|
25
|
+
globals.MouseEvent = dom.MouseEvent;
|
|
26
|
+
globals.FocusEvent = dom.FocusEvent;
|
|
27
|
+
globals.KeyboardEvent = dom.KeyboardEvent;
|
|
28
|
+
globals.getComputedStyle = dom.getComputedStyle.bind(dom);
|
|
29
|
+
globals.requestAnimationFrame = dom.requestAnimationFrame.bind(dom);
|
|
30
|
+
globals.cancelAnimationFrame = dom.cancelAnimationFrame.bind(dom);
|
|
31
|
+
globals.localStorage = dom.localStorage;
|
|
32
|
+
globals.sessionStorage = dom.sessionStorage;
|
|
33
|
+
globals.matchMedia = dom.matchMedia.bind(dom);
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
const walkthroughs: SubsystemWalkthrough[] = [
|
|
37
|
+
{
|
|
38
|
+
id: 'auth-flow',
|
|
39
|
+
title: 'Auth flow',
|
|
40
|
+
steps: [
|
|
41
|
+
{
|
|
42
|
+
from: 'ui',
|
|
43
|
+
to: 'api',
|
|
44
|
+
mechanism: 'calls',
|
|
45
|
+
file: 'src/ui/login.tsx',
|
|
46
|
+
line: 42,
|
|
47
|
+
purl: 'pkg:github/acme/app',
|
|
48
|
+
symbol: 'Login.submit',
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const writeText = mock(() => Promise.resolve());
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
writeText.mockClear();
|
|
58
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
59
|
+
value: { writeText },
|
|
60
|
+
configurable: true,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(cleanup);
|
|
65
|
+
|
|
66
|
+
function renderPanel(overrides: Partial<WalkthroughsPanelProps> = {}) {
|
|
67
|
+
const props: WalkthroughsPanelProps = {
|
|
68
|
+
walkthroughs,
|
|
69
|
+
expandedWalkthroughs: new Set(['auth-flow']),
|
|
70
|
+
focusedWalkthroughId: null,
|
|
71
|
+
focusedStepIndex: null,
|
|
72
|
+
hoveredWalkthroughStep: null,
|
|
73
|
+
onToggleCollapsed: () => {},
|
|
74
|
+
onFocusFlow: () => {},
|
|
75
|
+
onClearFocus: () => {},
|
|
76
|
+
onFocusStep: () => {},
|
|
77
|
+
onHoverStep: () => {},
|
|
78
|
+
onHoverFlow: () => {},
|
|
79
|
+
onLeaveStep: () => {},
|
|
80
|
+
...overrides,
|
|
81
|
+
};
|
|
82
|
+
return render(
|
|
83
|
+
<ThemeProvider theme={defaultEditorTheme}>
|
|
84
|
+
<WalkthroughsPanel {...props} />
|
|
85
|
+
</ThemeProvider>,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const COPY_LABEL = 'Copy step 1 of Auth flow for an agent';
|
|
90
|
+
|
|
91
|
+
describe('WalkthroughsPanel step copy', () => {
|
|
92
|
+
test('copy icon is hidden until the step row is hovered', () => {
|
|
93
|
+
const { getByText, queryByLabelText } = renderPanel();
|
|
94
|
+
expect(queryByLabelText(COPY_LABEL)).toBeNull();
|
|
95
|
+
const row = getByText('Login.submit').closest('button')!;
|
|
96
|
+
fireEvent.mouseEnter(row);
|
|
97
|
+
expect(queryByLabelText(COPY_LABEL)).not.toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('copy icon is revealed on keyboard focus', () => {
|
|
101
|
+
const { getByText, queryByLabelText } = renderPanel();
|
|
102
|
+
const row = getByText('Login.submit').closest('button')!;
|
|
103
|
+
fireEvent.focus(row);
|
|
104
|
+
expect(queryByLabelText(COPY_LABEL)).not.toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('clicking copies the step brief and flashes a copied state', async () => {
|
|
108
|
+
const { getByText, getByLabelText } = renderPanel();
|
|
109
|
+
fireEvent.mouseEnter(getByText('Login.submit').closest('button')!);
|
|
110
|
+
const copy = getByLabelText(COPY_LABEL);
|
|
111
|
+
fireEvent.mouseDown(copy);
|
|
112
|
+
fireEvent.click(copy);
|
|
113
|
+
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
|
|
114
|
+
expect(writeText).toHaveBeenCalledWith(buildStepBrief(walkthroughs[0]!, 0));
|
|
115
|
+
expect(copy.getAttribute('title')).toBe('Copied');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('hovering the row still focuses the step on click', () => {
|
|
119
|
+
const onFocusStep = mock(() => {});
|
|
120
|
+
const { getByText } = renderPanel({ onFocusStep });
|
|
121
|
+
const row = getByText('Login.submit').closest('button')!;
|
|
122
|
+
fireEvent.mouseEnter(row);
|
|
123
|
+
fireEvent.click(row);
|
|
124
|
+
expect(onFocusStep).toHaveBeenCalledWith(walkthroughs[0]!, 0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('the copied checkmark clears after the feedback window', async () => {
|
|
128
|
+
const { getByText, getByLabelText } = renderPanel();
|
|
129
|
+
fireEvent.mouseEnter(getByText('Login.submit').closest('button')!);
|
|
130
|
+
const copy = getByLabelText(COPY_LABEL);
|
|
131
|
+
fireEvent.click(copy);
|
|
132
|
+
await waitFor(() => expect(copy.getAttribute('title')).toBe('Copied'));
|
|
133
|
+
await new Promise((r) => setTimeout(r, STEP_COPY_FEEDBACK_MS + 20));
|
|
134
|
+
await waitFor(() => expect(copy.getAttribute('title')).not.toBe('Copied'));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a failed copy shows no copied state', async () => {
|
|
138
|
+
writeText.mockImplementationOnce(() => Promise.reject(new Error('denied')));
|
|
139
|
+
const { getByText, getByLabelText } = renderPanel();
|
|
140
|
+
fireEvent.mouseEnter(getByText('Login.submit').closest('button')!);
|
|
141
|
+
const copy = getByLabelText(COPY_LABEL);
|
|
142
|
+
fireEvent.click(copy);
|
|
143
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
144
|
+
expect(copy.getAttribute('title')).toBe('Copy this step for an agent');
|
|
145
|
+
});
|
|
146
|
+
});
|