Skip to content

Commit 96db8c8

Browse files
authored
Merge pull request #727 from rarepops/fix/graph-filters-keep-sidebar
fix(graph-ui): keep filter sidebar visible when all nodes are filtered out
2 parents fa12a38 + a0e970d commit 96db8c8

2 files changed

Lines changed: 138 additions & 70 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/* @vitest-environment jsdom */
2+
import "@testing-library/jest-dom/vitest";
3+
import { fireEvent, render, screen } from "@testing-library/react";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import { GraphTab } from "./GraphTab";
6+
import type { GraphData } from "../lib/types";
7+
8+
/* GraphScene renders a WebGL <Canvas> which jsdom can't run — stub it out. */
9+
vi.mock("./GraphScene", () => ({
10+
GraphScene: () => null,
11+
computeCameraTarget: () => null,
12+
}));
13+
14+
const SAMPLE: GraphData = {
15+
nodes: [
16+
{ id: 1, x: 0, y: 0, z: 0, label: "Function", name: "foo", size: 1, color: "#fff" },
17+
{ id: 2, x: 1, y: 0, z: 0, label: "Class", name: "Bar", size: 1, color: "#fff" },
18+
],
19+
edges: [{ source: 1, target: 2, type: "CALLS" }],
20+
total_nodes: 2,
21+
};
22+
23+
function mockLayoutFetch(data: GraphData) {
24+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
25+
const url = String(input);
26+
if (url.startsWith("/api/layout")) {
27+
return new Response(JSON.stringify(data), {
28+
status: 200,
29+
headers: { "Content-Type": "application/json" },
30+
});
31+
}
32+
return new Response("{}", { status: 200 });
33+
});
34+
vi.stubGlobal("fetch", fetchMock);
35+
return fetchMock;
36+
}
37+
38+
describe("GraphTab filters", () => {
39+
afterEach(() => {
40+
vi.unstubAllGlobals();
41+
});
42+
43+
it("keeps the filter sidebar visible when all nodes are filtered out", async () => {
44+
mockLayoutFetch(SAMPLE);
45+
46+
render(<GraphTab project="demo" />);
47+
48+
/* Wait for the layout to load — the filter panel header appears. */
49+
expect(await screen.findByText("Filters")).toBeInTheDocument();
50+
51+
/* Disable every filter via the "None" shortcut. */
52+
fireEvent.click(screen.getByRole("button", { name: "None" }));
53+
54+
/* The graph area reports that everything is filtered out… */
55+
expect(screen.getByText("All nodes filtered out")).toBeInTheDocument();
56+
57+
/* …but the filter sidebar must stay so the user can re-enable filters
58+
instead of being forced to reset everything. */
59+
expect(screen.getByText("Filters")).toBeInTheDocument();
60+
expect(screen.getByRole("button", { name: "All" })).toBeInTheDocument();
61+
expect(screen.getByRole("button", { name: "None" })).toBeInTheDocument();
62+
});
63+
});

graph-ui/src/components/GraphTab.tsx

Lines changed: 75 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -211,21 +211,13 @@ export function GraphTab({ project }: GraphTabProps) {
211211
);
212212
}
213213

