Skip to content

Commit c399a75

Browse files
Merge pull request finos#1592 from LeighFinegold/feature/document-block-architecture-widget
Block Architecture Widget
2 parents fed336e + 20b8d3b commit c399a75

File tree

99 files changed

+8952
-47
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

99 files changed

+8952
-47
lines changed

calm-widgets/README.md

Lines changed: 180 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,182 @@ classDef highlight fill:#f2bbae;
124124
```
125125

126126

127-
5. **Test thoroughly** with `npm test`
128127

128+
### Block Architecture Widget
129+
130+
Renders a system architecture as a Mermaid flowchart with optional containers (systems), interfaces, highlights, and flow-focused slices.
131+
132+
```handlebars
133+
{{!-- Basic: render entire architecture --}}
134+
{{block-architecture this}}
135+
136+
{{!-- Hide containers (just the services) --}}
137+
{{block-architecture this include-containers="none"}}
138+
139+
{{!-- Show node interfaces as small attachment boxes --}}
140+
{{block-architecture this render-interfaces=true}}
141+
142+
{{!-- Focus specific nodes (comma-separated) and highlight them --}}
143+
{{block-architecture this focus-nodes="trading-system,position-system" highlight-nodes="trade-svc,position-svc"}}
144+
145+
{{!-- Focus one or more flows by unique-id or name (case-insensitive) --}}
146+
{{block-architecture this focus-flows="order-flow"}}
147+
148+
{{!-- Flow focus + hide containers --}}
149+
{{block-architecture this focus-flows="order-flow" include-containers="none"}}
150+
151+
{{!-- Multiple flows --}}
152+
{{block-architecture this focus-flows="order-flow,onboarding-flow"}}
153+
```
154+
155+
**What it shows**
156+
- **Containers (systems)** as Mermaid subgraphs, with contained **nodes (services, dbs, etc.)** inside.
157+
- Optional **interfaces** as dotted attachments to their parent node.
158+
- **Edges** for `connects`/`interacts` relationships, using the relationship **`description`** as the label when present.
159+
- **Highlights** for any nodes listed in `highlight-nodes`.
160+
- Optional **clickable links** per node/interface (via `link-prefix` or `link-map`).
161+
162+
**Options**
163+
164+
| Option | Type | Default | Description |
165+
|-----------------------|---|---:|---|
166+
| `focus-nodes` | string (CSV) || Restrict the view to these node IDs (and, if containers are shown, their parent/child context per other options). |
167+
| `focus-relationships` | string (CSV) || Restrict view to the specified relationship unique-ids. Only those relationships and the nodes they connect are included (plus containers per settings). |
168+
| `focus-flows` | string (CSV) || Restrict edges to transitions that belong to the given **flow unique-ids or names** (case-insensitive). Only nodes touching those edges are included (plus containers per settings). |
169+
| `focus-controls` | string (CSV) || Restrict view to nodes and relationships linked to the specified control IDs. Only nodes touching those controls are included (plus containers per settings). |
170+
| `focus-interfaces` | string (CSV) || Restrict view to nodes and relationships linked to the specified interface IDs. Only nodes touching those interfaces are included (plus containers per settings). || `highlight-nodes` | string (CSV) || Nodes to visually highlight. |
171+
| `render-interfaces` | boolean | `false` | If `true`, render each node’s `interfaces` as small interface boxes connected by dotted lines. |
172+
| `include-containers` | `'none' \| 'parents' \| 'all'` | `'all'` | Which containers (systems) to draw. |
173+
| `include-children` | `'none' \| 'direct' \| 'all'` | `'all'` | When focusing container nodes, include their direct/all descendants. |
174+
| `edges` | `'connected' \| 'seeded' \| 'all' \| 'none'` | `'connected'` | For non-flow views, expand visible set with directly connected neighbors. When flows are focused, only flow edges are shown. |
175+
| `node-types` | string (CSV) || Only include nodes whose `node-type` is in this list. |
176+
| `direction` | `'both' \| 'in' \| 'out'` | `'both'` | Reserved (currently not used by the renderer). |
177+
| `edge-labels` | `'description' \| 'none'` | `'description'` | Use the relationship `description` for edge labels; or hide labels entirely. |
178+
| `link-prefix` | string || Prefix for clickable `click` links in Mermaid (e.g., `/docs/` makes `/docs/<node-id>`). |
179+
| `link-map` | stringified JSON map || Explicit per-id links, e.g. `{"trade-svc": "/svc/trade"}`. Map entries override `link-prefix`. |
180+
181+
> **Sorting:** Containers and nodes are always sorted **alphabetically by label** for stable layouts.
182+
183+
**Context requirements**
184+
- The context must be a **CALM core canonical model**, e.g. `{ nodes, relationships, flows? }`.
185+
- To use `focus-flows`, `context.flows` must include the target flows. Each flow’s `transitions[*].relationship-unique-id` must point to a relationship in `context.relationships`.
186+
187+
**Example Visual**
188+
189+
For more examples, see the test fixtures:
190+
- [Basic structures](./test-fixtures/block-architecture-widget/basic-structures/)
191+
- [Enterprise trading system](./test-fixtures/block-architecture-widget/enterprise-bank-trading/)
192+
- [Interface variations](./test-fixtures/block-architecture-widget/interface-variations/)
193+
- [Focus flows](./test-fixtures/block-architecture-widget/focus-flows/)
194+
- [Domain interaction](./test-fixtures/block-architecture-widget/domain-interaction/)
195+
196+
#### Example block architecture diagram with interfaces
197+
198+
```mermaid
199+
%%{init: {"flowchart": {"htmlLabels": false}}}%%
200+
flowchart TB
201+
classDef boundary fill:#f8fafc,stroke:#64748b,stroke-dasharray: 5 4,stroke-width:2px,color:#000;
202+
classDef node fill:#ffffff,stroke:#1f2937,stroke-width:1px,color:#000;
203+
classDef iface fill:#f1f5f9,stroke:#64748b,stroke-width:1px,font-size:10px,color:#000;
204+
classDef highlight fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#000;
205+
206+
subgraph enterprise-bank["Enterprise Bank"]
207+
direction TB
208+
subgraph messaging-system["Messaging System"]
209+
direction TB
210+
message-bus["Message Bus"]:::node
211+
message-bus__iface__trade-events-topic["◻ Trade Events Topic"]:::iface
212+
end
213+
class messaging-system boundary
214+
subgraph trading-system["Trading System"]
215+
direction TB
216+
trade-svc["Trade Service"]:::node
217+
trade-svc__iface__api["◻ API: /trades"]:::iface
218+
trade-svc__iface__jdbc["◻ JDBC: trading-db"]:::iface
219+
trade-svc__iface__events["◻ Topic: trade.events"]:::iface
220+
trading-db["Trading DB"]:::node
221+
trading-db__iface__sql["◻ SQL Interface"]:::iface
222+
trading-ui["Trading UI"]:::node
223+
trading-ui__iface__web-ui["◻ Web Interface"]:::iface
224+
end
225+
class trading-system boundary
226+
end
227+
class enterprise-bank boundary
228+
229+
230+
trading-ui__iface__web-ui -->|Place Trade| trade-svc__iface__api
231+
trade-svc__iface__jdbc -->|Persist| trading-db__iface__sql
232+
trade-svc__iface__events -->|Publish Events| message-bus__iface__trade-events-topic
233+
234+
trading-ui -.- trading-ui__iface__web-ui
235+
trade-svc -.- trade-svc__iface__api
236+
trade-svc -.- trade-svc__iface__jdbc
237+
trade-svc -.- trade-svc__iface__events
238+
trading-db -.- trading-db__iface__sql
239+
message-bus -.- message-bus__iface__trade-events-topic
240+
241+
class trade-svc highlight
242+
class trading-ui highlight
243+
class trading-db highlight
244+
class message-bus highlight
245+
click message-bus "#message-bus" "Jump to Message Bus"
246+
click message-bus__iface__trade-events-topic "#message-bus__iface__trade-events-topic" "Jump to ◻ Trade Events Topic"
247+
click trade-svc "#trade-service" "Jump to Trade Service"
248+
click trade-svc__iface__api "#trade-service-api" "Jump to ◻ API: /trades"
249+
click trade-svc__iface__jdbc "#trade-service-storage" "Jump to ◻ JDBC: trading-db"
250+
click trade-svc__iface__events "#trade-service-events" "Jump to ◻ Topic: trade.events"
251+
click trading-db "#trading-db" "Jump to Trading DB"
252+
click trading-db__iface__sql "#trading-db__iface__sql" "Jump to ◻ SQL Interface"
253+
click trading-ui "#trading-ui" "Jump to Trading UI"
254+
click trading-ui__iface__web-ui "#trading-ui__iface__web-ui" "Jump to ◻ Web Interface"
255+
256+
```
257+
258+
---
259+
260+
#### Example block architecture diagram without interfaces
129261

262+
```mermaid
263+
%%{init: {"flowchart": {"htmlLabels": false}}}%%
264+
flowchart TB
265+
classDef boundary fill:#f8fafc,stroke:#64748b,stroke-dasharray: 5 4,stroke-width:2px,color:#000;
266+
classDef node fill:#ffffff,stroke:#1f2937,stroke-width:1px,color:#000;
267+
classDef iface fill:#f1f5f9,stroke:#64748b,stroke-width:1px,font-size:10px,color:#000;
268+
classDef highlight fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#000;
269+
270+
subgraph enterprise-bank["Enterprise Bank"]
271+
direction TB
272+
subgraph messaging-system["Messaging System"]
273+
direction TB
274+
message-bus["Message Bus"]:::node
275+
end
276+
class messaging-system boundary
277+
subgraph trading-system["Trading System"]
278+
direction TB
279+
trade-svc["Trade Service"]:::node
280+
trading-db["Trading DB"]:::node
281+
trading-ui["Trading UI"]:::node
282+
end
283+
class trading-system boundary
284+
end
285+
class enterprise-bank boundary
286+
287+
288+
trading-ui -->|Place Trade| trade-svc
289+
trade-svc -->|Persist| trading-db
290+
trade-svc -->|Publish Events| message-bus
291+
292+
293+
class trade-svc highlight
294+
class trading-ui highlight
295+
class trading-db highlight
296+
class message-bus highlight
297+
click message-bus "#message-bus" "Jump to Message Bus"
298+
click trade-svc "#trade-service" "Jump to Trade Service"
299+
click trading-db "#trading-db" "Jump to Trading DB"
300+
click trading-ui "#trading-ui" "Jump to Trading UI"
301+
302+
```
130303

131304
## 🛠️ Creating Custom Widgets
132305

@@ -157,20 +330,20 @@ export interface MyWidgetViewModel {
157330

158331
export const MyWidget: CalmWidget<
159332
MyWidgetContext,
160-
MyWidgetOptions,
333+
MyWidgetOptions,
161334
MyWidgetViewModel
162335
> = {
163336
id: 'my-widget',
164337
templatePartial: 'my-widget-template.html',
165-
338+
166339
// Optional: additional template partials
167340
partials: ['item-template.html'],
168-
341+
169342
// Transform input data to view model
170343
transformToViewModel: (context, options) => {
171344
const showCount = options?.hash?.showCount ?? false;
172345
const prefix = options?.hash?.prefix ?? '';
173-
346+
174347
return {
175348
title: context.title,
176349
items: context.items,
@@ -198,6 +371,8 @@ export const MyWidget: CalmWidget<
198371
};
199372
```
200373

