@principal-ai/subsystems-react 0.53.3 → 0.53.5
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 +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/subsystem/C4Graph.d.ts +42 -5
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +253 -101
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +55 -2
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +145 -35
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts +10 -1
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +13 -5
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +2 -2
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/c4.d.ts +39 -9
- package/dist/subsystem/c4.d.ts.map +1 -1
- package/dist/subsystem/c4.js +28 -6
- package/dist/subsystem/c4.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +19 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +37 -10
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +8 -1
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +1 -1
- package/src/stories/Subsystem/C4Graph/C4Transition.stories.tsx +117 -16
- package/src/subsystem/C4Graph.groups.test.ts +202 -12
- package/src/subsystem/C4Graph.tsx +297 -107
- package/src/subsystem/C4NodeCard.tsx +162 -34
- package/src/subsystem/IssueList.tsx +30 -5
- package/src/subsystem/SubsystemComponentGraph.tsx +11 -1
- package/src/subsystem/c4.ts +50 -11
- package/src/utils/elkLayout.test.ts +23 -0
- package/src/utils/elkLayout.ts +63 -11
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* pure and needs no ELK — the layout half it feeds is covered by the node e2e.
|
|
13
13
|
*/
|
|
14
14
|
import { describe, expect, test } from 'bun:test';
|
|
15
|
-
import { c4GroupDefs, rebaseGroupBoundsAbsolute, showDescriptionFor } from './C4Graph';
|
|
15
|
+
import { c4GroupDefs, hidesComponentDuringMorph, rebaseGroupBoundsAbsolute, showDescriptionFor } from './C4Graph';
|
|
16
|
+
import { OPEN_FOOTER_PAD, OPEN_SIDE_PAD } from './C4NodeCard';
|
|
17
|
+
import { openContainerSet, openStateKey } from './c4';
|
|
16
18
|
import type { C4Element, C4Model } from './c4';
|
|
17
19
|
|
|
18
20
|
const SYSTEM = 'system:studio';
|
|
@@ -54,7 +56,7 @@ const groupById = (defs: ReturnType<typeof c4GroupDefs>['groups']) =>
|
|
|
54
56
|
|
|
55
57
|
describe('c4GroupDefs — the system boundary', () => {
|
|
56
58
|
test('is a group of its own, even with nothing open', () => {
|
|
57
|
-
const { groups } = c4GroupDefs(base,
|
|
59
|
+
const { groups } = c4GroupDefs(base, []);
|
|
58
60
|
expect(groupById(groups).has(SYSTEM)).toBe(true);
|
|
59
61
|
});
|
|
60
62
|
|
|
@@ -64,7 +66,7 @@ describe('c4GroupDefs — the system boundary', () => {
|
|
|
64
66
|
{ id: 'external:github', kind: 'external-system', label: 'GitHub' },
|
|
65
67
|
{ id: 'person:dev', kind: 'person', label: 'Dev' },
|
|
66
68
|
]);
|
|
67
|
-
const system = groupById(c4GroupDefs(withOutsiders,
|
|
69
|
+
const system = groupById(c4GroupDefs(withOutsiders, []).groups).get(SYSTEM)!;
|
|
68
70
|
// People and external systems sit outside the boundary — that is what the
|
|
69
71
|
// boundary means — so they must not be laid out as its contents.
|
|
70
72
|
expect(system.memberIds).toEqual([HOST, NOTES]);
|
|
@@ -73,7 +75,7 @@ describe('c4GroupDefs — the system boundary', () => {
|
|
|
73
75
|
test('is not dropped when a container opens', () => {
|
|
74
76
|
// The frame must not shrink to the opened container: it is the *system*
|
|
75
77
|
// boundary, and the open state adds a container inside it, not replaces it.
|
|
76
|
-
const { groups } = c4GroupDefs(base, HOST);
|
|
78
|
+
const { groups } = c4GroupDefs(base, [HOST]);
|
|
77
79
|
const system = groupById(groups).get(SYSTEM)!;
|
|
78
80
|
expect(system.memberIds).toContain(HOST);
|
|
79
81
|
expect(system.memberIds).toContain(NOTES);
|
|
@@ -85,7 +87,7 @@ describe('c4GroupDefs — the opened container', () => {
|
|
|
85
87
|
// The load-bearing assertion. As a sibling it is laid out from the root, the
|
|
86
88
|
// frame is no longer the origin of its contents, and the boundary's corner
|
|
87
89
|
// stops being pinned across the two layouts.
|
|
88
|
-
const { groups } = c4GroupDefs(base, HOST);
|
|
90
|
+
const { groups } = c4GroupDefs(base, [HOST]);
|
|
89
91
|
const open = groupById(groups).get(HOST)!;
|
|
90
92
|
expect(open.parentId).toBe(SYSTEM);
|
|
91
93
|
});
|
|
@@ -93,12 +95,12 @@ describe('c4GroupDefs — the opened container', () => {
|
|
|
93
95
|
test('is drawn as a card as well as laid out as a group', () => {
|
|
94
96
|
// It has a node (the card we click) *and* a group (the parent ELK sizes to
|
|
95
97
|
// hold its components). Without the flag, ELK takes the bare leaf box.
|
|
96
|
-
const open = groupById(c4GroupDefs(base, HOST).groups).get(HOST)!;
|
|
98
|
+
const open = groupById(c4GroupDefs(base, [HOST]).groups).get(HOST)!;
|
|
97
99
|
expect(open.drawnAsCard).toBe(true);
|
|
98
100
|
});
|
|
99
101
|
|
|
100
102
|
test('lists its components, and reserves room for its own chrome', () => {
|
|
101
|
-
const open = groupById(c4GroupDefs(base, HOST).groups).get(HOST)!;
|
|
103
|
+
const open = groupById(c4GroupDefs(base, [HOST]).groups).get(HOST)!;
|
|
102
104
|
expect(open.memberIds).toEqual(['cmp:a', 'cmp:b']);
|
|
103
105
|
// Its label slot and technology row, so children land below them.
|
|
104
106
|
expect(open.padTop).toBeGreaterThan(0);
|
|
@@ -106,14 +108,42 @@ describe('c4GroupDefs — the opened container', () => {
|
|
|
106
108
|
expect(open.minWidth).toBeGreaterThan(0);
|
|
107
109
|
});
|
|
108
110
|
|
|
111
|
+
test('reserves the footer too, so components clear the collapse toggle', () => {
|
|
112
|
+
// The toggle is absolutely positioned, so it takes no space in the card's flow
|
|
113
|
+
// and ELK cannot see it. Without the footer the parent's fit stops at the last
|
|
114
|
+
// component row and that row is drawn straight through the button — measured at
|
|
115
|
+
// 12px of clearance against the 28px the button occupies.
|
|
116
|
+
const open = groupById(c4GroupDefs(base, [HOST]).groups).get(HOST)!;
|
|
117
|
+
expect(open.padBottom).toBe(OPEN_FOOTER_PAD);
|
|
118
|
+
// And it is enough: the toggle's top edge is inset + size above the card bottom.
|
|
119
|
+
expect(open.padBottom!).toBeGreaterThanOrEqual(6 + 22);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('reserves the same inset at the sides as at the footer', () => {
|
|
123
|
+
// One uniform inset around the nested cards, rather than a wider one at the
|
|
124
|
+
// bottom than at the sides.
|
|
125
|
+
const open = groupById(c4GroupDefs(base, [HOST]).groups).get(HOST)!;
|
|
126
|
+
expect(open.padX).toBe(OPEN_SIDE_PAD);
|
|
127
|
+
expect(open.padX).toBe(open.padBottom);
|
|
128
|
+
// And it is a real inset, wider than the card's own 12px text padding — the
|
|
129
|
+
// reason it exists is that matching that padding read as too tight.
|
|
130
|
+
expect(open.padX!).toBeGreaterThan(12);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a container that is not open reserves no footer', () => {
|
|
134
|
+
// The padding is a property of being *grown*. A closed container is a plain
|
|
135
|
+
// leaf and ELK never applies it, so asking for it would be misleading.
|
|
136
|
+
expect(groupById(c4GroupDefs(base, []).groups).has(HOST)).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
109
139
|
test('is absent when nothing is open, so the closed card stays a leaf', () => {
|
|
110
140
|
// The closed container is a plain node — no group. The group's whole purpose is
|
|
111
141
|
// to hold components, and there are none in view.
|
|
112
|
-
expect(groupById(c4GroupDefs(base,
|
|
142
|
+
expect(groupById(c4GroupDefs(base, []).groups).has(HOST)).toBe(false);
|
|
113
143
|
});
|
|
114
144
|
|
|
115
145
|
test('adds no group for a container with no components in view', () => {
|
|
116
|
-
const { groups } = c4GroupDefs(model([container(NOTES)]), NOTES);
|
|
146
|
+
const { groups } = c4GroupDefs(model([container(NOTES)]), [NOTES]);
|
|
117
147
|
expect(groupById(groups).has(NOTES)).toBe(false);
|
|
118
148
|
});
|
|
119
149
|
});
|
|
@@ -122,26 +152,186 @@ describe('c4GroupDefs — frames', () => {
|
|
|
122
152
|
test('the opened container is not also drawn as a frame', () => {
|
|
123
153
|
// It is drawn as a grown card. Leaving it in `frames` would draw a frame
|
|
124
154
|
// around the card.
|
|
125
|
-
const { frames } = c4GroupDefs(base, HOST);
|
|
155
|
+
const { frames } = c4GroupDefs(base, [HOST]);
|
|
126
156
|
expect(frames.map((g) => g.id)).not.toContain(HOST);
|
|
127
157
|
});
|
|
128
158
|
|
|
129
159
|
test('frames feed the bands, so they exclude the opened container too', () => {
|
|
130
160
|
// Bands come from frames only. Counting the opened container as a frame would
|
|
131
161
|
// give it its own layer, away from the containers it belongs beside.
|
|
132
|
-
const { frames } = c4GroupDefs(base, HOST);
|
|
162
|
+
const { frames } = c4GroupDefs(base, [HOST]);
|
|
133
163
|
expect(frames).toEqual([expect.objectContaining({ id: SYSTEM, kind: 'system' })]);
|
|
134
164
|
});
|
|
135
165
|
|
|
136
166
|
test('the component view keeps a frame per container with components', () => {
|
|
137
167
|
const componentView: C4Model = { ...base, view: 'component' };
|
|
138
|
-
const { frames } = c4GroupDefs(componentView,
|
|
168
|
+
const { frames } = c4GroupDefs(componentView, []);
|
|
139
169
|
expect(frames.map((g) => g.id)).toContain(HOST);
|
|
140
170
|
// Those frames nest inside the system boundary.
|
|
141
171
|
expect(frames.find((g) => g.id === HOST)?.parentId).toBe(SYSTEM);
|
|
142
172
|
});
|
|
143
173
|
});
|
|
144
174
|
|
|
175
|
+
describe('c4GroupDefs — several containers open at once', () => {
|
|
176
|
+
// The second open container needs components of its own to be a parent at all,
|
|
177
|
+
// so this fixture has two containers that both nest.
|
|
178
|
+
const pair = model([
|
|
179
|
+
container(HOST),
|
|
180
|
+
container(NOTES),
|
|
181
|
+
component('cmp:a', HOST),
|
|
182
|
+
component('cmp:b', HOST),
|
|
183
|
+
component('cmp:x', NOTES),
|
|
184
|
+
component('cmp:y', NOTES),
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
test('each open container becomes its own parent, holding only its own components', () => {
|
|
188
|
+
const groups = groupById(c4GroupDefs(pair, [HOST, NOTES]).groups);
|
|
189
|
+
// The bug this replaces: nesting both under one parent, or letting the second
|
|
190
|
+
// overwrite the first, puts one container's components inside the other.
|
|
191
|
+
expect(groups.get(HOST)!.memberIds).toEqual(['cmp:a', 'cmp:b']);
|
|
192
|
+
expect(groups.get(NOTES)!.memberIds).toEqual(['cmp:x', 'cmp:y']);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('both nest inside the system frame, and neither displaces it', () => {
|
|
196
|
+
const { groups } = c4GroupDefs(pair, [HOST, NOTES]);
|
|
197
|
+
const byId = groupById(groups);
|
|
198
|
+
expect(byId.get(HOST)!.parentId).toBe(SYSTEM);
|
|
199
|
+
expect(byId.get(NOTES)!.parentId).toBe(SYSTEM);
|
|
200
|
+
// The boundary is the system. Growing a second container inside it must not
|
|
201
|
+
// shrink the frame to whichever one is open.
|
|
202
|
+
expect(byId.get(SYSTEM)!.memberIds).toEqual([HOST, NOTES]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('neither is also drawn as a frame', () => {
|
|
206
|
+
const { frames } = c4GroupDefs(pair, [HOST, NOTES]);
|
|
207
|
+
const ids = frames.map((g) => g.id);
|
|
208
|
+
expect(ids).not.toContain(HOST);
|
|
209
|
+
expect(ids).not.toContain(NOTES);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('an unopened third container stays a plain leaf', () => {
|
|
213
|
+
const trio = model([
|
|
214
|
+
container(HOST),
|
|
215
|
+
container(NOTES),
|
|
216
|
+
container('container:cache'),
|
|
217
|
+
component('cmp:a', HOST),
|
|
218
|
+
component('cmp:x', NOTES),
|
|
219
|
+
component('cmp:c', 'container:cache'),
|
|
220
|
+
]);
|
|
221
|
+
const groups = groupById(c4GroupDefs(trio, [HOST, NOTES]).groups);
|
|
222
|
+
expect(groups.has('container:cache')).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('the group list is identical whatever order the open set is given in', () => {
|
|
226
|
+
// The cache is keyed on the set, so `[a,b]` and `[b,a]` must produce one
|
|
227
|
+
// layout — which means the defs have to match exactly, order included.
|
|
228
|
+
const forwards = c4GroupDefs(pair, [HOST, NOTES]).groups;
|
|
229
|
+
const backwards = c4GroupDefs(pair, [NOTES, HOST]).groups;
|
|
230
|
+
expect(backwards).toEqual(forwards);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('a duplicate id in the open set is not emitted twice', () => {
|
|
234
|
+
// `openContainerIds` is caller-supplied; a repeated id would otherwise become
|
|
235
|
+
// two groups with the same id, which ELK rejects as a duplicate.
|
|
236
|
+
const groups = c4GroupDefs(pair, [HOST, HOST]).groups;
|
|
237
|
+
expect(groups.filter((g) => g.id === HOST)).toHaveLength(1);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('openContainerSet', () => {
|
|
242
|
+
test('is empty when nothing is open, in either spelling', () => {
|
|
243
|
+
expect(openContainerSet({})).toEqual([]);
|
|
244
|
+
expect(openContainerSet({ openContainerId: null })).toEqual([]);
|
|
245
|
+
expect(openContainerSet({ openContainerIds: [] })).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('sorts, so the same set in any order is one cache key', () => {
|
|
249
|
+
// Insertion order would give `[b,a]` and `[a,b]` separate keys, and a second
|
|
250
|
+
// ELK run for a diagram that did not change.
|
|
251
|
+
expect(openContainerSet({ openContainerIds: ['b', 'a'] })).toEqual(['a', 'b']);
|
|
252
|
+
expect(openStateKey(openContainerSet({ openContainerIds: ['b', 'a'] }))).toBe(
|
|
253
|
+
openStateKey(openContainerSet({ openContainerIds: ['a', 'b'] })),
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('folds the deprecated single id in, and unions it with the set', () => {
|
|
258
|
+
// Both fields set must not lose either: a caller migrating across would
|
|
259
|
+
// silently drop a container from the drill-down.
|
|
260
|
+
expect(openContainerSet({ openContainerId: 'b', openContainerIds: ['a'] })).toEqual(['a', 'b']);
|
|
261
|
+
expect(openContainerSet({ openContainerId: 'a' })).toEqual(['a']);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('collapses a duplicate rather than emitting a repeated group', () => {
|
|
265
|
+
expect(openContainerSet({ openContainerIds: ['a', 'a'] })).toEqual(['a']);
|
|
266
|
+
expect(openContainerSet({ openContainerId: 'a', openContainerIds: ['a'] })).toEqual(['a']);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('the closed diagram keys as the empty string', () => {
|
|
270
|
+
// `''` is the cache key the closed state has always used; a different
|
|
271
|
+
// sentinel would silently orphan every cached closed layout.
|
|
272
|
+
expect(openStateKey(openContainerSet({}))).toBe('');
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('hidesComponentDuringMorph', () => {
|
|
277
|
+
// The reason the drill-down keeps several containers open: a container that was
|
|
278
|
+
// already open must not blink because a *different* one is changing.
|
|
279
|
+
const HOST = 'container:host';
|
|
280
|
+
const CLI = 'container:notes';
|
|
281
|
+
/** `open` is the full open set; `targets` is only what is changing this tick. */
|
|
282
|
+
const ids = (...v: string[]) => new Set(v);
|
|
283
|
+
const none = new Set<string>();
|
|
284
|
+
|
|
285
|
+
test('withholds the components of a container that is opening', () => {
|
|
286
|
+
expect(hidesComponentDuringMorph(HOST, ids(HOST), ids(HOST), false)).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('withholds them again while it is closing', () => {
|
|
290
|
+
// Closing is the same transition seen from the other side: the card shrinks
|
|
291
|
+
// and the components must not be visible being squeezed out of it.
|
|
292
|
+
expect(hidesComponentDuringMorph(HOST, ids(HOST), ids(HOST), false)).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('keeps them on screen while a *different* container opens', () => {
|
|
296
|
+
// The load-bearing case. HOST is open and unaffected; CLI is arriving.
|
|
297
|
+
expect(hidesComponentDuringMorph(HOST, ids(HOST), ids(CLI), false)).toBe(false);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('keeps them on screen while a different container closes', () => {
|
|
301
|
+
// Targets are the symmetric difference, so a closing CLI names only CLI.
|
|
302
|
+
expect(hidesComponentDuringMorph(HOST, ids(HOST), ids(CLI), false)).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('withholds them when the container itself is the one closing', () => {
|
|
306
|
+
// HOST stays in the open set for this render — it is still drawn, shrinking —
|
|
307
|
+
// so the open set alone cannot be the test. The target set can.
|
|
308
|
+
expect(hidesComponentDuringMorph(HOST, ids(HOST), ids(HOST, CLI), false)).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('shows everything once the morph settles', () => {
|
|
312
|
+
expect(hidesComponentDuringMorph(HOST, ids(HOST), ids(HOST), true)).toBe(false);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('shows nothing once the morph settles, even with targets left over', () => {
|
|
316
|
+
// Belt and braces: a stale target set must not outlive the settle, and if it
|
|
317
|
+
// did, settled still wins.
|
|
318
|
+
expect(hidesComponentDuringMorph(HOST, ids(HOST), ids(HOST, CLI), true)).toBe(false);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('never withholds a component of a closed container', () => {
|
|
322
|
+
expect(hidesComponentDuringMorph(HOST, ids(CLI), ids(HOST), false)).toBe(false);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('never withholds a node with no container', () => {
|
|
326
|
+
// Externals and people have no owner; they are root cards, not nested ones.
|
|
327
|
+
expect(hidesComponentDuringMorph(undefined, ids(HOST), ids(HOST), false)).toBe(false);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('settled with nothing open is not hidden either', () => {
|
|
331
|
+
expect(hidesComponentDuringMorph(HOST, none, none, true)).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
145
335
|
describe('rebaseGroupBoundsAbsolute', () => {
|
|
146
336
|
// Real numbers from the drill-down: the system frame sits at (12,12), and the
|
|
147
337
|
// opened container nests inside it, so ELK reports it 24px/64px in — offset by
|