diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-schema.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-schema.svg index f261c798531c..693c2a617e6e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-schema.svg +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-schema.svg @@ -1 +1,18 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeGraph/KnowledgeGraph.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeGraph/KnowledgeGraph.interface.ts index 18c2a292a5b5..6b7d279f8933 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeGraph/KnowledgeGraph.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeGraph/KnowledgeGraph.interface.ts @@ -61,6 +61,17 @@ export type GraphInteractionCtx = { pendingHighlightRef: React.MutableRefObject; selectedNodeIdRef: React.MutableRefObject; setSelectedNode: (node: GraphNode | null) => void; + setEdgeTooltip: (state: EdgeTooltipState | null) => void; + canvasRef: React.RefObject; }; +export interface EdgeTooltipState { + x: number; + y: number; + labels: string[]; + sourceLabel: string; + targetLabel: string; + edgeId: string; +} + export type KnowledgeGraphLayout = 'dagre' | 'radial'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeGraph/KnowledgeGraph.style.less b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeGraph/KnowledgeGraph.style.less index 5dda1c464b1a..71b7b0714b27 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeGraph/KnowledgeGraph.style.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeGraph/KnowledgeGraph.style.less @@ -120,3 +120,30 @@ height: 100%; } } + +.kg-edge-tooltip { + z-index: 1200; + pointer-events: none; + background: @white; + border: @global-border; + border-radius: 6px; + padding: 6px 10px; + box-shadow: @box-shadow-base; + max-width: 280px; + font-size: 12px; + line-height: 1.5; + + &__direction { + color: @text-color-tertiary; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__label { + color: @text-color; + font-weight: 500; + word-break: break-word; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeGraph/KnowledgeGraph.tsx b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeGraph/KnowledgeGraph.tsx index 9b93c0dd4775..2afd4dfc29fc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeGraph/KnowledgeGraph.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeGraph/KnowledgeGraph.tsx @@ -100,6 +100,7 @@ import { ZOOM_OUT_FACTOR, } from './KnowledgeGraph.constants'; import { + EdgeTooltipState, GraphData, GraphNode, KnowledgeGraphLayout, @@ -127,6 +128,7 @@ const KnowledgeGraph: React.FC = ({ const [selectedDepth, setSelectedDepth] = useState(depth); const [layout, setLayout] = useState('dagre'); const [selectedNode, setSelectedNode] = useState(null); + const [edgeTooltip, setEdgeTooltip] = useState(null); const [selectedEntityTypes, setSelectedEntityTypes] = useState([]); const [selectedRelationshipTypes, setSelectedRelationshipTypes] = useState< string[] @@ -392,6 +394,7 @@ const KnowledgeGraph: React.FC = ({ const focusNodeId = entity?.id ? (g6Data.nodes ?? []).find( + // Server may prefix IDs (e.g. "table::"); suffix-match the raw UUID to cover both forms. (n) => n.id === entity.id || n.id.endsWith(entity.id) )?.id ?? entity.id : ''; @@ -544,6 +547,8 @@ const KnowledgeGraph: React.FC = ({ pendingHighlightRef, selectedNodeIdRef, setSelectedNode, + setEdgeTooltip, + canvasRef: containerRef, }); resizeObserver = new ResizeObserver(() => { @@ -728,6 +733,31 @@ const KnowledgeGraph: React.FC = ({ ))} + {edgeTooltip && ( + + )} + {selectedNode?.fullyQualifiedName && ( { pendingHighlightRef: { current: null }, selectedNodeIdRef: { current: null }, setSelectedNode: jest.fn(), + setEdgeTooltip: jest.fn(), + canvasRef: { current: null }, }, graph: mockGraph, }; }; - it('registers all 5 expected G6 event handlers', () => { + it('registers all 8 expected G6 event handlers', () => { const { ctx, graph } = buildCtx(); setupGraphEventHandlers(ctx); - expect(graph.on).toHaveBeenCalledTimes(5); + expect(graph.on).toHaveBeenCalledTimes(8); const registeredEvents = graph.on.mock.calls.map( ([event]: [string]) => event @@ -503,6 +505,9 @@ describe('KnowledgeGraph.utils', () => { expect(registeredEvents).toContain('node:dblclick'); expect(registeredEvents).toContain('node:pointerover'); expect(registeredEvents).toContain('node:pointerleave'); + expect(registeredEvents).toContain('edge:pointerover'); + expect(registeredEvents).toContain('edge:pointerleave'); + expect(registeredEvents).toContain('edge:click'); expect(registeredEvents).toContain('canvas:click'); }); @@ -532,4 +537,230 @@ describe('KnowledgeGraph.utils', () => { expect(ctx.setSelectedNode).toHaveBeenCalledWith(null); }); }); + + describe('setupGraphEventHandlers – edge events', () => { + const buildMockGraph = () => ({ + on: jest.fn(), + updateNodeData: jest.fn(), + updateEdgeData: jest.fn(), + focusElement: jest.fn().mockResolvedValue(undefined), + draw: jest.fn().mockResolvedValue(undefined), + }); + + const buildCtx = (graphOverride?: ReturnType) => { + const mockGraph = graphOverride ?? buildMockGraph(); + + return { + ctx: { + graph: mockGraph as unknown as Graph, + g6Nodes: [makeNode('A'), makeNode('B')], + g6Edges: [ + { + id: 'e1', + source: 'A', + target: 'B', + data: { label: 'owns' }, + }, + ], + focusNodeId: 'A', + graphDataNodes: [ + { + id: 'A', + type: 'table', + fullyQualifiedName: 'ns.A', + label: 'A', + }, + { + id: 'B', + type: 'user', + fullyQualifiedName: 'user.B', + label: 'B', + }, + ], + pendingHighlightRef: { current: null }, + selectedNodeIdRef: { current: null }, + setSelectedNode: jest.fn(), + setEdgeTooltip: jest.fn(), + canvasRef: { current: null }, + }, + graph: mockGraph, + }; + }; + + const getHandler = ( + graph: ReturnType, + eventName: string + ) => { + const call = graph.on.mock.calls.find(([e]: [string]) => e === eventName); + + return call?.[1] as ((...args: unknown[]) => void) | undefined; + }; + + it('edge:pointerover calls setEdgeTooltip with correct position, labels, sourceLabel, targetLabel', () => { + const { ctx, graph } = buildCtx(); + setupGraphEventHandlers(ctx); + + const handler = getHandler(graph, 'edge:pointerover'); + handler?.({ target: { id: 'e1' }, client: { x: 100, y: 200 } }); + + expect(ctx.setEdgeTooltip).toHaveBeenCalledWith({ + x: 100, + y: 200, + edgeId: 'e1', + labels: ['owns'], + sourceLabel: 'A', + targetLabel: 'B', + }); + }); + + it('edge:pointerover uses mergedLabels array when present', () => { + const mockGraph = buildMockGraph(); + const { ctx } = buildCtx(mockGraph); + ctx.g6Edges = [ + { + id: 'e1', + source: 'A', + target: 'B', + data: { label: 'rel1 · rel2', mergedLabels: ['rel1', 'rel2'] }, + }, + ] as unknown as typeof ctx.g6Edges; + setupGraphEventHandlers(ctx); + + const handler = getHandler(mockGraph, 'edge:pointerover'); + handler?.({ target: { id: 'e1' }, client: { x: 0, y: 0 } }); + + expect(ctx.setEdgeTooltip).toHaveBeenCalledWith( + expect.objectContaining({ labels: ['rel1', 'rel2'] }) + ); + }); + + it('edge:pointerover highlights source and target nodes', () => { + const { ctx, graph } = buildCtx(); + setupGraphEventHandlers(ctx); + + const handler = getHandler(graph, 'edge:pointerover'); + handler?.({ target: { id: 'e1' }, client: { x: 0, y: 0 } }); + + const updatedIds = graph.updateNodeData.mock.calls.flatMap( + (args: unknown[][]) => + (args[0] as Array<{ id: string }>).map((item) => item.id) + ); + + expect(updatedIds).toContain('A'); + expect(updatedIds).toContain('B'); + }); + + it('edge:pointerleave calls setEdgeTooltip(null)', () => { + const { ctx, graph } = buildCtx(); + setupGraphEventHandlers(ctx); + + const overHandler = getHandler(graph, 'edge:pointerover'); + overHandler?.({ target: { id: 'e1' }, client: { x: 0, y: 0 } }); + + const leaveHandler = getHandler(graph, 'edge:pointerleave'); + leaveHandler?.(); + + expect(ctx.setEdgeTooltip).toHaveBeenLastCalledWith(null); + }); + + it('edge:pointerleave resets edge style after hover', () => { + const { ctx, graph } = buildCtx(); + setupGraphEventHandlers(ctx); + + const overHandler = getHandler(graph, 'edge:pointerover'); + overHandler?.({ target: { id: 'e1' }, client: { x: 0, y: 0 } }); + + graph.updateEdgeData.mockClear(); + + const leaveHandler = getHandler(graph, 'edge:pointerleave'); + leaveHandler?.(); + + const resetIds = graph.updateEdgeData.mock.calls.flatMap( + (args: unknown[][]) => + (args[0] as Array<{ id: string }>).map((item) => item.id) + ); + + expect(resetIds).toContain('e1'); + }); + + it('edge:pointerleave re-applies path highlight when a node is selected', () => { + const { ctx, graph } = buildCtx(); + setupGraphEventHandlers(ctx); + + const nodeClickHandler = getHandler(graph, 'node:click'); + nodeClickHandler?.({ target: { id: 'A' } }); + + const overHandler = getHandler(graph, 'edge:pointerover'); + overHandler?.({ target: { id: 'e1' }, client: { x: 0, y: 0 } }); + + graph.updateNodeData.mockClear(); + + const leaveHandler = getHandler(graph, 'edge:pointerleave'); + leaveHandler?.(); + + expect(graph.updateNodeData).toHaveBeenCalled(); + }); + + it('edge:click focuses target when source is selected', () => { + const { ctx, graph } = buildCtx(); + setupGraphEventHandlers(ctx); + + const nodeClickHandler = getHandler(graph, 'node:click'); + nodeClickHandler?.({ target: { id: 'A' } }); + + const edgeClickHandler = getHandler(graph, 'edge:click'); + edgeClickHandler?.({ target: { id: 'e1' } }); + + expect(graph.focusElement).toHaveBeenCalledWith( + 'B', + expect.objectContaining({ duration: expect.any(Number) }) + ); + }); + + it('edge:click focuses source when target is selected', () => { + const { ctx, graph } = buildCtx(); + setupGraphEventHandlers(ctx); + + const nodeClickHandler = getHandler(graph, 'node:click'); + nodeClickHandler?.({ target: { id: 'B' } }); + + const edgeClickHandler = getHandler(graph, 'edge:click'); + edgeClickHandler?.({ target: { id: 'e1' } }); + + expect(graph.focusElement).toHaveBeenCalledWith( + 'A', + expect.objectContaining({ duration: expect.any(Number) }) + ); + }); + + it('edge:click defaults to target when nothing is selected', () => { + const { ctx, graph } = buildCtx(); + setupGraphEventHandlers(ctx); + + const edgeClickHandler = getHandler(graph, 'edge:click'); + edgeClickHandler?.({ target: { id: 'e1' } }); + + expect(graph.focusElement).toHaveBeenCalledWith( + 'B', + expect.objectContaining({ duration: expect.any(Number) }) + ); + }); + + it('node:dblclick calls window.open with entity URL', () => { + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + const { ctx, graph } = buildCtx(); + setupGraphEventHandlers(ctx); + + const dblClickHandler = getHandler(graph, 'node:dblclick'); + dblClickHandler?.({ target: { id: 'B' } }); + + expect(openSpy).toHaveBeenCalledWith( + '/test/entity/path', + '_blank', + 'noopener,noreferrer' + ); + + openSpy.mockRestore(); + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/KnowledgeGraph.utils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/KnowledgeGraph.utils.ts index ed87cfe51ad4..11f895df419b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/KnowledgeGraph.utils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/KnowledgeGraph.utils.ts @@ -28,6 +28,8 @@ import { MIN_NODE_WIDTH, NODE_HEIGHT, NODE_WIDTH, + ZOOM_DURATION_MS, + ZOOM_EASING, } from '../components/KnowledgeGraph/KnowledgeGraph.constants'; import { GraphData, @@ -71,8 +73,8 @@ export const getColorSetForType = ( type: string ): { main: string; light: string } => { let hash = 0; - for (let i = 0; i < type.length; i++) { - hash = (hash * 31 + (type.codePointAt(i) ?? 0)) >>> 0; + for (const ch of type) { + hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) >>> 0; } return COLOR_SETS[hash % COLOR_SETS.length]; @@ -748,10 +750,21 @@ export const applyInitialFocus = async ( }; export const setupGraphEventHandlers = (ctx: GraphInteractionCtx): void => { - const { graph, graphDataNodes, selectedNodeIdRef, setSelectedNode } = ctx; + const { + graph, + graphDataNodes, + selectedNodeIdRef, + setSelectedNode, + setEdgeTooltip, + canvasRef, + } = ctx; const activeHighlightEdges = new Set(); const activeHighlightNodes = new Set(); const nodeMap = new Map(ctx.g6Nodes.map((n) => [n.id, n])); + let hoveredEdgeId: string | null = null; + let hoveredEndpoints: [string, string] | null = null; + const nodeLabelMap = new Map(graphDataNodes.map((n) => [n.id, n.label])); + const edgeMap = new Map(ctx.g6Edges.map((e) => [String(e.id), e])); const fwdAdj = new Map>(); ctx.g6Nodes.forEach((n) => fwdAdj.set(n.id, [])); @@ -815,6 +828,7 @@ export const setupGraphEventHandlers = (ctx: GraphInteractionCtx): void => { newPathNodes.forEach((id) => activeHighlightNodes.add(id)); } + // Guard against stale async draws: if the user moved to a different node before this draw runs, skip it. if (ctx.pendingHighlightRef.current !== nodeId) { return; } @@ -886,6 +900,138 @@ export const setupGraphEventHandlers = (ctx: GraphInteractionCtx): void => { } }); + graph.on('edge:pointerover', (evt: IElementEvent) => { + const edgeId = evt.target.id; + if (!edgeId) { + return; + } + const edge = edgeMap.get(edgeId); + if (!edge) { + return; + } + + const srcId = String(edge.source); + const tgtId = String(edge.target); + const rawLabels = edge.data?.['mergedLabels']; + const labels: string[] = ( + Array.isArray(rawLabels) + ? (rawLabels as string[]) + : [String(edge.data?.['label'] ?? '')] + ).filter((s) => s.length > 0); + + // Restore previous edge's visual state before applying the new one; + // prevents permanently-highlighted edges when switching edges without a gap. + if (hoveredEdgeId && hoveredEdgeId !== edgeId) { + if (!activeHighlightEdges.has(hoveredEdgeId)) { + graph.updateEdgeData([{ id: hoveredEdgeId, style: EDGE_STYLE_RESET }]); + } + if (hoveredEndpoints) { + const staleNodes = hoveredEndpoints.filter( + (id) => !activeHighlightNodes.has(id) + ); + if (staleNodes.length > 0) { + graph.updateNodeData( + staleNodes.map((id) => buildNodeUpdateData(id, nodeMap, false)) + ); + } + } + } + + hoveredEdgeId = edgeId; + hoveredEndpoints = [srcId, tgtId]; + + graph.updateEdgeData([ + { + id: edgeId, + style: buildEdgeHighlightStyle( + ctx.brandColors?.primaryColor ?? PRIMARY_COLOR + ), + }, + ]); + graph.updateNodeData([ + buildNodeUpdateData(srcId, nodeMap, true), + buildNodeUpdateData(tgtId, nodeMap, true), + ]); + void graph.draw(); + + const canvasEl = canvasRef.current?.querySelector('canvas'); + if (canvasEl) { + canvasEl.style.cursor = 'pointer'; + } + + setEdgeTooltip({ + x: evt.client.x, + y: evt.client.y, + labels, + sourceLabel: nodeLabelMap.get(srcId) ?? srcId, + targetLabel: nodeLabelMap.get(tgtId) ?? tgtId, + edgeId, + }); + }); + + graph.on('edge:pointerleave', () => { + const canvasEl = canvasRef.current?.querySelector('canvas'); + if (canvasEl) { + canvasEl.style.cursor = ''; + } + + setEdgeTooltip(null); + + if (hoveredEdgeId && !activeHighlightEdges.has(hoveredEdgeId)) { + graph.updateEdgeData([{ id: hoveredEdgeId, style: EDGE_STYLE_RESET }]); + } + + if (hoveredEndpoints) { + const [srcId, tgtId] = hoveredEndpoints; + const nodesToReset = [srcId, tgtId].filter( + (id) => !activeHighlightNodes.has(id) + ); + if (nodesToReset.length > 0) { + graph.updateNodeData( + nodesToReset.map((id) => buildNodeUpdateData(id, nodeMap, false)) + ); + } + } + + hoveredEdgeId = null; + hoveredEndpoints = null; + + void graph.draw(); + + // Re-apply the selected node's path highlight because edge hover temporarily overrides it. + const highlightTarget = selectedNodeIdRef.current; + if (highlightTarget) { + applyPathHighlight(highlightTarget); + } + }); + + graph.on('edge:click', (evt: IElementEvent) => { + const edgeId = evt.target.id; + if (!edgeId) { + return; + } + const edge = edgeMap.get(edgeId); + if (!edge) { + return; + } + + const srcId = String(edge.source); + const tgtId = String(edge.target); + // Focus the endpoint that isn't currently selected; default to target when nothing is selected. + const farId = + selectedNodeIdRef.current === srcId + ? tgtId + : selectedNodeIdRef.current === tgtId + ? srcId + : tgtId; + + void graph.focusElement(farId, { + duration: ZOOM_DURATION_MS, + easing: ZOOM_EASING, + }); + selectedNodeIdRef.current = farId; + }); + graph.on('canvas:click', () => { setSelectedNode(null); selectedNodeIdRef.current = null;