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
7 changes: 7 additions & 0 deletions apps/web/app/admin/courses/[year]/(directory)/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";

export default function Loading() {
return (
<CatalogueLoading noun="courses" layout="directory" hideAcademicYear />
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueYearRoute } from "@/ui/admin/catalogue/catalogue-route-pages";

export const dynamic = "force-dynamic";

export default async function Page({
params,
searchParams,
Expand Down
5 changes: 5 additions & 0 deletions apps/web/app/admin/majors/[year]/(directory)/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";

export default function Loading() {
return <CatalogueLoading noun="majors" layout="directory" hideAcademicYear />;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueYearRoute } from "@/ui/admin/catalogue/catalogue-route-pages";

export const dynamic = "force-dynamic";

export default async function Page({
params,
searchParams,
Expand Down
5 changes: 5 additions & 0 deletions apps/web/app/admin/minors/[year]/(directory)/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";

export default function Loading() {
return <CatalogueLoading noun="minors" layout="directory" hideAcademicYear />;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueYearRoute } from "@/ui/admin/catalogue/catalogue-route-pages";

export const dynamic = "force-dynamic";

export default async function Page({
params,
searchParams,
Expand Down
7 changes: 7 additions & 0 deletions apps/web/app/admin/programmes/[year]/(directory)/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";

export default function Loading() {
return (
<CatalogueLoading noun="programmes" layout="directory" hideAcademicYear />
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueYearRoute } from "@/ui/admin/catalogue/catalogue-route-pages";

export const dynamic = "force-dynamic";

export default async function Page({
params,
searchParams,
Expand Down
11 changes: 11 additions & 0 deletions apps/web/app/admin/specialisations/[year]/(directory)/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";

export default function Loading() {
return (
<CatalogueLoading
noun="specialisations"
layout="directory"
hideAcademicYear
/>
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueYearRoute } from "@/ui/admin/catalogue/catalogue-route-pages";

export const dynamic = "force-dynamic";

export default async function Page({
params,
searchParams,
Expand Down
27 changes: 15 additions & 12 deletions apps/web/lib/assistant/draft-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,31 @@ export function createAssistantDraftStore(key: string) {
let snapshot = empty;
let loaded = false;
const listeners = new Set<() => void>();
function getSnapshot() {
if (!loaded && typeof window !== "undefined") {
try {
snapshot = readAssistantHistory(localStorage.getItem(key));
} catch {
snapshot = empty;
}
loaded = true;
function loadStoredSnapshot() {
if (loaded) return;
try {
snapshot = readAssistantHistory(localStorage.getItem(key));
} catch {
snapshot = empty;
}
return snapshot;
loaded = true;
}
function notify() {
listeners.forEach((listener) => listener());
}
return {
getSnapshot,
// Keep the first browser snapshot equal to the server snapshot. Storage is
// read when React subscribes after hydration, then React's subscription
// check applies the restored drafts without changing the server markup.
getSnapshot: () => snapshot,
getServerSnapshot: () => empty,
subscribe(listener: () => void) {
listeners.add(listener);
loadStoredSnapshot();
function onStorage(event: StorageEvent) {
if (event.key === key || event.key === null) {
loaded = false;
getSnapshot();
loadStoredSnapshot();
notify();
}
}
Expand All @@ -40,7 +42,8 @@ export function createAssistantDraftStore(key: string) {
};
},
update(change: (previous: AssistantDraft[]) => AssistantDraft[]) {
snapshot = change(getSnapshot());
loadStoredSnapshot();
snapshot = change(snapshot);
try {
localStorage.setItem(key, JSON.stringify(snapshot));
} catch {
Expand Down
17 changes: 17 additions & 0 deletions apps/web/lib/coursemap/catalogue-kinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ export function adminCatalogueVersionPath(
return `${adminCatalogueRecordPath(kind, year, code)}/changelog/${versionOrdinal}`;
}

export const ADMIN_CATALOGUE_OPERATIONS_PATH = "/admin/operations/catalogue";

export function adminCatalogueSyncPath(syncId: string) {
return `${ADMIN_CATALOGUE_OPERATIONS_PATH}/syncs/${syncId}`;
}

export function adminCatalogueDiscoveryPath(checkId: number) {
return `${ADMIN_CATALOGUE_OPERATIONS_PATH}/discovery/${checkId}`;
}

export function publicCatalogueRecordPath(
kind: CatalogueKind,
year: number,
Expand Down Expand Up @@ -80,6 +90,13 @@ export type CatalogueDirectoryRecord = {
} | null;
};

export type CatalogueTableLayout =
| "public-courses"
| "users"
| "directory"
| "operations-syncs"
| "operations-discovery";

export type CatalogueDirectoryPage = {
kind: CatalogueKind;
academicYear: number;
Expand Down
24 changes: 24 additions & 0 deletions apps/web/tests/assistant-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ASSISTANT_PREVIEW_RESPONSE } from "@/lib/assistant/history";
import { SidebarProvider, SidebarMenu } from "@coursemap/ui/primitives/sidebar";
import { AssistantRecentChat } from "@/ui/assistant/assistant-recent-chat";
import { assistantTitle, readAssistantHistory } from "@/lib/assistant/history";
import { createAssistantDraftStore } from "@/lib/assistant/draft-store";
import {
AssistantProvider,
useAssistant,
Expand Down Expand Up @@ -50,6 +51,29 @@ vi.mock("sonner", () => ({ toast: { info: vi.fn() } }));

vi.mock("@coursemap/ui/hooks/use-mobile", () => ({ useIsMobile: () => false }));

test("restores browser drafts only after the hydration snapshot", () => {
localStorage.setItem(
"coursemap:compass:drafts:test",
JSON.stringify([
{
id: "saved",
draft: "Continue planning",
model: "",
updatedAt: "2026-09-22T00:00:00.000Z",
messages: [],
},
]),
);
const store = createAssistantDraftStore("coursemap:compass:drafts:test");
expect(store.getServerSnapshot()).toEqual([]);
expect(store.getSnapshot()).toEqual([]);
const unsubscribe = store.subscribe(() => {});
expect(store.getSnapshot()).toEqual([
expect.objectContaining({ id: "saved", draft: "Continue planning" }),
]);
unsubscribe();
});

test("keeps a draft when closed and reopened, and clears it for a new chat", () => {
const close = vi.fn();
const { rerender } = render(
Expand Down
13 changes: 13 additions & 0 deletions apps/web/tests/breadcrumbs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,16 @@ test("shows three short breadcrumbs until width requires collapsing the middle",
resize(600);
expect(within(trail).getByRole("link", { name: "Courses" })).toBeVisible();
});

test("does not repeat a catalogue section on its year directory", () => {
measureAt(600);
render(<Breadcrumbs segmentLabels={{ "2026": null, infs1001: null }} />);
const trail = screen.getByRole("navigation", { name: "Breadcrumb" });
expect(within(trail).getAllByRole("listitem")).toHaveLength(2);
expect(within(trail).getByRole("link", { name: "Admin" })).toBeVisible();
expect(within(trail).getByRole("link", { name: "Courses" })).toHaveAttribute(
"aria-current",
"page",
);
expect(trail).not.toHaveTextContent("2026");
});
21 changes: 21 additions & 0 deletions apps/web/tests/catalogue-empty.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { render } from "@testing-library/react";
import { expect, test } from "vitest";
import { CatalogueEmpty } from "@/ui/admin/catalogue-table/catalogue-empty";

test("uses stable artwork for catalogue empty states", () => {
const { container, rerender } = render(
<CatalogueEmpty title="No courses" description="Nothing here yet." />,
);
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container).not.toHaveTextContent("0.");

rerender(
<CatalogueEmpty
filtered
title="No courses"
description="Nothing here yet."
/>,
);
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.querySelectorAll("svg")).toHaveLength(1);
});
5 changes: 3 additions & 2 deletions apps/web/tests/operations-sync-views.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ test("the sync list carries the technical columns an operator needs", () => {

test("an empty list says what fills it rather than showing an empty table", () => {
renderSyncList(syncPage({ rows: [], total: 0 }));
expect(screen.getByText("No syncs match")).toBeTruthy();
expect(screen.getByText("No syncs yet")).toBeTruthy();
expect(screen.queryByRole("table")).toBeNull();
});

Expand Down Expand Up @@ -217,6 +217,7 @@ test("an incomplete listing check says so, because it cannot retire anything", (
render(<DiscoveryList checks={checks} />);
expect(screen.getByText("Partial")).toBeTruthy();
expect(
screen.getByRole("link", { name: "course" }).getAttribute("href"),
screen.getByRole("link", { name: "Courses" }).getAttribute("href"),
).toBe("/admin/operations/catalogue/discovery/7");
expect(screen.getByText("120 discovered")).toBeTruthy();
});
8 changes: 1 addition & 7 deletions apps/web/ui/admin/catalogue-table/catalogue-empty.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export function CatalogueEmpty({
title,
description,
filtered = false,
imports = false,
error = false,
clearHref,
onSync,
Expand All @@ -18,19 +17,14 @@ export function CatalogueEmpty({
title: string;
description: string;
filtered?: boolean;
imports?: boolean;
error?: boolean;
clearHref?: string;
onSync?: () => void;
children?: ReactNode;
}) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 overflow-auto rounded-xl border-2 border-dotted border-border bg-card px-5 py-8 text-center">
{error ? (
<ErrorIllustration kind="server" />
) : (
<CatalogueIllustration variant={filtered ? 5 : imports ? 2 : 6} />
)}
{error ? <ErrorIllustration kind="server" /> : <CatalogueIllustration />}
<h2 className="text-lg font-semibold">
{filtered ? "No matches this time." : title}
</h2>
Expand Down
42 changes: 40 additions & 2 deletions apps/web/ui/admin/catalogue-table/catalogue-loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import {
* replaces it and the whole list reflows on arrival.
*/
export type CatalogueLoadingLayout =
"public-courses" | "users" | "directory" | "import-records";
| "public-courses"
| "users"
| "directory"
| "import-records"
| "operations-syncs"
| "operations-discovery";

/**
* A skeleton cell per real cell. The kind decides the shape, so a placeholder
Expand Down Expand Up @@ -47,6 +52,27 @@ function columnsFor(noun: string, layout: CatalogueLoadingLayout): Column[] {
{ label: "Updated", kind: "text" },
{ label: "Actions", kind: "actions" },
];
if (layout === "operations-syncs")
return [
{ label: "Record", kind: "identity" },
{ label: "Year", kind: "text" },
{ label: "Status", kind: "text" },
{ label: "Trigger", kind: "text" },
{ label: "Started", kind: "text" },
{ label: "Duration", kind: "text" },
{ label: "Model", kind: "text" },
{ label: "Cost", kind: "text" },
];
if (layout === "operations-discovery")
return [
{ label: "Listing", kind: "identity" },
{ label: "Year", kind: "text" },
{ label: "Status", kind: "text" },
{ label: "Complete", kind: "text" },
{ label: "Discovered", kind: "text" },
{ label: "Started", kind: "text" },
{ label: "Duration", kind: "text" },
];
if (layout === "import-records")
return [
{ label: "Import", kind: "identity" },
Expand Down Expand Up @@ -174,12 +200,24 @@ export function CatalogueTableLoading({
export function CatalogueLoading({
noun,
layout,
hideAcademicYear = false,
}: {
noun: string;
layout: CatalogueLoadingLayout;
hideAcademicYear?: boolean;
}) {
const breadcrumbSegmentLabels = hideAcademicYear
? Object.fromEntries(
Array.from({ length: 11 }, (_, index) => [String(2020 + index), null]),
)
: undefined;
return (
<AppShell loading admin={layout !== "public-courses"} fill>
<AppShell
loading
admin={layout !== "public-courses"}
fill
breadcrumbSegmentLabels={breadcrumbSegmentLabels}
>
<h1 className="sr-only">Loading {noun}</h1>
<CatalogueTableLoading noun={noun} layout={layout} />
</AppShell>
Expand Down
18 changes: 18 additions & 0 deletions apps/web/ui/admin/catalogue-table/catalogue-table.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,24 @@ a.title:focus-visible {
170px 170px 60px;
}

/* Operations syncs: record, year, status, trigger, started, duration, model,
cost. Wider than the catalogue layouts because every column here is a fact
rather than a label. */
.shell[data-layout="operations-syncs"] tr {
min-width: 1020px;
grid-template-columns:
minmax(220px, 1.2fr) 72px 150px 100px 170px
92px minmax(150px, 1fr) 96px;
}
/* Operations discovery: kind, year, status, completeness, discovered,
started, duration. */
.shell[data-layout="operations-discovery"] tr {
min-width: 880px;
grid-template-columns:
minmax(180px, 1fr) 72px 130px 110px 110px 170px
92px;
}

@media (pointer: coarse) {
.actions button {
min-height: 44px;
Expand Down
Loading
Loading