Skip to content

Commit b9f3612

Browse files
Merge pull request finos#1670 from LeighFinegold/block-architecture-shapes
feat(calm-widgets): Node Type Visual Differentiation for Block Architecture Widget
2 parents 7d6b181 + c8d89b6 commit b9f3612

31 files changed

+952
-57
lines changed

calm-widgets/README.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# CALM Widgets Framework
22

3-
A TypeScript widget system built on Handlebars that provides reusable components for generating Markdown documentation. The framework allows you to create custom widgets that can transform data into formatted output using Handlebars templates.
3+
A TypeScript widget system built on Handlebars that provides reusable components for generating Markdown documentation. The framework allows you to create custom widgets.
44

55
## 🔧 Built-in Widgets
66

@@ -155,6 +155,12 @@ Renders a system architecture as a Mermaid flowchart with optional containers (s
155155
156156
{{!-- Collapse multiple relationships between same source-target pairs --}}
157157
{{block-architecture this collapse-relationships=true}}
158+
159+
{{!-- Render nodes with different shapes based on node-type --}}
160+
{{block-architecture this render-node-type-shapes=true}}
161+
162+
{{!-- Custom node type mapping to built-in shapes --}}
163+
{{block-architecture this render-node-type-shapes=true node-type-map='{"cache": "database", "queue": "messagebus", "proxy": "service"}'}}
158164
```
159165

160166
**What it shows**
@@ -172,7 +178,10 @@ Renders a system architecture as a Mermaid flowchart with optional containers (s
172178
| `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). |
173179
| `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). |
174180
| `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). |
175-
| `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. |
181+
| `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). |
182+
| `highlight-nodes` | string (CSV) || Nodes to visually highlight. |
183+
| `render-node-type-shapes` | boolean | `false` | If `true`, render nodes with different Mermaid shapes based on their `node-type`. Supports built-in CALM types: `actor`, `database`, `webclient`, `service`, `system`, `messagebus`. |
184+
| `node-type-map` | stringified JSON map || Custom mapping of node types to built-in shapes, e.g. `{"cache": "database", "queue": "messagebus"}`. Only used when `render-node-type-shapes` is `true`. |
176185
| `render-interfaces` | boolean | `false` | If `true`, render each node’s `interfaces` as small interface boxes connected by dotted lines. |
177186
| `include-containers` | `'none' \| 'parents' \| 'all'` | `'all'` | Which containers (systems) to draw. |
178187
| `include-children` | `'none' \| 'direct' \| 'all'` | `'all'` | When focusing container nodes, include their direct/all descendants. |
@@ -184,6 +193,17 @@ Renders a system architecture as a Mermaid flowchart with optional containers (s
184193
| `link-prefix` | string || Prefix for clickable `click` links in Mermaid (e.g., `/docs/` makes `/docs/<node-id>`). |
185194
| `link-map` | stringified JSON map || Explicit per-id links, e.g. `{"trade-svc": "/svc/trade"}`. Map entries override `link-prefix`. |
186195

196+
**Built-in Node Type Shapes**
197+
198+
When `render-node-type-shapes` is enabled, the following CALM node types are rendered with distinctive Mermaid shapes:
199+
200+
- `actor` → Circle with person icon 👤
201+
- `database` → Cylinder shape with database icon 🗄️
202+
- `webclient` → Rectangle with web icon 💻
203+
- `service` → Rounded rectangle with gear icon ⚙️
204+
- `system` → Rectangle with system icon 🏢
205+
- `messagebus` → horizontal cylinder with web icon 📨 - this isn't in schema but think we need it
206+
187207
> **Sorting:** Containers and nodes are always sorted **alphabetically by label** for stable layouts.
188208
189209
**Context requirements**
@@ -198,6 +218,8 @@ For more examples, see the test fixtures:
198218
- [Interface variations](./test-fixtures/block-architecture-widget/interface-variations/)
199219
- [Focus flows](./test-fixtures/block-architecture-widget/focus-flows/)
200220
- [Domain interaction](./test-fixtures/block-architecture-widget/domain-interaction/)
221+
- [Node type shapes](./test-fixtures/block-architecture-widget/node-type-shapes/)
222+
- [Custom node type mapping](./test-fixtures/block-architecture-widget/custom-node-type-map/)
201223

202224
#### Example block architecture diagram with interfaces
203225

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,5 +309,12 @@ describe('Widgets E2E - Handlebars Integration', () => {
309309
const result = compiled(context);
310310
expect(result.trim()).toBe(expected);
311311
});
312+
313+
it('renders nodes with type-specific shapes and colors when render-node-type-shapes option is enabled', () => {
314+
const { context, template, expected } = fixtures.loadFixture('block-architecture-widget', 'node-type-shapes');
315+
const compiled = handlebars.compile(template);
316+
const result = compiled(context);
317+
expect(result.trim()).toBe(expected);
318+
});
312319
});
313320
});

