Skip to content

Commit 330e500

Browse files
feat: shared CALM architecture diff with calm diff CLI and calm-hub-ui /diff route (finos#2442)
1 parent 779ebc2 commit 330e500

30 files changed

Lines changed: 4542 additions & 2 deletions

.github/workflows/build-calm-hub-ui.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ jobs:
3434
run: npm run lint --workspace=calm-hub-ui
3535

3636
- name: Build workspace
37-
run: npm run build --workspace=calm-hub-ui
37+
run: npm run build:calm-hub-ui
3838

3939
- name: Run tests for Shared
4040
run: npm run test --workspace=calm-hub-ui

calm-hub-ui/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { HashRouter as Router, Route, Routes } from 'react-router-dom';
22
import Hub from './hub/Hub.js';
33
import Visualizer from './visualizer/Visualizer.js';
4+
import Diff from './diff/Diff.js';
45

56
function App() {
67
//TODO: The artifacts route will eventually need to be changed/replaced once we create a unique identifier for resources that can be used across CalmHubs.
@@ -12,6 +13,7 @@ function App() {
1213
<Route path="/" element={<Hub />} />
1314
<Route path="/:namespace/:type/:id/:version" element={<Hub />} />
1415
<Route path="/visualizer" element={<Visualizer />} />
16+
<Route path="/diff" element={<Diff />} />
1517
</Routes>
1618
</Router>
1719
);

calm-hub-ui/src/diff/Diff.css

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
.diff-container {
2+
display: flex;
3+
height: 100%;
4+
}
5+
6+
.diff-graph-panel {
7+
flex: 1;
8+
display: flex;
9+
flex-direction: column;
10+
border-right: 1px solid #e5e7eb;
11+
}
12+
13+
.diff-panel {
14+
width: 400px;
15+
background: white;
16+
border-left: 1px solid #e5e7eb;
17+
overflow-y: auto;
18+
}
19+
20+
.file-upload-area {
21+
padding: 20px;
22+
border: 2px dashed #d1d5db;
23+
border-radius: 8px;
24+
text-align: center;
25+
margin: 20px;
26+
transition: border-color 0.2s;
27+
}
28+
29+
.file-upload-area:hover {
30+
border-color: #9ca3af;
31+
}
32+
33+
.file-upload-area.drag-active {
34+
border-color: #3b82f6;
35+
background-color: #eff6ff;
36+
}
37+
38+
.file-upload-input {
39+
display: none;
40+
}
41+
42+
.file-upload-label {
43+
cursor: pointer;
44+
color: #6b7280;
45+
}
46+
47+
.file-upload-label:hover {
48+
color: #374151;
49+
}
50+
51+
.architectures-container {
52+
display: flex;
53+
flex: 1;
54+
min-height: 0;
55+
}
56+
57+
.architecture-panel {
58+
flex: 1;
59+
display: flex;
60+
flex-direction: column;
61+
border-right: 1px solid #e5e7eb;
62+
}
63+
64+
.architecture-panel:last-child {
65+
border-right: none;
66+
}
67+
68+
.architecture-header {
69+
padding: 12px 16px;
70+
background: #f9fafb;
71+
border-bottom: 1px solid #e5e7eb;
72+
font-weight: 600;
73+
color: #374151;
74+
}
75+
76+
.architecture-graph {
77+
flex: 1;
78+
min-height: 0;
79+
}
80+
81+
.diff-summary {
82+
padding: 16px;
83+
}
84+
85+
.diff-section {
86+
margin-bottom: 24px;
87+
}
88+
89+
.diff-section-title {
90+
font-size: 14px;
91+
font-weight: 600;
92+
color: #374151;
93+
margin-bottom: 8px;
94+
text-transform: uppercase;
95+
letter-spacing: 0.05em;
96+
}
97+
98+
.diff-item {
99+
padding: 8px 12px;
100+
margin-bottom: 4px;
101+
border-radius: 4px;
102+
font-size: 13px;
103+
line-height: 1.4;
104+
}
105+
106+
.diff-item-added {
107+
background-color: #dcfce7;
108+
border-left: 3px solid #16a34a;
109+
}
110+
111+
.diff-item-removed {
112+
background-color: #fef2f2;
113+
border-left: 3px solid #dc2626;
114+
}
115+
116+
.diff-item-modified {
117+
background-color: #fef3c7;
118+
border-left: 3px solid #d97706;
119+
}
120+
121+
.diff-item-renamed {
122+
background-color: #e0e7ff;
123+
border-left: 3px solid #6366f1;
124+
}
125+
126+
.diff-item-unchanged {
127+
background-color: #f3f4f6;
128+
border-left: 3px solid #9ca3af;
129+
opacity: 0.7;
130+
}
131+
132+
.diff-stats {
133+
display: flex;
134+
gap: 16px;
135+
margin-bottom: 16px;
136+
flex-wrap: wrap;
137+
}
138+
139+
.diff-stat {
140+
display: flex;
141+
align-items: center;
142+
gap: 6px;
143+
font-size: 13px;
144+
color: #6b7280;
145+
}
146+
147+
.diff-stat-number {
148+
font-weight: 600;
149+
color: #374151;
150+
}

calm-hub-ui/src/diff/Diff.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { useState } from 'react';
2+
import { Navbar } from '../components/navbar/Navbar.js';
3+
import type { CalmArchitectureSchema } from '@finos/calm-models/types';
4+
import { diffArchitectures, type DiffResult } from '@finos/calm-models/diff';
5+
import './Diff.css';
6+
import { DiffPanel } from './components/DiffPanel.js';
7+
import { DiffGraphPanel } from './components/DiffGraphPanel.js';
8+
9+
export default function Diff() {
10+
const [archA, setArchA] = useState<CalmArchitectureSchema | null>(null);
11+
const [archB, setArchB] = useState<CalmArchitectureSchema | null>(null);
12+
const [diffResult, setDiffResult] = useState<DiffResult | null>(null);
13+
14+
const handleFileLoad = (file: File, isFirst: boolean) => {
15+
const reader = new FileReader();
16+
reader.onload = (e) => {
17+
try {
18+
const data = JSON.parse(e.target?.result as string) as CalmArchitectureSchema;
19+
if (isFirst) {
20+
setArchA(data);
21+
} else {
22+
setArchB(data);
23+
}
24+
25+
if ((isFirst && archB) || (!isFirst && archA)) {
26+
const result = diffArchitectures(isFirst ? data : archA!, isFirst ? archB! : data);
27+
setDiffResult(result);
28+
}
29+
} catch (error) {
30+
console.error('Error parsing CALM file:', error);
31+
alert('Error parsing CALM file. Please ensure it\'s valid JSON.');
32+
}
33+
};
34+
reader.readAsText(file);
35+
};
36+
37+
return (
38+
<div className="h-screen flex flex-col">
39+
<Navbar />
40+
<div className="flex-1 flex">
41+
<DiffGraphPanel
42+
archA={archA}
43+
archB={archB}
44+
diffResult={diffResult}
45+
onFileLoad={handleFileLoad}
46+
/>
47+
<DiffPanel diffResult={diffResult} />
48+
</div>
49+
</div>
50+
);
51+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import testArchitectures from '../fixtures/diff-test-architectures.json' with { type: 'json' };
4+
import { DiffResult } from '@finos/calm-models/diff';
5+
import { DiffGraph } from './DiffGraph.js';
6+
7+
describe('DiffGraph', () => {
8+
it('should render without crashing', () => {
9+
const architecture = testArchitectures.baseArchitecture;
10+
const diffResult: DiffResult = {
11+
nodesAdded: [],
12+
nodesRemoved: [],
13+
nodesModified: [],
14+
nodesSame: architecture.nodes,
15+
nodesRenamed: [],
16+
edgesAdded: [],
17+
edgesRemoved: [],
18+
edgesModified: [],
19+
edgesSame: architecture.relationships,
20+
edgesRenamed: [],
21+
};
22+
23+
render(
24+
<DiffGraph
25+
architecture={architecture}
26+
diffResult={diffResult}
27+
isFirst={true}
28+
/>
29+
);
30+
31+
// Check if nodes are rendered by looking for names
32+
testArchitectures.baseArchitecture.nodes.forEach(node => {
33+
expect(screen.getByText(node.name)).toBeInTheDocument();
34+
});
35+
36+
// Expect there to be as many edges rendered as in the architecture
37+
screen.findAllByRole('edge').then(edges => {
38+
expect(edges).toHaveLength(testArchitectures.baseArchitecture.relationships.length);
39+
});
40+
});
41+
42+
it('should handle empty architecture gracefully', () => {
43+
const emptyArchitecture = {
44+
nodes: [],
45+
relationships: [],
46+
};
47+
const diffResult: DiffResult = {
48+
nodesAdded: [],
49+
nodesRemoved: [],
50+
nodesModified: [],
51+
nodesSame: [],
52+
nodesRenamed: [],
53+
edgesAdded: [],
54+
edgesRemoved: [],
55+
edgesModified: [],
56+
edgesSame: [],
57+
edgesRenamed: [],
58+
};
59+
60+
render(
61+
<DiffGraph
62+
architecture={emptyArchitecture}
63+
diffResult={diffResult}
64+
isFirst={true}
65+
/>
66+
);
67+
68+
screen.findAllByRole('node').then(nodes => {
69+
expect(nodes).toHaveLength(0);
70+
});
71+
screen.findAllByRole('edge').then(edges => {
72+
expect(edges).toHaveLength(0);
73+
});
74+
});
75+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useEffect, useMemo } from 'react';
2+
import ReactFlow, {
3+
Background,
4+
Controls,
5+
MiniMap,
6+
useNodesState,
7+
useEdgesState,
8+
} from 'reactflow';
9+
import 'reactflow/dist/style.css';
10+
import { FloatingEdge } from '../../visualizer/components/reactflow/FloatingEdge.js';
11+
import { CustomNode } from '../../visualizer/components/reactflow/CustomNode.js';
12+
import { SystemGroupNode } from '../../visualizer/components/reactflow/SystemGroupNode.js';
13+
import { parseCALMDataWithDiff } from './utils/diffTransformer.js';
14+
import type { DiffGraphProps } from '../model/diff-ui-types.js';
15+
16+
const edgeTypes = { custom: FloatingEdge };
17+
const nodeTypes = { custom: CustomNode, group: SystemGroupNode };
18+
19+
export function DiffGraph({ architecture, diffResult, isFirst }: DiffGraphProps) {
20+
const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
21+
return parseCALMDataWithDiff(architecture, diffResult, isFirst);
22+
}, [architecture, diffResult, isFirst]);
23+
24+
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
25+
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
26+
27+
useEffect(() => {
28+
setNodes(initialNodes);
29+
setEdges(initialEdges);
30+
}, [initialNodes, initialEdges, setNodes, setEdges]);
31+
32+
return (
33+
<div style={{ height: '100%', width: '100%' }}>
34+
<ReactFlow
35+
nodes={nodes}
36+
edges={edges}
37+
onNodesChange={onNodesChange}
38+
onEdgesChange={onEdgesChange}
39+
nodeTypes={nodeTypes}
40+
edgeTypes={edgeTypes}
41+
fitView
42+
attributionPosition="bottom-left"
43+
>
44+
<Background />
45+
<Controls />
46+
<MiniMap />
47+
</ReactFlow>
48+
</div>
49+
);
50+
}

0 commit comments

Comments
 (0)