diff --git a/graph-ui/src/components/GraphTab.filters.test.tsx b/graph-ui/src/components/GraphTab.filters.test.tsx new file mode 100644 index 000000000..9ee22f210 --- /dev/null +++ b/graph-ui/src/components/GraphTab.filters.test.tsx @@ -0,0 +1,63 @@ +/* @vitest-environment jsdom */ +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GraphTab } from "./GraphTab"; +import type { GraphData } from "../lib/types"; + +/* GraphScene renders a WebGL which jsdom can't run — stub it out. */ +vi.mock("./GraphScene", () => ({ + GraphScene: () => null, + computeCameraTarget: () => null, +})); + +const SAMPLE: GraphData = { + nodes: [ + { id: 1, x: 0, y: 0, z: 0, label: "Function", name: "foo", size: 1, color: "#fff" }, + { id: 2, x: 1, y: 0, z: 0, label: "Class", name: "Bar", size: 1, color: "#fff" }, + ], + edges: [{ source: 1, target: 2, type: "CALLS" }], + total_nodes: 2, +}; + +function mockLayoutFetch(data: GraphData) { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("/api/layout")) { + return new Response(JSON.stringify(data), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("{}", { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +describe("GraphTab filters", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps the filter sidebar visible when all nodes are filtered out", async () => { + mockLayoutFetch(SAMPLE); + + render(); + + /* Wait for the layout to load — the filter panel header appears. */ + expect(await screen.findByText("Filters")).toBeInTheDocument(); + + /* Disable every filter via the "None" shortcut. */ + fireEvent.click(screen.getByRole("button", { name: "None" })); + + /* The graph area reports that everything is filtered out… */ + expect(screen.getByText("All nodes filtered out")).toBeInTheDocument(); + + /* …but the filter sidebar must stay so the user can re-enable filters + instead of being forced to reset everything. */ + expect(screen.getByText("Filters")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "All" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "None" })).toBeInTheDocument(); + }); +}); diff --git a/graph-ui/src/components/GraphTab.tsx b/graph-ui/src/components/GraphTab.tsx index 0e6c8fb08..7986c3c99 100644 --- a/graph-ui/src/components/GraphTab.tsx +++ b/graph-ui/src/components/GraphTab.tsx @@ -211,21 +211,13 @@ export function GraphTab({ project }: GraphTabProps) { ); } - if (!data || !filteredData || filteredData.nodes.length === 0) { + /* No data, or the project genuinely has no nodes — there are no filters to + interact with, so show a plain full-screen message. The "all filtered out" + case is handled inside the layout below so the filter sidebar stays put. */ + if (!data || !filteredData || data.nodes.length === 0) { return (
-
-

- {data && filteredData?.nodes.length === 0 - ? "All nodes filtered out" - : "No nodes in this project"} -

- {data && filteredData?.nodes.length === 0 && ( - - )} -
+

No nodes in this project

); } @@ -267,65 +259,78 @@ export function GraphTab({ project }: GraphTabProps) { {/* Graph area */}
- - - + {filteredData.nodes.length === 0 ? ( +
+
+

All nodes filtered out

+ +
+
+ ) : ( + <> + + + - {/* HUD */} -
-

- {filteredData.nodes.length.toLocaleString()} nodes /{" "} - {filteredData.edges.length.toLocaleString()} edges -

- {data.nodes.length > filteredData.nodes.length && ( -

- filtered from {data.nodes.length.toLocaleString()} -

- )} - {limitNotice && ( -

{limitNotice}

- )} - {highlightedIds && highlightedIds.size > 0 && ( -

- {highlightedIds.size} selected -

- )} -
+ {/* HUD */} +
+

+ {filteredData.nodes.length.toLocaleString()} nodes /{" "} + {filteredData.edges.length.toLocaleString()} edges +

+ {data.nodes.length > filteredData.nodes.length && ( +

+ filtered from {data.nodes.length.toLocaleString()} +

+ )} + {limitNotice && ( +

{limitNotice}

+ )} + {highlightedIds && highlightedIds.size > 0 && ( +

+ {highlightedIds.size} selected +

+ )} +
-
- {highlightedIds && ( - - )} - -
+
+ {highlightedIds && ( + + )} + +
+ + )}
{/* Right detail panel — resizable */}