calm-widgets/src/widgets/block-architecture/block-architecture.hbs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ classDef boundary fill:#f8fafc,stroke:#64748b,stroke-dasharray: 5 4,stroke-width
55
classDef node fill:#ffffff,stroke:#1f2937,stroke-width:1px,color:#000;
66
classDef iface fill:#f1f5f9,stroke:#64748b,stroke-width:1px,font-size:10px,color:#000;
77
classDef highlight fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#000;
8+
{{#if renderNodeTypeShapes}}
9+
classDef actor fill:#e3f2fd,stroke:#1976d2,stroke-width:2px,color:#000;
10+
classDef database fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#000;
11+
classDef webclient fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#000;
12+
classDef service fill:#e8f5e8,stroke:#388e3c,stroke-width:2px,color:#000;
13+
classDef messagebus fill:#fce4ec,stroke:#c2185b,stroke-width:2px,color:#000;
14+
classDef system fill:#fff8e1,stroke:#f9a825,stroke-width:2px,color:#000;
15+
{{/if}}
816

917
{{!-- Render container forest (if any) --}}
1018
{{#each containers}}
@@ -13,7 +21,11 @@ classDef highlight fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#000;
1321

1422
{{!-- Render loose nodes (not inside any rendered container) --}}
1523
{{#each looseNodes}}
24+
{{#if ../renderNodeTypeShapes}}
25+
{{> typed-node.hbs}}
26+
{{else}}
1627
{{id}}["{{label}}"]:::node
28+
{{/if}}
1729
{{#each interfaces}}
1830
{{id}}["{{label}}"]:::iface
1931
{{/each}}

calm-widgets/src/widgets/block-architecture/container.hbs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
subgraph {{id}}["{{label}}"]
44
direction TB
55
{{#each nodes}}
6+
{{#if @root.renderNodeTypeShapes}}
7+
{{> typed-node.hbs}}
8+
{{else}}
69
{{id}}["{{label}}"]:::node
10+
{{/if}}
711
{{#each interfaces}}
812
{{id}}["{{label}}"]:::iface
913
{{/each}}

calm-widgets/src/widgets/block-architecture/core/builders/container-builder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export function buildContainerForest(
4040
vmContainers.set(id, {
4141
id,
4242
label: labelFor(node, id),
43+
nodeType: node?.['node-type'],
4344
nodes: [],
4445
containers: [],
4546
});

calm-widgets/src/widgets/block-architecture/core/factories/node-factory.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { CalmNodeCanonicalModel } from '@finos/calm-models/canonical';
22
import { VMLeafNode, VMAttach } from '../../types';
33
import { VMNodeFactory } from './vm-factory-interfaces';
4-
import {ifaceId, prettyLabel} from '../utils';
4+
import { ifaceId, prettyLabel } from '../utils';
55

66

77
type WithOptionalLabel = CalmNodeCanonicalModel & { label?: string };
@@ -18,7 +18,8 @@ export class StandardVMNodeFactory implements VMNodeFactory {
1818
const attachments: VMAttach[] = [];
1919
const leaf: VMLeafNode = {
2020
id: node['unique-id'],
21-
label: labelFor(node, node['unique-id'])
21+
label: labelFor(node, node['unique-id']),
22+
nodeType: node['node-type']
2223
};
2324

2425
if (renderInterfaces && Array.isArray(node.interfaces) && node.interfaces.length > 0) {

calm-widgets/src/widgets/block-architecture/core/options-parser.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,29 @@ describe('options-parser', () => {
118118
expect(out.direction).toBe('both');
119119
expect(out.edgeLabels).toBe('description');
120120
});
121+
122+
it('parses node-type-map when provided as object', () => {
123+
const out = parseOptions(
124+
raw('{ "node-type-map": { "postgres": "database", "nginx": "service" } }')
125+
);
126+
expect(out.nodeTypeMap).toEqual({ postgres: 'database', nginx: 'service' });
127+
});
128+
129+
it('parses node-type-map when provided as JSON string', () => {
130+
const out = parseOptions({
131+
'node-type-map': JSON.stringify({ redis: 'database', 'load-balancer': 'service' })
132+
});
133+
expect(out.nodeTypeMap).toEqual({ redis: 'database', 'load-balancer': 'service' });
134+
});
135+
136+
it('ignores bad JSON for node-type-map', () => {
137+
const out = parseOptions({ 'node-type-map': '{bad json for node types' });
138+
expect(out.nodeTypeMap).toBeUndefined();
139+
});
140+
141+
it('render-node-type-shapes: false by default; true only when explicitly true', () => {
142+
expect(parseOptions().renderNodeTypeShapes).toBe(false);
143+
expect(parseOptions({ 'render-node-type-shapes': false }).renderNodeTypeShapes).toBe(false);
144+
expect(parseOptions({ 'render-node-type-shapes': true }).renderNodeTypeShapes).toBe(true);
145+
});
121146
});

calm-widgets/src/widgets/block-architecture/core/options-parser.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export function parseOptions(raw?: BlockArchOptions): NormalizedOptions {
3232
edges: 'connected',
3333
direction: 'both',
3434
renderInterfaces: false,
35+
renderNodeTypeShapes: false,
3536
edgeLabels: 'description',
3637
collapseRelationships: false,
3738
};
@@ -47,6 +48,7 @@ export function parseOptions(raw?: BlockArchOptions): NormalizedOptions {
4748
if (raw['highlight-nodes']) o.highlightNodes = csv(raw['highlight-nodes']);
4849
if (raw['node-types']) o.nodeTypes = csv(raw['node-types']);
4950
if (raw['render-interfaces']) o.renderInterfaces = true;
51+
if (raw['render-node-type-shapes']) o.renderNodeTypeShapes = true;
5052
if (raw['collapse-relationships']) o.collapseRelationships = true;
5153

5254
o.edgeLabels = pickEnum(raw['edge-labels'], ['description', 'none'] as const, o.edgeLabels);
@@ -75,8 +77,22 @@ export function parseOptions(raw?: BlockArchOptions): NormalizedOptions {
7577
} else {
7678
o.linkMap = raw['link-map'];
7779
}
78-
} catch {
79-
// ignore bad JSON; keep linkMap undefined
80+
} catch (error) {
81+
console.warn(`[options-parser] Failed to parse link-map JSON: ${error instanceof Error ? error.message : 'Unknown error'}`);
82+
// keep linkMap undefined
83+
}
84+
}
85+
86+
if (raw['node-type-map']) {
87+
try {
88+
if (typeof raw['node-type-map'] === 'string') {
89+
o.nodeTypeMap = JSON.parse(raw['node-type-map']);
90+
} else {
91+
o.nodeTypeMap = raw['node-type-map'];
92+
}
93+
} catch (error) {
94+
console.warn(`[options-parser] Failed to parse node-type-map JSON: ${error instanceof Error ? error.message : 'Unknown error'}`);
95+
// keep nodeTypeMap undefined
8096
}
8197
}
8298

calm-widgets/src/widgets/block-architecture/core/strategies/expand/children-strategy.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const base = (overrides: Partial<NormalizedOptions> = {}): NormalizedOptions =>
1111
edges: 'connected',
1212
direction: 'both',
1313
renderInterfaces: false,
14+
renderNodeTypeShapes: false,
1415
edgeLabels: 'description',
1516
collapseRelationships: false,
1617
...overrides,

calm-widgets/src/widgets/block-architecture/core/strategies/expand/container-strategy.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const base = (includeContainers: NormalizedOptions['includeContainers']): Normal
1111
edges: 'connected',
1212
direction: 'both',
1313
renderInterfaces: false,
14+
renderNodeTypeShapes: false,
1415
edgeLabels: 'description',
1516
collapseRelationships: false
1617
});

0 commit comments

Comments
 (0)