@principal-ai/subsystems-react 0.53.0 → 0.53.2
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/subsystem/C4Graph.d.ts +91 -1
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +672 -180
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +36 -1
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +45 -10
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +14 -5
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/c4.d.ts +27 -3
- package/dist/subsystem/c4.d.ts.map +1 -1
- package/dist/subsystem/c4.js +48 -0
- package/dist/subsystem/c4.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +68 -8
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +95 -14
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +2 -1
- package/src/stories/Subsystem/C4Graph/C4CardGrow.stories.tsx +125 -0
- package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +21 -11
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +1 -9
- package/src/stories/Subsystem/C4Graph/C4Transition.stories.tsx +132 -0
- package/src/subsystem/C4Graph.groups.test.ts +217 -0
- package/src/subsystem/C4Graph.morph.test.tsx +208 -0
- package/src/subsystem/C4Graph.tsx +803 -226
- package/src/subsystem/C4NodeCard.test.ts +6 -3
- package/src/subsystem/C4NodeCard.tsx +92 -20
- package/src/subsystem/IssueList.category.test.ts +39 -0
- package/src/subsystem/IssueList.tsx +16 -5
- package/src/subsystem/c4.ts +74 -3
- package/src/utils/elkLayout.test.ts +131 -0
- package/src/utils/elkLayout.ts +133 -16
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
|
|
4
|
+
import { C4NodeCard, NODE_W, NODE_H } from '../../../subsystem/C4NodeCard';
|
|
5
|
+
import type { C4Container, C4Component } from '../../../subsystem/c4';
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Subsystem/C4Graph/C4CardGrow',
|
|
9
|
+
parameters: { layout: 'fullscreen' },
|
|
10
|
+
tags: ['autodocs'],
|
|
11
|
+
decorators: [
|
|
12
|
+
(Story) => (
|
|
13
|
+
<ThemeProvider theme={defaultEditorTheme}>
|
|
14
|
+
<Story />
|
|
15
|
+
</ThemeProvider>
|
|
16
|
+
),
|
|
17
|
+
],
|
|
18
|
+
} satisfies Meta;
|
|
19
|
+
|
|
20
|
+
export default meta;
|
|
21
|
+
type Story = StoryObj<typeof meta>;
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Isolated grow/shrink of one container card — no graph, no frames, no
|
|
25
|
+
// components. Toggling the expand affordance animates the card's own size
|
|
26
|
+
// between the box dimensions and an expanded rectangle, so the FLIP motion can
|
|
27
|
+
// be judged on its own.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
const GROWN_W = 420;
|
|
31
|
+
const GROWN_H = 300;
|
|
32
|
+
|
|
33
|
+
const card: C4Container = {
|
|
34
|
+
kind: 'container',
|
|
35
|
+
id: 'container:studio-host',
|
|
36
|
+
label: 'Subsystem Studio — host',
|
|
37
|
+
containerKind: 'application',
|
|
38
|
+
technology: 'Bun + Electrobun',
|
|
39
|
+
description: 'Owns the model store on disk and serves the renderer over RPC.',
|
|
40
|
+
state: 'accepted',
|
|
41
|
+
parentId: 'system:acme',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const COMPONENTS: C4Component[] = [
|
|
45
|
+
{
|
|
46
|
+
kind: 'component',
|
|
47
|
+
id: 'component:audit-verification',
|
|
48
|
+
label: 'Audit & verification',
|
|
49
|
+
container: card.id,
|
|
50
|
+
technology: 'Bun',
|
|
51
|
+
description: 'Checks a subsystem model and reports what is wrong.',
|
|
52
|
+
state: 'accepted',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
kind: 'component',
|
|
56
|
+
id: 'component:model-store',
|
|
57
|
+
label: 'Model store I/O',
|
|
58
|
+
container: card.id,
|
|
59
|
+
technology: 'Bun fs',
|
|
60
|
+
description: 'Reads and writes model JSON under ~/.principal.',
|
|
61
|
+
state: 'accepted',
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function Demo() {
|
|
66
|
+
const [open, setOpen] = useState(false);
|
|
67
|
+
const [grown, setGrown] = useState(false);
|
|
68
|
+
// True when the card has finished shrinking back to its box — only then is the
|
|
69
|
+
// description shown again (it stays hidden through open *and* the shrink).
|
|
70
|
+
const [settled, setSettled] = useState(true);
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
style={{
|
|
74
|
+
width: '100%',
|
|
75
|
+
height: '100vh',
|
|
76
|
+
display: 'flex',
|
|
77
|
+
alignItems: 'center',
|
|
78
|
+
justifyContent: 'center',
|
|
79
|
+
background: '#12141a',
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<div
|
|
83
|
+
onTransitionEnd={(e) => {
|
|
84
|
+
// The card's own size transition bubbles here.
|
|
85
|
+
if (e.propertyName !== 'width') return;
|
|
86
|
+
if (open) setGrown(true);
|
|
87
|
+
else setSettled(true);
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<C4NodeCard
|
|
91
|
+
node={card}
|
|
92
|
+
width={open ? GROWN_W : NODE_W}
|
|
93
|
+
height={open ? GROWN_H : NODE_H}
|
|
94
|
+
showDescription={settled}
|
|
95
|
+
onExpand={() => {
|
|
96
|
+
// Leaving the resting state: hide the description immediately.
|
|
97
|
+
setSettled(false);
|
|
98
|
+
if (open) setGrown(false);
|
|
99
|
+
setOpen((v) => !v);
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
{grown && (
|
|
103
|
+
<div
|
|
104
|
+
style={{
|
|
105
|
+
display: 'grid',
|
|
106
|
+
gridTemplateColumns: '1fr 1fr',
|
|
107
|
+
gap: 10,
|
|
108
|
+
marginTop: 8,
|
|
109
|
+
minHeight: 0,
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
{COMPONENTS.map((c) => (
|
|
113
|
+
<C4NodeCard key={c.id} node={c} />
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</C4NodeCard>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const Grow: Story = {
|
|
124
|
+
render: () => <Demo />,
|
|
125
|
+
};
|
|
@@ -3,7 +3,7 @@ import '@xyflow/react/dist/style.css';
|
|
|
3
3
|
import type { Meta, StoryObj } from '@storybook/react';
|
|
4
4
|
import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
|
|
5
5
|
import { C4Graph } from '../../../subsystem/C4Graph';
|
|
6
|
-
import type { C4Edge,
|
|
6
|
+
import type { C4Edge, C4Model, C4Element, C4Component, C4Container } from '../../../subsystem/c4';
|
|
7
7
|
|
|
8
8
|
const meta = {
|
|
9
9
|
title: 'Subsystem/C4Graph/C4Component',
|
|
@@ -53,7 +53,25 @@ function component(
|
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** A container element. Its components make it frame in the renderer. */
|
|
57
|
+
function container(over: Partial<C4Container> & Pick<C4Container, 'id' | 'label'>): C4Element {
|
|
58
|
+
return {
|
|
59
|
+
kind: 'container',
|
|
60
|
+
containerKind: 'application',
|
|
61
|
+
technology: '',
|
|
62
|
+
state: 'accepted',
|
|
63
|
+
members: [],
|
|
64
|
+
constructs: [],
|
|
65
|
+
parentId: SYSTEM.id,
|
|
66
|
+
...over,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
const NODES: C4Element[] = [
|
|
71
|
+
// --- the containers these components sit in ------------------------------
|
|
72
|
+
container({ id: HOST, label: 'Subsystem Studio — host', technology: 'Bun + Electrobun' }),
|
|
73
|
+
container({ id: RENDERER, label: 'Subsystem Studio — renderer', technology: 'React 19' }),
|
|
74
|
+
|
|
57
75
|
// --- inside the host ------------------------------------------------------
|
|
58
76
|
component({
|
|
59
77
|
id: 'component:audit-verification',
|
|
@@ -131,13 +149,6 @@ const NODES: C4Element[] = [
|
|
|
131
149
|
}),
|
|
132
150
|
];
|
|
133
151
|
|
|
134
|
-
/** The two containers at this level, drawn as frames. */
|
|
135
|
-
const GROUPS: C4Group[] = [
|
|
136
|
-
{ id: HOST, kind: 'container', label: 'Subsystem Studio — host', parentId: SYSTEM.id, memberIds: NODES.filter((n) => n.parentId === HOST).map((n) => n.id) },
|
|
137
|
-
{ id: RENDERER, kind: 'container', label: 'Subsystem Studio — renderer', parentId: SYSTEM.id, memberIds: NODES.filter((n) => n.parentId === RENDERER).map((n) => n.id) },
|
|
138
|
-
{ id: SYSTEM.id, kind: 'system', label: SYSTEM.label, memberIds: [HOST, RENDERER] },
|
|
139
|
-
];
|
|
140
|
-
|
|
141
152
|
/** A line between components — protocol-labelled, like the container view. */
|
|
142
153
|
function edge(id: string, source: string, target: string, protocol: string): C4Edge {
|
|
143
154
|
return { id, source, target, label: protocol, protocol, mechanisms: [], count: 1 };
|
|
@@ -158,7 +169,6 @@ const componentModel: C4Model = {
|
|
|
158
169
|
view: 'component',
|
|
159
170
|
system: SYSTEM,
|
|
160
171
|
nodes: NODES,
|
|
161
|
-
groups: GROUPS,
|
|
162
172
|
edges: EDGES,
|
|
163
173
|
};
|
|
164
174
|
|
|
@@ -170,7 +180,7 @@ function Demo() {
|
|
|
170
180
|
return (
|
|
171
181
|
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
172
182
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
|
173
|
-
<C4Graph model={model}
|
|
183
|
+
<C4Graph model={model} onSelectNode={setSelected} />
|
|
174
184
|
</div>
|
|
175
185
|
<div
|
|
176
186
|
style={{
|
|
@@ -184,7 +194,7 @@ function Demo() {
|
|
|
184
194
|
borderTop: '1px solid #333',
|
|
185
195
|
}}
|
|
186
196
|
>
|
|
187
|
-
<span>{components} components in {model.
|
|
197
|
+
<span>{components} components in {model.nodes.filter((n) => n.kind === 'container').length} containers</span>
|
|
188
198
|
<span>{model.edges.length} lines</span>
|
|
189
199
|
<span>selected: {selected ?? '(none)'}</span>
|
|
190
200
|
<span style={{ color: '#777' }}>each component is a grouping stitched across many models</span>
|
|
@@ -195,14 +195,6 @@ const containerModel: C4Model = {
|
|
|
195
195
|
view: 'container',
|
|
196
196
|
system: SYSTEM,
|
|
197
197
|
nodes: NODES,
|
|
198
|
-
groups: [
|
|
199
|
-
{
|
|
200
|
-
id: SYSTEM.id,
|
|
201
|
-
kind: 'system',
|
|
202
|
-
label: SYSTEM.label,
|
|
203
|
-
memberIds: NODES.filter((n) => n.parentId === SYSTEM.id).map((n) => n.id),
|
|
204
|
-
},
|
|
205
|
-
],
|
|
206
198
|
edges: EDGES,
|
|
207
199
|
};
|
|
208
200
|
|
|
@@ -218,7 +210,7 @@ function Demo() {
|
|
|
218
210
|
return (
|
|
219
211
|
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
220
212
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
|
221
|
-
<C4Graph model={model}
|
|
213
|
+
<C4Graph model={model} onSelectNode={setSelected} />
|
|
222
214
|
</div>
|
|
223
215
|
<div
|
|
224
216
|
style={{
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import '@xyflow/react/dist/style.css';
|
|
3
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
4
|
+
import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
|
|
5
|
+
import { C4Graph } from '../../../subsystem/C4Graph';
|
|
6
|
+
import type { C4Edge, C4Model, C4Element, C4Component, C4Container } from '../../../subsystem/c4';
|
|
7
|
+
|
|
8
|
+
const meta = {
|
|
9
|
+
title: 'Subsystem/C4Graph/C4Transition',
|
|
10
|
+
component: C4Graph,
|
|
11
|
+
parameters: { layout: 'fullscreen' },
|
|
12
|
+
tags: ['autodocs'],
|
|
13
|
+
decorators: [
|
|
14
|
+
(Story) => (
|
|
15
|
+
<ThemeProvider theme={defaultEditorTheme}>
|
|
16
|
+
<Story />
|
|
17
|
+
</ThemeProvider>
|
|
18
|
+
),
|
|
19
|
+
],
|
|
20
|
+
} satisfies Meta<typeof C4Graph>;
|
|
21
|
+
|
|
22
|
+
export default meta;
|
|
23
|
+
type Story = StoryObj<typeof meta>;
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Drill-down: two layouts, one transition.
|
|
27
|
+
//
|
|
28
|
+
// Closed, the container is a box. Its expand affordance (bottom-right) opens it,
|
|
29
|
+
// and the graph swaps to a *second, already-computed* layout in which the same
|
|
30
|
+
// element is an ELK parent — so ELK sizes it to hold its own components, and they
|
|
31
|
+
// arrive as nested cards. The same affordance closes it again.
|
|
32
|
+
//
|
|
33
|
+
// Nothing is a frame here: the container stays a card and grows (its own
|
|
34
|
+
// width/height transition), and the system frame around it grows with it — also
|
|
35
|
+
// by size, since it is a real ELK parent and ELK owns both its origin and its fit.
|
|
36
|
+
// Components stay hidden until the grow settles, and the card's description
|
|
37
|
+
// returns only after the shrink — hence the two settled states below.
|
|
38
|
+
//
|
|
39
|
+
// The story also carries component→component edges, which the nested layout
|
|
40
|
+
// routes inside the parent.
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const SYSTEM = { id: 'system:acme', label: 'Subsystem Modeling', repoKey: 'pkg:github/acme/subsystem-modeling' };
|
|
44
|
+
const HOST = 'container:studio-host';
|
|
45
|
+
|
|
46
|
+
function container(over: Partial<C4Container> & Pick<C4Container, 'id' | 'label'>): C4Element {
|
|
47
|
+
return {
|
|
48
|
+
kind: 'container',
|
|
49
|
+
containerKind: 'application',
|
|
50
|
+
technology: '',
|
|
51
|
+
state: 'accepted',
|
|
52
|
+
members: [],
|
|
53
|
+
constructs: [],
|
|
54
|
+
parentId: SYSTEM.id,
|
|
55
|
+
...over,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function component(
|
|
60
|
+
over: Partial<C4Component> & Pick<C4Component, 'id' | 'label' | 'container'>,
|
|
61
|
+
): C4Element {
|
|
62
|
+
return {
|
|
63
|
+
kind: 'component',
|
|
64
|
+
members: [],
|
|
65
|
+
constructs: [],
|
|
66
|
+
state: 'accepted',
|
|
67
|
+
parentId: over.container,
|
|
68
|
+
technology: 'Bun',
|
|
69
|
+
...over,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const NODES: C4Element[] = [
|
|
74
|
+
container({ id: HOST, label: 'Subsystem Studio — host', technology: 'Bun + Electrobun', description: 'The Electron main process.' }),
|
|
75
|
+
component({ id: 'component:audit-verification', label: 'Audit & verification', container: HOST, description: 'Checks a model, reports problems.' }),
|
|
76
|
+
component({ id: 'component:maintain-proposals', label: 'Maintain & proposals', container: HOST, description: 'Runs the maintain agent, records proposals.' }),
|
|
77
|
+
component({ id: 'component:model-store', label: 'Model store I/O', container: HOST, technology: 'Bun fs', description: 'Reads and writes model JSON.' }),
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
function edge(id: string, source: string, target: string, protocol: string): C4Edge {
|
|
81
|
+
return { id, source, target, label: protocol, protocol, mechanisms: [], count: 1 };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const EDGES: C4Edge[] = [
|
|
85
|
+
edge('e:audit-store', 'component:audit-verification', 'component:model-store', 'fs · JSON'),
|
|
86
|
+
edge('e:proposals-store', 'component:maintain-proposals', 'component:model-store', 'fs · JSON'),
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
function Demo() {
|
|
90
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
91
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
92
|
+
|
|
93
|
+
const model = useMemo<C4Model>(
|
|
94
|
+
() => ({ view: 'container', system: SYSTEM, nodes: NODES, edges: EDGES, openContainerId: openId }),
|
|
95
|
+
[openId],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
100
|
+
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
|
101
|
+
<C4Graph
|
|
102
|
+
model={model}
|
|
103
|
+
onSelectNode={setSelected}
|
|
104
|
+
onOpenContainer={setOpenId}
|
|
105
|
+
onCloseContainer={() => setOpenId(null)}
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
<div
|
|
109
|
+
style={{
|
|
110
|
+
padding: '6px 12px',
|
|
111
|
+
display: 'flex',
|
|
112
|
+
alignItems: 'center',
|
|
113
|
+
gap: 18,
|
|
114
|
+
fontFamily: 'monospace',
|
|
115
|
+
fontSize: 12,
|
|
116
|
+
color: '#aaa',
|
|
117
|
+
borderTop: '1px solid #333',
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
<span>{openId ? `opened: ${openId}` : 'closed'}</span>
|
|
121
|
+
<span>selected: {selected ?? '(none)'}</span>
|
|
122
|
+
<span style={{ color: '#777' }}>
|
|
123
|
+
the expand icon (bottom-right of the container) opens it, and closes it again
|
|
124
|
+
</span>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const DrillDown: Story = {
|
|
131
|
+
render: () => <Demo />,
|
|
132
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nesting the drill-down's stability rests on.
|
|
3
|
+
*
|
|
4
|
+
* The 206px "moves down" report was not a FLIP bug or a camera bug — it was the
|
|
5
|
+
* software-system boundary having no position of its own. Flattened, the frame was
|
|
6
|
+
* synthesized as a union of its members' bounds, so it inherited their top edge and
|
|
7
|
+
* slid whenever ELK re-placed one of them. Making it a real ELK parent fixed it,
|
|
8
|
+
* and only because the opened container nests *inside* it.
|
|
9
|
+
*
|
|
10
|
+
* None of this is visible in the rendered output: get the nesting wrong and the
|
|
11
|
+
* graph still draws, just wrong. So it is asserted here instead. `c4GroupDefs` is
|
|
12
|
+
* pure and needs no ELK — the layout half it feeds is covered by the node e2e.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, test } from 'bun:test';
|
|
15
|
+
import { c4GroupDefs, rebaseGroupBoundsAbsolute, showDescriptionFor } from './C4Graph';
|
|
16
|
+
import type { C4Element, C4Model } from './c4';
|
|
17
|
+
|
|
18
|
+
const SYSTEM = 'system:studio';
|
|
19
|
+
|
|
20
|
+
const container = (id: string): C4Element => ({
|
|
21
|
+
id,
|
|
22
|
+
kind: 'container',
|
|
23
|
+
label: id,
|
|
24
|
+
parentId: SYSTEM,
|
|
25
|
+
});
|
|
26
|
+
const component = (id: string, of: string): C4Element => ({
|
|
27
|
+
id,
|
|
28
|
+
kind: 'component',
|
|
29
|
+
label: id,
|
|
30
|
+
container: of,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** A container view: two containers, one of them with components. */
|
|
34
|
+
function model(nodes: C4Element[]): C4Model {
|
|
35
|
+
return {
|
|
36
|
+
view: 'container',
|
|
37
|
+
system: { id: SYSTEM, label: 'Studio' },
|
|
38
|
+
nodes,
|
|
39
|
+
edges: [],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const HOST = 'container:host';
|
|
44
|
+
const NOTES = 'container:notes';
|
|
45
|
+
const base = model([
|
|
46
|
+
container(HOST),
|
|
47
|
+
container(NOTES),
|
|
48
|
+
component('cmp:a', HOST),
|
|
49
|
+
component('cmp:b', HOST),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const groupById = (defs: ReturnType<typeof c4GroupDefs>['groups']) =>
|
|
53
|
+
new Map(defs.map((g) => [g.id, g]));
|
|
54
|
+
|
|
55
|
+
describe('c4GroupDefs — the system boundary', () => {
|
|
56
|
+
test('is a group of its own, even with nothing open', () => {
|
|
57
|
+
const { groups } = c4GroupDefs(base, null);
|
|
58
|
+
expect(groupById(groups).has(SYSTEM)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('holds the containers, and not the things outside the boundary', () => {
|
|
62
|
+
const withOutsiders = model([
|
|
63
|
+
...base.nodes,
|
|
64
|
+
{ id: 'external:github', kind: 'external-system', label: 'GitHub' },
|
|
65
|
+
{ id: 'person:dev', kind: 'person', label: 'Dev' },
|
|
66
|
+
]);
|
|
67
|
+
const system = groupById(c4GroupDefs(withOutsiders, null).groups).get(SYSTEM)!;
|
|
68
|
+
// People and external systems sit outside the boundary — that is what the
|
|
69
|
+
// boundary means — so they must not be laid out as its contents.
|
|
70
|
+
expect(system.memberIds).toEqual([HOST, NOTES]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('is not dropped when a container opens', () => {
|
|
74
|
+
// The frame must not shrink to the opened container: it is the *system*
|
|
75
|
+
// boundary, and the open state adds a container inside it, not replaces it.
|
|
76
|
+
const { groups } = c4GroupDefs(base, HOST);
|
|
77
|
+
const system = groupById(groups).get(SYSTEM)!;
|
|
78
|
+
expect(system.memberIds).toContain(HOST);
|
|
79
|
+
expect(system.memberIds).toContain(NOTES);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('c4GroupDefs — the opened container', () => {
|
|
84
|
+
test('nests inside the system frame, not beside it', () => {
|
|
85
|
+
// The load-bearing assertion. As a sibling it is laid out from the root, the
|
|
86
|
+
// frame is no longer the origin of its contents, and the boundary's corner
|
|
87
|
+
// stops being pinned across the two layouts.
|
|
88
|
+
const { groups } = c4GroupDefs(base, HOST);
|
|
89
|
+
const open = groupById(groups).get(HOST)!;
|
|
90
|
+
expect(open.parentId).toBe(SYSTEM);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('is drawn as a card as well as laid out as a group', () => {
|
|
94
|
+
// It has a node (the card we click) *and* a group (the parent ELK sizes to
|
|
95
|
+
// hold its components). Without the flag, ELK takes the bare leaf box.
|
|
96
|
+
const open = groupById(c4GroupDefs(base, HOST).groups).get(HOST)!;
|
|
97
|
+
expect(open.drawnAsCard).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('lists its components, and reserves room for its own chrome', () => {
|
|
101
|
+
const open = groupById(c4GroupDefs(base, HOST).groups).get(HOST)!;
|
|
102
|
+
expect(open.memberIds).toEqual(['cmp:a', 'cmp:b']);
|
|
103
|
+
// Its label slot and technology row, so children land below them.
|
|
104
|
+
expect(open.padTop).toBeGreaterThan(0);
|
|
105
|
+
// Never narrower than the closed card: opening only grows the box.
|
|
106
|
+
expect(open.minWidth).toBeGreaterThan(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('is absent when nothing is open, so the closed card stays a leaf', () => {
|
|
110
|
+
// The closed container is a plain node — no group. The group's whole purpose is
|
|
111
|
+
// to hold components, and there are none in view.
|
|
112
|
+
expect(groupById(c4GroupDefs(base, null).groups).has(HOST)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('adds no group for a container with no components in view', () => {
|
|
116
|
+
const { groups } = c4GroupDefs(model([container(NOTES)]), NOTES);
|
|
117
|
+
expect(groupById(groups).has(NOTES)).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('c4GroupDefs — frames', () => {
|
|
122
|
+
test('the opened container is not also drawn as a frame', () => {
|
|
123
|
+
// It is drawn as a grown card. Leaving it in `frames` would draw a frame
|
|
124
|
+
// around the card.
|
|
125
|
+
const { frames } = c4GroupDefs(base, HOST);
|
|
126
|
+
expect(frames.map((g) => g.id)).not.toContain(HOST);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('frames feed the bands, so they exclude the opened container too', () => {
|
|
130
|
+
// Bands come from frames only. Counting the opened container as a frame would
|
|
131
|
+
// give it its own layer, away from the containers it belongs beside.
|
|
132
|
+
const { frames } = c4GroupDefs(base, HOST);
|
|
133
|
+
expect(frames).toEqual([expect.objectContaining({ id: SYSTEM, kind: 'system' })]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('the component view keeps a frame per container with components', () => {
|
|
137
|
+
const componentView: C4Model = { ...base, view: 'component' };
|
|
138
|
+
const { frames } = c4GroupDefs(componentView, null);
|
|
139
|
+
expect(frames.map((g) => g.id)).toContain(HOST);
|
|
140
|
+
// Those frames nest inside the system boundary.
|
|
141
|
+
expect(frames.find((g) => g.id === HOST)?.parentId).toBe(SYSTEM);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('rebaseGroupBoundsAbsolute', () => {
|
|
146
|
+
// Real numbers from the drill-down: the system frame sits at (12,12), and the
|
|
147
|
+
// opened container nests inside it, so ELK reports it 24px/64px in — offset by
|
|
148
|
+
// the frame's own 12,12 plus its 12px padding.
|
|
149
|
+
const result = () => ({
|
|
150
|
+
groupBounds: new Map([
|
|
151
|
+
[SYSTEM, { x: 12, y: 12, width: 298, height: 874 }],
|
|
152
|
+
[HOST, { x: 24, y: 64, width: 250, height: 596 }],
|
|
153
|
+
]),
|
|
154
|
+
absoluteRects: new Map([
|
|
155
|
+
[SYSTEM, { x: 12, y: 12, width: 298, height: 874 }],
|
|
156
|
+
[HOST, { x: 36, y: 76, width: 250, height: 596 }],
|
|
157
|
+
]),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('lifts a nested group out of its parent origin', () => {
|
|
161
|
+
const rebased = rebaseGroupBoundsAbsolute(result());
|
|
162
|
+
// Unrebased, `containerRect` would read (24,64) and the morph would glide the
|
|
163
|
+
// container to a place it was never drawn.
|
|
164
|
+
expect(rebased.get(HOST)).toEqual({ x: 36, y: 76, width: 250, height: 596 });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('leaves a root-level group alone', () => {
|
|
168
|
+
const rebased = rebaseGroupBoundsAbsolute(result());
|
|
169
|
+
expect(rebased.get(SYSTEM)).toEqual({ x: 12, y: 12, width: 298, height: 874 });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('keeps a flat frame, which has no node and is already absolute', () => {
|
|
173
|
+
// Flat frames are synthesized from absolute member bounds and have no entry in
|
|
174
|
+
// `absoluteRects` — they must survive rather than drop out.
|
|
175
|
+
const r = result();
|
|
176
|
+
r.groupBounds.set('flat', { x: 12, y: 402, width: 274, height: 150 });
|
|
177
|
+
const rebased = rebaseGroupBoundsAbsolute(r);
|
|
178
|
+
expect(rebased.get('flat')).toEqual({ x: 12, y: 402, width: 274, height: 150 });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('does not mutate the layout result', () => {
|
|
182
|
+
// `computeElkLayout`'s own result is read elsewhere in its native mixed space.
|
|
183
|
+
const r = result();
|
|
184
|
+
rebaseGroupBoundsAbsolute(r);
|
|
185
|
+
expect(r.groupBounds.get(HOST)).toEqual({ x: 24, y: 64, width: 250, height: 596 });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('keeps every entry — no group is lost in the translation', () => {
|
|
189
|
+
const rebased = rebaseGroupBoundsAbsolute(result());
|
|
190
|
+
expect([...rebased.keys()].sort()).toEqual([SYSTEM, HOST].sort());
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('showDescriptionFor', () => {
|
|
195
|
+
test('a plain card shows its description', () => {
|
|
196
|
+
expect(showDescriptionFor(false, true)).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('no description while a morph is in flight', () => {
|
|
200
|
+
// The card is resizing underneath it; the line would slide across a moving box.
|
|
201
|
+
expect(showDescriptionFor(false, false)).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('an expanded container never shows it, however long it has settled', () => {
|
|
205
|
+
// The regression: keying only on `settled` brought the line back the moment the
|
|
206
|
+
// grow finished, into the space the components now occupy. ELK reserves header
|
|
207
|
+
// chrome only, so it landed on top of the first component rather than above it.
|
|
208
|
+
expect(showDescriptionFor(true, true)).toBe(false);
|
|
209
|
+
expect(showDescriptionFor(true, false)).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('an unset open flag is not "open"', () => {
|
|
213
|
+
// `open` is undefined until the layout stamps it, so `undefined` must not be
|
|
214
|
+
// read as truthy — that would hide descriptions on every card, always.
|
|
215
|
+
expect(showDescriptionFor(undefined, true)).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
});
|