Skip to content

Commit 6da1e62

Browse files
authored
feat: improve catalogue import review and model controls (#101)
1 parent 4a1d156 commit 6da1e62

44 files changed

Lines changed: 2177 additions & 1238 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/web/app/admin/page.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,15 @@ export default async function AdminOverviewPage() {
6161
/>
6262
</div>
6363

64-
<ImportModelCard
65-
canManage={canManageImports}
66-
configured={importModel.configured}
67-
model={importModel.model}
68-
options={importModel.options}
69-
updatedAt={importModel.updatedAt}
70-
/>
64+
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
65+
<ImportModelCard
66+
canManage={canManageImports}
67+
model={importModel.model}
68+
models={importModel.models}
69+
error={importModel.error}
70+
updatedAt={importModel.updatedAt}
71+
/>
72+
</div>
7173
</div>
7274
</AppShell>
7375
);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
2+
import { expect, test, vi } from "vitest";
3+
import { SearchPicker } from "@/ui/admin/requisites/condition-search-picker";
4+
import { simulateOverlayAnimation } from "./helpers/overlay-animation";
5+
6+
vi.mock("@/lib/coursemap/requisite-search-actions", () => ({
7+
searchRequisiteCourses: vi.fn(),
8+
searchRequisiteProgrammes: vi.fn(),
9+
}));
10+
11+
for (const label of ["Course", "Programme"]) {
12+
test(`${label} search retains results throughout dismissal and resets when reopened`, async () => {
13+
const animation = simulateOverlayAnimation();
14+
const scroll = vi.fn();
15+
const originalScroll = HTMLElement.prototype.scrollIntoView;
16+
HTMLElement.prototype.scrollIntoView = scroll;
17+
try {
18+
render(
19+
<SearchPicker
20+
label={label}
21+
empty="No matches."
22+
onSearch={async () => [{ code: "COMP3600", title: "Algorithms" }]}
23+
onSelect={vi.fn()}
24+
value=""
25+
/>,
26+
);
27+
const trigger = screen.getByRole("button", { name: label });
28+
fireEvent.click(trigger);
29+
fireEvent.change(screen.getByRole("combobox"), {
30+
target: { value: "COMP" },
31+
});
32+
const result = await screen.findByRole("option", { name: /Algorithms/ });
33+
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" });
34+
expect(trigger).toHaveAttribute("aria-expanded", "false");
35+
expect(result).toBeInTheDocument();
36+
expect(screen.queryByText("No matches.")).not.toBeInTheDocument();
37+
const exit = new Event("animationend", { bubbles: true });
38+
Object.defineProperty(exit, "animationName", { value: "overlay-exit" });
39+
fireEvent(screen.getByRole("dialog", { hidden: true }), exit);
40+
await waitFor(() => expect(trigger).toHaveFocus());
41+
fireEvent.click(trigger);
42+
expect(screen.getByRole("combobox")).toHaveValue("");
43+
expect(screen.queryByRole("option")).not.toBeInTheDocument();
44+
expect(screen.getByText("Type a code or title.")).toBeInTheDocument();
45+
} finally {
46+
animation.mockRestore();
47+
HTMLElement.prototype.scrollIntoView = originalScroll;
48+
}
49+
});
50+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { afterEach, expect, test, vi } from "vitest";
2+
import { fireEvent, render, screen } from "@testing-library/react";
3+
import { DatabaseScrollPreview } from "@/ui/admin/imports/database-scroll-preview";
4+
5+
afterEach(() => vi.restoreAllMocks());
6+
7+
test("the capped thumb reaches both ends of its track with the content", () => {
8+
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(300);
9+
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(600);
10+
render(
11+
<DatabaseScrollPreview label="Rows">
12+
<p>Content</p>
13+
</DatabaseScrollPreview>,
14+
);
15+
const bar = screen.getByRole("scrollbar", { name: "Rows scrollbar" });
16+
const viewport = document.getElementById(bar.getAttribute("aria-controls")!)!;
17+
const thumb = bar.firstElementChild as HTMLElement;
18+
expect(thumb.style.height).toBe("72px");
19+
fireEvent.keyDown(bar, { key: "End" });
20+
fireEvent.scroll(viewport);
21+
expect(viewport.scrollTop).toBe(300);
22+
expect(thumb.style.transform).toBe("translateY(224px)");
23+
expect(bar).toHaveAttribute("aria-valuenow", "300");
24+
fireEvent.keyDown(bar, { key: "Home" });
25+
fireEvent.scroll(viewport);
26+
expect(viewport.scrollTop).toBe(0);
27+
expect(thumb.style.transform).toBe("translateY(0px)");
28+
});
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { render, screen, waitFor } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { beforeEach, expect, test, vi } from "vitest";
4+
import { TooltipProvider } from "@coursemap/ui/primitives/tooltip";
5+
import { ImportModelCard } from "../ui/admin/imports/import-model-card";
6+
const actions = vi.hoisted(() => ({
7+
setImportModel: vi.fn(),
8+
saveImportModel: vi.fn(),
9+
removeImportModel: vi.fn(),
10+
setImportModelVisibility: vi.fn(),
11+
refresh: vi.fn(),
12+
}));
13+
vi.mock("@/lib/admin/settings-actions", () => actions);
14+
vi.mock("next/navigation", () => ({
15+
useRouter: () => ({ refresh: actions.refresh }),
16+
}));
17+
const models = [
18+
{
19+
id: "google/test",
20+
name: "Gemini Test",
21+
provider: "Google",
22+
enabled: true,
23+
visible: true,
24+
input_usd_per_million: 0.25,
25+
output_usd_per_million: 1.5,
26+
pricing_updated_at: "2026-09-07T00:00:00Z",
27+
},
28+
{
29+
id: "anthropic/test",
30+
name: "Claude Test",
31+
provider: "Anthropic",
32+
enabled: true,
33+
visible: true,
34+
input_usd_per_million: 1,
35+
output_usd_per_million: 5,
36+
pricing_updated_at: "2026-09-07T00:00:00Z",
37+
},
38+
];
39+
beforeEach(() => vi.resetAllMocks());
40+
function setup(canManage = true) {
41+
render(
42+
<TooltipProvider>
43+
<ImportModelCard
44+
canManage={canManage}
45+
model="google/test"
46+
models={models}
47+
updatedAt={null}
48+
/>
49+
</TooltipProvider>,
50+
);
51+
return userEvent.setup();
52+
}
53+
test("shows compact pricing and selects a model through the workspace menu", async () => {
54+
actions.setImportModel.mockResolvedValue({ ok: true, message: "Saved." });
55+
const user = setup();
56+
expect(screen.getByText("0.55¢")).toBeInTheDocument();
57+
await user.click(screen.getByRole("button", { name: "Import model" }));
58+
await user.click(
59+
screen.getByRole("menuitem", { name: "Claude Test, Anthropic" }),
60+
);
61+
expect(actions.setImportModel).toHaveBeenCalledWith("anthropic/test");
62+
expect(actions.refresh).toHaveBeenCalled();
63+
});
64+
test("model management prevents removing the default and preserves failed additions", async () => {
65+
actions.saveImportModel.mockResolvedValue({
66+
ok: false,
67+
message: "The model was not found.",
68+
});
69+
const user = setup();
70+
await user.click(screen.getByRole("button", { name: "Import model" }));
71+
await user.click(screen.getByRole("menuitem", { name: "Manage models" }));
72+
await user.click(
73+
screen.getByRole("button", { name: "Actions for Gemini Test" }),
74+
);
75+
expect(
76+
screen.getByRole("button", { name: "Refresh pricing" }),
77+
).toBeInTheDocument();
78+
expect(
79+
screen.queryByRole("button", { name: "Remove model" }),
80+
).not.toBeInTheDocument();
81+
await user.keyboard("{Escape}");
82+
await user.type(screen.getByLabelText("OpenRouter model ID"), "test/missing");
83+
await user.click(screen.getByRole("button", { name: "Add model" }));
84+
expect(await screen.findByRole("alert")).toHaveTextContent(
85+
"The model was not found.",
86+
);
87+
expect(screen.getByLabelText("OpenRouter model ID")).toHaveValue(
88+
"test/missing",
89+
);
90+
});
91+
test("read-only viewers cannot change the catalogue", () => {
92+
setup(false);
93+
expect(screen.getByRole("button", { name: "Import model" })).toBeDisabled();
94+
});
95+
96+
test("Escape returns focus from model management to its selector", async () => {
97+
const user = setup();
98+
const trigger = screen.getByRole("button", { name: "Import model" });
99+
await user.click(trigger);
100+
await user.click(screen.getByRole("menuitem", { name: "Manage models" }));
101+
await user.keyboard("{Escape}");
102+
await waitFor(() => expect(trigger).toHaveFocus());
103+
});
104+
105+
test("hides a model from selection while keeping it manageable", async () => {
106+
actions.setImportModelVisibility.mockResolvedValue({
107+
ok: true,
108+
message: "Hidden.",
109+
});
110+
const user = setup();
111+
await user.click(screen.getByRole("button", { name: "Import model" }));
112+
await user.click(screen.getByRole("menuitem", { name: "Manage models" }));
113+
await user.click(
114+
screen.getByRole("button", { name: "Actions for Claude Test" }),
115+
);
116+
await user.click(screen.getByRole("button", { name: "Hide model" }));
117+
expect(actions.setImportModelVisibility).toHaveBeenCalledWith(
118+
"anthropic/test",
119+
false,
120+
);
121+
expect(actions.refresh).toHaveBeenCalled();
122+
});
123+
124+
test("hidden models can be shown again and are absent from the selector", async () => {
125+
actions.setImportModelVisibility.mockResolvedValue({
126+
ok: true,
127+
message: "Visible.",
128+
});
129+
render(
130+
<TooltipProvider>
131+
<ImportModelCard
132+
canManage
133+
model="google/test"
134+
models={models.map((model) => ({
135+
...model,
136+
visible: model.id === "google/test",
137+
}))}
138+
updatedAt={null}
139+
/>
140+
</TooltipProvider>,
141+
);
142+
const user = userEvent.setup();
143+
await user.click(screen.getByRole("button", { name: "Import model" }));
144+
expect(
145+
screen.queryByRole("menuitem", { name: "Claude Test, Anthropic" }),
146+
).not.toBeInTheDocument();
147+
await user.click(screen.getByRole("menuitem", { name: "Manage models" }));
148+
expect(screen.getByText("Hidden").closest("details")).not.toHaveAttribute(
149+
"open",
150+
);
151+
await user.click(screen.getByText("Hidden"));
152+
expect(screen.getByText("Hidden").closest("details")).toHaveAttribute("open");
153+
await user.click(
154+
screen.getByRole("button", { name: "Actions for Claude Test" }),
155+
);
156+
await user.click(screen.getByRole("button", { name: "Show model" }));
157+
expect(actions.setImportModelVisibility).toHaveBeenCalledWith(
158+
"anthropic/test",
159+
true,
160+
);
161+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { ReactNode } from "react";
2+
import { render, screen, within } from "@testing-library/react";
3+
import { expect, test, vi } from "vitest";
4+
import { Tabs } from "@coursemap/ui/primitives/tabs";
5+
import { ImportTargetReviewLoading } from "@/ui/admin/imports/import-target-review-loading";
6+
import { ImportSectionTabs } from "@/ui/admin/imports/import-section-tabs";
7+
import { Breadcrumbs } from "@/ui/shell/breadcrumbs";
8+
9+
vi.mock("next/navigation", () => ({
10+
usePathname: () =>
11+
"/admin/courses/imports/a23de36a-bd72-4d9d-89ec-3b548799815d",
12+
}));
13+
14+
vi.mock("@/ui/shell", () => ({
15+
AppShell: ({
16+
children,
17+
tabs,
18+
breadcrumbSegmentLabels,
19+
}: {
20+
children: ReactNode;
21+
tabs?: ReactNode;
22+
breadcrumbSegmentLabels?: Record<string, string | null>;
23+
}) => (
24+
<>
25+
<Breadcrumbs segmentLabels={breadcrumbSegmentLabels} />
26+
{tabs}
27+
{children}
28+
</>
29+
),
30+
}));
31+
32+
test("keeps the collapsed ancestors in place while the import name loads", () => {
33+
const { rerender } = render(<ImportTargetReviewLoading noun="course" />);
34+
const trail = screen.getByRole("navigation", { name: "Breadcrumb" });
35+
expect(within(trail).getAllByRole("listitem")).toHaveLength(3);
36+
expect(within(trail).getByRole("link", { name: "Admin" })).toBeVisible();
37+
expect(
38+
within(trail).getByRole("button", { name: "Show hidden breadcrumbs" }),
39+
).toBeVisible();
40+
expect(
41+
within(trail).queryByRole("link", { name: "Imports" }),
42+
).not.toBeInTheDocument();
43+
expect(trail).not.toHaveTextContent("a23de36a");
44+
rerender(<Breadcrumbs currentLabel="INFS1001" />);
45+
const loaded = screen.getByRole("navigation", { name: "Breadcrumb" });
46+
expect(within(loaded).getAllByRole("listitem")).toHaveLength(3);
47+
expect(
48+
within(loaded).getByRole("button", { name: "Show hidden breadcrumbs" }),
49+
).toBeVisible();
50+
expect(
51+
within(loaded).getByRole("link", { name: "INFS1001" }),
52+
).toHaveAttribute("aria-current", "page");
53+
});
54+
55+
test.each(["course", "programme"])(
56+
"matches the %s import tabs and pipeline table while loading",
57+
(noun) => {
58+
const { rerender } = render(<ImportTargetReviewLoading noun={noun} />);
59+
const labels = [
60+
"Pipeline",
61+
"Source and artefacts",
62+
"Database rows",
63+
noun === "course" ? "Course preview" : "Preview",
64+
];
65+
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(
66+
labels,
67+
);
68+
screen.getAllByRole("tab").forEach((tab) => expect(tab).toBeDisabled());
69+
const table = screen.getByRole("table", {
70+
name: "Loading import pipeline stages",
71+
});
72+
expect(
73+
within(table)
74+
.getAllByRole("columnheader")
75+
.map((cell) => cell.textContent),
76+
).toEqual(["Step", "Stage", "Status", "Attempts", "Duration", "Error"]);
77+
expect(within(table).getAllByRole("row")).toHaveLength(11);
78+
rerender(
79+
<Tabs defaultValue="pipeline">
80+
<ImportSectionTabs course={noun === "course"} />
81+
</Tabs>,
82+
);
83+
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(
84+
labels,
85+
);
86+
screen.getAllByRole("tab").forEach((tab) => expect(tab).toBeEnabled());
87+
},
88+
);

0 commit comments

Comments
 (0)