@principal-ai/subsystems-react 0.56.11 → 0.56.13
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.map +1 -1
- package/dist/subsystem/C4Graph.js +10 -4
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/DescriptionOverlay.d.ts +6 -0
- package/dist/subsystem/DescriptionOverlay.d.ts.map +1 -0
- package/dist/subsystem/DescriptionOverlay.js +58 -0
- package/dist/subsystem/DescriptionOverlay.js.map +1 -0
- package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
- package/dist/subsystem/DiagnosticToggle.js +2 -1
- package/dist/subsystem/DiagnosticToggle.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +2 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +307 -284
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -1
- package/dist/subsystem/SubsystemFileTree.js +2 -1
- package/dist/subsystem/SubsystemFileTree.js.map +1 -1
- package/dist/subsystem/TrailStepsNav.d.ts.map +1 -1
- package/dist/subsystem/TrailStepsNav.js +11 -8
- package/dist/subsystem/TrailStepsNav.js.map +1 -1
- package/dist/subsystem/TrailsPanel.d.ts +1 -1
- package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
- package/dist/subsystem/TrailsPanel.js +42 -66
- package/dist/subsystem/TrailsPanel.js.map +1 -1
- package/package.json +1 -1
- package/src/stories/Canvases/C4/C4Draft.stories.tsx +238 -0
- package/src/stories/Canvases/Subsystem/TrailSidebarViews.stories.tsx +2 -2
- package/src/subsystem/C4Graph.tsx +10 -4
- package/src/subsystem/DescriptionOverlay.test.tsx +76 -0
- package/src/subsystem/DescriptionOverlay.tsx +101 -0
- package/src/subsystem/DiagnosticToggle.tsx +2 -1
- package/src/subsystem/SubsystemComponentGraph.sidebar.test.tsx +81 -10
- package/src/subsystem/SubsystemComponentGraph.tsx +68 -26
- package/src/subsystem/SubsystemFileTree.tsx +2 -1
- package/src/subsystem/TrailStepsNav.test.tsx +38 -0
- package/src/subsystem/TrailStepsNav.tsx +11 -8
- package/src/subsystem/TrailsPanel.test.tsx +99 -35
- package/src/subsystem/TrailsPanel.tsx +38 -85
|
@@ -0,0 +1,238 @@
|
|
|
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 { C4ConstructGraph } from '../../../subsystem/C4Graph';
|
|
7
|
+
import type { C4Edge, C4Element, C4Model } from '../../../subsystem/c4';
|
|
8
|
+
import {
|
|
9
|
+
DRILLDOWN_COMPONENTS,
|
|
10
|
+
DRILLDOWN_PURL,
|
|
11
|
+
DRILLDOWN_TRAILS,
|
|
12
|
+
} from '../../../subsystem/__fixtures__/c4Drilldown';
|
|
13
|
+
|
|
14
|
+
const meta = {
|
|
15
|
+
title: 'Canvases/C4/C4Draft',
|
|
16
|
+
component: C4Graph,
|
|
17
|
+
parameters: { layout: 'fullscreen' },
|
|
18
|
+
tags: ['autodocs'],
|
|
19
|
+
decorators: [
|
|
20
|
+
(Story) => (
|
|
21
|
+
<ThemeProvider theme={defaultEditorTheme}>
|
|
22
|
+
<Story />
|
|
23
|
+
</ThemeProvider>
|
|
24
|
+
),
|
|
25
|
+
],
|
|
26
|
+
} satisfies Meta<typeof C4Graph>;
|
|
27
|
+
|
|
28
|
+
export default meta;
|
|
29
|
+
type Story = StoryObj<typeof meta>;
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// The minimal C4: no accepted containers, no authored components.
|
|
33
|
+
//
|
|
34
|
+
// Every box is derived from the model itself:
|
|
35
|
+
//
|
|
36
|
+
// - one *draft* container per runtime `process` key (members resolve by
|
|
37
|
+
// exact process match, the rule accepted containers use);
|
|
38
|
+
// - a file-less / external process becomes an external system, so lines
|
|
39
|
+
// leaving the boundary have somewhere to land;
|
|
40
|
+
// - lines are the fold of cross-boundary trail steps, labelled by
|
|
41
|
+
// mechanism;
|
|
42
|
+
// - each opened container hosts its member constructs directly, because no
|
|
43
|
+
// C4 component layer exists to group them.
|
|
44
|
+
//
|
|
45
|
+
// Nothing here claims verification. This is what the API's `source=draft`
|
|
46
|
+
// projection looks like: a reading of the model, not a stored decision.
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
const SYSTEM = {
|
|
50
|
+
id: 'system:draft',
|
|
51
|
+
label: 'Gist share from Studio',
|
|
52
|
+
repoKey: DRILLDOWN_PURL,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const byAlias = new Map(DRILLDOWN_COMPONENTS.map((c) => [c.alias, c]));
|
|
56
|
+
|
|
57
|
+
/** `local/disk` → `local-disk`; keeps ids readable in the story. */
|
|
58
|
+
function slug(value: string): string {
|
|
59
|
+
return value.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface DraftElement {
|
|
63
|
+
element: C4Element;
|
|
64
|
+
members: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const elements: C4Element[] = [];
|
|
68
|
+
const membersByElement = new Map<string, string[]>();
|
|
69
|
+
const elementOfAlias = new Map<string, string>();
|
|
70
|
+
|
|
71
|
+
const byProcess = new Map<string, string[]>();
|
|
72
|
+
for (const component of DRILLDOWN_COMPONENTS) {
|
|
73
|
+
const process = component.process?.trim();
|
|
74
|
+
if (!process) continue;
|
|
75
|
+
const list = byProcess.get(process) ?? [];
|
|
76
|
+
list.push(component.alias);
|
|
77
|
+
byProcess.set(process, list);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const drafts: DraftElement[] = [];
|
|
81
|
+
for (const [process, members] of byProcess) {
|
|
82
|
+
// A process with no grounded code behind it is not a deployable unit: it is
|
|
83
|
+
// the model's way of naming something outside the boundary.
|
|
84
|
+
const fileless = members.every((alias) => !byAlias.get(alias)?.file);
|
|
85
|
+
const external =
|
|
86
|
+
fileless || process.startsWith('external/') || process.startsWith('local/');
|
|
87
|
+
|
|
88
|
+
if (external) {
|
|
89
|
+
const element: C4Element = {
|
|
90
|
+
kind: 'external-system',
|
|
91
|
+
id: `external:${slug(process)}`,
|
|
92
|
+
label: process,
|
|
93
|
+
members,
|
|
94
|
+
technology: byAlias.get(members[0] ?? '')?.framework ?? 'external',
|
|
95
|
+
state: 'accepted',
|
|
96
|
+
description: `${members.length} member${members.length === 1 ? '' : 's'} outside the boundary.`,
|
|
97
|
+
};
|
|
98
|
+
elements.push(element);
|
|
99
|
+
drafts.push({ element, members });
|
|
100
|
+
membersByElement.set(element.id, members);
|
|
101
|
+
} else {
|
|
102
|
+
const element: C4Element = {
|
|
103
|
+
kind: 'container',
|
|
104
|
+
id: `container:${slug(process)}`,
|
|
105
|
+
label: process,
|
|
106
|
+
containerKind: members.some((alias) => byAlias.get(alias)?.construct === 'store')
|
|
107
|
+
? 'data-store'
|
|
108
|
+
: 'application',
|
|
109
|
+
technology: byAlias.get(members[0] ?? '')?.framework ?? '',
|
|
110
|
+
process,
|
|
111
|
+
parentId: SYSTEM.id,
|
|
112
|
+
state: 'accepted',
|
|
113
|
+
description: `${members.length} component${members.length === 1 ? '' : 's'} derived from process ${process}.`,
|
|
114
|
+
};
|
|
115
|
+
elements.push(element);
|
|
116
|
+
drafts.push({ element, members });
|
|
117
|
+
membersByElement.set(element.id, members);
|
|
118
|
+
}
|
|
119
|
+
for (const alias of members) elementOfAlias.set(alias, elements[elements.length - 1]!.id);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const containers = drafts.filter((d) => d.element.kind === 'container');
|
|
123
|
+
const externals = drafts.filter((d) => d.element.kind === 'external-system');
|
|
124
|
+
|
|
125
|
+
// Fold cross-boundary trail steps into lines. Same rule the accepted-store
|
|
126
|
+
// projection uses; only the membership source differs.
|
|
127
|
+
const edges: C4Edge[] = (() => {
|
|
128
|
+
const byKey = new Map<
|
|
129
|
+
string,
|
|
130
|
+
{ source: string; target: string; mechanism: string; refs: Map<string, number[]> }
|
|
131
|
+
>();
|
|
132
|
+
for (const trail of DRILLDOWN_TRAILS) {
|
|
133
|
+
trail.steps.forEach((step, index) => {
|
|
134
|
+
const source = elementOfAlias.get(step.from);
|
|
135
|
+
const target = elementOfAlias.get(step.to);
|
|
136
|
+
if (!source || !target || source === target) return;
|
|
137
|
+
const key = [source, target, step.mechanism].join('\0');
|
|
138
|
+
let acc = byKey.get(key);
|
|
139
|
+
if (!acc) {
|
|
140
|
+
acc = { source, target, mechanism: step.mechanism, refs: new Map() };
|
|
141
|
+
byKey.set(key, acc);
|
|
142
|
+
}
|
|
143
|
+
const steps = acc.refs.get(trail.id) ?? [];
|
|
144
|
+
steps.push(index);
|
|
145
|
+
acc.refs.set(trail.id, steps);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return [...byKey.values()].map((acc) => ({
|
|
149
|
+
id: `e:${slug(acc.source)}-${slug(acc.target)}-${acc.mechanism}`,
|
|
150
|
+
source: acc.source,
|
|
151
|
+
target: acc.target,
|
|
152
|
+
label: acc.mechanism,
|
|
153
|
+
protocol: acc.mechanism,
|
|
154
|
+
mechanisms: [acc.mechanism],
|
|
155
|
+
count: [...acc.refs.values()].reduce((n, steps) => n + steps.length, 0),
|
|
156
|
+
trailRefs: [...acc.refs.entries()].map(([trailId, stepIndices]) => ({
|
|
157
|
+
trailId,
|
|
158
|
+
stepIndices,
|
|
159
|
+
})),
|
|
160
|
+
}));
|
|
161
|
+
})();
|
|
162
|
+
|
|
163
|
+
// No `componentOf`: the constructs nest directly under their container.
|
|
164
|
+
const constructGraphByContainer = new Map<string, C4ConstructGraph>();
|
|
165
|
+
for (const draft of containers) {
|
|
166
|
+
const members = membersByElement.get(draft.element.id) ?? [];
|
|
167
|
+
constructGraphByContainer.set(draft.element.id, {
|
|
168
|
+
components: members
|
|
169
|
+
.map((alias) => byAlias.get(alias))
|
|
170
|
+
.filter((c): c is NonNullable<typeof c> => Boolean(c)),
|
|
171
|
+
trails: DRILLDOWN_TRAILS,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const allContainerIds = containers.map((d) => d.element.id);
|
|
176
|
+
|
|
177
|
+
function Demo() {
|
|
178
|
+
const [openIds, setOpenIds] = useState<string[]>(allContainerIds);
|
|
179
|
+
|
|
180
|
+
const model = useMemo<C4Model>(
|
|
181
|
+
() => ({
|
|
182
|
+
view: 'container',
|
|
183
|
+
system: SYSTEM,
|
|
184
|
+
nodes: elements,
|
|
185
|
+
edges,
|
|
186
|
+
openContainerIds: openIds,
|
|
187
|
+
}),
|
|
188
|
+
[openIds],
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const toggle = (id: string) =>
|
|
192
|
+
setOpenIds((prev) =>
|
|
193
|
+
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id].sort(),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
198
|
+
<div
|
|
199
|
+
style={{
|
|
200
|
+
display: 'flex',
|
|
201
|
+
alignItems: 'center',
|
|
202
|
+
gap: 16,
|
|
203
|
+
padding: '7px 12px',
|
|
204
|
+
borderBottom: '1px solid #333',
|
|
205
|
+
background: '#1b1b1b',
|
|
206
|
+
color: '#aaa',
|
|
207
|
+
fontFamily: 'ui-monospace, monospace',
|
|
208
|
+
fontSize: 12,
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
<strong style={{ color: '#e3b341' }}>DRAFT</strong>
|
|
212
|
+
<span>
|
|
213
|
+
no accepted containers — {containers.length} derived from process keys,{' '}
|
|
214
|
+
{externals.length} externals, {edges.length} lines
|
|
215
|
+
</span>
|
|
216
|
+
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
|
|
217
|
+
<button type="button" onClick={() => setOpenIds(allContainerIds)}>
|
|
218
|
+
Open all
|
|
219
|
+
</button>
|
|
220
|
+
<button type="button" onClick={() => setOpenIds([])}>
|
|
221
|
+
Collapse all
|
|
222
|
+
</button>
|
|
223
|
+
</span>
|
|
224
|
+
</div>
|
|
225
|
+
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
|
226
|
+
<C4Graph
|
|
227
|
+
model={model}
|
|
228
|
+
onToggleContainer={toggle}
|
|
229
|
+
constructGraphByContainer={constructGraphByContainer}
|
|
230
|
+
/>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export const DraftFromProcessKeys: Story = {
|
|
237
|
+
render: () => <Demo />,
|
|
238
|
+
};
|
|
@@ -13,11 +13,11 @@ const components: SubsystemComponent[] = [
|
|
|
13
13
|
{ alias: 'mail', name: 'Mail', construct: 'function', file: 'src/mail.ts', purl: 'pkg:github/acme/app' },
|
|
14
14
|
];
|
|
15
15
|
const trails: SubsystemTrail[] = [
|
|
16
|
-
{ id: 'checkout', title: '
|
|
16
|
+
{ id: 'checkout', title: 'Validate the checkout and persist the customer’s order with all selected items', steps: [
|
|
17
17
|
{ from: 'checkout', to: 'store', mechanism: 'calls', file: 'src/checkout.ts',
|
|
18
18
|
purl: 'pkg:github/acme/app', symbol: 'saveCheckout', annotation: 'Persist the checkout.' },
|
|
19
19
|
] },
|
|
20
|
-
{ id: 'report', title: '
|
|
20
|
+
{ id: 'report', title: 'Generate the monthly account activity report and deliver it to every subscribed recipient', steps: [
|
|
21
21
|
{ from: 'report', to: 'mail', mechanism: 'calls', file: 'src/report.ts',
|
|
22
22
|
purl: 'pkg:github/acme/app', symbol: 'sendReport' },
|
|
23
23
|
] },
|
|
@@ -1383,10 +1383,16 @@ function Inner({ model, onSelectNode, onSelectEdge, onTrailClick, onToggleContai
|
|
|
1383
1383
|
element: n,
|
|
1384
1384
|
selected: false,
|
|
1385
1385
|
componentCount: childComponentCount.get(n.id) ?? 0,
|
|
1386
|
-
// A container with components
|
|
1387
|
-
//
|
|
1388
|
-
|
|
1389
|
-
|
|
1386
|
+
// A container with components — or with constructs hosted from the
|
|
1387
|
+
// model, when no C4 component layer exists — opens; the same
|
|
1388
|
+
// affordance closes it again, so the toggle lives in one place.
|
|
1389
|
+
...(n.kind === 'container' &&
|
|
1390
|
+
((childComponentCount.get(n.id) ?? 0) > 0 ||
|
|
1391
|
+
(constructGraphByContainer?.get(n.id)?.components.length ?? 0) > 0)
|
|
1392
|
+
? {
|
|
1393
|
+
...(canToggle ? { onExpand: () => toggle(n.id) } : {}),
|
|
1394
|
+
open: opened.has(n.id),
|
|
1395
|
+
}
|
|
1390
1396
|
: {}),
|
|
1391
1397
|
// A component grown to hold its constructs is a drawn-as-card group:
|
|
1392
1398
|
// no description, absolute box, sized by ELK.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { createElement } from 'react';
|
|
3
|
+
import { Window } from 'happy-dom';
|
|
4
|
+
import { cleanup, fireEvent, render } from '@testing-library/react/pure';
|
|
5
|
+
import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
|
|
6
|
+
import { SubsystemComponentGraph } from './SubsystemComponentGraph';
|
|
7
|
+
|
|
8
|
+
const dom = new Window();
|
|
9
|
+
const globals = globalThis as unknown as Record<string, unknown>;
|
|
10
|
+
globals.window = dom;
|
|
11
|
+
globals.document = dom.document;
|
|
12
|
+
globals.navigator = dom.navigator;
|
|
13
|
+
globals.HTMLElement = dom.HTMLElement;
|
|
14
|
+
globals.SVGElement = dom.SVGElement;
|
|
15
|
+
globals.Node = dom.Node;
|
|
16
|
+
globals.Element = dom.Element;
|
|
17
|
+
globals.Event = dom.Event;
|
|
18
|
+
globals.localStorage = dom.localStorage;
|
|
19
|
+
globals.ResizeObserver = dom.ResizeObserver;
|
|
20
|
+
globals.getComputedStyle = dom.getComputedStyle.bind(dom);
|
|
21
|
+
globals.requestAnimationFrame = dom.requestAnimationFrame.bind(dom);
|
|
22
|
+
globals.cancelAnimationFrame = dom.cancelAnimationFrame.bind(dom);
|
|
23
|
+
globals.matchMedia = dom.matchMedia.bind(dom);
|
|
24
|
+
|
|
25
|
+
afterEach(cleanup);
|
|
26
|
+
|
|
27
|
+
function renderGraph(description: string | undefined) {
|
|
28
|
+
return render(createElement(
|
|
29
|
+
ThemeProvider,
|
|
30
|
+
{ theme: defaultEditorTheme },
|
|
31
|
+
createElement(SubsystemComponentGraph, {
|
|
32
|
+
components: [
|
|
33
|
+
{ alias: 'entry', name: 'Entry', construct: 'function', file: 'entry.ts' },
|
|
34
|
+
{ alias: 'store', name: 'Store', construct: 'function', file: 'store.ts' },
|
|
35
|
+
],
|
|
36
|
+
trails: [{
|
|
37
|
+
id: 'request',
|
|
38
|
+
title: 'Request flow',
|
|
39
|
+
steps: [{ from: 'entry', to: 'store', mechanism: 'calls' }],
|
|
40
|
+
}],
|
|
41
|
+
description,
|
|
42
|
+
}),
|
|
43
|
+
));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('subsystem description overlay', () => {
|
|
47
|
+
for (const closeWithEscape of [false, true]) {
|
|
48
|
+
test(`More opens the full markdown over the graph and closes with ${closeWithEscape ? 'Escape' : 'the close button'}`, () => {
|
|
49
|
+
const description = `${'A long description. '.repeat(50)}\n\n**Final detail**`;
|
|
50
|
+
const view = renderGraph(description);
|
|
51
|
+
const more = view.getByRole('button', { name: 'Read full description' });
|
|
52
|
+
more.focus();
|
|
53
|
+
fireEvent.click(more);
|
|
54
|
+
const overlay = view.getByRole('dialog', { name: 'Subsystem description' });
|
|
55
|
+
expect(overlay.textContent).toContain('A long description. '.repeat(50).trim());
|
|
56
|
+
expect(overlay.querySelector('strong')?.textContent).toBe('Final detail');
|
|
57
|
+
expect(overlay.style.position).toBe('absolute');
|
|
58
|
+
expect(overlay.parentElement?.contains(view.getByLabelText('Resize sidebar'))).toBe(false);
|
|
59
|
+
expect(more.getAttribute('aria-expanded')).toBe('true');
|
|
60
|
+
const close = view.getByRole('button', { name: 'Close description' });
|
|
61
|
+
expect(document.activeElement).toBe(close);
|
|
62
|
+
if (closeWithEscape) fireEvent.keyDown(close, { key: 'Escape' });
|
|
63
|
+
else fireEvent.click(close);
|
|
64
|
+
expect(view.queryByRole('dialog')).toBeNull();
|
|
65
|
+
expect(more.getAttribute('aria-expanded')).toBe('false');
|
|
66
|
+
expect(document.activeElement).toBe(more);
|
|
67
|
+
expect(view.getByText('Request flow')).toBeTruthy();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test('does not offer More when there is no description', () => {
|
|
72
|
+
const view = renderGraph(undefined);
|
|
73
|
+
expect(view.queryByRole('button', { name: 'Read full description' })).toBeNull();
|
|
74
|
+
expect(view.queryByRole('dialog')).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { useTheme } from '@principal-ade/industry-theme';
|
|
3
|
+
import { X } from 'lucide-react';
|
|
4
|
+
import { IndustryMarkdownSlide } from 'themed-markdown';
|
|
5
|
+
|
|
6
|
+
export function DescriptionOverlay({ description, onClose }: {
|
|
7
|
+
description: string;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
}) {
|
|
10
|
+
const { theme } = useTheme();
|
|
11
|
+
const closeRef = useRef<HTMLButtonElement>(null);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const previousFocus = document.activeElement;
|
|
15
|
+
closeRef.current?.focus();
|
|
16
|
+
return () => {
|
|
17
|
+
if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus();
|
|
18
|
+
};
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
role="dialog"
|
|
24
|
+
aria-label="Subsystem description"
|
|
25
|
+
onKeyDown={(event) => {
|
|
26
|
+
if (event.key === 'Escape') {
|
|
27
|
+
event.stopPropagation();
|
|
28
|
+
onClose();
|
|
29
|
+
}
|
|
30
|
+
}}
|
|
31
|
+
style={{
|
|
32
|
+
position: 'absolute',
|
|
33
|
+
inset: 0,
|
|
34
|
+
zIndex: 10,
|
|
35
|
+
display: 'flex',
|
|
36
|
+
flexDirection: 'column',
|
|
37
|
+
background: theme.colors.background,
|
|
38
|
+
color: theme.colors.text,
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<div style={{
|
|
42
|
+
display: 'flex',
|
|
43
|
+
alignItems: 'center',
|
|
44
|
+
gap: 8,
|
|
45
|
+
flexShrink: 0,
|
|
46
|
+
padding: '12px 16px',
|
|
47
|
+
borderBottom: `1px solid ${theme.colors.border}`,
|
|
48
|
+
}}>
|
|
49
|
+
<h3 style={{
|
|
50
|
+
flex: 1,
|
|
51
|
+
margin: 0,
|
|
52
|
+
fontSize: theme.fontSizes[1],
|
|
53
|
+
fontFamily: theme.fonts.monospace,
|
|
54
|
+
textTransform: 'uppercase',
|
|
55
|
+
color: theme.colors.textSecondary,
|
|
56
|
+
}}>
|
|
57
|
+
Overview
|
|
58
|
+
</h3>
|
|
59
|
+
<button
|
|
60
|
+
ref={closeRef}
|
|
61
|
+
type="button"
|
|
62
|
+
aria-label="Close description"
|
|
63
|
+
onClick={onClose}
|
|
64
|
+
style={{
|
|
65
|
+
display: 'inline-flex',
|
|
66
|
+
alignItems: 'center',
|
|
67
|
+
justifyContent: 'center',
|
|
68
|
+
width: 24,
|
|
69
|
+
height: 24,
|
|
70
|
+
padding: 0,
|
|
71
|
+
border: 'none',
|
|
72
|
+
borderRadius: 4,
|
|
73
|
+
background: 'transparent',
|
|
74
|
+
color: theme.colors.textSecondary,
|
|
75
|
+
cursor: 'pointer',
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<X size={14} />
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '20px 0 32px' }}>
|
|
82
|
+
<div style={{ lineHeight: 1.7 }}>
|
|
83
|
+
<IndustryMarkdownSlide
|
|
84
|
+
content={description}
|
|
85
|
+
slideIdPrefix="subsystem-desc-overlay"
|
|
86
|
+
slideIndex={0}
|
|
87
|
+
isVisible
|
|
88
|
+
theme={theme}
|
|
89
|
+
disableScroll
|
|
90
|
+
disableBasePadding
|
|
91
|
+
transparentBackground
|
|
92
|
+
additionalPadding={{ left: '24px', right: '24px' }}
|
|
93
|
+
fontSizeScale={1.05}
|
|
94
|
+
enableKeyboardScrolling={false}
|
|
95
|
+
autoFocusOnVisible={false}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -155,7 +155,7 @@ export function SubsystemDiagnosticToggle({
|
|
|
155
155
|
justifyContent: bordered || segmented ? 'center' : undefined,
|
|
156
156
|
gap: 4,
|
|
157
157
|
height: bordered || segmented ? 28 : 22,
|
|
158
|
-
padding:
|
|
158
|
+
padding: segmented ? '4px 16px' : bordered ? '4px 7px' : showCount ? '0 7px 0 6px' : '0 5px',
|
|
159
159
|
border: bordered ? `1px solid ${theme.colors.border}` : 'none',
|
|
160
160
|
borderRadius: 4,
|
|
161
161
|
background,
|
|
@@ -227,6 +227,7 @@ export function SubsystemDiagnosticToggle({
|
|
|
227
227
|
fontFamily: theme.fonts.monospace,
|
|
228
228
|
fontSize: theme.fontSizes[0],
|
|
229
229
|
fontWeight: 600,
|
|
230
|
+
marginLeft: segmented && labelText ? 4 : undefined,
|
|
230
231
|
lineHeight: 1,
|
|
231
232
|
}}
|
|
232
233
|
>
|
|
@@ -50,12 +50,19 @@ describe('subsystem graph sidebar', () => {
|
|
|
50
50
|
expect(content?.textContent).not.toContain('Request subsystem');
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
test('
|
|
53
|
+
test('truncates the description without scrolling and offers the full description', () => {
|
|
54
54
|
const body = renderGraph();
|
|
55
|
-
const description = body.querySelector('[style*="
|
|
55
|
+
const description = body.querySelector('[style*="-webkit-line-clamp:6"]') as HTMLElement;
|
|
56
56
|
expect(description).not.toBeNull();
|
|
57
57
|
expect(description.textContent).toContain('Always visible description.');
|
|
58
|
-
expect(description.style.
|
|
58
|
+
expect(description.style.overflow).toBe('hidden');
|
|
59
|
+
expect(description.style.maxHeight).toBe('9em');
|
|
60
|
+
const more = body.querySelector('button[aria-label="Read full description"]');
|
|
61
|
+
expect(more?.textContent).toBe('More');
|
|
62
|
+
expect(more?.getAttribute('aria-haspopup')).toBe('dialog');
|
|
63
|
+
expect(more?.getAttribute('aria-expanded')).toBe('false');
|
|
64
|
+
expect(body.querySelector('[role="dialog"]')).toBeNull();
|
|
65
|
+
expect(body.querySelector('[style*="max-height:30%"]')).toBeNull();
|
|
59
66
|
});
|
|
60
67
|
|
|
61
68
|
test('keeps the Files button in the header when no name is provided', () => {
|
|
@@ -73,16 +80,51 @@ describe('subsystem graph sidebar', () => {
|
|
|
73
80
|
});
|
|
74
81
|
|
|
75
82
|
test('groups Trails, Files, and Issues into one switch with Trails selected by default', () => {
|
|
76
|
-
const body = renderGraph();
|
|
83
|
+
const body = renderGraph({ diagnostic: { status: 'issues', issueCount: 1 } });
|
|
77
84
|
const group = body.querySelector('header [role="group"][aria-label="Sidebar view"]');
|
|
78
85
|
expect(group?.querySelectorAll('button').length).toBe(3);
|
|
79
86
|
expect(group?.querySelectorAll('button[aria-pressed="true"]').length).toBe(1);
|
|
80
|
-
expect(group?.querySelector('button[aria-label
|
|
81
|
-
expect(group?.querySelector('button[aria-label
|
|
82
|
-
expect(group?.querySelector('button[aria-label^="Issues:"]')?.textContent).toBe('
|
|
87
|
+
expect(group?.querySelector('button[aria-label^="Trails"]')?.getAttribute('aria-pressed')).toBe('true');
|
|
88
|
+
expect(group?.querySelector('button[aria-label^="Trails"]')?.textContent).toBe('Trails1');
|
|
89
|
+
expect(group?.querySelector('button[aria-label^="Issues:"]')?.textContent).toBe('Issues1');
|
|
83
90
|
expect((group as HTMLElement).style.borderStyle).toBe('solid');
|
|
91
|
+
expect((group as HTMLElement).style.gridAutoColumns).toBe('1fr');
|
|
92
|
+
for (const button of group!.querySelectorAll('button')) {
|
|
93
|
+
expect((button as HTMLElement).style.paddingLeft).toBe('16px');
|
|
94
|
+
expect((button as HTMLElement).style.paddingRight).toBe('16px');
|
|
95
|
+
expect((button.lastElementChild as HTMLElement).style.marginLeft).toBe('4px');
|
|
96
|
+
}
|
|
84
97
|
});
|
|
85
98
|
|
|
99
|
+
for (const count of [0, 1, 3]) {
|
|
100
|
+
test(`shows ${count} in the plural Trails option`, () => {
|
|
101
|
+
const body = renderGraph({
|
|
102
|
+
trails: Array.from({ length: count }, (_, index) => ({ ...trails![0], id: `trail-${index}` })),
|
|
103
|
+
graphifyRelations,
|
|
104
|
+
});
|
|
105
|
+
const button = body.querySelector('header button[aria-label^="Trails"]');
|
|
106
|
+
expect(button?.textContent).toBe(`Trails${count}`);
|
|
107
|
+
expect(button?.getAttribute('aria-label')).toBe(`Trails (${count} ${count === 1 ? 'trail' : 'trails'})`);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const props of [
|
|
112
|
+
{},
|
|
113
|
+
{ diagnostic: { status: 'ok' as const, issueCount: 0 } },
|
|
114
|
+
{ issues: [], diagnostic: { status: 'issues' as const, issueCount: 3 } },
|
|
115
|
+
{ issues: [], showIssues: true },
|
|
116
|
+
]) {
|
|
117
|
+
test(`hides Issues and keeps Trails active for an empty scope: ${JSON.stringify(props)}`, () => {
|
|
118
|
+
const body = renderGraph(props);
|
|
119
|
+
const group = body.querySelector('header [aria-label="Sidebar view"]');
|
|
120
|
+
expect(group?.querySelectorAll('button').length).toBe(2);
|
|
121
|
+
expect(group?.querySelector('button[aria-label^="Issues:"]')).toBeNull();
|
|
122
|
+
expect(group?.querySelector('button[aria-label^="Trails"]')?.getAttribute('aria-pressed')).toBe('true');
|
|
123
|
+
expect(body.textContent).toContain('Request flow');
|
|
124
|
+
expect((group as HTMLElement).style.gridAutoColumns).toBe('1fr');
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
86
128
|
test('omits the Files button when no components have files', () => {
|
|
87
129
|
const body = renderGraph({
|
|
88
130
|
components: components.map((component) => ({ ...component, file: '' })),
|
|
@@ -111,7 +153,7 @@ describe('subsystem graph sidebar', () => {
|
|
|
111
153
|
expect(toggle?.getAttribute('aria-pressed')).toBe(String(active));
|
|
112
154
|
expect(header?.querySelectorAll('button').length).toBe(3);
|
|
113
155
|
expect(header?.querySelectorAll('button[aria-pressed="true"]').length).toBe(1);
|
|
114
|
-
expect(header?.querySelector('button[aria-label
|
|
156
|
+
expect(header?.querySelector('button[aria-label^="Trails"]')?.getAttribute('aria-pressed')).toBe(String(!active));
|
|
115
157
|
expect(body.querySelectorAll('button[aria-label^="Issues:"]').length).toBe(1);
|
|
116
158
|
expect(header?.nextElementSibling?.querySelector('button[aria-label^="Issues:"]')).toBeNull();
|
|
117
159
|
expect(body.textContent).toContain('Always visible description.');
|
|
@@ -190,7 +232,7 @@ describe('subsystem graph sidebar', () => {
|
|
|
190
232
|
try {
|
|
191
233
|
const body = renderGraph({ persistKey: 'trail-nav-test' });
|
|
192
234
|
expect(body.querySelector('header button[aria-label="Trail"]')?.textContent).toBe('Trail');
|
|
193
|
-
expect(body.querySelector('header button[aria-label
|
|
235
|
+
expect(body.querySelector('header button[aria-label^="Trails"]')).toBeNull();
|
|
194
236
|
expect(body.querySelector('[aria-hidden="false"] button[aria-label="Back to trails"]')).not.toBeNull();
|
|
195
237
|
expect(body.querySelector('button[aria-label^="Play "]')).toBeNull();
|
|
196
238
|
expect(body.querySelector('button[aria-label^="Focus layout"]')).toBeNull();
|
|
@@ -234,6 +276,32 @@ describe('subsystem graph sidebar', () => {
|
|
|
234
276
|
}
|
|
235
277
|
});
|
|
236
278
|
|
|
279
|
+
test('hides Issues for a selected trail with no findings even when other trails have issues', () => {
|
|
280
|
+
const previousWindow = globalThis.window;
|
|
281
|
+
const dom = new Window();
|
|
282
|
+
dom.localStorage.setItem('principal.subsystems.viewState.empty-trail-issues-test', JSON.stringify({
|
|
283
|
+
sidebarTrailId: 'request',
|
|
284
|
+
}));
|
|
285
|
+
globalThis.window = dom as unknown as typeof window;
|
|
286
|
+
try {
|
|
287
|
+
const body = renderGraph({
|
|
288
|
+
persistKey: 'empty-trail-issues-test',
|
|
289
|
+
showIssues: true,
|
|
290
|
+
issues: [{
|
|
291
|
+
id: 'unrelated', severity: 'error', kind: 'missing_file', message: 'Unrelated finding',
|
|
292
|
+
target: { kind: 'component', id: 'other', label: 'Other' },
|
|
293
|
+
}],
|
|
294
|
+
diagnostic: { status: 'issues', issueCount: 1 },
|
|
295
|
+
});
|
|
296
|
+
expect(body.querySelector('header button[aria-label^="Issues:"]')).toBeNull();
|
|
297
|
+
expect(body.querySelector('header button[aria-label="Trail"]')?.getAttribute('aria-pressed')).toBe('true');
|
|
298
|
+
expect(body.querySelector('[aria-hidden="false"] button[aria-label="Back to trails"]')).not.toBeNull();
|
|
299
|
+
expect(body.querySelector('[aria-hidden="false"]')?.textContent).toContain('Entry');
|
|
300
|
+
} finally {
|
|
301
|
+
globalThis.window = previousWindow;
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
237
305
|
test('static graphs keep their description but do not show a file panel', () => {
|
|
238
306
|
const body = renderGraph({ trails: undefined, graphifyRelations });
|
|
239
307
|
expect(body.textContent).toContain('Always visible description.');
|
|
@@ -254,7 +322,10 @@ describe('subsystem graph sidebar', () => {
|
|
|
254
322
|
});
|
|
255
323
|
|
|
256
324
|
test('the description stays visible while diagnostics replace the trails', () => {
|
|
257
|
-
const body = renderGraph({ showIssues: true, issues: [
|
|
325
|
+
const body = renderGraph({ showIssues: true, issues: [{
|
|
326
|
+
id: 'related', severity: 'error', kind: 'missing_file', message: 'Related finding',
|
|
327
|
+
target: { kind: 'component', id: 'entry', label: 'Entry' },
|
|
328
|
+
}] });
|
|
258
329
|
expect(body.textContent).toContain('Always visible description.');
|
|
259
330
|
expect(body.textContent).not.toContain('Request flow');
|
|
260
331
|
expect(body.querySelector('[aria-label="Resize sidebar"]')).not.toBeNull();
|