Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions graph-ui/src/components/GraphTab.filters.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
145 changes: 75 additions & 70 deletions graph-ui/src/components/GraphTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
);
}
Expand Down Expand Up @@ -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 */}
Expand Down
Loading