Skip to content

Commit f7bccab

Browse files
committed
fix(ui): load every projects page
Follow list_projects pagination metadata so the management UI does not stop at 50 projects. Publish the complete list before schema hydration and cap schema lookups at four concurrent requests. Signed-off-by: Rares Popa <2606875+rarepops@users.noreply.github.com>
1 parent 010569f commit f7bccab

2 files changed

Lines changed: 168 additions & 17 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/* @vitest-environment jsdom */
2+
import "@testing-library/jest-dom/vitest";
3+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import { useProjects } from "./useProjects";
6+
7+
const { callToolMock } = vi.hoisted(() => ({ callToolMock: vi.fn() }));
8+
9+
vi.mock("../api/rpc", () => ({ callTool: callToolMock }));
10+
11+
function ProjectsHarness() {
12+
const { projects } = useProjects();
13+
return <output data-testid="project-count">{projects.length}</output>;
14+
}
15+
16+
describe("useProjects", () => {
17+
afterEach(() => {
18+
cleanup();
19+
vi.clearAllMocks();
20+
});
21+
22+
it("loads every list_projects page", async () => {
23+
const firstPage = Array.from({ length: 50 }, (_, index) => ({
24+
name: `project-${index + 1}`,
25+
root_path: `/repo/project-${index + 1}`,
26+
indexed_at: "2026-01-01T00:00:00Z",
27+
}));
28+
const finalProject = {
29+
name: "project-51",
30+
root_path: "/repo/project-51",
31+
indexed_at: "2026-01-01T00:00:00Z",
32+
};
33+
34+
callToolMock.mockImplementation(
35+
async (name: string, args: Record<string, unknown> = {}) => {
36+
if (name === "list_projects") {
37+
if (args.offset === 50) {
38+
return {
39+
projects: [finalProject],
40+
total: 51,
41+
offset: 50,
42+
limit: 50,
43+
returned: 1,
44+
has_more: false,
45+
};
46+
}
47+
return {
48+
projects: firstPage,
49+
total: 51,
50+
offset: 0,
51+
limit: 50,
52+
returned: 50,
53+
has_more: true,
54+
};
55+
}
56+
return {
57+
node_labels: [],
58+
edge_types: [],
59+
total_nodes: 0,
60+
total_edges: 0,
61+
};
62+
},
63+
);
64+
65+
render(<ProjectsHarness />);
66+
67+
await waitFor(() => {
68+
expect(screen.getByTestId("project-count")).toHaveTextContent("51");
69+
});
70+
expect(callToolMock).toHaveBeenCalledWith("list_projects", {
71+
offset: 50,
72+
limit: 50,
73+
});
74+
});
75+
76+
it("renders projects before bounded schema hydration completes", async () => {
77+
const projects = Array.from({ length: 10 }, (_, index) => ({
78+
name: `project-${index + 1}`,
79+
root_path: `/repo/project-${index + 1}`,
80+
indexed_at: "2026-01-01T00:00:00Z",
81+
}));
82+
83+
callToolMock.mockImplementation(async (name: string) => {
84+
if (name === "list_projects") {
85+
return {
86+
projects,
87+
total: projects.length,
88+
offset: 0,
89+
limit: 50,
90+
returned: projects.length,
91+
has_more: false,
92+
};
93+
}
94+
return new Promise(() => {});
95+
});
96+
97+
render(<ProjectsHarness />);
98+
99+
await waitFor(() => {
100+
expect(screen.getByTestId("project-count")).toHaveTextContent("10");
101+
});
102+
const schemaCalls = callToolMock.mock.calls.filter(([name]) => name === "get_graph_schema");
103+
expect(schemaCalls).toHaveLength(4);
104+
});
105+
});

‎graph-ui/src/hooks/useProjects.ts‎

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,64 @@ interface UseProjectsResult {
1414
refresh: () => void;
1515
}
1616

17+
interface ProjectPage {
18+
projects?: Project[];
19+
offset?: number;
20+
returned?: number;
21+
has_more?: boolean;
22+
}
23+
24+
const PROJECT_PAGE_SIZE = 50;
25+
const SCHEMA_CONCURRENCY = 4;
26+
27+
async function fetchAllProjects(): Promise<Project[]> {
28+
const projects: Project[] = [];
29+
let offset = 0;
30+
31+
while (true) {
32+
const page = await callTool<ProjectPage>("list_projects", {
33+
offset,
34+
limit: PROJECT_PAGE_SIZE,
35+
});
36+
const pageProjects = page.projects ?? [];
37+
projects.push(...pageProjects);
38+
39+
if (!page.has_more) return projects;
40+
41+
const pageOffset = Number.isSafeInteger(page.offset) ? page.offset! : offset;
42+
const returned = Number.isSafeInteger(page.returned) ? page.returned! : pageProjects.length;
43+
const nextOffset = pageOffset + returned;
44+
if (returned <= 0 || nextOffset <= offset) {
45+
throw new Error("list_projects pagination did not advance");
46+
}
47+
offset = nextOffset;
48+
}
49+
}
50+
51+
async function hydrateSchemas(infos: ProjectInfo[]): Promise<void> {
52+
let nextIndex = 0;
53+
54+
async function worker() {
55+
while (nextIndex < infos.length) {
56+
const index = nextIndex++;
57+
const project = infos[index].project;
58+
try {
59+
infos[index] = {
60+
project,
61+
schema: await callTool<SchemaInfo>("get_graph_schema", {
62+
project: project.name,
63+
}),
64+
};
65+
} catch {
66+
infos[index] = { project, schema: null };
67+
}
68+
}
69+
}
70+
71+
const workerCount = Math.min(SCHEMA_CONCURRENCY, infos.length);
72+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
73+
}
74+
1775
export function useProjects(): UseProjectsResult {
1876
const [projects, setProjects] = useState<ProjectInfo[]>([]);
1977
const [loading, setLoading] = useState(true);
@@ -23,24 +81,12 @@ export function useProjects(): UseProjectsResult {
2381
setLoading(true);
2482
setError(null);
2583
try {
26-
const result = await callTool<{ projects: Project[] }>("list_projects");
27-
const list = result.projects ?? [];
28-
29-
/* Fetch schema for each project */
30-
const infos: ProjectInfo[] = await Promise.all(
31-
list.map(async (p) => {
32-
try {
33-
const schema = await callTool<SchemaInfo>("get_graph_schema", {
34-
project: p.name,
35-
});
36-
return { project: p, schema };
37-
} catch {
38-
return { project: p, schema: null };
39-
}
40-
}),
41-
);
42-
84+
const list = await fetchAllProjects();
85+
const infos: ProjectInfo[] = list.map((project) => ({ project, schema: null }));
4386
setProjects(infos);
87+
88+
await hydrateSchemas(infos);
89+
setProjects([...infos]);
4490
} catch (e) {
4591
setError(e instanceof Error ? e.message : "Failed to fetch projects");
4692
} finally {

0 commit comments

Comments
 (0)