214-
if (!data || !filteredData || filteredData.nodes.length === 0) {
214+
/* No data, or the project genuinely has no nodes — there are no filters to
215+
interact with, so show a plain full-screen message. The "all filtered out"
216+
case is handled inside the layout below so the filter sidebar stays put. */
217+
if (!data || !filteredData || data.nodes.length === 0) {
215218
return (
216219
<div className="flex items-center justify-center h-full">
217-
<div className="text-center">
218-
<p className="text-white/30 text-sm mb-3">
219-
{data && filteredData?.nodes.length === 0
220-
? "All nodes filtered out"
221-
: "No nodes in this project"}
222-
</p>
223-
{data && filteredData?.nodes.length === 0 && (
224-
<Button size="sm" onClick={enableAll}>
225-
Reset Filters
226-
</Button>
227-
)}
228-
</div>
220+
<p className="text-white/30 text-sm">No nodes in this project</p>
229221
</div>
230222
);
231223
}
@@ -267,65 +259,78 @@ export function GraphTab({ project }: GraphTabProps) {
267259

268260
{/* Graph area */}
269261
<div className="flex-1 relative overflow-hidden">
270-
<ErrorBoundary>
271-
<GraphScene
272-
data={filteredData}
273-
highlightedIds={highlightedIds}
274-
cameraTarget={cameraTarget}
275-
showLabels={showLabels}
276-
onNodeClick={handleNodeClick}
277-
/>
278-
</ErrorBoundary>
262+
{filteredData.nodes.length === 0 ? (
263+
<div className="flex items-center justify-center h-full">
264+
<div className="text-center">
265+
<p className="text-white/30 text-sm mb-3">All nodes filtered out</p>
266+
<Button size="sm" onClick={enableAll}>
267+
Reset Filters
268+
</Button>
269+
</div>
270+
</div>
271+
) : (
272+
<>
273+
<ErrorBoundary>
274+
<GraphScene
275+
data={filteredData}
276+
highlightedIds={highlightedIds}
277+
cameraTarget={cameraTarget}
278+
showLabels={showLabels}
279+
onNodeClick={handleNodeClick}
280+
/>
281+
</ErrorBoundary>
279282

280-
{/* HUD */}
281-
<div className="absolute top-4 left-4 text-[11px] text-white/30 pointer-events-none font-mono">
282-
<p>
283-
{filteredData.nodes.length.toLocaleString()} nodes /{" "}
284-
{filteredData.edges.length.toLocaleString()} edges
285-
</p>
286-
{data.nodes.length > filteredData.nodes.length && (
287-
<p className="text-white/25 mt-0.5">
288-
filtered from {data.nodes.length.toLocaleString()}
289-
</p>
290-
)}
291-
{limitNotice && (
292-
<p className="text-amber-300/80 mt-0.5">{limitNotice}</p>
293-
)}
294-
{highlightedIds && highlightedIds.size > 0 && (
295-
<p className="text-cyan-400/50 mt-0.5">
296-
{highlightedIds.size} selected
297-
</p>
298-
)}
299-
</div>
283+
{/* HUD */}
284+
<div className="absolute top-4 left-4 text-[11px] text-white/30 pointer-events-none font-mono">
285+
<p>
286+
{filteredData.nodes.length.toLocaleString()} nodes /{" "}
287+
{filteredData.edges.length.toLocaleString()} edges
288+
</p>
289+
{data.nodes.length > filteredData.nodes.length && (
290+
<p className="text-white/25 mt-0.5">
291+
filtered from {data.nodes.length.toLocaleString()}
292+
</p>
293+
)}
294+
{limitNotice && (
295+
<p className="text-amber-300/80 mt-0.5">{limitNotice}</p>
296+
)}
297+
{highlightedIds && highlightedIds.size > 0 && (
298+
<p className="text-cyan-400/50 mt-0.5">
299+
{highlightedIds.size} selected
300+
</p>
301+
)}
302+
</div>
300303

301-
<div className="absolute top-4 right-4 flex gap-2">
302-
{highlightedIds && (
303-
<Button
304-
size="sm"
305-
onClick={() => {
306-
setHighlightedIds(null);
307-
setSelectedPath(null);
308-
setSelectedNode(null);
309-
setCameraTarget(null);
310-
}}
311-
>
312-
Clear
313-
</Button>
314-
)}
315-
<Button
316-
variant="outline"
317-
size="sm"
318-
onClick={() => {
319-
setHighlightedIds(null);
320-
setSelectedPath(null);
321-
setSelectedNode(null);
322-
setCameraTarget(null);
323-
fetchOverview(project);
324-
}}
325-
>
326-
Refresh
327-
</Button>
328-
</div>
304+
<div className="absolute top-4 right-4 flex gap-2">
305+
{highlightedIds && (
306+
<Button
307+
size="sm"
308+
onClick={() => {
309+
setHighlightedIds(null);
310+
setSelectedPath(null);
311+
setSelectedNode(null);
312+
setCameraTarget(null);
313+
}}
314+
>
315+
Clear
316+
</Button>
317+
)}
318+
<Button
319+
variant="outline"
320+
size="sm"
321+
onClick={() => {
322+
setHighlightedIds(null);
323+
setSelectedPath(null);
324+
setSelectedNode(null);
325+
setCameraTarget(null);
326+
fetchOverview(project);
327+
}}
328+
>
329+
Refresh
330+
</Button>
331+
</div>
332+
</>
333+
)}
329334
</div>
330335

331336
{/* Right detail panel — resizable */}

0 commit comments

Comments
 (0)