From e1bd26866a363623b8b11fafb13f432f14e2e88e Mon Sep 17 00:00:00 2001 From: Faye Kelmith Date: Thu, 27 Aug 2026 08:49:16 +0000 Subject: [PATCH 1/2] [Jamie] Add collapsible node-type legend to Graph Explorer 2D view --- .../graph-explorer/GraphLegend.test.tsx | 83 +++++++++++++++++++ src/components/graph-explorer/Graph2DView.tsx | 26 +++--- src/components/graph-explorer/GraphLegend.tsx | 74 +++++++++++++++++ 3 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/unit/components/graph-explorer/GraphLegend.test.tsx create mode 100644 src/components/graph-explorer/GraphLegend.tsx diff --git a/src/__tests__/unit/components/graph-explorer/GraphLegend.test.tsx b/src/__tests__/unit/components/graph-explorer/GraphLegend.test.tsx new file mode 100644 index 0000000000..6d663455df --- /dev/null +++ b/src/__tests__/unit/components/graph-explorer/GraphLegend.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +import React from "react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, test, expect } from "vitest"; +import { GraphLegend } from "@/components/graph-explorer/GraphLegend"; +import { getNodeColor } from "@/components/graph/graphUtils"; + +describe("GraphLegend", () => { + test("renders nothing for an empty node list", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + test("aggregates per-type counts and sorts descending", () => { + render( + + ); + + const fileEntry = screen.getByTestId("graph-legend-entry-File"); + const functionEntry = screen.getByTestId("graph-legend-entry-Function"); + + expect(within(fileEntry).getByText("3")).toBeInTheDocument(); + expect(within(functionEntry).getByText("2")).toBeInTheDocument(); + + // "File" (count 3) should come before "Function" (count 2) in the DOM. + const entries = screen.getAllByTestId(/^graph-legend-entry-/); + expect(entries[0]).toBe(fileEntry); + expect(entries[1]).toBe(functionEntry); + }); + + test("toggle button collapses and expands the list, updating aria-expanded", async () => { + const user = userEvent.setup(); + render(); + + const toggle = screen.getByTestId("graph-legend-toggle"); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByTestId("graph-legend-entry-File")).toBeInTheDocument(); + + await user.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByTestId("graph-legend-entry-File")).not.toBeInTheDocument(); + + await user.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByTestId("graph-legend-entry-File")).toBeInTheDocument(); + }); + + test("swatch color matches getNodeColor for a listed and an unlisted type", () => { + const colorMap = { File: "#123456" }; + render( + + ); + + const fileEntry = screen.getByTestId("graph-legend-entry-File"); + const mysteryEntry = screen.getByTestId("graph-legend-entry-Mystery"); + + const fileSwatch = fileEntry.querySelector("span[style]") as HTMLElement; + const mysterySwatch = mysteryEntry.querySelector("span[style]") as HTMLElement; + + expect(fileSwatch.style.backgroundColor).toBe(rgbFromHex(getNodeColor("File", colorMap))); + expect(mysterySwatch.style.backgroundColor).toBe( + rgbFromHex(getNodeColor("Mystery", colorMap)) + ); + }); +}); + +/** jsdom normalizes inline hex colors to rgb() when read back from style. */ +function rgbFromHex(hex: string): string { + const value = hex.replace("#", ""); + const r = parseInt(value.substring(0, 2), 16); + const g = parseInt(value.substring(2, 4), 16); + const b = parseInt(value.substring(4, 6), 16); + return `rgb(${r}, ${g}, ${b})`; +} diff --git a/src/components/graph-explorer/Graph2DView.tsx b/src/components/graph-explorer/Graph2DView.tsx index e0da39600d..67c5c95a2d 100644 --- a/src/components/graph-explorer/Graph2DView.tsx +++ b/src/components/graph-explorer/Graph2DView.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { GraphVisualization } from "@/components/graph/GraphVisualization"; +import { GraphLegend } from "./GraphLegend"; import type { GraphEdge, GraphNode } from "@/components/graph/graphUtils"; import { GRAPH_EXPLORER_COLORS } from "./nodeColors"; import { LEGAL_NODE_ICONS, resolveEdgeStyle } from "./legalGraphStyles"; @@ -71,18 +72,21 @@ export function Graph2DView({ ); return ( -
+
{size && nodes.length > 0 && ( - + <> + + + )}
); diff --git a/src/components/graph-explorer/GraphLegend.tsx b/src/components/graph-explorer/GraphLegend.tsx new file mode 100644 index 0000000000..d6b7ccd4e5 --- /dev/null +++ b/src/components/graph-explorer/GraphLegend.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState } from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { getNodeColor, type GraphNode } from "@/components/graph/graphUtils"; + +/** + * Small overlay explaining what the 2D graph's node colors mean. + * + * Renders nothing when there are no nodes to summarize, so it never occupies + * layout space (or shows an empty card) before the graph has data. + */ +export function GraphLegend({ + nodes, + colorMap, +}: { + nodes: Pick[]; + colorMap?: Record; +}) { + const [expanded, setExpanded] = useState(true); + + const counts = new Map(); + for (const node of nodes) { + counts.set(node.type, (counts.get(node.type) ?? 0) + 1); + } + + const entries = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]); + + if (entries.length === 0) { + return null; + } + + return ( + + + {expanded && ( +
    + {entries.map(([type, count]) => ( +
  • + + {type} + {count} +
  • + ))} +
+ )} +
+ ); +} From e5085828487789354a8b075251c7f4205b977d6e Mon Sep 17 00:00:00 2001 From: fayekelmith Date: Thu, 27 Aug 2026 09:04:08 +0000 Subject: [PATCH 2/2] Generated with Hive: Import React in GraphLegend to fix unit test failures --- src/components/graph-explorer/GraphLegend.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/graph-explorer/GraphLegend.tsx b/src/components/graph-explorer/GraphLegend.tsx index d6b7ccd4e5..95e7ac35db 100644 --- a/src/components/graph-explorer/GraphLegend.tsx +++ b/src/components/graph-explorer/GraphLegend.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import React, { useState } from "react"; import { ChevronDown, ChevronUp } from "lucide-react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button";