374+
375+
201376
### 2. Template Files
202377

203378
Create Handlebars templates for your widget:

calm-widgets/src/test-utils/fixture-loader.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,24 @@ export class FixtureLoader {
3131
throw new Error(`Expected file not found: ${expectedPath}`);
3232
}
3333

34-
const context = JSON.parse(fs.readFileSync(contextPath, 'utf-8'));
34+
const rawContext = fs.readFileSync(contextPath, 'utf-8');
35+
let context: unknown;
36+
try {
37+
context = JSON.parse(rawContext);
38+
} catch {
39+
// Try to be forgiving: strip common markdown code fences and JS-style comments
40+
const stripped = rawContext
41+
.replace(/^\uFEFF/, '') // BOM
42+
.replace(/```(?:json)?\n?|```/g, '') // remove ```json fences
43+
.replace(/\/\/.*$/gm, '') // remove // line comments
44+
.replace(/\/\*[\s\S]*?\*\//g, '') // remove /* */ block comments
45+
.trim();
46+
try {
47+
context = JSON.parse(stripped);
48+
} catch (err2) {
49+
throw new Error(`Failed to parse JSON context file ${contextPath}: ${err2 instanceof Error ? err2.message : String(err2)}`);
50+
}
51+
}
3552
const template = fs.readFileSync(templatePath, 'utf-8');
3653
const expected = fs.readFileSync(expectedPath, 'utf-8').trim();
3754

calm-widgets/src/widget-engine.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,15 +122,16 @@ describe('WidgetEngine', () => {
122122
});
123123

124124
describe('registerDefaultWidgets', () => {
125-
it('registers the default widgets (list, table, json-viewer)', () => {
125+
it('registers the default widgets (list, table, json-viewer, flow-sequence, related-nodes, block-architecture)', () => {
126126
engine.registerDefaultWidgets();
127127

128-
expect(registerMock).toHaveBeenCalledTimes(5);
128+
expect(registerMock).toHaveBeenCalledTimes(6);
129129
expect(localHandlebars.registerHelper).toHaveBeenCalledWith('list', expect.any(Function));
130130
expect(localHandlebars.registerHelper).toHaveBeenCalledWith('table', expect.any(Function));
131131
expect(localHandlebars.registerHelper).toHaveBeenCalledWith('json-viewer', expect.any(Function));
132132
expect(localHandlebars.registerHelper).toHaveBeenCalledWith('flow-sequence', expect.any(Function));
133133
expect(localHandlebars.registerHelper).toHaveBeenCalledWith('related-nodes', expect.any(Function));
134+
expect(localHandlebars.registerHelper).toHaveBeenCalledWith('block-architecture', expect.any(Function));
134135
});
135136
});
136137
});

calm-widgets/src/widget-engine.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ListWidget } from './widgets/list';
99
import { JsonViewerWidget } from './widgets/json-viewer';
1010
import { FlowSequenceWidget } from './widgets/flow-sequence';
1111
import { RelatedNodesWidget } from './widgets/related-nodes';
12+
import { BlockArchitectureWidget } from './widgets/block-architecture';
1213

