Skip to content

Commit 4918887

Browse files
authored
Merge pull request #1157 from aamanrebello/layout
Display external nodes outside the system
2 parents d38552a + 33f2ef6 commit 4918887

File tree

9 files changed

+743
-54
lines changed

9 files changed

+743
-54
lines changed

calm-hub-ui/src/tests/Sidebar.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import { render, screen, fireEvent } from '@testing-library/react';
22
import { describe, it, expect, vi } from 'vitest';
3-
import { Edge, Node } from '../visualizer/components/cytoscape-renderer/CytoscapeRenderer.js';
43
import { Sidebar } from '../visualizer/components/sidebar/Sidebar.js';
4+
import { Edge, CalmNode } from '../visualizer/contracts/contracts.js';
55

66
describe('Sidebar Component', () => {
77
const mockCloseSidebar = vi.fn();
88

9-
const mockNodeData: Node['data'] = {
9+
const mockNodeData: CalmNode['data'] = {
1010
id: 'node-1',
1111
label: 'Node 1',
1212
type: 'type-1',
1313
description: 'Mock Node',
14-
};
14+
} as CalmNode['data'];
1515

1616
const mockEdgeData: Edge['data'] = {
1717
id: 'edge-1',
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { describe, it, vi, beforeEach, expect } from "vitest";
2+
import cytoscape from "cytoscape";
3+
import { LayoutCorrectionService } from "../visualizer/services/layout-correction-service.js";
4+
import { BoundingBox, CalmNode } from '../visualizer/contracts/contracts.js';
5+
import { afterEach } from "node:test";
6+
7+
function generateMockNodeObj(id: string, parentId?: string): CalmNode {
8+
const nodeObj = {
9+
classes: `class-${id}`,
10+
data: {
11+
description: `Node ${id} description`,
12+
type: `type-${id}`,
13+
label: `Node ${id}`,
14+
id: id,
15+
parent: parentId,
16+
_displayPlaceholderWithDesc: `Display ${id} with desc`,
17+
_displayPlaceholderWithoutDesc: `Display ${id} without desc`,
18+
extraField: `extraValue-${id}`,
19+
},
20+
};
21+
if (parentId != null) {
22+
nodeObj.data.parent = parentId;
23+
}
24+
return nodeObj as CalmNode;
25+
}
26+
27+
function generateBoundingBox(x1: number, y1: number, w: number, h: number): BoundingBox {
28+
return {
29+
x1: x1,
30+
x2: x1 + w,
31+
y1: y1,
32+
y2: y1 + h,
33+
w: w,
34+
h: h
35+
};
36+
}
37+
38+
function generateMockCyRefGetElementById(x1: number, y1: number, w: number, h: number): cytoscape.NodeSingular {
39+
return {
40+
boundingBox: vi.fn().mockReturnValue(generateBoundingBox(x1, y1, w, h)),
41+
position: vi.fn(),
42+
} as unknown as cytoscape.NodeSingular;
43+
}
44+
45+
describe(LayoutCorrectionService.name, () => {
46+
47+
let mockCyRef: cytoscape.Core;
48+
let mockCyRefGetElementById: cytoscape.NodeSingular;
49+
50+
beforeEach(() => {
51+
mockCyRefGetElementById = generateMockCyRefGetElementById(0, 0, 100, 100);
52+
mockCyRef = {
53+
getElementById: vi.fn().mockImplementation(() => mockCyRefGetElementById),
54+
nodes: vi.fn().mockReturnValue([]),
55+
edges: vi.fn().mockReturnValue([]),
56+
} as unknown as cytoscape.Core;
57+
});
58+
59+
afterEach(() => {
60+
vi.resetAllMocks();
61+
});
62+
63+
function getInstance(): LayoutCorrectionService {
64+
return new LayoutCorrectionService();
65+
}
66+
67+
it('should call getElementById and bounding box functions on nodes to determine the nodes to be moved', () => {
68+
const instance = getInstance();
69+
const nodes: CalmNode[] = [
70+
generateMockNodeObj('node1'),
71+
generateMockNodeObj('node2'),
72+
generateMockNodeObj('node3', 'node1'),
73+
];
74+
75+
instance.calculateAndUpdateNodePositions(mockCyRef, nodes);
76+
expect(mockCyRef.getElementById).toHaveBeenCalledWith("node1");
77+
expect(mockCyRef.getElementById).toHaveBeenCalledWith("node2");
78+
expect(mockCyRef.getElementById).toHaveBeenCalledWith("node3");
79+
expect(mockCyRefGetElementById.boundingBox).toHaveBeenCalledTimes(7);
80+
});
81+
82+
it('should update position for nodes that are inside non-parents', () => {
83+
const instance = getInstance();
84+
//Here, node 2 is inside node 1 but node 1 is not node 2's parent
85+
//So, node 2 is expected to be moved, but node 1 and node 3 are not
86+
const nodes: CalmNode[] = [
87+
generateMockNodeObj('node1'),
88+
generateMockNodeObj('node2'),
89+
generateMockNodeObj('node3', 'node1'),
90+
];
91+
92+
instance.calculateAndUpdateNodePositions(mockCyRef, nodes);
93+
//Once for node 2
94+
expect(mockCyRefGetElementById.position).toHaveBeenCalledWith({
95+
x: -100, y: -100,
96+
});
97+
});
98+
99+
it('should update position for nodes that are not inside their parents', () => {
100+
const instance = getInstance();
101+
//Here, node 3 is not inside node 1 but node 1 is node 3's parent
102+
//So, node 3 is expected to be moved, but node 1 and node 2 are not
103+
const mockCyRefGetElementByIdNode1 = generateMockCyRefGetElementById(0, 0, 100, 100);
104+
const mockCyRefGetElementByIdNode2 = generateMockCyRefGetElementById(150, 150, 100, 100);
105+
const mockCyRefGetElementByIdNode3 = generateMockCyRefGetElementById(-150, -150, 100, 100);
106+
mockCyRef = {
107+
getElementById: vi.fn().mockImplementation((id) => {
108+
if (id === "node1") return mockCyRefGetElementByIdNode1;
109+
if (id === "node2") return mockCyRefGetElementByIdNode2;
110+
if (id === "node3") return mockCyRefGetElementByIdNode3;
111+
return null;
112+
}),
113+
nodes: vi.fn().mockReturnValue([]),
114+
edges: vi.fn().mockReturnValue([]),
115+
} as unknown as cytoscape.Core;
116+
117+
const nodes: CalmNode[] = [
118+
generateMockNodeObj('node1'),
119+
generateMockNodeObj('node2'),
120+
generateMockNodeObj('node3', 'node1')
121+
];
122+
123+
instance.calculateAndUpdateNodePositions(mockCyRef, nodes);
124+
//Once for node 3
125+
expect(mockCyRefGetElementByIdNode3.position).toHaveBeenCalledWith({
126+
x: 50, y: 50,
127+
});
128+
});
129+
130+
it('should be able to handle nested parents', () => {
131+
const instance = getInstance();
132+
//Here, node 3 should be inside node 2 and node 2 should be inside node 1
133+
//So, nodes 2 first, then node 3 are expected to be moved, but node 1 is not
134+
const mockCyRefGetElementByIdNode1 = generateMockCyRefGetElementById(0, 0, 100, 100);
135+
const mockCyRefGetElementByIdNode2 = generateMockCyRefGetElementById(50, 50, 70, 70);
136+
const mockCyRefGetElementByIdNode3 = generateMockCyRefGetElementById(-50, -50, 50, 50);
137+
mockCyRef = {
138+
getElementById: vi.fn().mockImplementation((id) => {
139+
if (id === "node1") return mockCyRefGetElementByIdNode1;
140+
if (id === "node2") return mockCyRefGetElementByIdNode2;
141+
if (id === "node3") return mockCyRefGetElementByIdNode3;
142+
return null;
143+
}),
144+
nodes: vi.fn().mockReturnValue([]),
145+
edges: vi.fn().mockReturnValue([]),
146+
} as unknown as cytoscape.Core;
147+
148+
const nodes: CalmNode[] = [
149+
generateMockNodeObj('node1'),
150+
generateMockNodeObj('node2', 'node1'),
151+
generateMockNodeObj('node3', 'node2'),
152+
];
153+
154+
instance.calculateAndUpdateNodePositions(mockCyRef, nodes);
155+
//Once for node 2 (move to centre of node 1)
156+
expect(mockCyRefGetElementByIdNode2.position).toHaveBeenCalledWith({
157+
x: 50, y: 50,
158+
});
159+
//Once for node 3 (move to centre of node 2, which has just moved)
160+
expect(mockCyRefGetElementByIdNode3.position).toHaveBeenCalledWith({
161+
x: 85, y: 85,
162+
});
163+
});
164+
165+
it('should be able to place a node to be moved between two nodes if necessary', () => {
166+
const instance = getInstance();
167+
//Here, node 3 should not be inside node 1. It will bemoved so as to not overlap with node 2.
168+
const mockCyRefGetElementByIdNode1 = generateMockCyRefGetElementById(0, 0, 100, 100);
169+
const mockCyRefGetElementByIdNode2 = generateMockCyRefGetElementById(-200, -200, 100, 100);
170+
const mockCyRefGetElementByIdNode3 = generateMockCyRefGetElementById(0, 0, 50, 50);
171+
const mockCyRefGetElementByIdNode4 = generateMockCyRefGetElementById(10, 10, 40, 40);
172+
mockCyRef = {
173+
getElementById: vi.fn().mockImplementation((id) => {
174+
if (id === "node1") return mockCyRefGetElementByIdNode1;
175+
if (id === "node2") return mockCyRefGetElementByIdNode2;
176+
if (id === "node3") return mockCyRefGetElementByIdNode3;
177+
if (id === "node4") return mockCyRefGetElementByIdNode4;
178+
return null;
179+
}),
180+
nodes: vi.fn().mockReturnValue([]),
181+
edges: vi.fn().mockReturnValue([]),
182+
} as unknown as cytoscape.Core;
183+
184+
const nodes: CalmNode[] = [
185+
generateMockNodeObj('node1'),
186+
generateMockNodeObj('node2'),
187+
generateMockNodeObj('node3'),
188+
generateMockNodeObj('node4', 'node1'),
189+
];
190+
191+
instance.calculateAndUpdateNodePositions(mockCyRef, nodes);
192+
//Once for node 3 (move between node 1 and node 2)
193+
expect(mockCyRefGetElementByIdNode3.position).toHaveBeenCalledWith({
194+
x: -50, y: -50,
195+
});
196+
});
197+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it } from "vitest";
2+
import { difference, intersection, union } from "../visualizer/helpers/set-functions.js";
3+
4+
describe('union', () => {
5+
it('should return the union of two sets', () => {
6+
const set1 = new Set([1, 2, 3]);
7+
const set2 = new Set([3, 4, 5]);
8+
const result = union(set1, set2);
9+
expect(result).toEqual(new Set([1, 2, 3, 4, 5]));
10+
});
11+
12+
it('should return the first set if the second set is empty', () => {
13+
const set1 = new Set([1, 2, 3]);
14+
const set2 = new Set();
15+
const result = union(set1, set2);
16+
expect(result).toEqual(new Set([1, 2, 3]));
17+
});
18+
19+
it('should return the second set if the first set is empty', () => {
20+
const set1 = new Set();
21+
const set2 = new Set([3, 4, 5]);
22+
const result = union(set1, set2);
23+
expect(result).toEqual(new Set([3, 4, 5]));
24+
});
25+
});
26+
27+
describe('intersection', () => {
28+
it('should return the intersection of two sets', () => {
29+
const set1 = new Set([1, 2, 3]);
30+
const set2 = new Set([2, 3, 4]);
31+
const result = intersection(set1, set2);
32+
expect(result).toEqual(new Set([2, 3]));
33+
});
34+
35+
it('should return an empty set if there is no intersection', () => {
36+
const set1 = new Set([1, 2, 3]);
37+
const set2 = new Set([4, 5, 6]);
38+
const result = intersection(set1, set2);
39+
expect(result).toEqual(new Set());
40+
});
41+
42+
it('should return an empty set if one of the sets is empty', () => {
43+
const set1 = new Set([1, 2, 3]);
44+
const set2 = new Set();
45+
const result = intersection(set1, set2);
46+
expect(result).toEqual(new Set());
47+
});
48+
});
49+
50+
describe('difference', () => {
51+
it('should return the difference of two sets', () => {
52+
const set1 = new Set([1, 2, 3]);
53+
const set2 = new Set([2, 3, 4]);
54+
const result = difference(set1, set2);
55+
expect(result).toEqual(new Set([1]));
56+
});
57+
58+
it('should return the first set if the second set is empty', () => {
59+
const set1 = new Set([1, 2, 3]);
60+
const set2 = new Set();
61+
const result = difference(set1, set2);
62+
expect(result).toEqual(new Set([1, 2, 3]));
63+
});
64+
65+
it('should return an empty set if the first set is empty', () => {
66+
const set1 = new Set();
67+
const set2 = new Set([2, 3, 4]);
68+
const result = difference(set1, set2);
69+
expect(result).toEqual(new Set());
70+
});
71+
});

calm-hub-ui/src/visualizer/components/cytoscape-renderer/CytoscapeRenderer.tsx

Lines changed: 5 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,8 @@ import nodeEdgeHtmlLabel from 'cytoscape-node-edge-html-label';
55
import expandCollapse from 'cytoscape-expand-collapse';
66
import { Sidebar } from '../sidebar/Sidebar.js';
77
import { ZoomContext } from '../zoom-context.provider.js';
8-
import {
9-
CalmInterfaceTypeSchema,
10-
CalmHostPortInterfaceSchema,
11-
CalmHostnameInterfaceSchema,
12-
CalmPathInterfaceSchema,
13-
CalmOAuth2AudienceInterfaceSchema,
14-
CalmURLInterfaceSchema,
15-
CalmRateLimitInterfaceSchema,
16-
CalmContainerImageInterfaceSchema,
17-
CalmPortInterfaceSchema,
18-
} from '../../../../../shared/src/types/interface-types.js';
19-
import { CalmControlsSchema } from '../../../../../shared/src/types/control-types.js';
8+
import { Edge, CalmNode } from '../../contracts/contracts.js';
9+
import { LayoutCorrectionService } from '../../services/layout-correction-service.js';
2010

2111
// Initialize Cytoscape plugins
2212
nodeEdgeHtmlLabel(cytoscape);
@@ -34,42 +24,6 @@ const breadthFirstLayout = {
3424
spacingFactor: 1.25,
3525
};
3626

37-
// Types for nodes and edges
38-
export type CalmNode = {
39-
classes?: string;
40-
data: {
41-
description: string;
42-
type: string;
43-
label: string;
44-
id: string;
45-
_displayPlaceholderWithDesc: string;
46-
_displayPlaceholderWithoutDesc: string;
47-
parent?: string;
48-
interfaces?: (
49-
| CalmInterfaceTypeSchema
50-
| CalmHostPortInterfaceSchema
51-
| CalmHostnameInterfaceSchema
52-
| CalmPathInterfaceSchema
53-
| CalmOAuth2AudienceInterfaceSchema
54-
| CalmURLInterfaceSchema
55-
| CalmRateLimitInterfaceSchema
56-
| CalmContainerImageInterfaceSchema
57-
| CalmPortInterfaceSchema
58-
)[];
59-
controls?: CalmControlsSchema;
60-
};
61-
};
62-
63-
export type Edge = {
64-
data: {
65-
id: string;
66-
label: string;
67-
source: string;
68-
target: string;
69-
[idx: string]: string;
70-
};
71-
};
72-
7327
interface Props {
7428
title?: string;
7529
isNodeDescActive: boolean;
@@ -90,6 +44,8 @@ export const CytoscapeRenderer = ({
9044
const { zoomLevel, updateZoom } = useContext(ZoomContext);
9145
const [selectedItem, setSelectedItem] = useState<CalmNode['data'] | Edge['data'] | null>(null);
9246

47+
const layoutCorrectionService = new LayoutCorrectionService();
48+
9349
// Generate node label templates
9450
const getNodeLabelTemplateGenerator =
9551
(selected = false) =>
@@ -190,7 +146,7 @@ export const CytoscapeRenderer = ({
190146
tpl: getNodeLabelTemplateGenerator(true),
191147
},
192148
]);
193-
149+
layoutCorrectionService.calculateAndUpdateNodePositions(updatedCy, nodes);
194150
// Set Cytoscape instance
195151
setCy(updatedCy);
196152

calm-hub-ui/src/visualizer/components/drawer/Drawer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Sidebar } from '../sidebar/Sidebar.js';
22
import { useState } from 'react';
3-
import { CytoscapeRenderer, CalmNode, Edge } from '../cytoscape-renderer/CytoscapeRenderer.js';
3+
import { CytoscapeRenderer } from '../cytoscape-renderer/CytoscapeRenderer.js';
44
import {
55
CalmArchitectureSchema,
66
CalmRelationshipSchema,
@@ -11,6 +11,7 @@ import {
1111
CALMConnectsRelationship,
1212
CALMInteractsRelationship,
1313
} from '../../../../../shared/src/types.js';
14+
import { CalmNode, Edge } from '../../contracts/contracts.js';
1415

1516
interface DrawerProps {
1617
calmInstance?: CalmArchitectureSchema;

calm-hub-ui/src/visualizer/components/sidebar/Sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { IoAddOutline, IoCloseOutline, IoRemoveOutline } from 'react-icons/io5';
2-
import { Edge, CalmNode } from '../cytoscape-renderer/CytoscapeRenderer.js';
32
import { useState } from 'react';
3+
import { CalmNode, Edge } from '../../contracts/contracts.js';
44

55
interface SidebarProps {
66
selectedData: CalmNode['data'] | Edge['data'];

0 commit comments

Comments
 (0)