Merge pull request #727 from rarepops/fix/graph-filters-keep-sidebar

fix(graph-ui): keep filter sidebar visible when all nodes are filtered out
This commit is contained in:
Martin Vogel
2026-07-03 18:36:24 +02:00
committed by GitHub
2 changed files with 138 additions and 70 deletions
@@ -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 <Canvas> 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(<GraphTab project="demo" />);
/* 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();
});
});
+75 -70
View File
@@ -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 (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-white/30 text-sm mb-3">
{data && filteredData?.nodes.length === 0
? "All nodes filtered out"
: "No nodes in this project"}
</p>
{data && filteredData?.nodes.length === 0 && (
<Button size="sm" onClick={enableAll}>
Reset Filters
</Button>
)}
</div>
<p className="text-white/30 text-sm">No nodes in this project</p>
</div>
);
}
@@ -267,65 +259,78 @@ export function GraphTab({ project }: GraphTabProps) {
{/* Graph area */}
<div className="flex-1 relative overflow-hidden">
<ErrorBoundary>
<GraphScene
data={filteredData}
highlightedIds={highlightedIds}
cameraTarget={cameraTarget}
showLabels={showLabels}
onNodeClick={handleNodeClick}
/>
</ErrorBoundary>
{filteredData.nodes.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-white/30 text-sm mb-3">All nodes filtered out</p>
<Button size="sm" onClick={enableAll}>
Reset Filters
</Button>
</div>
</div>
) : (
<>
<ErrorBoundary>
<GraphScene
data={filteredData}
highlightedIds={highlightedIds}
cameraTarget={cameraTarget}
showLabels={showLabels}
onNodeClick={handleNodeClick}
/>
</ErrorBoundary>
{/* HUD */}
<div className="absolute top-4 left-4 text-[11px] text-white/30 pointer-events-none font-mono">
<p>
{filteredData.nodes.length.toLocaleString()} nodes /{" "}
{filteredData.edges.length.toLocaleString()} edges
</p>
{data.nodes.length > filteredData.nodes.length && (
<p className="text-white/25 mt-0.5">
filtered from {data.nodes.length.toLocaleString()}
</p>
)}
{limitNotice && (
<p className="text-amber-300/80 mt-0.5">{limitNotice}</p>
)}
{highlightedIds && highlightedIds.size > 0 && (
<p className="text-cyan-400/50 mt-0.5">
{highlightedIds.size} selected
</p>
)}
</div>
{/* HUD */}
<div className="absolute top-4 left-4 text-[11px] text-white/30 pointer-events-none font-mono">
<p>
{filteredData.nodes.length.toLocaleString()} nodes /{" "}
{filteredData.edges.length.toLocaleString()} edges
</p>
{data.nodes.length > filteredData.nodes.length && (
<p className="text-white/25 mt-0.5">
filtered from {data.nodes.length.toLocaleString()}
</p>
)}
{limitNotice && (
<p className="text-amber-300/80 mt-0.5">{limitNotice}</p>
)}
{highlightedIds && highlightedIds.size > 0 && (
<p className="text-cyan-400/50 mt-0.5">
{highlightedIds.size} selected
</p>
)}
</div>
<div className="absolute top-4 right-4 flex gap-2">
{highlightedIds && (
<Button
size="sm"
onClick={() => {
setHighlightedIds(null);
setSelectedPath(null);
setSelectedNode(null);
setCameraTarget(null);
}}
>
Clear
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => {
setHighlightedIds(null);
setSelectedPath(null);
setSelectedNode(null);
setCameraTarget(null);
fetchOverview(project);
}}
>
Refresh
</Button>
</div>
<div className="absolute top-4 right-4 flex gap-2">
{highlightedIds && (
<Button
size="sm"
onClick={() => {
setHighlightedIds(null);
setSelectedPath(null);
setSelectedNode(null);
setCameraTarget(null);
}}
>
Clear
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => {
setHighlightedIds(null);
setSelectedPath(null);
setSelectedNode(null);
setCameraTarget(null);
fetchOverview(project);
}}
>
Refresh
</Button>
</div>
</>
)}
</div>
{/* Right detail panel — resizable */}