mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-29 07:13:44 +08:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user