1314
export class WidgetEngine {
1415
constructor(
@@ -70,6 +71,10 @@ export class WidgetEngine {
7071
widget: RelatedNodesWidget as CalmWidget<unknown, object, unknown>,
7172
folder: __dirname + '/widgets/related-nodes',
7273
},
74+
{
75+
widget: BlockArchitectureWidget as CalmWidget<unknown, object, unknown>,
76+
folder: __dirname + '/widgets/block-architecture',
77+
},
7378
];
7479
this.setupWidgets(widgets);
7580
}

calm-widgets/src/widget-helpers.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@ export function registerGlobalTemplateHelpers(): Record<string, (...args: unknow
6565
return actualArgs.some(Boolean);
6666
},
6767

68+
length: (value: unknown): number => {
69+
if (Array.isArray(value) || typeof value === 'string') return value.length;
70+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
71+
return Object.keys(value).length;
72+
}
73+
return 0;
74+
},
75+
76+
gt: (a: unknown, b: unknown): boolean => {
77+
const an = typeof a === 'number' ? a : Number(a);
78+
const bn = typeof b === 'number' ? b : Number(b);
79+
return an > bn;
80+
},
81+
6882
eachInMap: (map: unknown, options: unknown): string => {
6983
let result = '';
7084
if (

calm-widgets/src/widgets.e2e.spec.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,93 @@ describe('Widgets E2E - Handlebars Integration', () => {
205205
expect(result.trim()).toBe(expected);
206206
});
207207
});
208+
209+
describe('Block Architecture Widget', () => {
210+
it('renders basic architecture structures (empty, single system, multiple systems)', () => {
211+
const { context, template, expected } = fixtures.loadFixture('block-architecture-widget', 'basic-structures');
212+
const compiled = handlebars.compile(template);
213+
const result = compiled(context);
214+
expect(result.trim()).toBe(expected);
215+
});
216+
217+
// Interface rendering variations - consolidated from interfaces-off, interfaces-on-both, interfaces-on-one-side
218+
it('handles all interface rendering variations in one comprehensive test', () => {
219+
const { context, template, expected } = fixtures.loadFixture('block-architecture-widget', 'interface-variations');
220+
const compiled = handlebars.compile(template);
221+
const result = compiled(context);
222+
expect(result.trim()).toBe(expected);
223+
});
224+
225+
// Complex real-world scenarios - keep these as they demonstrate practical use cases
226+
it('renders enterprise bank trading system with mixed communication patterns', () => {
227+
const { context, template, expected } = fixtures.loadFixture('block-architecture-widget', 'enterprise-bank-trading');
228+
const compiled = handlebars.compile(template);
229+
const result = compiled(context);
230+
expect(result.trim()).toBe(expected);
231+
});
232+
233+
it('renders enterprise bank with clickable navigation pattern and progressive detail levels', () => {
234+
const { context, template, expected } = fixtures.loadFixture('block-architecture-widget', 'enterprise-bank-navigation');
235+
const compiled = handlebars.compile(template);
236+
const result = compiled(context);
237+
expect(result.trim()).toBe(expected);
238+
});
239+
240+
it('renders a nested architecture with nested relationships', () => {
241+
const { context, template, expected } = fixtures.loadFixture('block-architecture-widget', 'nested-architecture');
242+
const compiled = handlebars.compile(template);
243+
const result = compiled(context);
244+
expect(result.trim()).toBe(expected);
245+
});
246+
247+
it('renders large topology with many nodes and connections efficiently', () => {
248+
const { context, template, expected } = fixtures.loadFixture('block-architecture-widget', 'large-topology');
249+
const compiled = handlebars.compile(template);
250+
const result = compiled(context);
251+
expect(result.trim()).toBe(expected);
252+
});
253+
254+
it('applies domain interaction diagram', () => {
255+
const { context, template, expected } = fixtures.loadFixture('block-architecture-widget', 'domain-interaction');
256+
const compiled = handlebars.compile(template);
257+
const result = compiled(context);
258+
expect(result.trim()).toBe(expected);
259+
});
260+
261+
// New focus flows test - demonstrates flow-based filtering
262+
it('filters architecture based on specific flows', () => {
263+
const { context, template, expected } = fixtures.loadFixture('block-architecture-widget', 'focus-flows');
264+
const compiled = handlebars.compile(template);
265+
const result = compiled(context);
266+
expect(result.trim()).toBe(expected);
267+
});
268+
269+
it('filters architecture based on interface matching by unique-id and properties', () => {
270+
const { context, template, expected } = fixtures.loadFixture('block-architecture-widget', 'focus-interfaces');
271+
const compiled = handlebars.compile(template);
272+
const result = compiled(context);
273+
expect(result.trim()).toBe(expected);
274+
});
275+
276+
it('filters architecture based on control matching by ID and properties', () => {
277+
const { context, template, expected } = fixtures.loadFixture('block-architecture-widget', 'focus-controls');
278+
const compiled = handlebars.compile(template);
279+
const result = compiled(context);
280+
expect(result.trim()).toBe(expected);
281+
});
282+
283+
it('filters architecture based on node focusing with comprehensive scenarios', () => {
284+
const { context, template, expected } = fixtures.loadFixture('block-architecture-widget', 'focus-nodes');
285+
const compiled = handlebars.compile(template);
286+
const result = compiled(context);
287+
expect(result.trim()).toBe(expected);
288+
});
289+
290+
it('filters architecture based on relationship focusing by unique-id', () => {
291+
const { context, template, expected } = fixtures.loadFixture('block-architecture-widget', 'focus-relationships');
292+
const compiled = handlebars.compile(template);
293+
const result = compiled(context);
294+
expect(result.trim()).toBe(expected);
295+
});
296+
});
208297
});

0 commit comments

Comments
 (0)