Skip to content

Commit 97146ca

Browse files
aniketkatkar97Copilot
andauthored
Chore(UI): Knowledge graph improvements (#27823)
* Fix knowledge graph issues * Fix checkstyle * Fix the performance issue * work on comment * Worked on comments * Fix the nodes not highlighting on hover or selection Co-authored-by: Copilot <copilot@github.com> * Fix checkstyle * worked on comment --------- Co-authored-by: Copilot <copilot@github.com>
1 parent 3504ef8 commit 97146ca

16 files changed

Lines changed: 3303 additions & 539 deletions
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/*
2+
* Copyright 2026 Collate.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
* Unless required by applicable law or agreed to in writing, software
8+
* distributed under the License is distributed on an "AS IS" BASIS,
9+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
* See the License for the specific language governing permissions and
11+
* limitations under the License.
12+
*/
13+
14+
import { NodeData } from '@antv/g6';
15+
import { render, screen } from '@testing-library/react';
16+
import React from 'react';
17+
import CustomNode from './CustomNode';
18+
19+
jest.mock('@antv/g6', () => ({}));
20+
21+
jest.mock('../../../utils/TableUtils', () => ({
22+
getEntityIcon: jest.fn(() => <svg data-testid="entity-icon" />),
23+
}));
24+
25+
jest.mock('@openmetadata/ui-core-components', () => {
26+
const R = require('react');
27+
28+
return {
29+
Box: ({
30+
children,
31+
...p
32+
}: React.PropsWithChildren<Record<string, unknown>>) =>
33+
R.createElement('div', p, children),
34+
Typography: ({
35+
children,
36+
'data-testid': testId,
37+
style,
38+
...p
39+
}: React.PropsWithChildren<{
40+
'data-testid'?: string;
41+
style?: React.CSSProperties;
42+
}>) =>
43+
R.createElement('span', { 'data-testid': testId, style, ...p }, children),
44+
};
45+
});
46+
47+
import { getNodeRenderKey } from '../../../utils/KnowledgeGraph.utils';
48+
import { getEntityIcon } from '../../../utils/TableUtils';
49+
50+
function makeNodeData(
51+
overrides: Record<string, unknown> = {},
52+
id = 'node-1'
53+
): NodeData {
54+
return {
55+
id,
56+
data: {
57+
label: 'TestNode',
58+
type: 'table',
59+
...overrides,
60+
},
61+
} as NodeData;
62+
}
63+
64+
function renderCustomNode(nodeData: NodeData) {
65+
return render(
66+
<CustomNode
67+
nodeData={nodeData}
68+
nodeRenderKey={getNodeRenderKey(nodeData)}
69+
/>
70+
);
71+
}
72+
73+
describe('CustomNode', () => {
74+
beforeEach(() => {
75+
jest.clearAllMocks();
76+
(getEntityIcon as jest.Mock).mockReturnValue(
77+
<svg data-testid="entity-icon" />
78+
);
79+
});
80+
81+
describe('Basic rendering', () => {
82+
it('renders without crashing with minimal props', () => {
83+
renderCustomNode(makeNodeData());
84+
85+
expect(screen.getByTestId('node-TestNode')).toBeInTheDocument();
86+
});
87+
88+
it('renders label from nodeData.data.label', () => {
89+
renderCustomNode(makeNodeData({ label: 'MyTable' }));
90+
91+
expect(screen.getByTestId('label')).toHaveTextContent('MyTable');
92+
});
93+
94+
it('renders type text in type-tag', () => {
95+
renderCustomNode(makeNodeData({ type: 'pipeline' }));
96+
97+
expect(screen.getByTestId('type-tag')).toHaveTextContent('pipeline');
98+
});
99+
100+
it('sets data-node-id attribute to nodeData.id', () => {
101+
renderCustomNode(makeNodeData({}, 'abc-123'));
102+
103+
expect(screen.getByTestId('node-TestNode')).toHaveAttribute(
104+
'data-node-id',
105+
'abc-123'
106+
);
107+
});
108+
109+
it('sets data-testid to "node-{label}" on root div', () => {
110+
renderCustomNode(makeNodeData({ label: 'SomeLabel' }));
111+
112+
expect(screen.getByTestId('node-SomeLabel')).toBeInTheDocument();
113+
});
114+
115+
it('exposes data-testid="label" on the label element', () => {
116+
renderCustomNode(makeNodeData());
117+
118+
expect(screen.getByTestId('label')).toBeInTheDocument();
119+
});
120+
121+
it('exposes data-testid="type-tag" on the type element', () => {
122+
renderCustomNode(makeNodeData());
123+
124+
expect(screen.getByTestId('type-tag')).toBeInTheDocument();
125+
});
126+
});
127+
128+
describe('Highlighted state', () => {
129+
it('does NOT add highlighted class when highlighted is undefined', () => {
130+
renderCustomNode(makeNodeData());
131+
132+
expect(screen.getByTestId('node-TestNode')).not.toHaveClass(
133+
'highlighted'
134+
);
135+
});
136+
137+
it('does NOT add highlighted class when highlighted is false', () => {
138+
renderCustomNode(makeNodeData({ highlighted: false }));
139+
140+
expect(screen.getByTestId('node-TestNode')).not.toHaveClass(
141+
'highlighted'
142+
);
143+
});
144+
145+
it('DOES add highlighted class when highlighted is true', () => {
146+
renderCustomNode(makeNodeData({ highlighted: true }));
147+
148+
expect(screen.getByTestId('node-TestNode')).toHaveClass('highlighted');
149+
});
150+
151+
it('updates highlighted class when same node object is mutated and rerendered', () => {
152+
const nodeData = makeNodeData({ highlighted: false });
153+
const { rerender } = renderCustomNode(nodeData);
154+
155+
expect(screen.getByTestId('node-TestNode')).not.toHaveClass(
156+
'highlighted'
157+
);
158+
159+
(nodeData.data as { highlighted?: boolean }).highlighted = true;
160+
rerender(
161+
<CustomNode
162+
nodeData={nodeData}
163+
nodeRenderKey={getNodeRenderKey(nodeData)}
164+
/>
165+
);
166+
167+
expect(screen.getByTestId('node-TestNode')).toHaveClass('highlighted');
168+
});
169+
});
170+
171+
describe('Custom color styles', () => {
172+
it('applies colorMain and colorLight as inline style on type-tag when both provided', () => {
173+
renderCustomNode(
174+
makeNodeData({
175+
colorMain: '#1677ff',
176+
colorLight: '#e6f4ff',
177+
})
178+
);
179+
180+
const tag = screen.getByTestId('type-tag');
181+
182+
expect(tag).toHaveStyle({ color: '#1677ff', backgroundColor: '#e6f4ff' });
183+
});
184+
185+
it('sets border:none on type-tag when both colors provided', () => {
186+
renderCustomNode(
187+
makeNodeData({
188+
colorMain: '#1677ff',
189+
colorLight: '#e6f4ff',
190+
})
191+
);
192+
193+
expect(screen.getByTestId('type-tag')).toHaveStyle({ border: 'none' });
194+
});
195+
196+
it('does NOT apply inline style when only colorMain is provided', () => {
197+
renderCustomNode(makeNodeData({ colorMain: '#1677ff' }));
198+
199+
expect(screen.getByTestId('type-tag')).not.toHaveStyle({
200+
color: '#1677ff',
201+
});
202+
});
203+
204+
it('does NOT apply inline style when only colorLight is provided', () => {
205+
renderCustomNode(makeNodeData({ colorLight: '#e6f4ff' }));
206+
207+
expect(screen.getByTestId('type-tag')).not.toHaveStyle({
208+
backgroundColor: '#e6f4ff',
209+
});
210+
});
211+
212+
it('does NOT apply inline style when neither color is provided', () => {
213+
renderCustomNode(makeNodeData());
214+
const tag = screen.getByTestId('type-tag');
215+
216+
expect(tag.getAttribute('style')).toBeFalsy();
217+
});
218+
});
219+
220+
describe('Icon rendering', () => {
221+
it('calls getEntityIcon with the node type string', () => {
222+
renderCustomNode(makeNodeData({ type: 'dashboard' }));
223+
224+
expect(getEntityIcon).toHaveBeenCalledWith(
225+
'dashboard',
226+
'',
227+
expect.objectContaining({ width: 12, height: 12 })
228+
);
229+
});
230+
231+
it('renders the icon returned by getEntityIcon', () => {
232+
renderCustomNode(makeNodeData());
233+
234+
expect(screen.getByTestId('entity-icon')).toBeInTheDocument();
235+
});
236+
});
237+
});

openmetadata-ui/src/main/resources/ui/src/components/KnowledgeGraph/GraphElements/CustomNode.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@
1313

1414
import { NodeData } from '@antv/g6';
1515
import { Box, Typography } from '@openmetadata/ui-core-components';
16+
import React from 'react';
1617
import { getEntityIcon } from '../../../utils/TableUtils';
1718
import './custom-node.less';
1819

1920
export interface CustomNodeProps {
2021
nodeData: NodeData;
22+
nodeRenderKey: string;
2123
}
2224

2325
function CustomNode({ nodeData }: Readonly<CustomNodeProps>) {
@@ -64,4 +66,12 @@ function CustomNode({ nodeData }: Readonly<CustomNodeProps>) {
6466
);
6567
}
6668

67-
export default CustomNode;
69+
// The G6 node object is mutable and can be updated in place.
70+
// In a custom memo comparator, prev.nodeData.data and next.nodeData.data
71+
// can end up reading the same already-mutated object
72+
// Hence adding nodeRenderKey which is derived from nodeData but is a string
73+
// and won't be affected by mutations to the nodeData object
74+
export default React.memo(
75+
CustomNode,
76+
(prev, next) => prev.nodeRenderKey === next.nodeRenderKey
77+
);

openmetadata-ui/src/main/resources/ui/src/components/KnowledgeGraph/GraphElements/custom-node.less

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
width: 100%;
2727
height: 100%;
2828
cursor: pointer;
29+
touch-action: none;
2930

3031
.asset-type-tag {
3132
padding: 2px 4px;

openmetadata-ui/src/main/resources/ui/src/components/KnowledgeGraph/KnowledgeGraph.constants.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
* limitations under the License.
1212
*/
1313

14+
import { EntityGraphExportFormat } from '../../rest/rdfAPI.interface';
15+
import { ExportFormat } from '../OntologyExplorer/ExportGraphPanel.interface';
16+
1417
export const GRAPH_NODE_COLORS = {
1518
table: {
1619
background: '#52c41a',
@@ -127,3 +130,47 @@ export const GRAPH_ANIMATION_OPTIONS = {
127130
animationDuration: 1000,
128131
easingFunction: 'easeInOutQuad',
129132
};
133+
134+
export const ENTITY_UUID_REGEX = /\/([a-f0-9-]{36})$/;
135+
export const PANEL_WIDTH = 576;
136+
export const FIT_SCALE_FACTOR = 0.9;
137+
export const ZOOM_IN_FACTOR = 1.2;
138+
export const ZOOM_OUT_FACTOR = 0.8;
139+
export const ZOOM_DURATION_MS = 300;
140+
export const ZOOM_EASING = 'easeCubic';
141+
142+
export const EXPORT_FORMAT_MAP: Partial<
143+
Record<ExportFormat, EntityGraphExportFormat>
144+
> = {
145+
[ExportFormat.JSONLD]: 'jsonld',
146+
[ExportFormat.TURTLE]: 'turtle',
147+
};
148+
149+
export const EDGE_STYLE_RESET = {
150+
stroke: '#d9d9d9',
151+
lineWidth: 1.5,
152+
opacity: 1,
153+
zIndex: 0,
154+
labelFontWeight: 400,
155+
labelBackgroundLineWidth: 1,
156+
};
157+
158+
export const EXPORT_FORMAT_TO_ACCEPT_HEADER: Record<string, string> = {
159+
jsonld: 'application/ld+json',
160+
turtle: 'text/turtle',
161+
rdfxml: 'application/rdf+xml',
162+
ntriples: 'application/n-triples',
163+
};
164+
165+
export const EXPORT_FORMAT_TO_FILE_EXTENSION: Record<string, string> = {
166+
jsonld: 'jsonld',
167+
turtle: 'ttl',
168+
rdfxml: 'rdf',
169+
ntriples: 'nt',
170+
};
171+
172+
export const NODE_WIDTH = 280;
173+
174+
export const NODE_HEIGHT = 36;
175+
export const MAX_NODE_WIDTH = 280;
176+
export const MIN_NODE_WIDTH = 120;

openmetadata-ui/src/main/resources/ui/src/components/KnowledgeGraph/KnowledgeGraph.interface.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
* See the License for the specific language governing permissions and
1111
* limitations under the License.
1212
*/
13+
import {
14+
EdgeData as G6EdgeData,
15+
Graph,
16+
NodeData as G6NodeData,
17+
} from '@antv/g6';
18+
import { BrandColors } from '../../context/UntitledUIThemeProvider/theme-provider.interface';
1319
import { EntityReference } from '../../generated/entity/type';
1420
import {
1521
GraphEdge,
@@ -44,3 +50,17 @@ export interface GraphData {
4450
source?: string;
4551
error?: string;
4652
}
53+
54+
export type GraphInteractionCtx = {
55+
graph: Graph;
56+
g6Nodes: G6NodeData[];
57+
g6Edges: G6EdgeData[];
58+
focusNodeId: string;
59+
graphDataNodes: GraphNode[];
60+
brandColors?: BrandColors;
61+
pendingHighlightRef: React.MutableRefObject<string | null>;
62+
selectedNodeIdRef: React.MutableRefObject<string | null>;
63+
setSelectedNode: (node: GraphNode | null) => void;
64+
};
65+
66+
export type KnowledgeGraphLayout = 'dagre' | 'radial';

0 commit comments

Comments
 (0)