diff --git a/.gitignore b/.gitignore index e78b35da..d17cd66d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ # Editor and agent session state # Shared instructions live in the tracked .agents directory. +/.claude/ /.codex/ /.cursor/ diff --git a/README.md b/README.md index f56ac33c..7f3b6686 100644 --- a/README.md +++ b/README.md @@ -59,12 +59,16 @@ pnpm install cp apps/web/.env.example apps/web/.env.local pnpm db:start # local Supabase stack pnpm db:reset # migrations plus demonstration fixtures -pnpm dev:local # http://127.0.0.1:3000 +pnpm dev # http://127.0.0.1:3000 +# Or build and run the production server against the same local stack: +pnpm build +pnpm start ``` Sign up at `/signup` and the local stack issues a session straight away. To run against a hosted Supabase project instead, configure its URL, publishable key -and your application origin in `apps/web/.env.local`, then use `pnpm dev`. +and your application origin in `apps/web/.env.local`, then use the explicit +`pnpm dev:prod`, `pnpm build:prod` and `pnpm start:prod` commands. The [environment template](apps/web/.env.example) explains the required settings, optional import credentials and map-service defaults. @@ -94,16 +98,22 @@ Level Security, and the service-role key never reaches the browser. ## Commands -| Command | What it does | -| ---------------- | ---------------------------------------------- | -| `pnpm dev:local` | Development server against local Supabase | -| `pnpm check` | Formatting, lint and strict types | -| `pnpm test` | Unit and component tests | -| `pnpm test:e2e` | Authenticated browser journeys | -| `pnpm db:reset` | Rebuild the local database and reseed fixtures | -| `pnpm db:test` | pgTAP database tests | -| `pnpm db:types` | Regenerate committed database types | -| `pnpm verify` | Local application delivery checks | +| Command | What it does | +| -------------------- | ------------------------------------------------------ | +| `pnpm dev` | Development server against local Supabase | +| `pnpm dev:prod` | Development server using configured hosted credentials | +| `pnpm build` | Production build against local Supabase | +| `pnpm start` | Built production server against local Supabase | +| `pnpm preview:local` | Build and start against local Supabase in one step | +| `pnpm build:prod` | Production build using configured hosted credentials | +| `pnpm start:prod` | Built server using configured hosted credentials | +| `pnpm check` | Formatting, lint and strict types | +| `pnpm test` | Unit and component tests | +| `pnpm test:e2e` | Authenticated browser journeys | +| `pnpm db:reset` | Rebuild the local database and reseed fixtures | +| `pnpm db:test` | pgTAP database tests | +| `pnpm db:types` | Regenerate committed database types | +| `pnpm verify` | Local application delivery checks | Run `pnpm verify` before opening a pull request. CI additionally runs database checks, authenticated browser journeys and a production dependency audit. See diff --git a/apps/web/.env.example b/apps/web/.env.example index 5b6c0b1a..a98cb357 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -5,9 +5,10 @@ # NEXT_PUBLIC_* values are browser-visible and baked into production builds. # Rebuild after changing them. Other values below are server-only. # -# Local setup: pnpm db:start, pnpm db:reset, then pnpm dev:local. -# dev:local reads the local Supabase URL and keys automatically and supplies the -# site URL. When using pnpm dev directly, fill in the required section yourself. +# Local setup: pnpm db:start, pnpm db:reset, then pnpm dev. +# dev, build, start and preview:local read the local Supabase URL and keys +# automatically and supply the site URL. Only the explicit :prod commands read +# the hosted values below. # Optional overrides stay commented out until needed. # ============================================================================= @@ -21,14 +22,14 @@ # project's Connect dialog. Keep this URL and the public key on the same project. NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 -# Browser-safe publishable key. Required when using pnpm dev directly. -# dev:local supplies this from Supabase; never substitute a server secret here. +# Browser-safe publishable key. Required by the explicit :prod commands. +# Local commands supply this from Supabase; never substitute a server secret. NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= # Public application origin for authentication redirects and cookie settings. # Use HTTPS outside localhost/127.0.0.1, with no path, query or fragment. # Match the allowed authentication URLs in your Supabase configuration. -# dev:local overrides this with http://127.0.0.1:3000. +# Local commands override this with http://127.0.0.1:3000. NEXT_PUBLIC_SITE_URL=http://localhost:3000 # ============================================================================= @@ -38,7 +39,7 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000 # Configure them when checking detailed ANU source material. # Privileged Supabase server key for local preview and browser test user setup. -# dev:local supplies the local server key automatically. +# Local commands supply the local server key automatically. # Use a key from the same project as NEXT_PUBLIC_SUPABASE_URL. Keep it server-only. # Used by: scripts/local/dev-preview.mjs and playwright/fixtures.ts. # SUPABASE_SECRET_KEY= diff --git a/apps/web/app/academic/page.tsx b/apps/web/app/academic/page.tsx index 808fec03..26875317 100644 --- a/apps/web/app/academic/page.tsx +++ b/apps/web/app/academic/page.tsx @@ -11,7 +11,7 @@ export default async function AcademicPage() { } catch { return ( ); diff --git a/apps/web/app/admin/operations/catalogue/error.tsx b/apps/web/app/admin/operations/catalogue/error.tsx new file mode 100644 index 00000000..2b60ce99 --- /dev/null +++ b/apps/web/app/admin/operations/catalogue/error.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { CatalogueOperationsError as default } from "@/ui/admin/operations/operations-error"; diff --git a/apps/web/app/auth/password/route.ts b/apps/web/app/auth/password/route.ts new file mode 100644 index 00000000..0d15cac3 --- /dev/null +++ b/apps/web/app/auth/password/route.ts @@ -0,0 +1,81 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { safeInternalRedirect } from "@/lib/auth/redirect"; +import { + getSiteOriginForRequest, + getSupabaseConfig, +} from "@/lib/supabase/config"; +import { createRequestClient } from "@/lib/supabase/request"; + +function noStore(response: NextResponse) { + response.headers.set( + "Cache-Control", + "private, no-cache, no-store, must-revalidate, max-age=0", + ); + response.headers.set("Expires", "0"); + response.headers.set("Pragma", "no-cache"); + return response; +} + +function loginRedirect(origin: string, next: string) { + const url = new URL("/login", origin); + url.searchParams.set("next", next); + url.searchParams.set("error", "invalid-login"); + return noStore(NextResponse.redirect(url, 303)); +} + +export async function POST(request: NextRequest) { + const siteOrigin = getSiteOriginForRequest( + request.nextUrl, + request.headers.get("x-forwarded-host") ?? request.headers.get("host"), + request.headers.get("x-forwarded-proto"), + ); + if (!siteOrigin || !getSupabaseConfig()) { + return new NextResponse("Coursemap authentication is not configured.", { + status: 503, + headers: { "Cache-Control": "private, no-store" }, + }); + } + + if (request.headers.get("origin") !== siteOrigin) { + return new NextResponse("Invalid request origin.", { + status: 403, + headers: { "Cache-Control": "private, no-store" }, + }); + } + + const formData = await request.formData(); + const emails = formData.getAll("email"); + const passwords = formData.getAll("password"); + const nextValues = formData.getAll("next"); + const email = emails.length === 1 ? emails[0] : null; + const password = passwords.length === 1 ? passwords[0] : null; + const next = safeInternalRedirect( + nextValues.length === 1 && typeof nextValues[0] === "string" + ? nextValues[0] + : null, + ); + + if ( + typeof email !== "string" || + typeof password !== "string" || + !email.trim() || + email.length > 254 || + password.length < 8 || + password.length > 128 + ) { + return loginRedirect(siteOrigin, next); + } + + const response = noStore( + NextResponse.redirect(new URL(next, siteOrigin), 303), + ); + const { supabase, applyTo } = createRequestClient(request, response); + const { error } = await supabase.auth.signInWithPassword({ + email: email.trim(), + password, + }); + + if (error) return applyTo(loginRedirect(siteOrigin, next)); + return response; +} diff --git a/apps/web/app/auth/sign-in/sign-in-form.tsx b/apps/web/app/auth/sign-in/sign-in-form.tsx index 24b5c87e..fac9032c 100644 --- a/apps/web/app/auth/sign-in/sign-in-form.tsx +++ b/apps/web/app/auth/sign-in/sign-in-form.tsx @@ -13,14 +13,16 @@ import { createClient } from "@/lib/supabase/browser"; export function SignInForm({ next, configured, + initialError = null, }: { next: string; configured: boolean; + initialError?: string | null; }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [submitting, setSubmitting] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(initialError); const submit = async (event: FormEvent) => { event.preventDefault(); @@ -56,7 +58,12 @@ export function SignInForm({ }; return ( - + diff --git a/apps/web/app/calendar/page.tsx b/apps/web/app/calendar/page.tsx index 723c5376..0e654be7 100644 --- a/apps/web/app/calendar/page.tsx +++ b/apps/web/app/calendar/page.tsx @@ -31,7 +31,7 @@ export default async function CalendarPage() { catalogue = await loadCurrentUserPlanCatalogue(); } catch { return ( - + ); } const keyDates = await loadAllPublishedKeyDates(); diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx index 9bc325d0..3b83054c 100644 --- a/apps/web/app/error.tsx +++ b/apps/web/app/error.tsx @@ -2,10 +2,12 @@ import { Button } from "@coursemap/ui/primitives/button"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import { ErrorPageLayout } from "@/ui/common/error-page-layout"; import { useOnlineStatus } from "@/lib/browser/use-online-status"; import { OfflineError } from "@/ui/errors/offline-error"; import { ErrorState } from "@/ui/common/error-state"; +import { AppShell } from "@/ui/shell"; export default function ErrorPage({ error, @@ -15,23 +17,33 @@ export default function ErrorPage({ reset: () => void; }) { const online = useOnlineStatus(); + const pathname = usePathname(); if (!online) return ; - return ( - - - - Try again - - - Back to home - - - + const admin = pathname.startsWith("/admin"); + const state = ( + + + Try again + + + + {admin ? "Back to overview" : "Back to home"} + + + + ); + + return admin ? ( + + {state} + + ) : ( + {state} ); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index cd981e70..a0ed3f47 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -199,7 +199,10 @@ --tooltip-foreground: oklch(1 0 0); --tooltip-border: oklch(1 0 0 / 18%); --card-foreground: oklch(0.985 0 0); - --popover: var(--background); + /* Menus and popovers lift off the page rather than sharing its ground. In + dark the border and shadow that carry elevation in light are both nearly + invisible, so the surface itself has to be the lighter one. */ + --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.606 0.25 292.717); --primary-foreground: oklch(0.985 0 0); diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index 5ac84012..80f33d0a 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -21,6 +21,10 @@ export default async function LoginPage({ }) { const params = await searchParams; const next = safeInternalRedirect(first(params.next)); + const initialError = + first(params.error) === "invalid-login" + ? "Email or password is incorrect." + : null; const configured = Boolean(getSupabaseConfig()); const signUpHref = `/signup?next=${encodeURIComponent(next)}`; @@ -56,7 +60,11 @@ export default async function LoginPage({ - + New to Coursemap?{" "} diff --git a/apps/web/app/profile/page.tsx b/apps/web/app/profile/page.tsx index 686cc6aa..3733655e 100644 --- a/apps/web/app/profile/page.tsx +++ b/apps/web/app/profile/page.tsx @@ -9,12 +9,7 @@ export default async function ProfilePage() { try { catalogue = await loadOnboardingCatalogue(); } catch { - return ( - - ); + return ; } return ; } diff --git a/apps/web/app/vendor.css b/apps/web/app/vendor.css index 89aa5d05..e677c4f3 100644 --- a/apps/web/app/vendor.css +++ b/apps/web/app/vendor.css @@ -3,9 +3,22 @@ own, so each rule notes what it is correcting. Imported by globals.css after the stylesheets they override. */ -/* Sonner keeps neutral surfaces with status borders and an inset close button. */ -.toaster [data-sonner-toast][data-styled="true"] { - padding-right: 48px; +/* Sonner keeps neutral surfaces with status borders and an inset close button. + The close button sits over the first line only, so the title gives up the + room for it rather than the whole toast; that leaves descriptions and + progress bars the full width. */ +.toaster + [data-sonner-toast][data-styled="true"]:has([data-close-button]) + [data-title] { + padding-right: 32px; +} + +/* An action sits on the same corner as the close button, so it steps aside by + the same amount the title does. */ +.toaster + [data-sonner-toast][data-styled="true"]:has([data-close-button]) + [data-button] { + margin-right: 24px; } .toaster [data-sonner-toast][data-type="success"] { @@ -294,3 +307,20 @@ } } } + +/* Nova's dropdown and select content carry its translucent menu preset, which + already highlights items with a foreground mix. Command does not, so a + command item falls back to --accent - and --accent is mixed against the page + ground, landing on oklch(~0.204) in dark while the popover surface a command + palette opens on is oklch(0.205). The highlight was invisible there. Mix + from the foreground instead: a translucent overlay reads on any ground. */ +@layer base { + .style-nova + [data-slot="command-item"]:is( + :hover, + [data-selected="true"], + [aria-selected="true"] + ) { + background-color: color-mix(in oklab, var(--foreground) 10%, transparent); + } +} diff --git a/apps/web/lib/auth/redirect.ts b/apps/web/lib/auth/redirect.ts index c353ad81..ef3aed62 100644 --- a/apps/web/lib/auth/redirect.ts +++ b/apps/web/lib/auth/redirect.ts @@ -3,6 +3,8 @@ const AUTH_HANDLER_PATHS = [ "/auth/callback", "/auth/confirm", "/auth/logout", + "/auth/password", + "/auth/sign-in", ] as const; function fullyDecodePath(pathname: string) { diff --git a/apps/web/lib/catalogue-import/directory.ts b/apps/web/lib/catalogue-import/directory.ts index 8bcbf5f7..aee5990e 100644 --- a/apps/web/lib/catalogue-import/directory.ts +++ b/apps/web/lib/catalogue-import/directory.ts @@ -53,7 +53,11 @@ async function recordDiscoverySourcePage( byteSize: number; }, ) { - const [row] = await sql` + // A source page is the record of one exact set of bytes, and the table + // rejects every update. Refetching a listing ANU has not changed therefore + // reuses the page already recorded rather than restamping it; when the + // refresh happened is on the discovery check that asked for it. + const [inserted] = await sql` insert into public.catalogue_source_pages ( source_id, academic_year_id, kind, external_key, canonical_url, media_type, content_sha256, http_status, http_etag, source_last_modified, fetched_at, @@ -63,10 +67,25 @@ async function recordDiscoverySourcePage( ${input.contentSha256}, ${input.httpStatus}, ${input.httpEtag}, ${input.sourceLastModified}, ${input.fetchedAt}, ${input.byteSize}, null, null) on conflict (source_id, academic_year_id, kind, external_key, content_sha256) - do update set fetched_at = excluded.fetched_at + do nothing returning id `; - return Number(row.id); + if (inserted) return Number(inserted.id); + const [existing] = await sql` + select id from public.catalogue_source_pages + where source_id = ${input.sourceId} + and academic_year_id = ${input.academicYearId} + and kind = 'directory' + and external_key = ${input.externalKey} + and content_sha256 = ${input.contentSha256} + `; + if (!existing) { + throw new DirectoryRefreshError( + "The source page could not be recorded.", + "SOURCE_PAGE_NOT_RECORDED", + ); + } + return Number(existing.id); } type DirectoryEntryInput = { diff --git a/apps/web/lib/catalogue/drafts.ts b/apps/web/lib/catalogue/drafts.ts index 3f8f963c..1b016be0 100644 --- a/apps/web/lib/catalogue/drafts.ts +++ b/apps/web/lib/catalogue/drafts.ts @@ -142,18 +142,22 @@ async function copyVersionProvenance( `; } -/** Creates the draft a record should start from: its publication, or an empty aggregate. */ -export async function createDraftInTransaction( - tx: Sql, +/** + * The aggregate an editor starts from: the current publication, or an empty + * record when nothing has been published. Reading it writes nothing, so a + * record can be opened and edited without a draft row coming into existence + * before there is anything to keep. + */ +export async function catalogueDraftBase( + sql: Sql, record: Record, - userId: string, ) { const publishedVersionId = record.published_version_id === null ? null : Number(record.published_version_id); const initial = publishedVersionId - ? await readVersionContent(tx, publishedVersionId) + ? await readVersionContent(sql, publishedVersionId) : emptyCatalogueContent({ kind: record.kind as CatalogueKind, code: String(record.code), @@ -167,7 +171,39 @@ export async function createDraftInTransaction( "INVALID_BASE", ); const contentHash = contentHashForCatalogueContent(initial); - const content = { ...initial, contentHash } satisfies CatalogueContent; + return { + publishedVersionId, + contentHash, + content: { ...initial, contentHash } satisfies CatalogueContent, + }; +} + +/** The draft-shaped view of a record whose draft row does not exist yet. */ +function unsavedDraft( + recordId: number, + base: Awaited>, +): CatalogueDraft { + return { + recordId, + baseVersionId: base.publishedVersionId, + restoredFromVersionId: null, + content: base.content, + contentHash: base.contentHash, + revision: 0, + updatedAt: new Date().toISOString(), + }; +} + +/** Creates the draft a record should start from: its publication, or an empty aggregate. */ +export async function createDraftInTransaction( + tx: Sql, + record: Record, + userId: string, +) { + const { publishedVersionId, contentHash, content } = await catalogueDraftBase( + tx, + record, + ); const [row] = await tx` insert into public.catalogue_drafts ( record_id, base_version_id, content, content_hash, @@ -190,16 +226,90 @@ export async function createDraftInTransaction( return draftFromRow(row); } -/** Returns the existing draft or creates the correct published/manual base. */ -export async function createCatalogueDraft({ +export async function loadCatalogueDraft(recordId: number) { + return withSyncDatabaseClient(async (sql) => { + const [row] = await sql` + select * from public.catalogue_drafts where record_id = ${recordId} + `; + return row ? draftFromRow(row) : null; + }); +} + +/** + * What the content editor opens on, and what state that content is in. + * + * A record without a draft row still has content to edit - its publication, or + * an empty aggregate - so reading a record is not what turns it into a draft. + * Asking to edit it is, and that is a deliberate act with a row behind it, so + * the record is still a draft when its editor comes back to it later. + * + * Whether the draft says anything new is a separate question from whether one + * is open, because an untouched draft can be discarded but not published. + */ +export async function loadCatalogueEditorState( + recordId: number, + sql?: SyncSql, +): Promise<{ + draft: CatalogueDraft; + /** Whether a draft row exists: the record is open for editing. */ + hasDraft: boolean; + /** Whether that draft differs from what it was opened on. */ + hasChanges: boolean; +}> { + const work = async (client: SyncSql) => { + const [record] = await client` + select records.id, records.kind, records.published_version_id, + codes.code, academic_years.year, listings.title as listing_title + from public.catalogue_records as records + join public.catalogue_codes as codes on codes.id = records.code_id + join public.academic_years on academic_years.id = records.academic_year_id + left join public.catalogue_listings as listings on listings.record_id = records.id + where records.id = ${recordId} + `; + if (!record) + throw new CatalogueDraftError( + "The catalogue record does not exist.", + "NOT_FOUND", + ); + const base = await catalogueDraftBase(client, record); + const [row] = await client` + select * from public.catalogue_drafts where record_id = ${recordId} + `; + if (!row) + return { + draft: unsavedDraft(recordId, base), + hasDraft: false, + hasChanges: false, + }; + const draft = draftFromRow(row); + return { + draft, + hasDraft: true, + hasChanges: draft.contentHash !== base.contentHash, + }; + }; + return sql ? work(sql) : withSyncDatabaseClient(work); +} + +/** + * Opens a draft on a record without changing a word of it. + * + * The editor asks for this when it is opened, so that backing out of it is + * always the same act - discarding a draft - and so that a record someone has + * started work on still reads as theirs after they have navigated away. + */ +export async function beginCatalogueDraft({ recordId, userId, + editingSessionId, sql, }: { recordId: number; userId: string; + editingSessionId: string; sql?: SyncSql; }) { + assertEditingSession(editingSessionId); const work = (client: SyncSql) => client.begin(async (tx) => { const record = await catalogueRecordForUpdate(tx, recordId); @@ -208,25 +318,18 @@ export async function createCatalogueDraft({ "The catalogue record is archived.", "ARCHIVED", ); - const [existing] = await tx` + const [row] = await tx` select * from public.catalogue_drafts where record_id = ${recordId} + for update `; - return existing - ? draftFromRow(existing) - : createDraftInTransaction(tx, record, userId); + const draft = row + ? draftFromRow(row) + : await createDraftInTransaction(tx, record, userId); + return { draft }; }); return sql ? work(sql) : withSyncDatabaseClient(work); } -export async function loadCatalogueDraft(recordId: number) { - return withSyncDatabaseClient(async (sql) => { - const [row] = await sql` - select * from public.catalogue_drafts where record_id = ${recordId} - `; - return row ? draftFromRow(row) : null; - }); -} - /** Saves one semantically changed aggregate and its audit rows atomically. */ export async function saveCatalogueDraft({ recordId, @@ -259,14 +362,24 @@ export async function saveCatalogueDraft({ where record_id = ${recordId} for update `; + const contentHash = contentHashForCatalogueContent(content); + const accepted = { ...content, contentHash } satisfies CatalogueContent; + // A record becomes a draft the moment it differs from what it started + // as, never because its editor autosaved what was already there. Saving + // an untouched record has to leave it exactly as it was found. + const base = draftRow ? null : await catalogueDraftBase(tx, record); + if (base && diffSnapshotWrites(base.content, accepted).length === 0) + return { + draft: unsavedDraft(recordId, base), + unchanged: true as const, + changedPaths: [] as string[], + }; const draft = draftRow ? draftFromRow(draftRow) : await createDraftInTransaction(tx, record, userId); if (draft.revision !== expectedRevision) throw new CatalogueDraftConflictError(draft.revision); - const contentHash = contentHashForCatalogueContent(content); - const accepted = { ...content, contentHash } satisfies CatalogueContent; const changes = diffSnapshotWrites(draft.content, accepted); if (changes.length === 0) return { diff --git a/apps/web/lib/coursemap/admin-catalogue-actions.ts b/apps/web/lib/coursemap/admin-catalogue-actions.ts index 44e1c1b3..fbacdd53 100644 --- a/apps/web/lib/coursemap/admin-catalogue-actions.ts +++ b/apps/web/lib/coursemap/admin-catalogue-actions.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { canWriteCatalogue, getAuthViewer } from "@/lib/auth/viewer"; import type { CatalogueContent } from "@/lib/catalogue/content"; import { + beginCatalogueDraft, CatalogueDraftConflictError, CatalogueDraftError, discardCatalogueDraft, @@ -126,6 +127,36 @@ export async function unpublishAction({ } } +/** + * Opening the editor is what makes a record a draft, so that is an act the + * server hears about rather than a state the browser holds on its own. + */ +export async function beginCatalogueDraftAction({ + recordId, + editingSessionId, + path, +}: { + recordId: number; + editingSessionId: string; + path: string; +}): Promise { + if (!(await canWriteCatalogue())) + return { ok: false, error: "Catalogue write permission is required." }; + const viewer = await getAuthViewer(); + if (!viewer) return { ok: false, error: "Authentication is required." }; + try { + const { draft } = await beginCatalogueDraft({ + recordId, + editingSessionId, + userId: viewer.id, + }); + revalidateRecord(path); + return { ok: true, revision: draft.revision }; + } catch (error) { + return draftFailure(error, "The draft could not be opened."); + } +} + export async function saveCatalogueDraftAction({ recordId, expectedRevision, diff --git a/apps/web/lib/coursemap/admin-catalogue.ts b/apps/web/lib/coursemap/admin-catalogue.ts index beb48241..16f09eb4 100644 --- a/apps/web/lib/coursemap/admin-catalogue.ts +++ b/apps/web/lib/coursemap/admin-catalogue.ts @@ -1,11 +1,15 @@ import "server-only"; import type { PostgrestError } from "@supabase/supabase-js"; +import { emptyCatalogueContent } from "@/lib/catalogue/content"; +import { contentHashForCatalogueContent } from "@/lib/catalogue-import/version-content"; import { createClient } from "@/lib/supabase/server"; import type { CatalogueDirectoryPage, CatalogueDirectoryRecord, CatalogueKind, + CatalogueRecordState, } from "./catalogue-kinds"; +import { catalogueRecordState } from "./catalogue-kinds"; export * from "./catalogue-kinds"; @@ -77,11 +81,14 @@ export async function loadCatalogueDirectoryPage({ kind, academicYear, query = "", + state = null, page = 1, }: { kind: CatalogueKind; academicYear: number; query?: string; + /** Narrows to the state the row's badge reports. Null leaves every row in. */ + state?: CatalogueRecordState | null; page?: number; }): Promise { const supabase = await createClient(); @@ -122,7 +129,7 @@ export async function loadCatalogueDirectoryPage({ readAllRows((from, to) => supabase .from("catalogue_records") - .select("id,code_id,public_id,published_version_id,archived_at") + .select("id,code_id,published_version_id,archived_at") .eq("academic_year_id", year.id) .eq("kind", kind) .order("code_id") @@ -141,7 +148,9 @@ export async function loadCatalogueDirectoryPage({ readAllRows((from, to) => supabase .from("catalogue_drafts") - .select("record_id,catalogue_records!inner(academic_year_id,kind)") + .select( + "record_id,content_hash,revision,catalogue_records!inner(academic_year_id,kind)", + ) .eq("catalogue_records.academic_year_id", year.id) .eq("catalogue_records.kind", kind) .order("record_id") @@ -192,7 +201,31 @@ export async function loadCatalogueDirectoryPage({ const recordByCodeId = new Map( records.data.map((record) => [record.code_id, record]), ); - const draftIds = new Set((drafts.data ?? []).map((draft) => draft.record_id)); + // A draft row is only worth reporting when it says something the record did + // not already say. Comparing hashes keeps a draft that was restored from the + // publication, or edited back to match it, out of the Draft state. + const draftRows = new Map( + (drafts.data ?? []).map((draft) => [draft.record_id, draft]), + ); + const draftedPublishedVersionIds = records.data.flatMap((record) => + draftRows.has(record.id) && record.published_version_id + ? [record.published_version_id] + : [], + ); + // Named by identifier rather than filtered by year, because only records + // that carry a draft reach this list and that is a handful, not thousands. + const { data: publishedVersions } = draftedPublishedVersionIds.length + ? await supabase + .from("catalogue_versions") + .select("id,content_hash") + .in("id", draftedPublishedVersionIds) + : { data: [] as Array<{ id: number; content_hash: string }> }; + const publishedHashes = new Map( + (publishedVersions ?? []).map((version) => [ + version.id, + version.content_hash, + ]), + ); const latestSync = new Map(); for (const sync of syncs.data ?? []) if (!latestSync.has(sync.record_id)) latestSync.set(sync.record_id, sync); @@ -229,7 +262,23 @@ export async function loadCatalogueDirectoryPage({ ? recordByCodeId.get(listing.itemId) : undefined; const sync = record ? latestSync.get(record.id) : undefined; - const hasDraft = record ? draftIds.has(record.id) : false; + const draftRow = record ? draftRows.get(record.id) : undefined; + // What the draft would have started as. An unpublished record starts + // empty, so a draft holding nothing has been opened but says nothing new. + const baseHash = !draftRow + ? null + : record?.published_version_id + ? publishedHashes.get(record.published_version_id) + : contentHashForCatalogueContent( + emptyCatalogueContent({ + kind, + code: listing.code, + academicYear, + title: listing.title, + }), + ); + const hasDraft = draftRow !== undefined; + const hasChanges = hasDraft && draftRow.content_hash !== baseHash; const isPublished = Boolean( record?.published_version_id && !record.archived_at, ); @@ -249,8 +298,10 @@ export async function loadCatalogueDirectoryPage({ code: listing.code, title: listing.title, summary: (listing.summary ?? {}) as Record, - recordPublicId: record?.public_id ?? null, + recordId: record?.id ?? null, hasDraft, + hasChanges, + draftRevision: draftRow?.revision ?? null, isPublished, isListedByAnu: listing.is_current, lastSeenAt: listing.last_seen_at, @@ -275,6 +326,9 @@ export async function loadCatalogueDirectoryPage({ row.code.includes(needle) || (row.title ?? "").toUpperCase().includes(needle), ) + // The whole year is already in memory, so narrowing by state costs a pass + // rather than a query, and it agrees with the badge by construction. + .filter((row) => !state || catalogueRecordState(row) === state) .sort((left, right) => left.code.localeCompare(right.code)); const safePage = Math.max( 1, diff --git a/apps/web/lib/coursemap/catalogue-kinds.ts b/apps/web/lib/coursemap/catalogue-kinds.ts index 40d616c4..c5a591b5 100644 --- a/apps/web/lib/coursemap/catalogue-kinds.ts +++ b/apps/web/lib/coursemap/catalogue-kinds.ts @@ -69,8 +69,14 @@ export type CatalogueDirectoryRecord = { code: string; title: string | null; summary: Record; - recordPublicId: string | null; + /** Null until ANU discovery has created the record a sync would run on. */ + recordId: number | null; + /** True once the record has been opened for editing. */ hasDraft: boolean; + /** True only when that draft says something the publication does not. */ + hasChanges: boolean; + /** The revision a row action has to submit to act on that draft. */ + draftRevision: number | null; isPublished: boolean; isListedByAnu: boolean | null; lastSeenAt: string | null; @@ -90,6 +96,53 @@ export type CatalogueDirectoryRecord = { } | null; }; +/** + * The one state a directory row is in. The badge that prints it and the filter + * that narrows to it both read this, so what an operator can select is exactly + * what they can see, and reordering the cascade moves the two together. + * + * Order is precedence, most urgent first: a broken sync before a delisting, a + * delisting before waiting changes, and only then how far the record has been + * taken. + */ +export type CatalogueRecordState = + | "sync_failed" + | "delisted" + | "syncing" + | "changes_available" + | "draft" + | "published" + | "unpublished"; + +export const CATALOGUE_STATE_LABELS: Record = { + sync_failed: "Sync failed", + delisted: "No longer listed", + syncing: "Syncing", + changes_available: "ANU changes", + draft: "Draft", + published: "Published", + unpublished: "Not published", +}; + +export const CATALOGUE_STATES = Object.keys( + CATALOGUE_STATE_LABELS, +) as CatalogueRecordState[]; + +export function catalogueRecordState( + record: CatalogueDirectoryRecord, +): CatalogueRecordState { + if (record.sourceState === "sync_failed") return "sync_failed"; + if (record.isListedByAnu === false) return "delisted"; + if (record.sourceState === "syncing") return "syncing"; + if (record.sourceState === "changes_available" && record.openChangeCount > 0) + return "changes_available"; + // Unpublished work outranks publication: a published record with a draft is + // the one a person still has to come back to. + if (record.hasDraft) return "draft"; + if (record.isPublished) return "published"; + return "unpublished"; +} + export type CatalogueTableLayout = | "public-courses" | "users" diff --git a/apps/web/lib/coursemap/catalogue-summary.ts b/apps/web/lib/coursemap/catalogue-summary.ts new file mode 100644 index 00000000..b1a8b3aa --- /dev/null +++ b/apps/web/lib/coursemap/catalogue-summary.ts @@ -0,0 +1,46 @@ +import type { CatalogueKind } from "@/lib/coursemap/catalogue-kinds"; + +/** + * The facts ANU publishes alongside a code in its directory listing. They are + * stored verbatim, so every reader has to tolerate a missing or unexpected + * shape rather than trusting the keys to be there. + */ +type ListingSummary = { + career?: unknown; + units?: unknown; + modeOfDelivery?: unknown; + durationYears?: unknown; +}; + +function text(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function count(value: unknown) { + const number = typeof value === "number" ? value : Number(value); + return Number.isFinite(number) && number > 0 ? number : null; +} + +/** + * The one-line description that sits under a record's title: what it is worth, + * who it is for and how it runs. Only the parts ANU actually gave are + * returned, so a sparse listing reads as a short line rather than a row of + * dashes. Session is deliberately left out - ANU joins every offering into one + * slash-separated string that is longer than the title it would sit beneath. + */ +export function catalogueSummaryMeta( + summary: Record, + kind: CatalogueKind, +): string[] { + const listing = summary as ListingSummary; + const parts: string[] = []; + const units = count(listing.units); + if (units) parts.push(`${units} unit${units === 1 ? "" : "s"}`); + const years = kind === "course" ? null : count(listing.durationYears); + if (years) parts.push(`${years} year${years === 1 ? "" : "s"}`); + const career = text(listing.career); + if (career) parts.push(career); + const mode = text(listing.modeOfDelivery); + if (mode) parts.push(mode); + return parts; +} diff --git a/apps/web/package.json b/apps/web/package.json index 5f60fd2f..560128f1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,16 +5,20 @@ "type": "module", "scripts": { "predev": "node scripts/copy-maplibre-worker.mjs", - "dev": "next dev --webpack", + "dev": "node scripts/local/dev-preview.mjs", + "dev:prod": "node scripts/copy-maplibre-worker.mjs && next dev --webpack", "prebuild": "node scripts/copy-maplibre-worker.mjs", - "build": "next build --webpack", - "start": "next start", + "build": "node scripts/local/build-preview.mjs", + "build:next": "next build --webpack", + "build:prod": "node scripts/copy-maplibre-worker.mjs && next build --webpack", + "start": "node scripts/local/start-preview.mjs", + "start:prod": "next start", "lint": "eslint . --ignore-pattern .next", "lint:fix": "pnpm lint --fix", "typecheck": "tsc --noEmit --incremental false", "test:unit": "vitest run --project unit --project component", "test:catalogue-db": "vitest run --project database", - "test:build:auth": "NEXT_PUBLIC_SITE_URL=http://127.0.0.1:4318 NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:9 NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_test pnpm build", + "test:build:auth": "NEXT_PUBLIC_SITE_URL=http://127.0.0.1:4318 NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:9 NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_test pnpm build:prod", "test:auth-access": "COURSEMAP_TEST_PROFILE=access playwright test", "test:e2e:build": "node scripts/local/build-e2e.mjs", "test:e2e": "COURSEMAP_TEST_PROFILE=authenticated playwright test", diff --git a/apps/web/playwright/catalogue-admin.spec.ts b/apps/web/playwright/catalogue-admin.spec.ts index 7338afb5..b2986fef 100644 --- a/apps/web/playwright/catalogue-admin.spec.ts +++ b/apps/web/playwright/catalogue-admin.spec.ts @@ -31,7 +31,9 @@ test("administrators browse year-first catalogue records", async ({ }); }); await page.getByRole("button", { name: "Refresh ANU listing" }).click(); - await expect(page.getByText("ANU listing refreshed.")).toBeVisible(); + // The refresh reports itself in a progress toast, which settles on what the + // listing actually returned rather than on a flat acknowledgement. + await expect(page.getByText("2026 programmes refreshed")).toBeVisible(); await page.goto("/admin/courses/2026?q=COMP1110"); const courseRow = page.getByRole("row", { name: /COMP1110/ }); diff --git a/apps/web/scripts/local/build-preview.mjs b/apps/web/scripts/local/build-preview.mjs new file mode 100644 index 00000000..03280c1c --- /dev/null +++ b/apps/web/scripts/local/build-preview.mjs @@ -0,0 +1,29 @@ +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { appRoot } from "../paths.mjs"; +import { createLocalApplicationEnvironment } from "./supabase-environment.mjs"; + +export function buildLocalProduction({ + environment = createLocalApplicationEnvironment(), + runCommand = spawnSync, +} = {}) { + return runCommand("pnpm", ["run", "build:next"], { + cwd: appRoot, + env: environment, + stdio: "inherit", + }).status; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + let status; + try { + status = buildLocalProduction(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } + process.exit(status ?? 1); +} diff --git a/apps/web/scripts/local/dev-preview.mjs b/apps/web/scripts/local/dev-preview.mjs index 16796241..7cd38fba 100644 --- a/apps/web/scripts/local/dev-preview.mjs +++ b/apps/web/scripts/local/dev-preview.mjs @@ -1,72 +1,53 @@ -import { appRoot, repositoryRoot } from "../paths.mjs"; -import { spawn, spawnSync } from "node:child_process"; - -export function parseSupabaseEnvironment(output) { - const values = new Map(); - for (const line of output.split(/\r?\n/)) { - const match = line.match(/^([A-Z_]+)=(?:"([^"]*)"|(.*))$/); - if (match) values.set(match[1], match[2] ?? match[3] ?? ""); - } - - return { - apiUrl: values.get("API_URL"), - databaseUrl: values.get("DB_URL"), - publishableKey: values.get("PUBLISHABLE_KEY") ?? values.get("ANON_KEY"), - secretKey: values.get("SECRET_KEY") ?? values.get("SERVICE_ROLE_KEY"), - }; +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { appRoot, nextCliPath } from "../paths.mjs"; +import { createLocalApplicationEnvironment } from "./supabase-environment.mjs"; + +export function startLocalDevelopmentPreview({ + environment = createLocalApplicationEnvironment(), + spawnCommand = spawn, +} = {}) { + return spawnCommand( + process.execPath, + [ + nextCliPath, + "dev", + "--webpack", + "--hostname", + "127.0.0.1", + "--port", + "3000", + ], + { + cwd: appRoot, + env: environment, + stdio: "inherit", + }, + ); } -function readSupabaseEnvironment() { - const result = spawnSync("supabase", ["status", "-o", "env"], { - cwd: repositoryRoot, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); - - if (result.status !== 0) { - console.error( - "Local Supabase is unavailable. Run `pnpm db:start` before `pnpm dev:local`.", - ); - process.exit(result.status ?? 1); +function run() { + let child; + try { + child = startLocalDevelopmentPreview(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); } - const { apiUrl, databaseUrl, publishableKey, secretKey } = - parseSupabaseEnvironment(result.stdout); - if (!apiUrl || !publishableKey || !secretKey) { - console.error( - "Supabase did not return its local API URL, public key and server key.", - ); - process.exit(1); + for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => child.kill(signal)); } - return { apiUrl, databaseUrl, publishableKey, secretKey }; + child.on("exit", (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 1); + }); } -const { apiUrl, databaseUrl, publishableKey, secretKey } = - readSupabaseEnvironment(); -const child = spawn( - "pnpm", - ["run", "dev", "--hostname", "127.0.0.1", "--port", "3000"], - { - cwd: appRoot, - env: { - ...process.env, - NEXT_PUBLIC_SITE_URL: "http://127.0.0.1:3000", - // The import pipeline and admin workspace connect to Postgres directly. - COURSEMAP_DATABASE_URL: databaseUrl, - NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: publishableKey, - NEXT_PUBLIC_SUPABASE_URL: apiUrl, - SUPABASE_SECRET_KEY: secretKey, - }, - stdio: "inherit", - }, -); - -for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, () => child.kill(signal)); +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + run(); } - -child.on("exit", (code, signal) => { - if (signal) process.kill(process.pid, signal); - else process.exit(code ?? 1); -}); diff --git a/apps/web/scripts/local/production-preview.mjs b/apps/web/scripts/local/production-preview.mjs new file mode 100644 index 00000000..cd931755 --- /dev/null +++ b/apps/web/scripts/local/production-preview.mjs @@ -0,0 +1,59 @@ +import { spawn, spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { appRoot, nextCliPath } from "../paths.mjs"; +import { createLocalApplicationEnvironment } from "./supabase-environment.mjs"; + +export function startLocalProductionPreview({ + environment = createLocalApplicationEnvironment(), + runBuild = spawnSync, + spawnServer = spawn, +} = {}) { + const build = runBuild("pnpm", ["run", "build"], { + cwd: appRoot, + env: environment, + stdio: "inherit", + }); + if (build.status !== 0) { + return { child: null, exitCode: build.status ?? 1 }; + } + + const child = spawnServer( + process.execPath, + [nextCliPath, "start", "--hostname", "127.0.0.1", "--port", "3000"], + { + cwd: appRoot, + env: environment, + stdio: "inherit", + }, + ); + return { child, exitCode: null }; +} + +function run() { + let result; + try { + result = startLocalProductionPreview(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } + + if (!result.child) process.exit(result.exitCode ?? 1); + const child = result.child; + + for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => child.kill(signal)); + } + + child.on("exit", (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 1); + }); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + run(); +} diff --git a/apps/web/scripts/local/start-preview.mjs b/apps/web/scripts/local/start-preview.mjs new file mode 100644 index 00000000..1c19599a --- /dev/null +++ b/apps/web/scripts/local/start-preview.mjs @@ -0,0 +1,45 @@ +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { appRoot, nextCliPath } from "../paths.mjs"; +import { createLocalApplicationEnvironment } from "./supabase-environment.mjs"; + +export function startBuiltLocalProduction({ + environment = createLocalApplicationEnvironment(), + spawnCommand = spawn, +} = {}) { + return spawnCommand( + process.execPath, + [nextCliPath, "start", "--hostname", "127.0.0.1", "--port", "3000"], + { + cwd: appRoot, + env: environment, + stdio: "inherit", + }, + ); +} + +function run() { + let child; + try { + child = startBuiltLocalProduction(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } + + for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => child.kill(signal)); + } + + child.on("exit", (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 1); + }); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + run(); +} diff --git a/apps/web/scripts/local/supabase-environment.mjs b/apps/web/scripts/local/supabase-environment.mjs new file mode 100644 index 00000000..3765c3bb --- /dev/null +++ b/apps/web/scripts/local/supabase-environment.mjs @@ -0,0 +1,60 @@ +import { spawnSync } from "node:child_process"; +import { repositoryRoot } from "../paths.mjs"; + +export function parseSupabaseEnvironment(output) { + const values = new Map(); + for (const line of output.split(/\r?\n/)) { + const match = line.match(/^([A-Z_]+)=(?:"([^"]*)"|(.*))$/); + if (match) values.set(match[1], match[2] ?? match[3] ?? ""); + } + + return { + apiUrl: values.get("API_URL"), + databaseUrl: values.get("DB_URL"), + publishableKey: values.get("PUBLISHABLE_KEY") ?? values.get("ANON_KEY"), + secretKey: values.get("SECRET_KEY") ?? values.get("SERVICE_ROLE_KEY"), + }; +} + +export function readLocalSupabaseEnvironment({ runCommand = spawnSync } = {}) { + const result = runCommand("supabase", ["status", "-o", "env"], { + cwd: repositoryRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.status !== 0) { + throw new Error( + "Local Supabase is unavailable. Run `pnpm db:start` before starting a local preview.", + ); + } + + const environment = parseSupabaseEnvironment(result.stdout); + if ( + !environment.apiUrl || + !environment.databaseUrl || + !environment.publishableKey || + !environment.secretKey + ) { + throw new Error( + "Supabase did not return its local API URL, database URL, public key and server key.", + ); + } + + return environment; +} + +export function createLocalApplicationEnvironment({ + baseEnvironment = process.env, + supabaseEnvironment = readLocalSupabaseEnvironment(), +} = {}) { + return { + ...baseEnvironment, + NEXT_PUBLIC_SITE_URL: "http://127.0.0.1:3000", + // The import pipeline and admin workspace connect to Postgres directly. + COURSEMAP_DATABASE_URL: supabaseEnvironment.databaseUrl, + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: supabaseEnvironment.publishableKey, + NEXT_PUBLIC_SUPABASE_URL: supabaseEnvironment.apiUrl, + SUPABASE_SECRET_KEY: supabaseEnvironment.secretKey, + }; +} diff --git a/apps/web/scripts/paths.mjs b/apps/web/scripts/paths.mjs index d3dade29..c0a46d32 100644 --- a/apps/web/scripts/paths.mjs +++ b/apps/web/scripts/paths.mjs @@ -1,5 +1,8 @@ import { fileURLToPath } from "node:url"; export const appRoot = fileURLToPath(new URL("../", import.meta.url)); +export const nextCliPath = fileURLToPath( + new URL("../node_modules/next/dist/bin/next", import.meta.url), +); export const repositoryRoot = fileURLToPath( new URL("../../../", import.meta.url), ); diff --git a/apps/web/tests/app-sidebar.test.ts b/apps/web/tests/app-sidebar.test.ts new file mode 100644 index 00000000..6af96954 --- /dev/null +++ b/apps/web/tests/app-sidebar.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; +import { adminCatalogueNavigationYear } from "@/ui/shell/app-sidebar"; + +test("admin catalogue navigation preserves the year being viewed", () => { + expect(adminCatalogueNavigationYear("/admin/courses/2027", 2026)).toBe(2027); + expect( + adminCatalogueNavigationYear("/admin/majors/2025/MATH-MAJ", 2026), + ).toBe(2025); +}); + +test("admin catalogue navigation falls back to the profile catalogue year", () => { + expect(adminCatalogueNavigationYear("/admin/dashboard", 2026)).toBe(2026); + expect( + adminCatalogueNavigationYear("/admin/operations/catalogue", 2026), + ).toBe(2026); +}); diff --git a/apps/web/tests/auth-redirect.test.mjs b/apps/web/tests/auth-redirect.test.mjs index 6b509d92..73671bfa 100644 --- a/apps/web/tests/auth-redirect.test.mjs +++ b/apps/web/tests/auth-redirect.test.mjs @@ -39,6 +39,8 @@ test("rejects external, decoded and handler redirect destinations", () => { "/auth/callback%3Fcode=secret", "/auth/callback%23fragment", "/auth/logout", + "/auth/password", + "/auth/sign-in", ]; unsafe.forEach((candidate) => { diff --git a/apps/web/tests/breadcrumbs.test.tsx b/apps/web/tests/breadcrumbs.test.tsx index b5524975..0d6df723 100644 --- a/apps/web/tests/breadcrumbs.test.tsx +++ b/apps/web/tests/breadcrumbs.test.tsx @@ -2,11 +2,14 @@ import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, expect, test, vi } from "vitest"; import { Breadcrumbs } from "@/ui/shell/breadcrumbs"; +let pathname = "/admin/courses/2026/infs1001"; + vi.mock("next/navigation", () => ({ - usePathname: () => "/admin/courses/2026/infs1001", + usePathname: () => pathname, })); afterEach(() => { + pathname = "/admin/courses/2026/infs1001"; vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -162,3 +165,35 @@ test("does not repeat a catalogue section on its year directory", () => { ); expect(trail).not.toHaveTextContent("2026"); }); + +test.each([ + { + route: "/admin/operations/catalogue", + currentLabel: "Catalogue", + trailingLabel: "Syncs", + }, + { + route: "/admin/operations/catalogue/discovery", + currentLabel: undefined, + trailingLabel: undefined, + }, +])("shows the active catalogue operations section on $route", (props) => { + pathname = props.route; + measureAt(600); + render( + , + ); + + const trail = screen.getByRole("navigation", { name: "Breadcrumb" }); + expect(within(trail).getByRole("link", { name: "Admin" })).toBeVisible(); + expect(within(trail).getByRole("link", { name: "Catalogue" })).toBeVisible(); + expect( + within(trail).getByRole("link", { + name: props.trailingLabel ?? "Discovery", + }), + ).toHaveAttribute("aria-current", "page"); +}); diff --git a/apps/web/tests/catalogue-changelog-database.test.mjs b/apps/web/tests/catalogue-changelog-database.test.mjs index 1ffaf5da..0ac98924 100644 --- a/apps/web/tests/catalogue-changelog-database.test.mjs +++ b/apps/web/tests/catalogue-changelog-database.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { afterAll, beforeAll, test } from "vitest"; import { - createCatalogueDraft, + loadCatalogueEditorState, discardCatalogueDraft, publishCatalogueDraft, restoreCatalogueVersion, @@ -89,7 +89,7 @@ afterAll(async () => { }); test("every operation leaves one attributable audit event behind", async () => { - const draft = await createCatalogueDraft({ recordId, userId: ADMIN_ID, sql }); + const draft = (await loadCatalogueEditorState(recordId, sql)).draft; let revision = draft.revision; let content = draft.content; for (const description of ["First pass.", "Second pass.", "Third pass."]) { @@ -147,7 +147,7 @@ test("every operation leaves one attributable audit event behind", async () => { }); test("restoring a version keeps the draft it replaces", async () => { - const draft = await createCatalogueDraft({ recordId, userId: ADMIN_ID, sql }); + const draft = (await loadCatalogueEditorState(recordId, sql)).draft; const inProgress = structuredClone(draft.content); inProgress.course.details.description = "Work in progress worth keeping."; const saved = await saveCatalogueDraft({ diff --git a/apps/web/tests/catalogue-content-editor.test.tsx b/apps/web/tests/catalogue-content-editor.test.tsx index 5d83e760..4351d035 100644 --- a/apps/web/tests/catalogue-content-editor.test.tsx +++ b/apps/web/tests/catalogue-content-editor.test.tsx @@ -5,12 +5,18 @@ import { screen, waitFor, } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { TooltipProvider } from "@coursemap/ui/primitives/tooltip"; + import { emptyCatalogueContent } from "@/lib/catalogue/content"; +import { CatalogueEditorProvider } from "@/ui/admin/catalogue/catalogue-editor-context"; +import { CatalogueEditorToolbar } from "@/ui/admin/catalogue/catalogue-editor-toolbar"; import { CatalogueContentEditor } from "@/ui/admin/catalogue/content-editor"; const actions = vi.hoisted(() => ({ + begin: vi.fn(), save: vi.fn(), publish: vi.fn(), unpublish: vi.fn(), @@ -18,6 +24,7 @@ const actions = vi.hoisted(() => ({ })); vi.mock("@/lib/coursemap/admin-catalogue-actions", () => ({ + beginCatalogueDraftAction: actions.begin, saveCatalogueDraftAction: actions.save, publishDraftAction: actions.publish, unpublishAction: actions.unpublish, @@ -41,21 +48,29 @@ function initialContent() { }); } -function renderEditor() { +function renderEditor({ hasDraft = true } = {}) { return render( - , + + + + + + , ); } beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); + actions.begin.mockReset(); + actions.begin.mockResolvedValue({ ok: true, revision: 0 }); actions.save.mockReset(); }); @@ -134,3 +149,89 @@ test("a failed autosave preserves the edited value and reports the error", async ); expect(screen.getByLabelText("Description")).toHaveValue("Keep this text"); }); + +test("editing opens the draft actions, with nothing yet to publish", async () => { + actions.save.mockResolvedValue({ ok: true, revision: 1, unchanged: false }); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderEditor({ hasDraft: false }); + await user.click(screen.getByRole("button", { name: "Edit" })); + + expect(screen.getByRole("status")).toHaveTextContent("Draft"); + expect( + screen.getByRole("button", { name: "Discard draft" }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Publish" })).toBeDisabled(); + + fireEvent.change(screen.getByLabelText("Description"), { + target: { value: "Worth keeping" }, + }); + await act(async () => vi.advanceTimersByTime(1_000)); + + await waitFor(() => + expect(screen.getByRole("button", { name: "Publish" })).toBeEnabled(), + ); +}); + +test("discarding a draft leaves the record with nothing to discard", async () => { + actions.discard.mockResolvedValue({ ok: true, message: "Draft discarded." }); + renderEditor(); + + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + await user.click(screen.getByRole("button", { name: "Discard draft" })); + const confirm = await screen.findByRole("button", { + name: "Discard draft", + // The trigger is behind the open dialog, so only the confirmation + // inside it is still reachable. + hidden: false, + }); + await user.click(confirm); + await waitFor(() => expect(actions.discard).toHaveBeenCalled()); + await waitFor(() => + expect( + screen.queryByRole("button", { name: "Discard draft" }), + ).not.toBeInTheDocument(), + ); +}); + +test("a record without a draft is read until editing is asked for", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderEditor({ hasDraft: false }); + + expect(screen.getByRole("status")).toHaveTextContent("Published"); + expect(screen.queryByLabelText("Title")).not.toBeInTheDocument(); + // The values are still there to read, just not to change. + expect(screen.getByText("Test course")).toBeInTheDocument(); + expect(actions.begin).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Edit" })); + expect(screen.getByLabelText("Title")).toHaveValue("Test course"); + // Asking to edit is what opens the draft, so the record is still a draft + // when whoever opened it comes back to the page later. + expect(actions.begin).toHaveBeenCalledWith( + expect.objectContaining({ recordId: 42 }), + ); + expect(actions.save).not.toHaveBeenCalled(); +}); + +test("backing out of an opened draft discards it, keeping no checkpoint", async () => { + actions.discard.mockResolvedValue({ ok: true, message: "Draft discarded." }); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderEditor({ hasDraft: false }); + await user.click(screen.getByRole("button", { name: "Edit" })); + + await user.click(screen.getByRole("button", { name: "Discard draft" })); + expect( + screen.getByText( + "The editor goes back to the published version. Nothing has been changed in it, so nothing is kept.", + ), + ).toBeInTheDocument(); + await user.click( + await screen.findByRole("button", { name: "Discard draft", hidden: false }), + ); + + await waitFor(() => expect(actions.discard).toHaveBeenCalled()); + await waitFor(() => + expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument(), + ); + expect(screen.queryByLabelText("Description")).not.toBeInTheDocument(); +}); diff --git a/apps/web/tests/catalogue-drafts-database.test.mjs b/apps/web/tests/catalogue-drafts-database.test.mjs index ed3f7c34..d5f57cf4 100644 --- a/apps/web/tests/catalogue-drafts-database.test.mjs +++ b/apps/web/tests/catalogue-drafts-database.test.mjs @@ -2,8 +2,9 @@ import assert from "node:assert/strict"; import { afterAll, beforeAll, test } from "vitest"; import { + beginCatalogueDraft, CatalogueDraftConflictError, - createCatalogueDraft, + loadCatalogueEditorState, discardCatalogueDraft, publishCatalogueDraft, restoreCatalogueVersion, @@ -82,11 +83,7 @@ afterAll(async () => { }); test("mutable drafts autosave, audit, publish, discard and restore safely", async () => { - const initial = await createCatalogueDraft({ - recordId, - userId: ADMIN_ID, - sql, - }); + const initial = (await loadCatalogueEditorState(recordId, sql)).draft; assert.equal(initial.revision, 0); assert.equal(initial.baseVersionId, null); assert.equal(initial.content.course.details.title, "Draft Systems"); @@ -195,11 +192,7 @@ test("mutable drafts autosave, audit, publish, discard and restore safely", asyn "A manually authored course.", ); - const fromPublished = await createCatalogueDraft({ - recordId, - userId: ADMIN_ID, - sql, - }); + const fromPublished = (await loadCatalogueEditorState(recordId, sql)).draft; assert.equal(fromPublished.baseVersionId, firstPublish.versionId); assert.equal( fromPublished.contentHash, @@ -243,11 +236,7 @@ test("mutable drafts autosave, audit, publish, discard and restore safely", asyn }); assert.equal(revertedDiscard.meaningful, false); - const nextDraft = await createCatalogueDraft({ - recordId, - userId: ADMIN_ID, - sql, - }); + const nextDraft = (await loadCatalogueEditorState(recordId, sql)).draft; const secondEdit = structuredClone(nextDraft.content); secondEdit.course.details.title = "Draft Systems Advanced"; await saveCatalogueDraft({ @@ -337,11 +326,7 @@ test("mutable drafts autosave, audit, publish, discard and restore safely", asyn 2, ); - const blank = await createCatalogueDraft({ - recordId, - userId: ADMIN_ID, - sql, - }); + const blank = (await loadCatalogueEditorState(recordId, sql)).draft; const discardContent = structuredClone(blank.content); discardContent.course.details.description = "Work worth restoring."; const discardSave = await saveCatalogueDraft({ @@ -432,3 +417,116 @@ test("mutable drafts autosave, audit, publish, discard and restore safely", asyn assert.equal(noOpDiscard.checkpointVersionId, null); assert.ok(secondPublish.versionId > firstPublish.versionId); }); + +async function draftRowCount() { + const [row] = await sql` + select count(*)::integer as count + from public.catalogue_drafts where record_id = ${recordId} + `; + return row.count; +} + +test("reading a record, or saving it unchanged, never makes it a draft", async () => { + await sql`delete from public.catalogue_drafts where record_id = ${recordId}`; + const opened = await loadCatalogueEditorState(recordId, sql); + assert.equal(opened.hasDraft, false); + assert.equal(opened.hasChanges, false); + assert.equal(opened.draft.revision, 0); + assert.equal(await draftRowCount(), 0); + + const untouched = await saveCatalogueDraft({ + recordId, + expectedRevision: 0, + content: opened.draft.content, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + assert.equal(untouched.unchanged, true); + assert.equal(await draftRowCount(), 0); + + const edited = structuredClone(opened.draft.content); + edited.course.details.description = "Now there is something to keep."; + const saved = await saveCatalogueDraft({ + recordId, + expectedRevision: 0, + content: edited, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + assert.equal(saved.draft.revision, 1); + assert.equal(await draftRowCount(), 1); + const editedState = await loadCatalogueEditorState(recordId, sql); + assert.equal(editedState.hasDraft, true); + assert.equal(editedState.hasChanges, true); + + // Undoing the edit by hand leaves the draft open - it is still the record + // someone is working on - but it no longer says anything to publish. + const reverted = await saveCatalogueDraft({ + recordId, + expectedRevision: 1, + content: opened.draft.content, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + assert.equal(reverted.draft.contentHash, opened.draft.contentHash); + const revertedState = await loadCatalogueEditorState(recordId, sql); + assert.equal(revertedState.hasDraft, true); + assert.equal(revertedState.hasChanges, false); + + const discarded = await discardCatalogueDraft({ + recordId, + expectedRevision: reverted.draft.revision, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + assert.equal(discarded.meaningful, false); + assert.equal(await draftRowCount(), 0); +}); + +test("asking to edit a record opens a draft on it, unchanged", async () => { + await sql`delete from public.catalogue_drafts where record_id = ${recordId}`; + const base = await loadCatalogueEditorState(recordId, sql); + + const { draft } = await beginCatalogueDraft({ + recordId, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + assert.equal(draft.revision, 0); + assert.equal(draft.contentHash, base.draft.contentHash); + assert.equal(await draftRowCount(), 1); + + // The record is now a draft, and stays one for whoever opens it next, but + // there is nothing in it that the publication does not already say. + const opened = await loadCatalogueEditorState(recordId, sql); + assert.equal(opened.hasDraft, true); + assert.equal(opened.hasChanges, false); + + // Asking twice is asking once: the draft already open is handed back. + const again = await beginCatalogueDraft({ + recordId, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + assert.equal(again.draft.revision, 0); + assert.equal(await draftRowCount(), 1); + + // Backing out of an untouched draft keeps no checkpoint: there was nothing + // in it to come back to. + const discarded = await discardCatalogueDraft({ + recordId, + expectedRevision: again.draft.revision, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + assert.equal(discarded.meaningful, false); + assert.equal(discarded.checkpointVersionId, null); + assert.equal(await draftRowCount(), 0); +}); diff --git a/apps/web/tests/catalogue-record-state.test.ts b/apps/web/tests/catalogue-record-state.test.ts new file mode 100644 index 00000000..eccf1a50 --- /dev/null +++ b/apps/web/tests/catalogue-record-state.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from "vitest"; +import { + CATALOGUE_STATES, + CATALOGUE_STATE_LABELS, + type CatalogueDirectoryRecord, + catalogueRecordState, +} from "@/lib/coursemap/catalogue-kinds"; + +function record( + overrides: Partial = {}, +): CatalogueDirectoryRecord { + return { + code: "INFS1001", + title: "Introduction", + summary: {}, + recordId: 1, + hasDraft: false, + hasChanges: false, + draftRevision: null, + isPublished: false, + isListedByAnu: true, + lastSeenAt: null, + sourceState: "up_to_date", + openChangeCount: 0, + conflictCount: 0, + latestSync: null, + ...overrides, + }; +} + +test("reports the most urgent fact about a record, in that order", () => { + expect( + catalogueRecordState( + record({ + sourceState: "sync_failed", + isListedByAnu: false, + hasDraft: true, + isPublished: true, + }), + ), + ).toBe("sync_failed"); + expect( + catalogueRecordState( + record({ isListedByAnu: false, hasDraft: true, isPublished: true }), + ), + ).toBe("delisted"); + expect(catalogueRecordState(record({ sourceState: "syncing" }))).toBe( + "syncing", + ); + expect( + catalogueRecordState( + record({ + sourceState: "changes_available", + openChangeCount: 2, + hasDraft: true, + }), + ), + ).toBe("changes_available"); + // Unpublished work outranks publication: that record still needs a person. + expect( + catalogueRecordState(record({ hasDraft: true, isPublished: true })), + ).toBe("draft"); + expect(catalogueRecordState(record({ isPublished: true }))).toBe("published"); + expect(catalogueRecordState(record())).toBe("unpublished"); +}); + +test("treats a sync that found nothing outstanding as settled", () => { + expect( + catalogueRecordState( + record({ + sourceState: "changes_available", + openChangeCount: 0, + isPublished: true, + }), + ), + ).toBe("published"); +}); + +test("offers every state the directory can be narrowed to a name", () => { + expect(CATALOGUE_STATES).toEqual(Object.keys(CATALOGUE_STATE_LABELS)); + for (const state of CATALOGUE_STATES) + expect(CATALOGUE_STATE_LABELS[state]).toBeTruthy(); +}); diff --git a/apps/web/tests/catalogue-student-view-panel.test.tsx b/apps/web/tests/catalogue-student-view-panel.test.tsx index 19d4832d..6411019b 100644 --- a/apps/web/tests/catalogue-student-view-panel.test.tsx +++ b/apps/web/tests/catalogue-student-view-panel.test.tsx @@ -57,7 +57,7 @@ test("the published version is one keyboard-reachable control away", () => { ); }); -test("an unpublished record shows its draft and says students see nothing", () => { +test("an unpublished record shows its draft without a dead control", () => { render( { diff --git a/apps/web/tests/catalogue-sync-button.test.tsx b/apps/web/tests/catalogue-sync-button.test.tsx index 399cd671..3ca963e4 100644 --- a/apps/web/tests/catalogue-sync-button.test.tsx +++ b/apps/web/tests/catalogue-sync-button.test.tsx @@ -2,19 +2,32 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, expect, test, vi } from "vitest"; import { CatalogueSyncButton } from "@/ui/admin/catalogue/sync-button"; -const { refresh, success, failure } = vi.hoisted(() => ({ +const { refresh, progress, success, info, failure } = vi.hoisted(() => ({ refresh: vi.fn(), + progress: vi.fn(), success: vi.fn(), + info: vi.fn(), failure: vi.fn(), })); vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh }) })); -vi.mock("sonner", () => ({ toast: { success, error: failure } })); +// The running toast is a plain toast rather than a loading one, because sonner +// withholds the close button from loading toasts. +vi.mock("sonner", () => ({ + toast: Object.assign(progress, { + loading: progress, + success, + info, + error: failure, + }), +})); beforeEach(() => { vi.restoreAllMocks(); refresh.mockReset(); + progress.mockReset(); success.mockReset(); + info.mockReset(); failure.mockReset(); }); @@ -25,24 +38,107 @@ test("starts one record-level ANU sync", async () => { headers: { "content-type": "application/json" }, }), ); - render(); + render( + , + ); - fireEvent.click(screen.getByRole("button", { name: "Sync from ANU" })); + fireEvent.click(screen.getByRole("button", { name: "Sync" })); await waitFor(() => expect(request).toHaveBeenCalledTimes(1)); expect(JSON.parse(String(request.mock.calls[0]?.[1]?.body))).toEqual({ recordId: 42, kind: "course", }); - expect(await screen.findByRole("button")).toBeDisabled(); - expect(screen.getByRole("button")).toHaveTextContent("Syncing from ANU..."); + // The progress belongs to the toast, so the button keeps its own label + // rather than reflowing the header it sits in. + const button = await screen.findByRole("button"); + expect(button).toBeDisabled(); + expect(button).toHaveTextContent("Sync"); + expect(progress).toHaveBeenCalledWith( + "Syncing COMP1100 from ANU", + expect.objectContaining({ id: "sync:42" }), + ); +}); + +test("reports a sync that could not start in its own toast", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ error: "Sync permission is required." }), { + status: 403, + headers: { "content-type": "application/json" }, + }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Sync" })); + + await waitFor(() => + expect(failure).toHaveBeenCalledWith( + "Syncing COMP1100 from ANU could not start", + expect.objectContaining({ id: "sync:42" }), + ), + ); + // The description is clamped to one line, so the reason it failed is carried + // whole in the tooltip. + const [, options] = failure.mock.calls[0] as [ + string, + { description: { props: { title: string } } }, + ]; + expect(options.description.props.title).toBe("Sync permission is required."); + expect(screen.getByRole("button")).toBeEnabled(); +}); + +test("hands the toast back when the page that was watching it goes", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ syncId: "sync-1", mode: "inline" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const view = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Sync" })); + await waitFor(() => expect(progress).toHaveBeenCalled()); + view.unmount(); + + // Otherwise the toast spins at whatever percentage it had reached, with + // nothing left polling to ever finish it. + await waitFor(() => + expect(info).toHaveBeenCalledWith( + "The ANU sync is still running", + expect.objectContaining({ id: "sync:42" }), + ), + ); }); test("offers a retry after a failed sync", () => { render( ({ + usePathname: () => pathname, +})); + +vi.mock("@/ui/shell", () => ({ + AppShell: ({ children }: { children: ReactNode }) => ( + {children} + ), +})); + test("missing pages have one heading and useful routes home and to the catalogue", () => { render(); expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1); @@ -43,6 +56,21 @@ test("client failures are not labelled as an HTTP server response", () => { expect(screen.queryByText(/500/)).not.toBeInTheDocument(); }); +test("admin failures keep the admin shell and return to its overview", () => { + pathname = "/admin/operations/catalogue"; + try { + render( + {}} />, + ); + expect(screen.getByTestId("admin-shell")).toBeVisible(); + expect( + screen.getByRole("link", { name: "Back to overview" }), + ).toHaveAttribute("href", "/admin/dashboard"); + } finally { + pathname = "/courses"; + } +}); + test("an offline failure shows reconnect guidance and returns to the normal error when online", async () => { const online = vi.spyOn(navigator, "onLine", "get").mockReturnValue(false); const reset = vi.fn(); diff --git a/apps/web/tests/local-preview-seed.test.mjs b/apps/web/tests/local-preview-seed.test.mjs index 2ff68524..422dd2cb 100644 --- a/apps/web/tests/local-preview-seed.test.mjs +++ b/apps/web/tests/local-preview-seed.test.mjs @@ -1,4 +1,4 @@ -import { repositoryRoot } from "../scripts/paths.mjs"; +import { nextCliPath, repositoryRoot } from "../scripts/paths.mjs"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import { readFile } from "node:fs/promises"; @@ -9,11 +9,14 @@ import { resetLocalPreview, } from "../scripts/local/reset-preview.mjs"; import { seedLocalPreview } from "../scripts/local/seed-preview.mjs"; - -const devPreviewSource = new URL( - "../scripts/local/dev-preview.mjs", - import.meta.url, -); +import { buildLocalProduction } from "../scripts/local/build-preview.mjs"; +import { startLocalDevelopmentPreview } from "../scripts/local/dev-preview.mjs"; +import { startLocalProductionPreview } from "../scripts/local/production-preview.mjs"; +import { startBuiltLocalProduction } from "../scripts/local/start-preview.mjs"; +import { + createLocalApplicationEnvironment, + parseSupabaseEnvironment, +} from "../scripts/local/supabase-environment.mjs"; test("keeps predictable preview credentials out of Supabase's default seed", async () => { const defaultSeed = await readFile( @@ -42,12 +45,146 @@ test("the local preview publishes every selectable academic structure kind", asy assert.match(previewSeed, /set published_version_id = snapshots\.id/u); }); -test("passes the local server key to durable import workers", async () => { - const source = await readFile(devPreviewSource, "utf8"); +test("passes the local server key to durable import workers", () => { + const supabaseEnvironment = parseSupabaseEnvironment( + [ + 'API_URL="http://127.0.0.1:54321"', + 'DB_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"', + 'ANON_KEY="public-key"', + 'SERVICE_ROLE_KEY="server-key"', + ].join("\n"), + ); + const environment = createLocalApplicationEnvironment({ + baseEnvironment: { KEEP_ME: "yes" }, + supabaseEnvironment, + }); + + assert.equal(environment.KEEP_ME, "yes"); + assert.equal( + environment.NEXT_PUBLIC_SUPABASE_URL, + supabaseEnvironment.apiUrl, + ); + assert.equal(environment.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, "public-key"); + assert.equal(environment.SUPABASE_SECRET_KEY, "server-key"); + assert.equal( + environment.COURSEMAP_DATABASE_URL, + supabaseEnvironment.databaseUrl, + ); +}); + +test("builds and starts the production preview with the same local environment", () => { + const calls = []; + const child = new EventEmitter(); + const environment = { LOCAL_PREVIEW: "true" }; + + const result = startLocalProductionPreview({ + environment, + runBuild(executable, args, options) { + calls.push({ executable, args, options }); + return { status: 0 }; + }, + spawnServer(executable, args, options) { + calls.push({ executable, args, options }); + return child; + }, + }); + + assert.equal(result.child, child); + assert.equal(result.exitCode, null); + assert.deepEqual(calls, [ + { + executable: "pnpm", + args: ["run", "build"], + options: { + cwd: new URL("../", import.meta.url).pathname, + env: environment, + stdio: "inherit", + }, + }, + { + executable: process.execPath, + args: [nextCliPath, "start", "--hostname", "127.0.0.1", "--port", "3000"], + options: { + cwd: new URL("../", import.meta.url).pathname, + env: environment, + stdio: "inherit", + }, + }, + ]); +}); + +test("the standalone build and start commands also inject the local environment", () => { + const calls = []; + const environment = { LOCAL_PREVIEW: "true" }; + const child = new EventEmitter(); - assert.match(source, /values\.get\("SECRET_KEY"\)/u); - assert.match(source, /values\.get\("SERVICE_ROLE_KEY"\)/u); - assert.match(source, /SUPABASE_SECRET_KEY: secretKey/u); + const buildStatus = buildLocalProduction({ + environment, + runCommand(executable, args, options) { + calls.push({ executable, args, options }); + return { status: 0 }; + }, + }); + const server = startBuiltLocalProduction({ + environment, + spawnCommand(executable, args, options) { + calls.push({ executable, args, options }); + return child; + }, + }); + + assert.equal(buildStatus, 0); + assert.equal(server, child); + assert.deepEqual( + calls.map(({ args, options }) => ({ args, environment: options.env })), + [ + { args: ["run", "build:next"], environment }, + { + args: [ + nextCliPath, + "start", + "--hostname", + "127.0.0.1", + "--port", + "3000", + ], + environment, + }, + ], + ); +}); + +test("development starts Next directly so stopping it cannot orphan a server", () => { + let command; + const child = new EventEmitter(); + const environment = { LOCAL_PREVIEW: "true" }; + + const server = startLocalDevelopmentPreview({ + environment, + spawnCommand(executable, args, options) { + command = { executable, args, options }; + return child; + }, + }); + + assert.equal(server, child); + assert.deepEqual(command, { + executable: process.execPath, + args: [ + nextCliPath, + "dev", + "--webpack", + "--hostname", + "127.0.0.1", + "--port", + "3000", + ], + options: { + cwd: new URL("../", import.meta.url).pathname, + env: environment, + stdio: "inherit", + }, + }); }); test("runs the preview fixture through the verified local database client", async () => { diff --git a/apps/web/tests/operations-error.test.tsx b/apps/web/tests/operations-error.test.tsx new file mode 100644 index 00000000..4559cd24 --- /dev/null +++ b/apps/web/tests/operations-error.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { expect, test, vi } from "vitest"; +import { CatalogueOperationsError } from "@/ui/admin/operations/operations-error"; + +vi.mock("@/ui/shell", () => ({ + AppShell: ({ children }: { children: ReactNode }) => ( + {children} + ), +})); + +test("keeps the admin shell around catalogue operations errors", () => { + const reset = vi.fn(); + render( + , + ); + + expect(screen.getByTestId("admin-shell")).toBeTruthy(); + expect( + screen.getByRole("heading", { + name: "We couldn't load catalogue activity", + }), + ).toBeTruthy(); + expect(screen.getByText("Error reference: reference-123")).toBeTruthy(); + expect( + screen.getByRole("link", { name: "Back to overview" }).getAttribute("href"), + ).toBe("/admin/dashboard"); + + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(reset).toHaveBeenCalledOnce(); +}); diff --git a/apps/web/tests/operations-sync-views.test.tsx b/apps/web/tests/operations-sync-views.test.tsx index f71ea429..e01e2919 100644 --- a/apps/web/tests/operations-sync-views.test.tsx +++ b/apps/web/tests/operations-sync-views.test.tsx @@ -1,5 +1,6 @@ import { render, screen, within } from "@testing-library/react"; -import { expect, test, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, test, vi } from "vitest"; import { TooltipProvider } from "@coursemap/ui/primitives/tooltip"; import type { @@ -9,12 +10,18 @@ import type { } from "@/lib/coursemap/admin-operations"; import { DiscoveryList } from "@/ui/admin/operations/discovery-list"; import { SyncDetailView } from "@/ui/admin/operations/sync-detail"; +import { + SyncDetailTabList, + SyncDetailTabs, +} from "@/ui/admin/operations/sync-detail-tabs"; import { SyncList } from "@/ui/admin/operations/sync-list"; +let searchParams = new URLSearchParams(); + vi.mock("next/navigation", () => ({ usePathname: () => "/admin/operations/catalogue", useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), - useSearchParams: () => new URLSearchParams(), + useSearchParams: () => searchParams, })); vi.mock("@/ui/admin/operations/artefact-viewer", () => ({ @@ -23,6 +30,10 @@ vi.mock("@/ui/admin/operations/artefact-viewer", () => ({ ), })); +afterEach(() => { + searchParams = new URLSearchParams(); +}); + function syncPage( overrides: Partial = {}, ): SyncOperationsPage { @@ -149,6 +160,23 @@ function syncDetail(overrides: Partial = {}): SyncDetail { }; } +/** The detail view reads one tab at a time, so its tab bar comes with it. */ +function renderSyncDetail(sync: SyncDetail) { + return render( + + stage.status === "failed").length + } + /> + + , + ); +} + function renderSyncList(page: SyncOperationsPage) { // FilterBar carries hints through the shared tooltip provider. return render( @@ -177,22 +205,30 @@ test("an empty list says what fills it rather than showing an empty table", () = expect(screen.queryByRole("table")).toBeNull(); }); -test("the sync detail shows the failure, the lease and the attempt that failed", () => { - render(); +test("the sync detail shows the failure, the lease and the attempt that failed", async () => { + const user = userEvent.setup(); + renderSyncDetail(syncDetail()); + // The failure and the lease are true of the sync, so they lead every tab. expect(screen.getByText("OPENROUTER_HTTP_500")).toBeTruthy(); - // The alert and the stage that failed both name it. - expect(screen.getAllByText("OpenRouter returned 500.").length).toBe(2); + expect(screen.getByText("OpenRouter returned 500.")).toBeTruthy(); expect(screen.getByText("99999999-9999-4999-8999-999999999999")).toBeTruthy(); + + await user.click(screen.getByRole("tab", { name: /Stages/ })); const stages = screen.getByText("Model extraction").closest("tr"); expect(within(stages!).getByText("failed")).toBeTruthy(); expect(within(stages!).getByText("3")).toBeTruthy(); + expect(within(stages!).getByText("OpenRouter returned 500.")).toBeTruthy(); + + await user.click(screen.getByRole("tab", { name: /Extractions/ })); expect(screen.getByText("openai/gpt-5-2026")).toBeTruthy(); expect(screen.getByText("2 errors")).toBeTruthy(); + + await user.click(screen.getByRole("tab", { name: /Artefacts/ })); expect(screen.getByTestId("artefacts").textContent).toBe("1"); }); test("the sync detail links back to the record it checked", () => { - render(); + renderSyncDetail(syncDetail()); expect( screen.getByRole("link", { name: /Open the record/ }).getAttribute("href"), ).toBe("/admin/courses/2027/comp2700"); @@ -214,10 +250,59 @@ test("an incomplete listing check says so, because it cannot retire anything", ( errorMessage: null, }, ]; - render(); + render( + + + , + ); + expect( + screen.getByPlaceholderText("Search listing checks"), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Filter" })).toBeInTheDocument(); expect(screen.getByText("Partial")).toBeTruthy(); expect( screen.getByRole("link", { name: "Courses" }).getAttribute("href"), ).toBe("/admin/operations/catalogue/discovery/7"); expect(screen.getByText("120 discovered")).toBeTruthy(); }); + +test("discovery search narrows the loaded listing checks", () => { + searchParams = new URLSearchParams("q=programmes"); + const checks: DiscoveryCheckRow[] = [ + { + id: 7, + kind: "course", + academicYear: 2027, + status: "completed", + isComplete: true, + discoveredCount: 120, + startedAt: "2026-09-21T10:00:00.000Z", + completedAt: "2026-09-21T10:00:20.000Z", + durationMs: 20_000, + errorCode: null, + errorMessage: null, + }, + { + id: 8, + kind: "programme", + academicYear: 2027, + status: "failed", + isComplete: false, + discoveredCount: 0, + startedAt: "2026-09-21T11:00:00.000Z", + completedAt: "2026-09-21T11:00:02.000Z", + durationMs: 2_000, + errorCode: "FETCH_FAILED", + errorMessage: "The listing could not be fetched.", + }, + ]; + + render( + + + , + ); + + expect(screen.getByRole("link", { name: "Programmes" })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Courses" })).toBeNull(); +}); diff --git a/apps/web/tests/sign-in-form.test.tsx b/apps/web/tests/sign-in-form.test.tsx new file mode 100644 index 00000000..deec71cb --- /dev/null +++ b/apps/web/tests/sign-in-form.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { SignInForm } from "@/app/auth/sign-in/sign-in-form"; + +describe("SignInForm", () => { + it("posts credentials when submitted before client hydration", () => { + render(); + + const form = screen + .getByRole("button", { name: "Sign in" }) + .closest("form"); + expect(form).toHaveAttribute("method", "post"); + expect(form).toHaveAttribute("action", "/auth/password"); + }); + + it("shows a server-side fallback error", () => { + render( + , + ); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Email or password is incorrect.", + ); + }); +}); diff --git a/apps/web/ui/admin/catalogue-table/catalogue-empty.tsx b/apps/web/ui/admin/catalogue-table/catalogue-empty.tsx index 5c17f5ec..8ef0f0f2 100644 --- a/apps/web/ui/admin/catalogue-table/catalogue-empty.tsx +++ b/apps/web/ui/admin/catalogue-table/catalogue-empty.tsx @@ -12,6 +12,7 @@ export function CatalogueEmpty({ error = false, clearHref, onSync, + syncing = false, children, }: { title: string; @@ -20,6 +21,7 @@ export function CatalogueEmpty({ error?: boolean; clearHref?: string; onSync?: () => void; + syncing?: boolean; children?: ReactNode; }) { return ( @@ -36,7 +38,13 @@ export function CatalogueEmpty({ Clear filters ) : onSync ? ( - + Run sync now ) : ( diff --git a/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx b/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx index 51508a17..82021b9e 100644 --- a/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx +++ b/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx @@ -128,9 +128,11 @@ export function CatalogueTableLoading({ ) : null} + {/* Search and the filter button beside it. Every table this stands in + for offers both, so the row is held open at its full width. */} - {imports ? : null} + (null); const router = useRouter(); + + // The menu is anchored to a row inside a table that scrolls on its own. It + // keeps tracking that row, so a scroll carries it out of the table and over + // the toolbar above while the row itself is clipped away. The menu belongs + // to a row that is no longer where it was, so it closes rather than chases. + // Scrolling within the menu's own list is not that, and is left alone. + useEffect(() => { + if (!open) return; + function closeOnScrollAway(event: Event) { + const target = event.target; + if (target instanceof Node && content.current?.contains(target)) return; + setOpen(false); + } + // Scroll does not bubble, so the capture phase is the only way to hear a + // scroll from a container this component does not own. + document.addEventListener("scroll", closeOnScrollAway, true); + return () => + document.removeEventListener("scroll", closeOnScrollAway, true); + }, [open]); const items = links.map((link, index) => ({ value: String(index), label: link.label, @@ -74,6 +94,10 @@ export function CatalogueRowActions({ + + Sync failed + + ); + case "delisted": + return ( + + + No longer listed + + ); + case "syncing": + return ( + + + Syncing + + ); + case "changes_available": + return ( + + + {record.conflictCount > 0 + ? `${record.openChangeCount} ANU change${record.openChangeCount === 1 ? "" : "s"}, ${record.conflictCount} conflict${record.conflictCount === 1 ? "" : "s"}` + : `${record.openChangeCount} ANU change${record.openChangeCount === 1 ? "" : "s"}`} + + ); + case "draft": + return Draft; + case "published": + return Published; + default: + return Not published; + } +} diff --git a/apps/web/ui/admin/catalogue-table/catalogue-table.module.css b/apps/web/ui/admin/catalogue-table/catalogue-table.module.css index a9920e90..7ad88c7e 100644 --- a/apps/web/ui/admin/catalogue-table/catalogue-table.module.css +++ b/apps/web/ui/admin/catalogue-table/catalogue-table.module.css @@ -176,14 +176,13 @@ a.title:focus-visible { min-width: 700px; grid-template-columns: minmax(260px, 1fr) 150px 120px 120px 60px; } -/* Catalogue directory: select, identity, details, workflow status, latest - import, actions. The actions column matches the width the sibling layouts - reserve, so the menu button lands in the same place on every admin table. */ +/* Catalogue directory: identity, state, latest sync, actions. The identity + takes every spare pixel because the title and its facts are what is read; + the actions column matches the width the sibling layouts reserve, so the + menu button lands in the same place on every admin table. */ .shell[data-layout="directory"] tr { - min-width: 880px; - grid-template-columns: - 48px minmax(280px, 1.4fr) minmax(160px, 1fr) - 170px 170px 60px; + min-width: 620px; + grid-template-columns: minmax(280px, 1fr) 210px 110px 60px; } /* Operations syncs: record, year, status, trigger, started, duration, model, diff --git a/apps/web/ui/admin/catalogue-table/catalogue-table.tsx b/apps/web/ui/admin/catalogue-table/catalogue-table.tsx index db95d128..010b76c8 100644 --- a/apps/web/ui/admin/catalogue-table/catalogue-table.tsx +++ b/apps/web/ui/admin/catalogue-table/catalogue-table.tsx @@ -70,12 +70,15 @@ export function CatalogueIdentity({ title, kind = "course", href, + meta = [], unavailable = false, }: { code: string; title: string; kind?: string; href?: string; + /** Facts that belong beside the code rather than in a column of their own. */ + meta?: string[]; unavailable?: boolean; }) { const subjectIcons = { @@ -113,8 +116,9 @@ export function CatalogueIdentity({ {title} )} - {code} - {unavailable ? " · No longer listed" : ""} + {[code, ...meta, ...(unavailable ? ["No longer listed"] : [])].join( + " · ", + )} diff --git a/apps/web/ui/admin/catalogue/catalogue-directory.tsx b/apps/web/ui/admin/catalogue/catalogue-directory.tsx index 4f484da6..753bb3ba 100644 --- a/apps/web/ui/admin/catalogue/catalogue-directory.tsx +++ b/apps/web/ui/admin/catalogue/catalogue-directory.tsx @@ -1,19 +1,20 @@ "use client"; -import { Badge } from "@coursemap/ui/components/badge"; import { Button } from "@coursemap/ui/primitives/button"; -import { LoaderCircle, RefreshCw, TriangleAlert } from "lucide-react"; +import { LoaderCircle, RefreshCw } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useState, useTransition } from "react"; -import { toast } from "sonner"; +import { catalogueSummaryMeta } from "@/lib/coursemap/catalogue-summary"; import { CATALOGUE_KIND_LABELS, + CATALOGUE_STATES, + CATALOGUE_STATE_LABELS, type CatalogueDirectoryPage, - type CatalogueDirectoryRecord, adminCatalogueRecordPath, adminCatalogueYearPath, } from "@/lib/coursemap/catalogue-kinds"; import { CatalogueEmpty } from "@/ui/admin/catalogue-table/catalogue-empty"; +import { CatalogueStateBadge } from "@/ui/admin/catalogue-table/catalogue-state-badge"; import { CatalogueIdentity, DataTableShell, @@ -25,55 +26,84 @@ import { TableHeader, TableRow, } from "@/ui/admin/catalogue-table/catalogue-table"; +import { DirectoryRowActions } from "@/ui/admin/catalogue/directory-row-actions"; import { FilterBar } from "@/ui/common/filter-bar"; import { LinkedTableRow } from "@/ui/common/linked-table-row"; import { Pagination } from "@/ui/common/pagination"; +import { startTask } from "@/ui/common/task-toast"; import { YearPicker } from "@/ui/common/year-picker"; import { readImportStream } from "./import-stream"; -function formatDate(value: string | null) { - if (!value) return null; - return new Intl.DateTimeFormat("en-AU", { dateStyle: "medium" }).format( - new Date(value), - ); +/** The column is scanned, so the year is dropped once it is the obvious one. */ +function shortDate(value: string) { + const date = new Date(value); + return new Intl.DateTimeFormat("en-AU", { + day: "numeric", + month: "short", + ...(date.getFullYear() === new Date().getFullYear() + ? {} + : { year: "numeric" }), + }).format(date); } -const SOURCE_STATE_LABELS = { - never_synced: "Never synced", - syncing: "Syncing", - up_to_date: "Up to date", - changes_available: "Changes available", - sync_failed: "Sync failed", -} as const; - -/** Names the work waiting on a record rather than the pipeline state. */ -function sourceStateLabel(record: CatalogueDirectoryRecord) { - if (record.sourceState !== "changes_available") - return SOURCE_STATE_LABELS[record.sourceState]; - const changes = `${record.openChangeCount} ANU change${record.openChangeCount === 1 ? "" : "s"}`; - if (record.conflictCount === 0) return changes; - return `${changes}, ${record.conflictCount} conflict${record.conflictCount === 1 ? "" : "s"}`; +/** The whole timestamp, for the hover that answers "when exactly?". */ +function fullDate(value: string) { + return new Intl.DateTimeFormat("en-AU", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); } -function sourceStateVariant( - state: keyof typeof SOURCE_STATE_LABELS, -): "outline" | "success-light" | "warning-light" | "destructive-light" { - if (state === "up_to_date") return "success-light"; - if (state === "changes_available" || state === "syncing") - return "warning-light"; - if (state === "sync_failed") return "destructive-light"; - return "outline"; +/** + * Each phase of the refresh gets a stretch of the bar: where it starts, which + * is what the work has reported, and where it ends, which the bar drifts + * towards while the phase lasts. ANU answers some phases instantly, so + * without the stretch the bar would be still for a second and then teleport. + */ +const REFRESH_PHASES: Record = { + fetching: { percent: 12, ceiling: 62 }, + saving: { percent: 68, ceiling: 92 }, + done: { percent: 94, ceiling: 99 }, +}; + +type RefreshResult = { + entryCount?: number; + added?: number; + updated?: number; + retired?: number; + isComplete?: boolean; +}; + +/** What the refresh actually did, rather than that it happened. */ +function refreshSummary(result: RefreshResult) { + const entries = `${(result.entryCount ?? 0).toLocaleString("en-AU")} ${ + result.entryCount === 1 ? "entry" : "entries" + }`; + const changes = [ + result.added ? `${result.added} added` : null, + result.updated ? `${result.updated} updated` : null, + result.retired ? `${result.retired} retired` : null, + ].filter(Boolean); + return `${entries} · ${changes.length ? changes.join(", ") : "no changes"}`; } +/** + * The states a row can be in, in the order the badge ranks them, so the menu + * reads down from the rows that need a person to the ones that do not. + */ +const STATE_OPTIONS = CATALOGUE_STATES.map((state) => ({ + value: state, + label: CATALOGUE_STATE_LABELS[state], +})); + export function CatalogueDirectory({ page }: { page: CatalogueDirectoryPage }) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const labels = CATALOGUE_KIND_LABELS[page.kind]; const [refreshing, setRefreshing] = useState(false); - const [refreshMessage, setRefreshMessage] = useState(null); const [, startTransition] = useTransition(); - const filtered = Boolean(searchParams.get("q")); + const filtered = Boolean(searchParams.get("q") || searchParams.get("state")); function changeYear(year: number | "all") { if (year === "all") return; @@ -87,7 +117,12 @@ export function CatalogueDirectory({ page }: { page: CatalogueDirectoryPage }) { async function refreshDirectory() { setRefreshing(true); - setRefreshMessage("Contacting ANU..."); + const task = startTask({ + id: `directory:${page.kind}:${page.academicYear}`, + title: `Refreshing the ANU ${labels.singular.toLowerCase()} listing`, + detail: "Contacting ANU.", + ceiling: 10, + }); try { const response = await fetch("/api/admin/catalogue-directory", { method: "POST", @@ -97,20 +132,43 @@ export function CatalogueDirectory({ page }: { page: CatalogueDirectoryPage }) { academicYear: page.academicYear, }), }); + let result: RefreshResult = {}; await readImportStream(response, (event) => { + if (event.type === "started") { + task.step({ percent: 4, ceiling: 20, detail: "Contacting ANU." }); + } if (event.type === "progress" && typeof event.message === "string") { - setRefreshMessage(event.message); + const phase = REFRESH_PHASES[String(event.phase)] ?? { + percent: 50, + ceiling: 80, + }; + task.step({ ...phase, detail: event.message }); + } + if (event.type === "complete" && event.result) { + result = event.result as RefreshResult; } }); - toast.success("ANU listing refreshed."); + const outcome = { + title: `${page.academicYear} ${labels.plural.toLowerCase()} refreshed`, + detail: refreshSummary(result), + }; + if (result.isComplete === false) { + task.note({ + ...outcome, + detail: `${outcome.detail}. The listing may be incomplete, so nothing was retired.`, + }); + } else { + task.done(outcome); + } router.refresh(); } catch (error) { - toast.error( - error instanceof Error ? error.message : "The refresh failed.", - ); + task.fail({ + title: "The ANU listing refresh failed", + detail: error instanceof Error ? error.message : "The refresh failed.", + retry: refreshDirectory, + }); } finally { setRefreshing(false); - setRefreshMessage(null); } } @@ -127,6 +185,7 @@ export function CatalogueDirectory({ page }: { page: CatalogueDirectoryPage }) { variant="outline" onClick={refreshDirectory} disabled={refreshing} + aria-busy={refreshing} > {refreshing ? ( )} - {refreshMessage ?? "Refresh ANU listing"} + Refresh ANU listing - {page.status.message ? ( - - {page.status.message} - - ) : null} {page.records.length === 0 ? ( ) : ( {labels.singular} - Publication - ANU listing - ANU source + State + Updated + + Actions + @@ -195,6 +260,7 @@ export function CatalogueDirectory({ page }: { page: CatalogueDirectoryPage }) { page.academicYear, record.code, ); + const updated = record.latestSync?.completedAt ?? null; return ( @@ -203,43 +269,30 @@ export function CatalogueDirectory({ page }: { page: CatalogueDirectoryPage }) { title={record.title ?? "Title not available"} kind={page.kind} href={href} + meta={catalogueSummaryMeta(record.summary, page.kind)} /> - - {record.isPublished ? "Published" : "Not published"} - + - - {record.isListedByAnu === false ? ( - - - No longer listed by ANU - {record.lastSeenAt - ? ` · Last seen ${formatDate(record.lastSeenAt)}` - : ""} - - ) : record.isListedByAnu ? ( - Listed by ANU + + {updated ? ( + shortDate(updated) ) : ( - + Never synced )} - - - {sourceStateLabel(record)} - - {record.latestSync?.completedAt ? ( - - {formatDate(record.latestSync.completedAt)} - - ) : null} + + ); diff --git a/apps/web/ui/admin/catalogue/catalogue-editor-context.tsx b/apps/web/ui/admin/catalogue/catalogue-editor-context.tsx new file mode 100644 index 00000000..6f6db115 --- /dev/null +++ b/apps/web/ui/admin/catalogue/catalogue-editor-context.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { + createContext, + useContext, + useEffect, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from "react"; +import { toast } from "sonner"; + +import type { CatalogueContent } from "@/lib/catalogue/content"; +import { + beginCatalogueDraftAction, + discardDraftAction, + publishDraftAction, + saveCatalogueDraftAction, + unpublishAction, +} from "@/lib/coursemap/admin-catalogue-actions"; + +export type CatalogueSaveState = "saved" | "saving" | "error" | "conflict"; + +export type CatalogueEditor = { + write: CatalogueContent; + setWrite: Dispatch>; + /** Whether the fields are being offered for change or merely read. */ + editing: boolean; + beginEditing: () => void; + cancelEditing: () => void; + dirty: boolean; + saveState: CatalogueSaveState; + saveError: string | null; + isPublished: boolean; + hasDraft: boolean; + hasUnpublishedChanges: boolean; + publish: () => Promise; + unpublish: () => Promise; + discard: () => Promise; +}; + +const CatalogueEditorContext = createContext(null); + +export function useCatalogueEditor() { + const editor = useContext(CatalogueEditorContext); + if (!editor) + throw new Error( + "A catalogue editor surface must be rendered inside CatalogueEditorProvider.", + ); + return editor; +} + +/** + * One record's editing session, shared by every surface that acts on it. + * + * The state lives here rather than beside the fields because the toolbar that + * reports and commits it sits above the record's title, outside the tab the + * fields are in. Both read the same session, so what the toolbar says is + * always what the fields hold. + * + * Accepted changes autosave against an expected revision, so another tab can + * never be overwritten silently. + */ +export function CatalogueEditorProvider({ + initial, + recordId, + initialRevision, + initiallyPublished, + initialHasDraft, + initialHasUnpublishedChanges, + path, + children, +}: { + initial: CatalogueContent; + recordId: number; + initialRevision: number; + initiallyPublished: boolean; + /** False until a change worth keeping has been saved against this record. */ + initialHasDraft: boolean; + initialHasUnpublishedChanges: boolean; + path: string; + children: ReactNode; +}) { + const router = useRouter(); + const [write, setWrite] = useState(initial); + const [revision, setRevision] = useState(initialRevision); + const [savedContent, setSavedContent] = useState(() => + JSON.stringify(initial), + ); + const [saveState, setSaveState] = useState("saved"); + const [saveError, setSaveError] = useState(null); + const [failedContent, setFailedContent] = useState(null); + const [isPublished, setIsPublished] = useState(initiallyPublished); + const [hasDraft, setHasDraft] = useState(initialHasDraft); + // A record that already carries a draft is already being worked on, so it + // opens ready to edit. Everything else opens as a reading of the record. + const [editing, setEditing] = useState(initialHasDraft); + const [opening, setOpening] = useState(false); + const [hasUnpublishedChanges, setHasUnpublishedChanges] = useState( + initialHasUnpublishedChanges, + ); + const [editingSessionId, setEditingSessionId] = useState(() => + crypto.randomUUID(), + ); + const currentContent = JSON.stringify(write); + const dirty = currentContent !== savedContent; + // Publication is the only action here that changes a public page, so it is + // also the only one that has to drop the cached public reads. + const publishedRecord = { + kind: initial.kind, + academicYear: initial.academicYear, + code: initial.code, + }; + + useEffect(() => { + const inactivityTimeout = window.setTimeout( + () => setEditingSessionId(crypto.randomUUID()), + 30 * 60 * 1000, + ); + return () => window.clearTimeout(inactivityTimeout); + }, [currentContent]); + + useEffect(() => { + if ( + !dirty || + saveState === "saving" || + saveState === "conflict" || + failedContent === currentContent + ) + return; + const snapshot = write; + const snapshotContent = currentContent; + const timeout = window.setTimeout(async () => { + setSaveState("saving"); + setSaveError(null); + const result = await saveCatalogueDraftAction({ + recordId, + expectedRevision: revision, + content: snapshot, + editingSessionId, + path, + }); + if (result.ok) { + setRevision(result.revision ?? revision); + setSavedContent(snapshotContent); + setFailedContent(null); + setSaveState("saved"); + if (!result.unchanged) { + setHasDraft(true); + setHasUnpublishedChanges(true); + } + return; + } + setSaveError(result.error); + setFailedContent(snapshotContent); + setSaveState(result.code === "STALE_DRAFT" ? "conflict" : "error"); + }, 1000); + return () => window.clearTimeout(timeout); + }, [ + currentContent, + dirty, + editingSessionId, + failedContent, + path, + recordId, + revision, + saveState, + write, + ]); + + async function publish() { + const result = await publishDraftAction({ + recordId, + expectedRevision: revision, + editingSessionId, + path, + record: publishedRecord, + }); + if (!result.ok) throw new Error(result.error); + toast.success(result.message); + setEditingSessionId(crypto.randomUUID()); + setIsPublished(true); + setHasDraft(false); + setHasUnpublishedChanges(false); + setEditing(false); + router.refresh(); + } + + async function unpublish() { + const result = await unpublishAction({ + recordId, + editingSessionId, + path, + record: publishedRecord, + }); + if (!result.ok) throw new Error(result.error); + toast.success(result.message); + setEditingSessionId(crypto.randomUUID()); + setIsPublished(false); + router.refresh(); + } + + async function discard() { + const result = await discardDraftAction({ + recordId, + expectedRevision: revision, + editingSessionId, + path, + }); + if (!result.ok) throw new Error(result.error); + toast.success(result.message); + setEditingSessionId(crypto.randomUUID()); + setHasDraft(false); + setHasUnpublishedChanges(false); + setEditing(false); + router.refresh(); + } + + /** + * The fields are offered straight away and the draft row is opened behind + * them, so asking to edit never waits on a round trip. Should opening fail, + * the editor stays open over a record with no draft row, which is the state + * it was in before the draft was asked for. + */ + async function openDraft() { + if (editing || opening) return; + setEditing(true); + setOpening(true); + const result = await beginCatalogueDraftAction({ + recordId, + editingSessionId, + path, + }); + setOpening(false); + if (!result.ok) { + toast.error(result.error); + return; + } + setRevision(result.revision ?? revision); + setHasDraft(true); + router.refresh(); + } + + return ( + void openDraft(), + // Leaving edit mode is only offered while nothing has been saved, so + // restoring what the record opened with can lose no stored work. + cancelEditing: () => { + setWrite(initial); + setSavedContent(JSON.stringify(initial)); + setSaveState("saved"); + setSaveError(null); + setEditing(false); + }, + dirty, + saveState, + saveError, + isPublished, + hasDraft, + hasUnpublishedChanges, + publish, + unpublish, + discard, + }} + > + {children} + + ); +} diff --git a/apps/web/ui/admin/catalogue/catalogue-editor-toolbar.tsx b/apps/web/ui/admin/catalogue/catalogue-editor-toolbar.tsx new file mode 100644 index 00000000..030721f0 --- /dev/null +++ b/apps/web/ui/admin/catalogue/catalogue-editor-toolbar.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { Button } from "@coursemap/ui/primitives/button"; +import { + Check, + EyeOff, + LoaderCircle, + Pencil, + RefreshCw, + Send, + Trash2, + TriangleAlert, +} from "lucide-react"; + +import { ConfirmDialog } from "@/ui/common/confirm-dialog"; +import { useCatalogueEditor } from "./catalogue-editor-context"; + +/** + * What state this record's content is in, and what can be done about it. + * + * It sits above the record's title rather than above the fields, because what + * it reports - read-only, unsaved, published - is true of the whole record and + * not of one tab. The actions follow the state, so nothing is offered that + * would fail if it were chosen: a record being read offers only Edit, and + * discarding and publishing appear for as long as the editor is open. + */ +export function CatalogueEditorToolbar() { + const { + beginEditing, + cancelEditing, + dirty, + discard, + editing, + hasDraft, + hasUnpublishedChanges, + isPublished, + publish, + saveError, + saveState, + unpublish, + } = useCatalogueEditor(); + const busy = dirty || saveState === "saving"; + // Opening the editor is itself the start of a draft: the record is being + // worked on whether or not a change has been saved against it yet, so the + // state and the actions that follow it do not wait for the first keystroke. + const drafting = hasDraft || editing; + // What the record is right now, in the same shorthand as the header badge + // beside the code. + const resting = drafting + ? { dot: "bg-violet-500", label: "Draft" } + : isPublished + ? { dot: "bg-emerald-500", label: "Published" } + : { dot: "bg-muted-foreground/40", label: "Not published" }; + + return ( + + + + {resting.label} + {editing ? ( + <> + + · + + {saveState === "saving" ? ( + + ) : saveState === "error" || saveState === "conflict" ? ( + + ) : ( + + )} + + {saveState === "saving" + ? "Saving..." + : saveState === "conflict" + ? "This draft changed elsewhere" + : saveState === "error" + ? `Unable to save${saveError ? `: ${saveError}` : ""}` + : "Saved"} + + {saveState === "conflict" ? ( + window.location.reload()} + > + Reload + + ) : null} + > + ) : null} + + + {isPublished ? ( + + Unpublish + + } + /> + ) : null} + {!editing ? ( + + Edit + + ) : null} + {drafting ? ( + <> + {/* + Discarding is the one way back out of the editor. A draft that + has not been opened on the server yet - the moment after Edit, or + after that failed - holds nothing, so backing out of it is only + leaving the editor and asks nothing. + */} + {hasDraft ? ( + + Discard draft + + } + /> + ) : ( + + Discard draft + + )} + + Publish + + } + /> + > + ) : null} + + + ); +} diff --git a/apps/web/ui/admin/catalogue/catalogue-pages.tsx b/apps/web/ui/admin/catalogue/catalogue-pages.tsx index a27dc1bc..2d6e1274 100644 --- a/apps/web/ui/admin/catalogue/catalogue-pages.tsx +++ b/apps/web/ui/admin/catalogue/catalogue-pages.tsx @@ -1,7 +1,9 @@ import { canManageCatalogueOperations } from "@/lib/auth/viewer"; import { CATALOGUE_KIND_LABELS, + CATALOGUE_STATE_LABELS, type CatalogueKind, + type CatalogueRecordState, loadCatalogueDirectoryPage, } from "@/lib/coursemap/admin-catalogue"; import { AppShell } from "@/ui/shell"; @@ -16,6 +18,13 @@ function first(value: string | string[] | undefined) { return Array.isArray(value) ? value[0] : value; } +/** An unknown state in the query string narrows to nothing, so it is dropped. */ +function recordState(value: string | undefined) { + return value && value in CATALOGUE_STATE_LABELS + ? (value as CatalogueRecordState) + : null; +} + /** The directory page for one kind; each route file calls this with its kind. */ export async function CatalogueDirectoryPage({ kind, @@ -33,6 +42,7 @@ export async function CatalogueDirectoryPage({ kind, academicYear, query: first(params.q) ?? "", + state: recordState(first(params.state)), page: Number(first(params.page)) || 1, }); return ( diff --git a/apps/web/ui/admin/catalogue/changelog/changelog-entry.tsx b/apps/web/ui/admin/catalogue/changelog/changelog-entry.tsx index 191b8e62..50e1e30a 100644 --- a/apps/web/ui/admin/catalogue/changelog/changelog-entry.tsx +++ b/apps/web/ui/admin/catalogue/changelog/changelog-entry.tsx @@ -102,15 +102,19 @@ function entryDetail(entry: ChangelogEntryView) { export function ChangelogEntry({ entry, versionHref, + syncsHref, }: { entry: ChangelogEntryView; versionHref: string | null; + /** Where the syncs behind a source entry can be read, for those allowed to. */ + syncsHref: string | null; }) { const detail = entryDetail(entry); + const fromSource = entry.origin === "source"; const actor = entry.kind === "edit" ? null - : (entry.actorName ?? (entry.origin === "source" ? "ANU sync" : null)); + : (entry.actorName ?? (fromSource ? "ANU sync" : null)); return ( @@ -122,7 +126,25 @@ export function ChangelogEntry({ {actor ? ( - {actor} + + {actor} + {/* + An entry ANU produced is only half the story: what it did and + why it did it are in the sync that ran, so the entry says where + that is rather than leaving it to be hunted for. + */} + {fromSource && syncsHref ? ( + <> + {" \u00b7 "} + + Sync diagnostics + + > + ) : null} + ) : null} {detail ? ( {detail} diff --git a/apps/web/ui/admin/catalogue/changelog/changelog-timeline.tsx b/apps/web/ui/admin/catalogue/changelog/changelog-timeline.tsx index d0d1bcab..3040b91c 100644 --- a/apps/web/ui/admin/catalogue/changelog/changelog-timeline.tsx +++ b/apps/web/ui/admin/catalogue/changelog/changelog-timeline.tsx @@ -27,11 +27,14 @@ function dayLabel(value: string, today: Date) { export function ChangelogTimeline({ changelog, path, + syncsHref = null, versionOrdinals, today = new Date(), }: { changelog: CatalogueChangelog; path: string; + /** Null for a reader without the permission to see catalogue operations. */ + syncsHref?: string | null; versionOrdinals: ReadonlyMap; today?: Date; }) { @@ -71,6 +74,7 @@ export function ChangelogTimeline({ 0 || !isPublished; + const showUnpublished = unpublishedCount > 0; return ( + {/* + Everything on this tab is the output of a sync, so the sync that + produced it is named here rather than left to be found in Activity. + */} + {latestSync ? ( + + {latestSync.completedAt + ? `Last checked against ANU on ${new Intl.DateTimeFormat("en-AU", { + dateStyle: "long", + timeStyle: "short", + }).format(new Date(latestSync.completedAt))}. ` + : "A check against ANU is under way. "} + + Sync diagnostics + + + ) : null} {conflicts.length === 0 && incoming.length === 0 ? ( ) : null} @@ -147,7 +173,7 @@ export function CatalogueChangesPanel({ ) : null} {showUnpublished ? ( - + ) : null} diff --git a/apps/web/ui/admin/catalogue/changes/unpublished-changes.tsx b/apps/web/ui/admin/catalogue/changes/unpublished-changes.tsx index cb220d0f..0938242e 100644 --- a/apps/web/ui/admin/catalogue/changes/unpublished-changes.tsx +++ b/apps/web/ui/admin/catalogue/changes/unpublished-changes.tsx @@ -2,22 +2,8 @@ import type { SnapshotChange } from "@/lib/catalogue-import/changes"; import { fieldLabel } from "@/lib/coursemap/catalogue-kinds"; import { FieldChangeList } from "../field-change-list"; -/** Saved draft work that students will not see until the record is published. */ -export function UnpublishedChanges({ - changes, - isPublished, -}: { - changes: SnapshotChange[]; - isPublished: boolean; -}) { - if (!isPublished) { - return ( - - This record has never been published, so nothing in the draft is visible - to students yet. - - ); - } +/** Saved draft work that sits on top of what is currently published. */ +export function UnpublishedChanges({ changes }: { changes: SnapshotChange[] }) { return ( ({ diff --git a/apps/web/ui/admin/catalogue/content-editor.tsx b/apps/web/ui/admin/catalogue/content-editor.tsx index b8d9083f..53ef61ad 100644 --- a/apps/web/ui/admin/catalogue/content-editor.tsx +++ b/apps/web/ui/admin/catalogue/content-editor.tsx @@ -7,19 +7,8 @@ import { CollapsibleTrigger, } from "@coursemap/ui/primitives/collapsible"; import { Input } from "@coursemap/ui/primitives/input"; -import { - Check, - ChevronDown, - EyeOff, - LoaderCircle, - RefreshCw, - Send, - Trash2, - TriangleAlert, -} from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; +import { ChevronDown } from "lucide-react"; +import { useMemo, useState } from "react"; import { requirementWriteWithTree, @@ -30,19 +19,13 @@ import type { CatalogueContent, RequirementRuleKind, } from "@/lib/catalogue/content"; -import { - discardDraftAction, - publishDraftAction, - saveCatalogueDraftAction, - unpublishAction, -} from "@/lib/coursemap/admin-catalogue-actions"; import { FIELD_LABELS } from "@/lib/coursemap/catalogue-kinds"; import { createEmptyTree, type ReviewedRuleTree, } from "@/lib/coursemap/requisite-conditions"; import { RequisiteRuleTree } from "@/ui/admin/requisites/requisite-rule-tree"; -import { ConfirmDialog } from "@/ui/common/confirm-dialog"; +import { useCatalogueEditor } from "./catalogue-editor-context"; import { DetailsEditor, RowsEditor } from "./section-editor"; import { JsonCode } from "@/ui/common/json-code"; @@ -231,104 +214,13 @@ function labelsFor(prefix: string) { } /** - * Edits one mutable catalogue draft. Accepted changes autosave with an - * expected revision so another tab can never be overwritten silently. + * The fields of one catalogue record. The editing session they read and write + * - what is saved, what is published, whether they may be changed at all - + * belongs to the provider above them, so the toolbar reporting that session + * can sit above the record's title instead of above these fields. */ -export function CatalogueContentEditor({ - initial, - recordId, - initialRevision, - initiallyPublished, - initialHasUnpublishedChanges, - path, -}: { - initial: CatalogueContent; - recordId: number; - initialRevision: number; - initiallyPublished: boolean; - initialHasUnpublishedChanges: boolean; - path: string; -}) { - const router = useRouter(); - const [write, setWrite] = useState(initial); - const [revision, setRevision] = useState(initialRevision); - const [savedContent, setSavedContent] = useState(() => - JSON.stringify(initial), - ); - const [saveState, setSaveState] = useState< - "saved" | "saving" | "error" | "conflict" - >("saved"); - const [saveError, setSaveError] = useState(null); - const [failedContent, setFailedContent] = useState(null); - const [isPublished, setIsPublished] = useState(initiallyPublished); - const [hasUnpublishedChanges, setHasUnpublishedChanges] = useState( - initialHasUnpublishedChanges, - ); - const [editingSessionId, setEditingSessionId] = useState(() => - crypto.randomUUID(), - ); - const currentContent = JSON.stringify(write); - const dirty = currentContent !== savedContent; - // Publication is the only action here that changes a public page, so it is - // also the only one that has to drop the cached public reads. - const publishedRecord = { - kind: initial.kind, - academicYear: initial.academicYear, - code: initial.code, - }; - - useEffect(() => { - const inactivityTimeout = window.setTimeout( - () => setEditingSessionId(crypto.randomUUID()), - 30 * 60 * 1000, - ); - return () => window.clearTimeout(inactivityTimeout); - }, [currentContent]); - - useEffect(() => { - if ( - !dirty || - saveState === "saving" || - saveState === "conflict" || - failedContent === currentContent - ) - return; - const snapshot = write; - const snapshotContent = currentContent; - const timeout = window.setTimeout(async () => { - setSaveState("saving"); - setSaveError(null); - const result = await saveCatalogueDraftAction({ - recordId, - expectedRevision: revision, - content: snapshot, - editingSessionId, - path, - }); - if (result.ok) { - setRevision(result.revision ?? revision); - setSavedContent(snapshotContent); - setFailedContent(null); - setSaveState("saved"); - if (!result.unchanged) setHasUnpublishedChanges(true); - return; - } - setSaveError(result.error); - setFailedContent(snapshotContent); - setSaveState(result.code === "STALE_DRAFT" ? "conflict" : "error"); - }, 1000); - return () => window.clearTimeout(timeout); - }, [ - currentContent, - dirty, - editingSessionId, - failedContent, - path, - recordId, - revision, - saveState, - write, - ]); +export function CatalogueContentEditor() { + const { editing, setWrite, write } = useCatalogueEditor(); function updateCourse( patch: Partial>, @@ -364,155 +256,11 @@ export function CatalogueContentEditor({ })); } - async function publish() { - const result = await publishDraftAction({ - recordId, - expectedRevision: revision, - editingSessionId, - path, - record: publishedRecord, - }); - if (!result.ok) throw new Error(result.error); - toast.success(result.message); - setEditingSessionId(crypto.randomUUID()); - setIsPublished(true); - setHasUnpublishedChanges(false); - router.refresh(); - } - - async function unpublish() { - const result = await unpublishAction({ - recordId, - editingSessionId, - path, - record: publishedRecord, - }); - if (!result.ok) throw new Error(result.error); - toast.success(result.message); - setEditingSessionId(crypto.randomUUID()); - setIsPublished(false); - router.refresh(); - } - - async function discard() { - const result = await discardDraftAction({ - recordId, - expectedRevision: revision, - editingSessionId, - path, - }); - if (!result.ok) throw new Error(result.error); - toast.success(result.message); - setEditingSessionId(crypto.randomUUID()); - setHasUnpublishedChanges(false); - router.push(`${path}/student-view`); - router.refresh(); - } - const courseLabels = labelsFor("course.details"); const structureLabels = labelsFor("structure.details"); return ( - - - {saveState === "saving" ? ( - - ) : saveState === "error" || saveState === "conflict" ? ( - - ) : ( - - )} - - {saveState === "saving" - ? "Saving..." - : saveState === "conflict" - ? "This draft changed elsewhere" - : saveState === "error" - ? `Unable to save${saveError ? `: ${saveError}` : ""}` - : "Saved"} - - {saveState === "conflict" ? ( - window.location.reload()} - > - Reload - - ) : null} - - - {isPublished ? ( - - Unpublish - - } - /> - ) : null} - - Discard draft - - } - /> - - Publish - - } - /> - - - {write.course ? ( <> @@ -520,6 +268,7 @@ export function CatalogueContentEditor({ idPrefix="course-details" value={write.course.details as unknown as Row} labels={courseLabels} + readOnly={!editing} readOnlyKeys={["subjectCode", "level"]} onChange={(details) => updateCourse({ @@ -530,43 +279,55 @@ export function CatalogueContentEditor({ } /> - - - updateCourse({ - offering: offering as NonNullable< - CatalogueContent["course"] - >["offering"], - }) - } - /> - - {COURSE_COLLECTIONS.map(({ key, template }) => ( - - updateCourse({ [key]: rows } as never)} + {!editing && + !Object.values(write.course.offering ?? {}).some( + (value) => value !== null && value !== "", + ) ? null : ( + + + updateCourse({ + offering: offering as NonNullable< + CatalogueContent["course"] + >["offering"], + }) + } /> - ))} + )} + {COURSE_COLLECTIONS.map(({ key, template }) => + // A collection nobody filled in is part of the form, not part of + // the record, so reading one leaves it out entirely. + !editing && (write.course![key] as Row[]).length === 0 ? null : ( + + updateCourse({ [key]: rows } as never)} + /> + + ), + )} {COURSE_RULES.map((ruleKey) => ( updateRule(ruleKey, tree, sourceText) @@ -583,6 +344,7 @@ export function CatalogueContentEditor({ idPrefix="structure-details" value={write.structure.details as unknown as Row} labels={structureLabels} + readOnly={!editing} onChange={(details) => updateStructure({ details: details as unknown as NonNullable< @@ -592,23 +354,27 @@ export function CatalogueContentEditor({ } /> - {STRUCTURE_COLLECTIONS.map(({ key, template }) => ( - - updateStructure({ [key]: rows } as never)} - /> - - ))} + {STRUCTURE_COLLECTIONS.map(({ key, template }) => + !editing && (write.structure![key] as Row[]).length === 0 ? null : ( + + updateStructure({ [key]: rows } as never)} + /> + + ), + )} updateRule("structure", tree, sourceText) @@ -624,10 +390,12 @@ function RuleSection({ ruleKey, requirements, onChange, + readOnly = false, }: { ruleKey: RequirementRuleKind; requirements: CatalogueContent["requirements"]; onChange: (tree: ReviewedRuleTree | null, sourceText: string) => void; + readOnly?: boolean; }) { const rule = requirements.rules.find( (candidate) => candidate.key === ruleKey, @@ -641,6 +409,7 @@ function RuleSection({ const conditionCount = requirements.conditions.filter( (condition) => condition.ruleKey === ruleKey, ).length; + if (readOnly && !rule && conditionCount === 0 && !sourceText) return null; return ( { setSourceText(event.target.value); @@ -666,7 +436,7 @@ function RuleSection({ {editable ? ( onChange(next, sourceText)} /> @@ -679,7 +449,7 @@ function RuleSection({ > )} - {rule && tree ? ( + {rule && tree && !readOnly ? ( (null); + const busy = syncing || record.sourceState === "syncing"; + const kindLabel = labels.singular.toLowerCase(); + const recordPath = adminCatalogueRecordPath(kind, academicYear, record.code); + const publishedRecord = { kind, academicYear, code: record.code }; + // A row acts on the draft the list last read. Publishing or discarding a + // revision that has since moved on is refused by the action rather than + // overwriting whoever is editing it in another tab. An opened draft can + // always be discarded; only one holding a change can be published. + const discardable = + record.hasDraft && + record.recordId !== null && + record.draftRevision !== null; + const publishable = discardable && record.hasChanges; + const unpublishable = record.isPublished && record.recordId !== null; + + async function startSync() { + if (record.recordId === null) { + toast.error("Refresh the ANU listing before syncing this record."); + return; + } + setSyncing(true); + try { + const response = await fetch("/api/admin/catalogue-syncs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ recordId: record.recordId, kind }), + }); + const result = (await response.json()) as { error?: string }; + if (!response.ok) { + toast.error(result.error ?? "The ANU sync could not start."); + return; + } + toast.success(`Syncing ${record.code} from ANU...`); + router.refresh(); + } catch { + toast.error("The ANU sync could not start."); + } finally { + setSyncing(false); + } + } + + async function runDraftAction(action: DraftAction) { + const recordId = record.recordId as number; + const editingSessionId = crypto.randomUUID(); + let result: DraftActionResult; + if (action === "publish") + result = await publishDraftAction({ + recordId, + expectedRevision: record.draftRevision as number, + editingSessionId, + path: recordPath, + record: publishedRecord, + }); + else if (action === "discard") + result = await discardDraftAction({ + recordId, + expectedRevision: record.draftRevision as number, + editingSessionId, + path: recordPath, + }); + else + result = await unpublishAction({ + recordId, + editingSessionId, + path: recordPath, + record: publishedRecord, + }); + if (result.ok) toast.success(result.message ?? "Done."); + else toast.error(result.error); + // Even a refusal refreshes: the row is out of date either way, and the + // menu it offers has to match what the record now is. + router.refresh(); + } + + const confirmations: Record< + DraftAction, + { title: string; description: string; confirmLabel: string } + > = { + publish: { + title: `Publish ${record.code}?`, + description: `The saved draft becomes the version students see for ${academicYear}.`, + confirmLabel: "Publish", + }, + discard: { + title: `Discard the draft of ${record.code}?`, + description: record.isPublished + ? `The ${kindLabel} goes back to its published version. A restorable checkpoint of the draft is kept in the changelog.` + : `The ${kindLabel} goes back to being unpublished, with nothing drafted. A restorable checkpoint of the draft is kept in the changelog.`, + confirmLabel: "Discard draft", + }, + unpublish: { + title: `Unpublish ${record.code}?`, + description: `Students will no longer see this ${kindLabel} for ${academicYear}. Versions and draft work are retained.`, + confirmLabel: "Unpublish", + }, + }; + + return ( + <> + {confirming ? ( + { + if (!open) setConfirming(null); + }} + destructive={confirming !== "publish"} + title={confirmations[confirming].title} + description={confirmations[confirming].description} + confirmLabel={confirmations[confirming].confirmLabel} + onConfirm={() => runDraftAction(confirming)} + /> + ) : null} + , + onSelect: () => { + if (!busy) void startSync(); + }, + }, + ...(publishable + ? [ + { + label: "Publish draft", + icon: , + onSelect: () => setConfirming("publish"), + }, + ] + : []), + ...(discardable + ? [ + { + label: "Discard draft", + icon: , + onSelect: () => setConfirming("discard"), + }, + ] + : []), + ...(unpublishable + ? [ + { + label: "Unpublish", + icon: , + onSelect: () => setConfirming("unpublish"), + }, + ] + : []), + ]} + /> + > + ); +} diff --git a/apps/web/ui/admin/catalogue/record-header.tsx b/apps/web/ui/admin/catalogue/record-header.tsx index 92b30637..0b0a8dc0 100644 --- a/apps/web/ui/admin/catalogue/record-header.tsx +++ b/apps/web/ui/admin/catalogue/record-header.tsx @@ -53,43 +53,34 @@ export function RecordHeader({ {publicationLabel} - {record.title} - - {record.isListedByAnu === false ? ( - - - No longer listed by ANU - {record.lastSeenAt - ? `. Last seen in the ANU catalogue on ${formatDate(record.lastSeenAt)}.` - : "."} - - ) : record.isListedByAnu ? ( - Listed by ANU - ) : ( - - No ANU listing information - - )} + {/* + Being listed by ANU is the resting state of every record here, so + saying so on each one said nothing. Only the delisting is worth a + line, and the source link belongs beside the title it is a link to. + */} + + {record.title} View on ANU - {canSync && record.syncs[0] ? ( - - Sync diagnostics - - ) : null} + {record.isListedByAnu === false ? ( + + + No longer listed by ANU + {record.lastSeenAt + ? `. Last seen in the ANU catalogue on ${formatDate(record.lastSeenAt)}.` + : "."} + + ) : null} {openChangeCount > 0 ? ( Checked ANU. No changes found. + {canSync ? ( + <> + {" "} + + Sync diagnostics + + > + ) : null} ) : record.syncs[0]?.status === "failed" ? ( @@ -124,8 +126,10 @@ export function RecordHeader({ {canSync ? ( ) : null} diff --git a/apps/web/ui/admin/catalogue/record-page.tsx b/apps/web/ui/admin/catalogue/record-page.tsx index 7d73dc16..a88762b8 100644 --- a/apps/web/ui/admin/catalogue/record-page.tsx +++ b/apps/web/ui/admin/catalogue/record-page.tsx @@ -3,12 +3,8 @@ import { TabsContent } from "@coursemap/ui/primitives/tabs"; import { canManageCatalogueOperations, canWriteCatalogue, - getAuthViewer, } from "@/lib/auth/viewer"; -import { - createCatalogueDraft, - loadCatalogueDraft, -} from "@/lib/catalogue/drafts"; +import { loadCatalogueEditorState } from "@/lib/catalogue/drafts"; import { diffSnapshotWrites } from "@/lib/catalogue-import/changes"; import { contentHashForCatalogueContent } from "@/lib/catalogue-import/version-content"; import { loadSourceReview } from "@/lib/catalogue/source-review-store"; @@ -21,6 +17,7 @@ import { } from "@/lib/coursemap/admin-catalogue-record"; import { courseDetailsFromWrite } from "@/lib/coursemap/course-version-view"; import { + ADMIN_CATALOGUE_OPERATIONS_PATH, CATALOGUE_KIND_LABELS, type CatalogueKind, adminCatalogueRecordPath, @@ -32,6 +29,8 @@ import { ChangelogTimeline } from "./changelog/changelog-timeline"; import { RecordHeader } from "./record-header"; import { StudentViewPanel } from "./student-view-panel"; import { RecordTabList, RecordTabs, type RecordSection } from "./record-tabs"; +import { CatalogueEditorProvider } from "./catalogue-editor-context"; +import { CatalogueEditorToolbar } from "./catalogue-editor-toolbar"; import { CatalogueContentEditor } from "./content-editor"; function FoundationEmpty({ @@ -81,14 +80,12 @@ export async function CatalogueRecordPage({ const labels = CATALOGUE_KIND_LABELS[kind]; const path = adminCatalogueRecordPath(kind, academicYear, record.code); - const viewer = canWrite ? await getAuthViewer() : null; - const draft = - section === "content" && viewer - ? await createCatalogueDraft({ - recordId: record.recordId, - userId: viewer.id, - }) - : await loadCatalogueDraft(record.recordId); + // Reading a record must never be what makes it a draft, so the editor is + // given the content it would start from - the publication, or an empty + // record - and the draft row is created by asking to edit it. + const { draft, hasDraft, hasChanges } = await loadCatalogueEditorState( + record.recordId, + ); const [studentContent, studentCourse] = await Promise.all([ record.publishedVersionId ? loadVersionWrite(record.publishedVersionId) @@ -98,25 +95,19 @@ export async function CatalogueRecordPage({ : null, ]); const hasUnpublishedChanges = Boolean( - draft && + hasChanges && (!studentContent || draft.contentHash !== contentHashForCatalogueContent(studentContent)), ); - const draftPreview = draft - ? { - course: - kind === "course" ? courseDetailsFromWrite(draft.content) : null, - content: kind === "course" ? null : draft.content, - } - : null; + const draftPreview = { + course: kind === "course" ? courseDetailsFromWrite(draft.content) : null, + content: kind === "course" ? null : draft.content, + }; const publishedPreview = studentContent ? { course: studentCourse, content: studentCourse ? null : studentContent } : null; - const review = await loadSourceReview( - record.recordId, - draft?.content ?? null, - ); - const unpublished = draft + const review = await loadSourceReview(record.recordId, draft.content); + const unpublished = hasChanges ? diffSnapshotWrites(studentContent, draft.content) : []; const changelog = await loadCatalogueChangelog({ @@ -142,60 +133,84 @@ export async function CatalogueRecordPage({ }} tabs={} > - - - - {draft && canWrite ? ( - + + {/* + The toolbar reports the record's state, so it leads the page + rather than the fields. It appears only where it can act: the + other tabs read the record and do not change it. + */} + {canWrite && section === "content" ? ( + + ) : null} + + + {canWrite ? ( + + ) : ( + + )} + + + + + + 0} + isPublished={record.publishedVersionId !== null} + kindLabel={labels.singular.toLowerCase()} + latestSync={ + canManageImports && record.syncs[0] + ? { + id: record.syncs[0].id, + completedAt: record.syncs[0].completedAt, + } + : null + } path={path} + recordId={record.recordId} + review={review} + unpublished={unpublished} /> - ) : ( - + + - )} - - - - - - 0} - isPublished={record.publishedVersionId !== null} - kindLabel={labels.singular.toLowerCase()} - path={path} - recordId={record.recordId} - review={review} - unpublished={unpublished} - /> - - - - - + + + ); diff --git a/apps/web/ui/admin/catalogue/record-tabs.tsx b/apps/web/ui/admin/catalogue/record-tabs.tsx index b19f616a..afdf99a4 100644 --- a/apps/web/ui/admin/catalogue/record-tabs.tsx +++ b/apps/web/ui/admin/catalogue/record-tabs.tsx @@ -4,6 +4,7 @@ import { Tabs } from "@coursemap/ui/primitives/tabs"; import { useRouter } from "next/navigation"; import type { ReactNode } from "react"; import { SectionTabs } from "@/ui/common/section-tabs"; +import { routeIcons } from "@/ui/shell/route-icons"; export type RecordSection = "content" | "student-view" | "changes" | "changelog"; @@ -36,10 +37,19 @@ export function RecordTabList({ changeCount = 0 }: { changeCount?: number }) { ); diff --git a/apps/web/ui/admin/catalogue/section-editor.tsx b/apps/web/ui/admin/catalogue/section-editor.tsx index 0dc98d79..f2264618 100644 --- a/apps/web/ui/admin/catalogue/section-editor.tsx +++ b/apps/web/ui/admin/catalogue/section-editor.tsx @@ -38,6 +38,22 @@ function parseScalar(previous: Scalar, raw: string): Scalar { return raw; } +/** + * A value nobody is being invited to change: the label and what it says. Used + * for fields the record owns rather than the author, and for every field while + * a record is being read rather than edited. + */ +function ReadOnlyField({ label, value }: { label: string; value: Scalar }) { + return ( + + {label} + + {value === null || value === "" ? "\u2014" : String(value)} + + + ); +} + /** One typed input for a scalar value, with null rendered as empty. */ export function ScalarField({ id, @@ -45,13 +61,16 @@ export function ScalarField({ value, onChange, long = false, + readOnly = false, }: { id: string; label: string; value: Scalar; onChange: (value: Scalar) => void; long?: boolean; + readOnly?: boolean; }) { + if (readOnly) return ; if ( typeof value === "boolean" || (value === null && /^(can|is|has|hurdle)/.test(label)) @@ -111,29 +130,31 @@ export function DetailsEditor({ onChange, labels = {}, readOnlyKeys = [], + readOnly = false, }: { idPrefix: string; value: Row; onChange: (value: Row) => void; labels?: Record; readOnlyKeys?: string[]; + /** Reads the whole form rather than offering it for editing. */ + readOnly?: boolean; }) { + // Reading a record should show what it says, not the shape of the form it + // was entered through. A page of labels above em dashes told a reader + // nothing, so a read of the record carries only the fields that were + // filled in. + const entries = Object.entries(value).filter( + ([, fieldValue]) => !readOnly || (fieldValue !== null && fieldValue !== ""), + ); + if (readOnly && entries.length === 0) + return ( + Nothing recorded yet. + ); return ( - - {Object.entries(value).map(([key, fieldValue]) => { + + {entries.map(([key, fieldValue]) => { const long = LONG_TEXT_KEYS.has(key); - if (readOnlyKeys.includes(key)) { - return ( - - - {labels[key] ?? humanise(key)} - - - {fieldValue === null ? "—" : String(fieldValue)} - - - ); - } return ( onChange({ ...value, [key]: next })} /> @@ -161,6 +183,7 @@ export function RowsEditor({ template, hiddenKeys = ["position"], emptyLabel, + readOnly = false, }: { idPrefix: string; rows: Row[]; @@ -168,6 +191,8 @@ export function RowsEditor({ template: Row; hiddenKeys?: string[]; emptyLabel: string; + /** Lists the rows as they stand, without add, remove or entry. */ + readOnly?: boolean; }) { const shape = rows[0] ?? template; const keys = Object.keys(shape).filter((key) => !hiddenKeys.includes(key)); @@ -185,21 +210,23 @@ export function RowsEditor({ Item {index + 1} - - onChange( - renumber( - rows.filter((_, candidate) => candidate !== index), - ), - ) - } - > - - + {readOnly ? null : ( + + onChange( + renumber( + rows.filter((_, candidate) => candidate !== index), + ), + ) + } + > + + + )} {keys.map((key) => { @@ -211,6 +238,7 @@ export function RowsEditor({ label={humanise(key)} value={row[key] ?? null} long={long} + readOnly={readOnly} onChange={(next) => onChange( rows.map((candidate, at) => @@ -228,34 +256,36 @@ export function RowsEditor({ ))} - - onChange( - renumber([ - ...rows, - Object.fromEntries( - Object.entries(shape).map(([key, sample]) => [ - key, - key === "position" - ? rows.length + 1 - : typeof sample === "number" - ? null - : typeof sample === "boolean" + {readOnly ? null : ( + + onChange( + renumber([ + ...rows, + Object.fromEntries( + Object.entries(shape).map(([key, sample]) => [ + key, + key === "position" + ? rows.length + 1 + : typeof sample === "number" ? null - : "", - ]), - ) as Row, - ]), - ) - } - > - - Add item - + : typeof sample === "boolean" + ? null + : "", + ]), + ) as Row, + ]), + ) + } + > + + Add item + + )} ); } diff --git a/apps/web/ui/admin/catalogue/student-view-panel.tsx b/apps/web/ui/admin/catalogue/student-view-panel.tsx index 8730335a..480951a8 100644 --- a/apps/web/ui/admin/catalogue/student-view-panel.tsx +++ b/apps/web/ui/admin/catalogue/student-view-panel.tsx @@ -48,19 +48,7 @@ export function StudentViewPanel({ ); } - if (!draft || !published) { - const only = draft ?? published!; - return ( - - {draft ? ( - - {`This ${kindLabel} hasn't been published yet. Students see nothing until you publish.`} - - ) : null} - - - ); - } + if (!draft || !published) return ; return ( = { + queued: { percent: 12, ceiling: 45, detail: "Waiting for a worker." }, + running: { percent: 50, ceiling: 88, detail: "Reading the ANU page." }, +}; + +const SYNC_OUTCOMES = { + applied: { + title: "ANU changes applied", + detail: "The record is up to date.", + }, + review_required: { + title: "ANU changes need review", + detail: "Open the changes to accept or reject them.", + }, + unchanged: { + title: "No ANU changes", + detail: "ANU has not changed this record since the last sync.", + }, +} as const; export function CatalogueSyncButton({ recordId, + code, kind, latestSync, + hasSynced, }: { recordId: number; + code: string; kind: CatalogueKind; latestSync: CatalogueSync | null; + /** Whether ANU has ever been read for this record, which names the action. */ + hasSynced: boolean; }) { const router = useRouter(); const [isPending, startTransition] = useTransition(); const [startedSyncId, setStartedSyncId] = useState(null); + const task = useRef(null); + const reportedStatus = useRef(null); const awaitingStartedSync = startedSyncId !== null && latestSync?.id !== startedSyncId; const isActive = @@ -33,7 +69,19 @@ export function CatalogueSyncButton({ return () => window.clearInterval(timer); }, [isActive, router]); - function startSync() { + // The retry offered by a failed sync restarts this same handler, so it is + // reached through a ref rather than the handler referring to itself. + const startSyncRef = useRef<() => void>(undefined); + const retrySync = useCallback(() => startSyncRef.current?.(), []); + + const startSync = useCallback(() => { + reportedStatus.current = null; + task.current = startTask({ + id: `sync:${recordId}`, + title: `Syncing ${code} from ANU`, + detail: "Asking ANU for the latest version.", + ceiling: 12, + }); startTransition(async () => { const response = await fetch("/api/admin/catalogue-syncs", { method: "POST", @@ -44,19 +92,68 @@ export function CatalogueSyncButton({ error?: string; syncId?: string; }; - if (!response.ok) { - toast.error(result.error ?? "The ANU sync could not start."); - return; - } - if (!result.syncId) { - toast.error("The ANU sync did not return an identifier."); + if (!response.ok || !result.syncId) { + task.current?.fail({ + title: `Syncing ${code} from ANU could not start`, + detail: result.error ?? "The sync did not return an identifier.", + retry: retrySync, + }); + task.current = null; return; } + task.current?.step(SYNC_PROGRESS.queued); setStartedSyncId(result.syncId); - toast.success("Syncing from ANU..."); router.refresh(); }); - } + }, [code, kind, recordId, retrySync, router]); + + useEffect(() => { + startSyncRef.current = startSync; + }, [startSync]); + + // The sync runs on the server and this button is the only thing watching it. + // Leaving the page stops the poll, so the toast is handed back rather than + // left spinning at whatever percentage it had reached. + useEffect( + () => () => + task.current?.abandon({ + title: "The ANU sync is still running", + detail: "Open the record again to see how it finished.", + }), + [], + ); + + // Only a sync started from this button owns a toast; a scheduled one running + // in the background should not interrupt whoever opened the page. + useEffect(() => { + if (!startedSyncId || latestSync?.id !== startedSyncId) return; + const status = latestSync.status; + if (status === reportedStatus.current) return; + reportedStatus.current = status; + const running = SYNC_PROGRESS[status]; + if (running) { + task.current?.step(running); + return; + } + if (status === "failed") { + task.current?.fail({ + title: `Syncing ${code} from ANU failed`, + detail: latestSync.errorMessage ?? "The sync did not finish.", + retry: retrySync, + }); + } else if (status === "cancelled") { + task.current?.note({ + title: `Syncing ${code} from ANU was cancelled`, + }); + } else { + const outcome = SYNC_OUTCOMES[status as keyof typeof SYNC_OUTCOMES]; + task.current?.done({ + title: outcome?.title ?? `${code} synced from ANU`, + detail: outcome?.detail, + }); + } + task.current = null; + }, [code, latestSync, retrySync, startedSyncId]); return ( - - {isActive - ? "Syncing from ANU..." - : latestSync?.status === "failed" - ? "Retry sync" - : "Sync from ANU"} + {isActive ? ( + + ) : ( + + )} + {latestSync?.status === "failed" && !isActive + ? "Retry sync" + : hasSynced + ? "Resync" + : "Sync"} ); } diff --git a/apps/web/ui/admin/imports/import-model-card.tsx b/apps/web/ui/admin/imports/import-model-card.tsx index 556fbc7d..deaa41b8 100644 --- a/apps/web/ui/admin/imports/import-model-card.tsx +++ b/apps/web/ui/admin/imports/import-model-card.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { ChevronsUpDown, Cpu, Plus, Settings2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@coursemap/ui/primitives/button"; +import { Card } from "@coursemap/ui/primitives/card"; import { DropdownMenu, DropdownMenuContent, @@ -60,20 +61,30 @@ export function ImportModelCard({ }); } return ( - - - - - Default import model - - {updatedAt ? ( - - Updated {dateFormatter.format(new Date(updatedAt))} - - ) : null} + + + + Default import model + + {updatedAt ? ( + + Updated {dateFormatter.format(new Date(updatedAt))} + + ) : null} + + + + @@ -120,7 +131,10 @@ export function ImportModelCard({ "h-14 gap-3 py-2.5", entry.id === model ? "bg-primary/10 text-primary data-highlighted:bg-primary/10 data-highlighted:text-primary" - : "data-highlighted:bg-accent data-highlighted:text-foreground", + : // --accent is mixed against the page, so on the lighter + // popover surface it disappears in dark. Mix from the + // foreground, which reads on either ground. + "data-highlighted:bg-foreground/8 data-highlighted:text-foreground", )} aria-label={`${entry.name}, ${entry.provider}${entry.id === model ? ", selected" : ""}`} > @@ -178,6 +192,6 @@ export function ImportModelCard({ selected={model} /> ) : null} - + ); } diff --git a/apps/web/ui/admin/operations/artefact-data.ts b/apps/web/ui/admin/operations/artefact-data.ts index 1ec2c3cb..01a60ecf 100644 --- a/apps/web/ui/admin/operations/artefact-data.ts +++ b/apps/web/ui/admin/operations/artefact-data.ts @@ -6,6 +6,26 @@ export type SyncArtefactSummary = { mediaType: string; }; +/** + * What each artefact actually is. The names are the pipeline's own vocabulary, + * which means nothing to a reader who has not written the pipeline, so every + * tab carries the one sentence that places it in the run. + */ +export const syncArtefactDescriptions: Record = { + raw_html: "The ANU page exactly as it was fetched, before anything read it.", + normalised_markdown: + "That page reduced to the plain text the extraction works from.", + model_input: "The markdown and instructions assembled for the model to read.", + deterministic_output: + "What rules alone could read off the page, without asking the model.", + model_request: "The request sent to the model, with the settings it ran on.", + model_response: "What the model returned, before anything checked it.", + validated_json: "The model's answer once it passed the schema.", + validation_report: "Every schema and domain check, and which ones failed.", + content_projection: + "The validated answer mapped onto this record's own fields.", +}; + export const syncArtefactLabels: Record = { raw_html: "Raw HTML", normalised_markdown: "Markdown", diff --git a/apps/web/ui/admin/operations/artefact-viewer.tsx b/apps/web/ui/admin/operations/artefact-viewer.tsx index 690a4bf2..2b2fce19 100644 --- a/apps/web/ui/admin/operations/artefact-viewer.tsx +++ b/apps/web/ui/admin/operations/artefact-viewer.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useState } from "react"; -import { LoaderCircle } from "lucide-react"; +import { CircleQuestionMark, LoaderCircle } from "lucide-react"; import { Alert, AlertDescription } from "@coursemap/ui/components/alert"; import { Button } from "@coursemap/ui/primitives/button"; import { @@ -10,11 +10,13 @@ import { TabsList, TabsTrigger, } from "@coursemap/ui/primitives/tabs"; +import { Hint } from "@/ui/common/hint"; import { OptionPicker } from "@/ui/common/option-picker"; import { JsonCode } from "@/ui/common/json-code"; import { ArtefactViewport } from "./artefact-viewport"; import { groupSyncArtefactSummarys, + syncArtefactDescriptions, syncArtefactLabels, parseSyncArtefactSummary, type SyncArtefactSummary, @@ -87,16 +89,40 @@ export function ArtefactViewer({ aria-label="Sync artefacts" className={`${navigationStyles.list} hidden h-auto w-full items-stretch gap-1 bg-transparent p-0 md:flex`} > - {grouped.map((entry) => ( - - {syncArtefactLabels[entry.kind] ?? - entry.kind.replaceAll("_", " ")} - - ))} + {grouped.map((entry) => { + const description = syncArtefactDescriptions[entry.kind]; + const trigger = ( + + {syncArtefactLabels[entry.kind] ?? + entry.kind.replaceAll("_", " ")} + {description ? ( + + ) : null} + + ); + // The tab itself carries the explanation, so the mark beside the + // name stays a mark rather than a second control to reach. + return description ? ( + + {trigger} + + ) : ( + trigger + ); + })} - {check.kind} listing + {CATALOGUE_KIND_LABELS[check.kind]?.plural ?? check.kind} listing {check.academicYear} ) : null} - - {[ - { label: "Discovered", value: String(check.discoveredCount) }, - { label: "Currently listed", value: String(check.listedCount) }, - { label: "No longer listed", value: String(check.retiredCount) }, - { label: "Started", value: formatTimestamp(check.startedAt) }, - { label: "Completed", value: formatTimestamp(check.completedAt) }, - { label: "Duration", value: formatDuration(check.durationMs) }, - ].map((item) => ( - - - {item.label} - - {item.value} - - ))} - + + + + - - - Pages read - - {check.sourcePages.length === 0 ? ( - - This check recorded no source pages. - - ) : ( - - {check.sourcePages.map((page) => ( - - {page.canonicalUrl} - - HTTP {page.httpStatus ?? "—"} ·{" "} - {formatTimestamp(page.fetchedAt)} ·{" "} - {page.contentSha256.slice(0, 16)} - - - ))} - - )} - + + {check.sourcePages.length === 0 ? ( + + This check recorded no source pages. + + ) : ( + + {check.sourcePages.map((page) => ( + + {page.canonicalUrl} + + HTTP {page.httpStatus ?? "—"} ·{" "} + {formatTimestamp(page.fetchedAt)} ·{" "} + {page.contentSha256.slice(0, 16)} + + + ))} + + )} + + ); } diff --git a/apps/web/ui/admin/operations/discovery-list.tsx b/apps/web/ui/admin/operations/discovery-list.tsx index 4837a69b..15935b48 100644 --- a/apps/web/ui/admin/operations/discovery-list.tsx +++ b/apps/web/ui/admin/operations/discovery-list.tsx @@ -1,8 +1,13 @@ +"use client"; + +import { Badge } from "@coursemap/ui/components/badge"; +import { useSearchParams } from "next/navigation"; +import { CATALOGUE_KINDS } from "@/lib/catalogue/content"; import { + ADMIN_CATALOGUE_OPERATIONS_PATH, CATALOGUE_KIND_LABELS, adminCatalogueDiscoveryPath, } from "@/lib/coursemap/catalogue-kinds"; -import { Badge } from "@coursemap/ui/components/badge"; import { CatalogueIdentity, DataTableShell, @@ -16,78 +21,158 @@ import { } from "@/ui/admin/catalogue-table/catalogue-table"; import type { DiscoveryCheckRow } from "@/lib/coursemap/admin-operations"; import { CatalogueEmpty } from "@/ui/admin/catalogue-table/catalogue-empty"; +import { FilterBar, type FilterConfig } from "@/ui/common/filter-bar"; import { LinkedTableRow } from "@/ui/common/linked-table-row"; import { formatDuration, formatTimestamp } from "./operations-format"; +const DISCOVERY_PATH = `${ADMIN_CATALOGUE_OPERATIONS_PATH}/discovery`; + +function discoveryFilters(checks: DiscoveryCheckRow[]): FilterConfig[] { + const years = [...new Set(checks.map((check) => check.academicYear))].sort( + (left, right) => right - left, + ); + return [ + { + key: "kind", + label: "Listing", + options: CATALOGUE_KINDS.map((kind) => ({ + value: kind, + label: CATALOGUE_KIND_LABELS[kind].plural, + })), + }, + { + key: "year", + label: "Year", + options: years.map((year) => ({ + value: String(year), + label: String(year), + })), + }, + { + key: "status", + label: "Status", + options: [ + { value: "running", label: "Running" }, + { value: "completed", label: "Completed" }, + { value: "failed", label: "Failed" }, + ], + }, + { + key: "complete", + label: "Completeness", + options: [ + { value: "complete", label: "Complete" }, + { value: "partial", label: "Partial" }, + ], + }, + ]; +} + /** * ANU listing checks. An incomplete check is why a record can be missing from * the directory without anything having been retired. */ export function DiscoveryList({ checks }: { checks: DiscoveryCheckRow[] }) { - if (checks.length === 0) { + const searchParams = useSearchParams(); + const query = (searchParams.get("q") ?? "").trim().toLocaleLowerCase(); + const kind = searchParams.get("kind") ?? ""; + const year = searchParams.get("year") ?? ""; + const status = searchParams.get("status") ?? ""; + const completeness = searchParams.get("complete") ?? ""; + const filtered = Boolean(query || kind || year || status || completeness); + const visibleChecks = checks.filter((check) => { + const labels = CATALOGUE_KIND_LABELS[check.kind]; + const matchesQuery = + !query || + [ + check.kind, + labels.singular, + labels.plural, + String(check.academicYear), + check.status, + check.isComplete ? "complete" : "partial", + ].some((value) => value.toLocaleLowerCase().includes(query)); return ( - + matchesQuery && + (!kind || check.kind === kind) && + (!year || String(check.academicYear) === year) && + (!status || check.status === status) && + (!completeness || + completeness === (check.isComplete ? "complete" : "partial")) ); - } + }); + return ( - - - ANU listing checks - - - Listing - Year - Status - Complete - Discovered - Started - Duration - - - - {checks.map((check) => ( - - - - - {check.academicYear} - - - {check.status} - - - - {check.isComplete ? ( - "Complete" - ) : ( - - Partial - - )} - - {check.discoveredCount} - {formatTimestamp(check.startedAt)} - {formatDuration(check.durationMs)} - - ))} - - - + + + {visibleChecks.length === 0 ? ( + + ) : ( + + + ANU listing checks + + + Listing + Year + Status + Complete + Discovered + Started + Duration + + + + {visibleChecks.map((check) => ( + + + + + {check.academicYear} + + + {check.status} + + + + {check.isComplete ? ( + "Complete" + ) : ( + + Partial + + )} + + {check.discoveredCount} + {formatTimestamp(check.startedAt)} + {formatDuration(check.durationMs)} + + ))} + + + + )} + ); } diff --git a/apps/web/ui/admin/operations/operations-error.tsx b/apps/web/ui/admin/operations/operations-error.tsx new file mode 100644 index 00000000..0576b244 --- /dev/null +++ b/apps/web/ui/admin/operations/operations-error.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Button } from "@coursemap/ui/primitives/button"; +import Link from "next/link"; +import { ErrorState } from "@/ui/common/error-state"; +import { AppShell } from "@/ui/shell"; + +export function CatalogueOperationsError({ + error, + reset, +}: { + error?: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + + + Try again + + + Back to overview + + + + ); +} diff --git a/apps/web/ui/admin/operations/operations-layout.tsx b/apps/web/ui/admin/operations/operations-layout.tsx new file mode 100644 index 00000000..f0b17ed9 --- /dev/null +++ b/apps/web/ui/admin/operations/operations-layout.tsx @@ -0,0 +1,66 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@coursemap/ui/primitives/card"; +import type { ReactNode } from "react"; + +/** + * The shared furniture of an operations detail page. Syncs and discovery + * checks answer the same kinds of question - what ran, against what, and what + * came back - so they are read in the same shapes rather than each inventing + * its own. + */ + +/** + * Diagnostics are read, not scanned: long fact grids and highlighted source + * become unreadable when a wide screen stretches them edge to edge. Tables and + * artefacts scroll inside this measure rather than widening past it. + */ +export function Measure({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function OperationsSection({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( + + + + {title} + + + {children} + + ); +} + +/** A flat set of recorded values, labelled and wrapped to the page. */ +export function Facts({ + items, +}: { + items: Array<{ label: string; value: string | null }>; +}) { + return ( + + {items.map((item) => ( + + + {item.label} + + {item.value ?? "—"} + + ))} + + ); +} diff --git a/apps/web/ui/admin/operations/operations-pages.tsx b/apps/web/ui/admin/operations/operations-pages.tsx index 81487a5a..e11e1959 100644 --- a/apps/web/ui/admin/operations/operations-pages.tsx +++ b/apps/web/ui/admin/operations/operations-pages.tsx @@ -18,6 +18,7 @@ import { type OperationsSection, } from "./operations-tabs"; import { SyncDetailView } from "./sync-detail"; +import { SyncDetailTabList, SyncDetailTabs } from "./sync-detail-tabs"; import { SyncList } from "./sync-list"; function first(value: string | string[] | undefined) { @@ -52,11 +53,13 @@ export async function CatalogueOperationsPage({ } > - Catalogue operations + Catalogue activity - - + + stage.status === "failed").length + } + /> + } + > + + + ); } @@ -123,7 +138,7 @@ export async function CatalogueDiscoveryDetailPage({ ); diff --git a/apps/web/ui/admin/operations/sync-detail-tabs.tsx b/apps/web/ui/admin/operations/sync-detail-tabs.tsx new file mode 100644 index 00000000..8b9b0928 --- /dev/null +++ b/apps/web/ui/admin/operations/sync-detail-tabs.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Tabs } from "@coursemap/ui/primitives/tabs"; +import { FileCode2, Info, ListChecks, Sparkles } from "lucide-react"; +import { useState, type ReactNode } from "react"; +import { SectionTabs } from "@/ui/common/section-tabs"; + +export type SyncDetailSection = + "overview" | "stages" | "extractions" | "artefacts"; + +/** + * One sync's diagnostics, split by the question being asked of it: what it + * was, where it stopped, what the model cost, and what it captured. The whole + * record used to be a single scroll, so the failing stage sat below several + * screens of contract versions and the artefact viewer never had the page to + * itself. + * + * The section is held here rather than in the URL: it is a place to look + * while reading one sync, not a page worth linking to on its own. + */ +export function SyncDetailTabs({ children }: { children: ReactNode }) { + const [section, setSection] = useState("overview"); + return ( + setSection(next as SyncDetailSection)} + > + {children} + + ); +} + +export function SyncDetailTabList({ + stageCount, + extractionCount, + artefactCount, + failedStageCount, +}: { + stageCount: number; + extractionCount: number; + artefactCount: number; + failedStageCount: number; +}) { + return ( + + ); +} diff --git a/apps/web/ui/admin/operations/sync-detail.tsx b/apps/web/ui/admin/operations/sync-detail.tsx index 16caf4af..607392d0 100644 --- a/apps/web/ui/admin/operations/sync-detail.tsx +++ b/apps/web/ui/admin/operations/sync-detail.tsx @@ -19,10 +19,16 @@ import { TableHeader, TableRow, } from "@coursemap/ui/primitives/table"; +import { TabsContent } from "@coursemap/ui/primitives/tabs"; import { badgeVariantForTone } from "@/lib/ui"; import type { SyncDetail } from "@/lib/coursemap/admin-operations"; import { DataTableShell } from "@/ui/common/data-table"; import { ArtefactViewer } from "./artefact-viewer"; +import { + Facts, + Measure, + OperationsSection as Section, +} from "./operations-layout"; import { formatBytes, formatCost, @@ -45,40 +51,6 @@ const STAGE_LABELS: Record = { source_version_persist: "Source version", }; -function Facts({ - items, -}: { - items: Array<{ label: string; value: string | null }>; -}) { - return ( - - {items.map((item) => ( - - - {item.label} - - {item.value ?? "—"} - - ))} - - ); -} - -function Section({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) { - return ( - - {title} - {children} - - ); -} - /** Everything one sync recorded, for a developer diagnosing or retrying it. */ export function SyncDetailView({ sync }: { sync: SyncDetail }) { const recordPath = adminCatalogueRecordPath( @@ -126,212 +98,247 @@ export function SyncDetailView({ sync }: { sync: SyncDetail }) { ) : null} - - - + + + + + - - - + + + - {sync.sourceDocument ? ( - - - - ) : null} + {sync.sourceDocument ? ( + + + + ) : null} + + - - {sync.stages.length === 0 ? ( - - This sync recorded no stages. - - ) : ( - - - Sync stages - - - Stage - Attempt - Status - Started - Duration - Error - - - - {sync.stages.map((stage) => ( - - - {STAGE_LABELS[stage.stageName] ?? stage.stageName} - - {stage.attemptNumber} - - - {stage.status} - - - {formatTimestamp(stage.startedAt)} - {formatDuration(stage.durationMs)} - - {stage.errorSummary ?? stage.errorCode ?? "—"} - - - ))} - - - - )} - + + + + {sync.stages.length === 0 ? ( + + This sync recorded no stages. + + ) : ( + + + Sync stages + + + Stage + Attempt + Status + Started + Duration + Error + + + + {sync.stages.map((stage) => ( + + + {STAGE_LABELS[stage.stageName] ?? stage.stageName} + + {stage.attemptNumber} + + + {stage.status} + + + + {formatTimestamp(stage.startedAt)} + + + {formatDuration(stage.durationMs)} + + + {stage.errorSummary ?? stage.errorCode ?? "—"} + + + ))} + + + + )} + + + - {sync.extractions.length > 0 ? ( - - - - Model extractions - - - # - Model - Validation - Tokens in - Tokens out - Latency - Cost - - - - {sync.extractions.map((extraction) => ( - - {extraction.extractionNumber} - - {extraction.resolvedModel ?? extraction.requestedModel} - {extraction.reusedFromExtractionId ? ( - - reused - - ) : null} - - - - {extraction.validationStatus} - - {extraction.errorCount > 0 ? ( - - {extraction.errorCount} errors - - ) : extraction.warningCount > 0 ? ( - - {extraction.warningCount} warnings - - ) : null} - - - {extraction.inputTokens} - {extraction.cachedInputTokens > 0 - ? ` (${extraction.cachedInputTokens} cached)` - : ""} - - - {extraction.outputTokens} - {extraction.reasoningTokens > 0 - ? ` (${extraction.reasoningTokens} reasoning)` - : ""} - - - {formatDuration(extraction.latencyMs)} - - {formatCost(extraction.costUsd)} - - ))} - - - - - ) : null} + + + {sync.extractions.length > 0 ? ( + + + + + Model extractions + + + + # + Model + Validation + Tokens in + Tokens out + Latency + Cost + + + + {sync.extractions.map((extraction) => ( + + {extraction.extractionNumber} + + {extraction.resolvedModel ?? + extraction.requestedModel} + {extraction.reusedFromExtractionId ? ( + + reused + + ) : null} + + + + {extraction.validationStatus} + + {extraction.errorCount > 0 ? ( + + {extraction.errorCount} errors + + ) : extraction.warningCount > 0 ? ( + + {extraction.warningCount} warnings + + ) : null} + + + {extraction.inputTokens} + {extraction.cachedInputTokens > 0 + ? ` (${extraction.cachedInputTokens} cached)` + : ""} + + + {extraction.outputTokens} + {extraction.reasoningTokens > 0 + ? ` (${extraction.reasoningTokens} reasoning)` + : ""} + + + {formatDuration(extraction.latencyMs)} + + {formatCost(extraction.costUsd)} + + ))} + + + + + ) : null} + + - - - ({ - id: artefact.id, - kind: artefact.kind, - attemptNumber: artefact.attemptNumber, - mediaType: artefact.mediaType, - }))} - endpoint="/api/admin/catalogue-syncs/artifacts" - /> - - + + + + {/* + An artefact is a whole fetched page or model transcript, so it is + given a window to scroll inside rather than being allowed to set + the length of the page it sits on. + */} + + ({ + id: artefact.id, + kind: artefact.kind, + attemptNumber: artefact.attemptNumber, + mediaType: artefact.mediaType, + }))} + endpoint="/api/admin/catalogue-syncs/artifacts" + /> + + + + ); } diff --git a/apps/web/ui/common/option-menu.tsx b/apps/web/ui/common/option-menu.tsx index 1b0e1c03..fa9613c0 100644 --- a/apps/web/ui/common/option-menu.tsx +++ b/apps/web/ui/common/option-menu.tsx @@ -77,9 +77,12 @@ export function OptionMenu({ aria-pressed={selected} className={cn( "flex h-9 w-full shrink-0 cursor-pointer items-center justify-between gap-2 rounded-md px-2.5 text-left text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring", + // These tones are mixed from the foreground rather than + // taken from --accent, which is mixed against the page and + // so disappears on the lighter popover surface. selected - ? "bg-primary/10 font-medium text-primary" - : "text-foreground/80 hover:bg-accent hover:text-foreground", + ? "bg-primary/15 font-medium text-primary" + : "text-foreground/80 hover:bg-foreground/8 hover:text-foreground", )} key={item.value} onClick={() => onSelect(item.value)} diff --git a/apps/web/ui/common/section-tabs.tsx b/apps/web/ui/common/section-tabs.tsx index 947cb67e..289cc5ef 100644 --- a/apps/web/ui/common/section-tabs.tsx +++ b/apps/web/ui/common/section-tabs.tsx @@ -1,13 +1,19 @@ "use client"; import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; import { Badge } from "@coursemap/ui/components/badge"; import { TabsList, TabsTrigger } from "@coursemap/ui/primitives/tabs"; export type SectionTab = { value: string; label: string; - icon?: ReactNode; + /** + * The section's icon, sized here so a bar of tabs stays even. Take it from + * `routeIcons` wherever the section is also a place in the product, so the + * tab, the breadcrumb and the sidebar all name it the same way. + */ + icon?: LucideIcon; /** Shown beside the label when the section holds outstanding work. */ count?: number; disabled?: boolean; @@ -34,11 +40,14 @@ export function SectionTabs({ {tabs.map((tab) => ( - {tab.icon} + {tab.icon ? ( + + ) : null} {tab.label} {tab.count ? ( diff --git a/apps/web/ui/common/task-toast.tsx b/apps/web/ui/common/task-toast.tsx new file mode 100644 index 00000000..0e6325e4 --- /dev/null +++ b/apps/web/ui/common/task-toast.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { Progress } from "@coursemap/ui/primitives/progress"; +import { LoaderCircle } from "lucide-react"; +import { toast } from "sonner"; + +export type TaskStep = { + /** Where the work has actually reached, 0-100. The bar catches up to it. */ + percent: number; + /** + * Where this phase ends, which is where the next one begins. Once the bar + * has caught up it drifts towards this for as long as the phase lasts, so + * the movement is continuous without ever claiming unreported progress. + */ + ceiling: number; + detail: string; +}; + +export type TaskOutcome = { + title: string; + detail?: string; + retry?: () => void; +}; + +export type TaskHandle = { + step: (step: TaskStep) => void; + done: (outcome: TaskOutcome) => void; + note: (outcome: TaskOutcome) => void; + fail: (outcome: TaskOutcome) => void; + /** Let go of work that outlives whatever was watching it. */ + abandon: (outcome: TaskOutcome) => void; +}; + +const SETTLED_DURATION = 6000; + +/** How often the bar is redrawn while work runs. */ +const TICK_MS = 90; + +/** + * The share of the distance each tick closes while the bar is behind what the + * work has reported. Brisk enough to feel answered, slow enough to read as + * movement rather than a jump. + */ +const CATCH_UP = 0.18; + +/** + * The least the bar moves per tick while catching up. Easing alone only ever + * approaches what was reported, which would strand the bar just short of it + * and never hand over to the drift. + */ +const MIN_CATCH = 0.35; + +/** + * The share closed each tick while the bar is merely waiting out a phase. It + * approaches the end of the phase without arriving, easing off as it goes. + */ +const DRIFT = 0.008; + +/** Below this the bar has settled into its phase and repainting only churns. */ +const STILL = 0.05; + +/** + * How long the running toast is held before it may settle. Phases the server + * answers instantly would otherwise flash past unread. + */ +const MIN_VISIBLE_MS = 800; + +/** + * How long a task may go without a step before it is assumed to have lost + * whatever was driving it. Both catalogue endpoints cap out at a minute, so + * anything past this is a driver that stopped reporting, not slow work. + */ +const STALL_MS = 150_000; + +/** + * The toast body while work runs. It stays the two lines every other toast + * uses, title then detail, with the bar as a rule beneath them; the button + * that started the work keeps its own label rather than reflowing the toolbar + * on every event. + */ +function TaskProgress({ + percent, + detail, +}: { + percent: number; + detail: string; +}) { + return ( + + + {detail} + {Math.round(percent)}% + + {/* The track is drawn against the toast, not the page, so it needs a + ground of its own to show how much of the work is left. */} + + + ); +} + +/** + * Opens one progress toast for a long-running operation and returns the handle + * that drives it. The `id` is the operation rather than the click, so starting + * the same work again replaces its toast instead of stacking another one. + * + * Phases arrive whenever the work reports them, which for a cached or empty + * step is no time at all. The bar therefore catches up to what was reported + * and then drifts through the rest of the phase, so the reported progress + * stays honest while the movement stays readable. + */ +export function startTask({ + id, + title, + detail, + ceiling = 20, +}: { + id: string; + title: string; + detail: string; + ceiling?: number; +}): TaskHandle { + const openedAt = Date.now(); + let shown = 0; + let floor = 0; + let limit = ceiling; + let text = detail; + let steppedAt = Date.now(); + let timer: number | undefined; + let settled = false; + let dismissed = false; + + function paint() { + // Not toast.loading: sonner withholds the close button from a loading + // toast, and work that carries on server-side has to be dismissable. + toast(title, { + id, + icon: , + description: , + duration: Number.POSITIVE_INFINITY, + onDismiss: () => { + dismissed = true; + stopGlide(); + }, + }); + } + + function stopGlide() { + if (timer === undefined) return; + window.clearInterval(timer); + timer = undefined; + } + + function glide() { + if (Date.now() - steppedAt > STALL_MS) { + abandon({ + title, + detail: "This is taking longer than expected. Reload to check on it.", + }); + return; + } + if (shown < floor) { + shown = Math.min( + floor, + shown + Math.max(MIN_CATCH, (floor - shown) * CATCH_UP), + ); + paint(); + return; + } + const next = shown + (limit - shown) * DRIFT; + if (next - shown < STILL) return; + shown = next; + paint(); + } + + function step({ percent, ceiling: end, detail: line }: TaskStep) { + if (settled || dismissed) return; + steppedAt = Date.now(); + floor = Math.max(floor, percent); + limit = Math.max(limit, end); + text = line; + // The wording is what the reader is waiting on, so it lands at once while + // the bar catches up behind it. + paint(); + if (timer === undefined) timer = window.setInterval(glide, TICK_MS); + } + + function settle(kind: "done" | "note" | "fail", outcome: TaskOutcome) { + if (settled) return; + settled = true; + stopGlide(); + // A toast dismissed by hand has been read and put away; only a failure is + // worth bringing back unasked. + if (dismissed && kind !== "fail") return; + const show = () => { + const { title: heading, detail: line, retry } = outcome; + const options = { + id, + // Sonner merges into the toast already on screen, so the spinner this + // task was painted with has to be cleared or it keeps turning under + // the outcome. Undefined hands the icon back to the toast's own type. + icon: undefined, + // A toast is its title and one line under it. A message too long for + // that line is kept whole in the tooltip rather than growing the toast. + description: line ? ( + + {line} + + ) : undefined, + duration: kind === "fail" ? Number.POSITIVE_INFINITY : SETTLED_DURATION, + ...(retry ? { action: { label: "Retry", onClick: retry } } : {}), + }; + if (kind === "done") toast.success(heading, options); + else if (kind === "note") toast.info(heading, options); + else toast.error(heading, options); + }; + const held = Date.now() - openedAt; + if (held >= MIN_VISIBLE_MS) show(); + else window.setTimeout(show, MIN_VISIBLE_MS - held); + } + + function abandon(outcome: TaskOutcome) { + settle("note", outcome); + } + + paint(); + timer = window.setInterval(glide, TICK_MS); + + return { + step, + done: (outcome) => settle("done", outcome), + note: (outcome) => settle("note", outcome), + fail: (outcome) => settle("fail", outcome), + abandon, + }; +} diff --git a/apps/web/ui/common/year-picker.tsx b/apps/web/ui/common/year-picker.tsx index f591a28c..8579de46 100644 --- a/apps/web/ui/common/year-picker.tsx +++ b/apps/web/ui/common/year-picker.tsx @@ -1,21 +1,30 @@ "use client"; +import { Button } from "@coursemap/ui/primitives/button"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@coursemap/ui/primitives/select"; + Popover, + PopoverContent, + PopoverTrigger, +} from "@coursemap/ui/primitives/popover"; + +import { useState } from "react"; +import { CalendarRange, ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/cn"; +import { MenuHint } from "@/ui/common/menu-hint"; +import { OptionMenu } from "@/ui/common/option-menu"; export type YearSelection = number | "all"; +/** Past this many years the list is worth narrowing by typing. */ +const SEARCHABLE_FROM = 8; + /** - * One academic year. This is a native-feeling select rather than the option - * picker: the values are a handful of four-digit years, and a searchable - * popover list drawn below the trigger read as a heavy component for choosing - * a single number. The select opens with the current year aligned over the - * trigger, so a change is one short movement. Newest first, because that is + * One academic year. It is built from the same popover and option list as the + * filter and sort controls beside it, so the toolbar reads as one set of + * controls rather than a native select dropped among them. The menu opens + * below the trigger instead of over it, which keeps the year being left + * behind visible while the next one is chosen. Newest first, because that is * the year being worked on. */ export function YearPicker({ @@ -36,24 +45,57 @@ export function YearPicker({ value: YearSelection; years: number[]; }) { + const [open, setOpen] = useState(false); const ordered = [...new Set(years)].sort((left, right) => right - left); + const items = [ + ...ordered.map((year) => ({ value: String(year), label: String(year) })), + ...(allowAll ? [{ value: "all", label: allLabel }] : []), + ]; + const selected = value === "all" ? allLabel : String(value); + return ( - onChange(next === "all" ? "all" : Number(next))} - > - - - - - {ordered.map((year) => ( - - {year} - - ))} - {allowAll ? {allLabel} : null} - - + + + + + + {selected} + {/* The chevron turns with the menu so the trigger shows its own + state, rather than only the panel below reporting it. */} + + + + + + { + setOpen(false); + onChange(next === "all" ? "all" : Number(next)); + }} + searchPlaceholder={ + items.length > SEARCHABLE_FROM ? "Search years..." : undefined + } + value={String(value)} + /> + + ); } diff --git a/apps/web/ui/shell/app-shell.tsx b/apps/web/ui/shell/app-shell.tsx index cee2dc6e..7313f1c2 100644 --- a/apps/web/ui/shell/app-shell.tsx +++ b/apps/web/ui/shell/app-shell.tsx @@ -13,6 +13,7 @@ import { } from "@coursemap/ui/primitives/sidebar"; import { cn } from "@/lib/cn"; import { AppSidebar } from "@/ui/shell/app-sidebar"; +import type { RouteIconKey } from "@/ui/shell/route-icons"; import { useSidebarDefaultOpen } from "@/ui/shell/sidebar-preference"; import { NotificationsMenu } from "@/ui/shell/notifications-menu"; import { Topbar } from "@/ui/shell/topbar"; @@ -28,6 +29,8 @@ export type AppShellProps = { breadcrumbSegmentLabels?: Record; /** Appends the open section, such as the active tab, to the breadcrumb. */ breadcrumbTrailingLabel?: string; + /** The appended section's icon, named by the route key its tab uses. */ + breadcrumbTrailingIcon?: RouteIconKey; loading?: boolean; admin?: boolean; /** Makes the main region a flex column so one child can claim the rest of the viewport. */ @@ -45,6 +48,7 @@ export function AppShell({ currentBreadcrumbLabel, breadcrumbSegmentLabels, breadcrumbTrailingLabel, + breadcrumbTrailingIcon, loading = false, admin = false, fill = false, @@ -135,6 +139,7 @@ export function AppShell({ breadcrumbSegmentLabels={breadcrumbSegmentLabels} currentBreadcrumbLabel={currentBreadcrumbLabel} breadcrumbTrailingLabel={breadcrumbTrailingLabel} + breadcrumbTrailingIcon={breadcrumbTrailingIcon} /> {tabs && ( ({ + href: `/admin/${segment}/${catalogueYear}`, + activePath: `/admin/${segment}`, + label, + icon, + }); + + return [ + { + label: null, + items: [ + { + href: "/admin/dashboard", + label: "Dashboard", + icon: routeIcons["admin-dashboard"], + }, + ], + }, + { + label: "Catalogue", + items: [ + catalogueItem("courses", "Courses", routeIcons.courses), + catalogueItem("programmes", "Programmes", routeIcons.programmes), + catalogueItem("majors", "Majors", routeIcons.majors), + catalogueItem("minors", "Minors", routeIcons.minors), + catalogueItem( + "specialisations", + "Specialisations", + routeIcons.specialisations, + ), + { + href: "/admin/operations/catalogue", + label: "Activity", + icon: routeIcons.sync, + }, + ], + }, + { + label: "Campus", + items: [ + { + href: "/admin/rooms", + label: "Indoor maps", + icon: routeIcons["admin-rooms"], + }, + ], + }, + { + label: "Access", + items: [ + { href: "/admin/users", label: "Users", icon: routeIcons.users }, + { href: "/admin/roles", label: "Roles", icon: routeIcons.roles }, + ], + }, + ]; +} + +export function adminCatalogueNavigationYear( + pathname: string, + profileCatalogueYear: number, +) { + const match = pathname.match( + /^\/admin\/(?:courses|programmes|majors|minors|specialisations)\/(\d{4})(?:\/|$)/, + ); + return match ? Number(match[1]) : profileCatalogueYear; +} /** Shown to students who hold an admin role. */ const adminEntryNav: NavSection[] = [ @@ -173,10 +188,11 @@ function NavMenuItem({ onNavigate: () => void; }) { const pathname = usePathname(); + const activePath = item.activePath ?? item.href; const isActive = item.href === "/admin/dashboard" ? pathname === item.href || pathname === "/admin" - : pathname === item.href || pathname.startsWith(`${item.href}/`); + : pathname === activePath || pathname.startsWith(`${activePath}/`); const Icon = item.icon; return ( @@ -233,7 +249,12 @@ function NavSections({ } export function AppSidebar({ admin }: { admin: boolean }) { - const { canAccessAdmin } = useCoursemap(); + const { canAccessAdmin, state } = useCoursemap(); + const pathname = usePathname(); + const catalogueYear = adminCatalogueNavigationYear( + pathname, + state.profile.catalogueYear, + ); const { isMobile, setOpenMobile } = useSidebar(); const closeMobileNav = () => { if (isMobile) setOpenMobile(false); @@ -260,7 +281,7 @@ export function AppSidebar({ admin }: { admin: boolean }) { diff --git a/apps/web/ui/shell/breadcrumbs.tsx b/apps/web/ui/shell/breadcrumbs.tsx index 3cf0b40b..84889ed6 100644 --- a/apps/web/ui/shell/breadcrumbs.tsx +++ b/apps/web/ui/shell/breadcrumbs.tsx @@ -13,7 +13,7 @@ import { BreadcrumbSeparator, } from "@coursemap/ui/primitives/breadcrumb"; import { BreadcrumbOverflow } from "@/ui/shell/breadcrumb-overflow"; -import { routeIcons } from "@/ui/shell/route-icons"; +import { routeIcons, type RouteIconKey } from "@/ui/shell/route-icons"; type Crumb = { label: string; href?: string; icon?: LucideIcon }; @@ -146,6 +146,7 @@ export function Breadcrumbs({ currentLabel, segmentLabels, trailingLabel, + trailingIcon, }: { currentLabel?: string; /** Relabels a route segment, or hides it when the value is null. */ @@ -155,6 +156,12 @@ export function Breadcrumbs({ * segment, so it is appended rather than read from the URL. */ trailingLabel?: string; + /** + * The appended section's icon, named by route key rather than passed as a + * component so a server page can ask for it. Give it the key its tab uses, + * and the breadcrumb and the tab bar say the same thing. + */ + trailingIcon?: RouteIconKey; }) { const pathname = usePathname(); const { crumbs } = buildCrumbs(pathname, segmentLabels); @@ -170,7 +177,10 @@ export function Breadcrumbs({ ? { ...crumb, href: pathname } : crumb, ), - { label: trailingLabel }, + { + label: trailingLabel, + icon: trailingIcon && routeIcons[trailingIcon], + }, ] : named; diff --git a/apps/web/ui/shell/notifications-menu.tsx b/apps/web/ui/shell/notifications-menu.tsx index 209f1c9d..b5fd48cc 100644 --- a/apps/web/ui/shell/notifications-menu.tsx +++ b/apps/web/ui/shell/notifications-menu.tsx @@ -154,7 +154,7 @@ function NotificationRow({ > ); const className = - "flex w-full items-start gap-2.5 rounded-md px-2 py-2.5 text-left text-sm transition-colors outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"; + "flex w-full items-start gap-2.5 rounded-md px-2 py-2.5 text-left text-sm transition-colors outline-none hover:bg-foreground/8 focus-visible:ring-2 focus-visible:ring-ring"; // A notification with somewhere to go is a link, so it opens in a new tab and // shows its destination like any other. One without is still readable, and diff --git a/apps/web/ui/shell/route-icons.ts b/apps/web/ui/shell/route-icons.ts index 21f43efa..9d4fef2d 100644 --- a/apps/web/ui/shell/route-icons.ts +++ b/apps/web/ui/shell/route-icons.ts @@ -3,16 +3,21 @@ import { BookOpen, CalendarDays, CalendarRange, + Eye, + FileText, GitCompareArrows, GraduationCap, + History, Import, KeyRound, LayoutDashboard, + Library, LifeBuoy, ListChecks, Map, MapPin, MapPinned, + Radar, RefreshCw, Route, Shield, @@ -51,8 +56,18 @@ export const routeIcons = { roles: KeyRound, imports: Import, sync: RefreshCw, - changes: GitCompareArrows, timetable: CalendarDays, + // Catalogue activity, and the two questions it is asked. + catalogue: Library, + syncs: RefreshCw, + discovery: Radar, + // The sections of one catalogue record. "content" is the record path itself + // rather than a segment of its own, and is named here so its tab and every + // link to it wear the same icon as its siblings. + content: FileText, + "student-view": Eye, + changes: GitCompareArrows, + changelog: History, } satisfies Record; export type RouteIconKey = keyof typeof routeIcons; diff --git a/apps/web/ui/shell/topbar.tsx b/apps/web/ui/shell/topbar.tsx index f40a2494..76a2e15c 100644 --- a/apps/web/ui/shell/topbar.tsx +++ b/apps/web/ui/shell/topbar.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from "react"; import { Separator } from "@coursemap/ui/primitives/separator"; import { SidebarTrigger } from "@coursemap/ui/primitives/sidebar"; import { cn } from "@/lib/cn"; +import type { RouteIconKey } from "@/ui/shell/route-icons"; import { useLoadingProgress } from "./use-loading-progress"; import { Breadcrumbs } from "@/ui/shell/breadcrumbs"; @@ -14,6 +15,7 @@ export function Topbar({ currentBreadcrumbLabel, breadcrumbSegmentLabels, breadcrumbTrailingLabel, + breadcrumbTrailingIcon, }: { loading?: boolean; title?: ReactNode; @@ -21,6 +23,7 @@ export function Topbar({ currentBreadcrumbLabel?: string; breadcrumbSegmentLabels?: Record; breadcrumbTrailingLabel?: string; + breadcrumbTrailingIcon?: RouteIconKey; }) { const header = useLoadingProgress(loading); return ( @@ -43,6 +46,7 @@ export function Topbar({ currentLabel={currentBreadcrumbLabel} segmentLabels={breadcrumbSegmentLabels} trailingLabel={breadcrumbTrailingLabel} + trailingIcon={breadcrumbTrailingIcon} /> )} diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 86b1ec9d..2f7a6c88 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -1,5 +1,6 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", + "buildCommand": "pnpm build:prod", "functions": { "app/api/queues/catalogue-sync/route.ts": { "maxDuration": 300, diff --git a/docs/catalogue-admin-rework.md b/docs/catalogue-admin-rework.md index 5a7dcd96..a71360cd 100644 --- a/docs/catalogue-admin-rework.md +++ b/docs/catalogue-admin-rework.md @@ -189,10 +189,9 @@ Publish. ## Working locally -- Use `pnpm dev:local`. `apps/web/.env.local` points at the hosted project, - which still carries the pre-redesign schema, so `pnpm dev` fails with - `PGRST205` against tables this branch removed. None of the fixes above are - live there; they travel with the cutover after A8. +- Use `pnpm dev`, `pnpm build` and `pnpm start` for the local stack. The + explicit `:prod` variants are the only commands that read hosted credentials + from `apps/web/.env.local`. - `pnpm db:reset` drops the storage buckets and does not recreate them, so imports then fail with an opaque gateway error. Recreate `course-import-artifacts` from `supabase/config.toml`. diff --git a/package.json b/package.json index 52398f36..91943280 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,14 @@ }, "packageManager": "pnpm@12.3.4", "scripts": { - "dev": "turbo run dev", - "dev:local": "node apps/web/scripts/local/dev-preview.mjs", + "dev": "pnpm --filter @coursemap/web dev", + "dev:local": "pnpm --filter @coursemap/web dev", + "dev:prod": "pnpm --filter @coursemap/web dev:prod", + "preview:local": "node apps/web/scripts/local/production-preview.mjs", "build": "turbo run build", + "build:prod": "pnpm --filter @coursemap/web build:prod", + "start": "pnpm --filter @coursemap/web start", + "start:prod": "pnpm --filter @coursemap/web start:prod", "lint": "turbo run lint", "typecheck": "turbo run typecheck", "format": "prettier --write .", diff --git a/packages/ui/style-nova.css b/packages/ui/style-nova.css index 19eace56..877cf85f 100644 --- a/packages/ui/style-nova.css +++ b/packages/ui/style-nova.css @@ -694,6 +694,12 @@ @apply rounded-2xl; } + /* The icon belongs to the title, so it holds the first line however many + lines the description below it runs to. */ + .cn-toast [data-icon] { + @apply mt-0.5 self-start; + } + /* MARK: Table */ .cn-table-container { @apply relative w-full overflow-x-auto; diff --git a/supabase/migrations/20260927100000_remove_untouched_catalogue_drafts.sql b/supabase/migrations/20260927100000_remove_untouched_catalogue_drafts.sql new file mode 100644 index 00000000..7fb9bca2 --- /dev/null +++ b/supabase/migrations/20260927100000_remove_untouched_catalogue_drafts.sql @@ -0,0 +1,13 @@ +-- Opening a catalogue record used to create its draft, so every record anyone +-- had ever looked at reported itself as a draft, and discarding one brought it +-- straight back on the next render. A draft is now created by the first change +-- worth keeping, which leaves the rows that bug produced behind. +-- +-- Those rows are exactly the drafts still at revision 0 that were not restored +-- from a version. A draft at revision 0 has never been saved, so its content is +-- byte-identical to the publication or the empty record it was created from, +-- and restoring is the only other way to sit at revision 0. Deleting them +-- therefore loses no authored work; their provenance rows cascade away with +-- them, and the next edit recreates whatever it needs. +delete from public.catalogue_drafts +where revision = 0 and restored_from_version_id is null;
New to Coursemap?{" "} diff --git a/apps/web/app/profile/page.tsx b/apps/web/app/profile/page.tsx index 686cc6aa..3733655e 100644 --- a/apps/web/app/profile/page.tsx +++ b/apps/web/app/profile/page.tsx @@ -9,12 +9,7 @@ export default async function ProfilePage() { try { catalogue = await loadOnboardingCatalogue(); } catch { - return ( - - ); + return ; } return ; } diff --git a/apps/web/app/vendor.css b/apps/web/app/vendor.css index 89aa5d05..e677c4f3 100644 --- a/apps/web/app/vendor.css +++ b/apps/web/app/vendor.css @@ -3,9 +3,22 @@ own, so each rule notes what it is correcting. Imported by globals.css after the stylesheets they override. */ -/* Sonner keeps neutral surfaces with status borders and an inset close button. */ -.toaster [data-sonner-toast][data-styled="true"] { - padding-right: 48px; +/* Sonner keeps neutral surfaces with status borders and an inset close button. + The close button sits over the first line only, so the title gives up the + room for it rather than the whole toast; that leaves descriptions and + progress bars the full width. */ +.toaster + [data-sonner-toast][data-styled="true"]:has([data-close-button]) + [data-title] { + padding-right: 32px; +} + +/* An action sits on the same corner as the close button, so it steps aside by + the same amount the title does. */ +.toaster + [data-sonner-toast][data-styled="true"]:has([data-close-button]) + [data-button] { + margin-right: 24px; } .toaster [data-sonner-toast][data-type="success"] { @@ -294,3 +307,20 @@ } } } + +/* Nova's dropdown and select content carry its translucent menu preset, which + already highlights items with a foreground mix. Command does not, so a + command item falls back to --accent - and --accent is mixed against the page + ground, landing on oklch(~0.204) in dark while the popover surface a command + palette opens on is oklch(0.205). The highlight was invisible there. Mix + from the foreground instead: a translucent overlay reads on any ground. */ +@layer base { + .style-nova + [data-slot="command-item"]:is( + :hover, + [data-selected="true"], + [aria-selected="true"] + ) { + background-color: color-mix(in oklab, var(--foreground) 10%, transparent); + } +} diff --git a/apps/web/lib/auth/redirect.ts b/apps/web/lib/auth/redirect.ts index c353ad81..ef3aed62 100644 --- a/apps/web/lib/auth/redirect.ts +++ b/apps/web/lib/auth/redirect.ts @@ -3,6 +3,8 @@ const AUTH_HANDLER_PATHS = [ "/auth/callback", "/auth/confirm", "/auth/logout", + "/auth/password", + "/auth/sign-in", ] as const; function fullyDecodePath(pathname: string) { diff --git a/apps/web/lib/catalogue-import/directory.ts b/apps/web/lib/catalogue-import/directory.ts index 8bcbf5f7..aee5990e 100644 --- a/apps/web/lib/catalogue-import/directory.ts +++ b/apps/web/lib/catalogue-import/directory.ts @@ -53,7 +53,11 @@ async function recordDiscoverySourcePage( byteSize: number; }, ) { - const [row] = await sql` + // A source page is the record of one exact set of bytes, and the table + // rejects every update. Refetching a listing ANU has not changed therefore + // reuses the page already recorded rather than restamping it; when the + // refresh happened is on the discovery check that asked for it. + const [inserted] = await sql` insert into public.catalogue_source_pages ( source_id, academic_year_id, kind, external_key, canonical_url, media_type, content_sha256, http_status, http_etag, source_last_modified, fetched_at, @@ -63,10 +67,25 @@ async function recordDiscoverySourcePage( ${input.contentSha256}, ${input.httpStatus}, ${input.httpEtag}, ${input.sourceLastModified}, ${input.fetchedAt}, ${input.byteSize}, null, null) on conflict (source_id, academic_year_id, kind, external_key, content_sha256) - do update set fetched_at = excluded.fetched_at + do nothing returning id `; - return Number(row.id); + if (inserted) return Number(inserted.id); + const [existing] = await sql` + select id from public.catalogue_source_pages + where source_id = ${input.sourceId} + and academic_year_id = ${input.academicYearId} + and kind = 'directory' + and external_key = ${input.externalKey} + and content_sha256 = ${input.contentSha256} + `; + if (!existing) { + throw new DirectoryRefreshError( + "The source page could not be recorded.", + "SOURCE_PAGE_NOT_RECORDED", + ); + } + return Number(existing.id); } type DirectoryEntryInput = { diff --git a/apps/web/lib/catalogue/drafts.ts b/apps/web/lib/catalogue/drafts.ts index 3f8f963c..1b016be0 100644 --- a/apps/web/lib/catalogue/drafts.ts +++ b/apps/web/lib/catalogue/drafts.ts @@ -142,18 +142,22 @@ async function copyVersionProvenance( `; } -/** Creates the draft a record should start from: its publication, or an empty aggregate. */ -export async function createDraftInTransaction( - tx: Sql, +/** + * The aggregate an editor starts from: the current publication, or an empty + * record when nothing has been published. Reading it writes nothing, so a + * record can be opened and edited without a draft row coming into existence + * before there is anything to keep. + */ +export async function catalogueDraftBase( + sql: Sql, record: Record, - userId: string, ) { const publishedVersionId = record.published_version_id === null ? null : Number(record.published_version_id); const initial = publishedVersionId - ? await readVersionContent(tx, publishedVersionId) + ? await readVersionContent(sql, publishedVersionId) : emptyCatalogueContent({ kind: record.kind as CatalogueKind, code: String(record.code), @@ -167,7 +171,39 @@ export async function createDraftInTransaction( "INVALID_BASE", ); const contentHash = contentHashForCatalogueContent(initial); - const content = { ...initial, contentHash } satisfies CatalogueContent; + return { + publishedVersionId, + contentHash, + content: { ...initial, contentHash } satisfies CatalogueContent, + }; +} + +/** The draft-shaped view of a record whose draft row does not exist yet. */ +function unsavedDraft( + recordId: number, + base: Awaited>, +): CatalogueDraft { + return { + recordId, + baseVersionId: base.publishedVersionId, + restoredFromVersionId: null, + content: base.content, + contentHash: base.contentHash, + revision: 0, + updatedAt: new Date().toISOString(), + }; +} + +/** Creates the draft a record should start from: its publication, or an empty aggregate. */ +export async function createDraftInTransaction( + tx: Sql, + record: Record, + userId: string, +) { + const { publishedVersionId, contentHash, content } = await catalogueDraftBase( + tx, + record, + ); const [row] = await tx` insert into public.catalogue_drafts ( record_id, base_version_id, content, content_hash, @@ -190,16 +226,90 @@ export async function createDraftInTransaction( return draftFromRow(row); } -/** Returns the existing draft or creates the correct published/manual base. */ -export async function createCatalogueDraft({ +export async function loadCatalogueDraft(recordId: number) { + return withSyncDatabaseClient(async (sql) => { + const [row] = await sql` + select * from public.catalogue_drafts where record_id = ${recordId} + `; + return row ? draftFromRow(row) : null; + }); +} + +/** + * What the content editor opens on, and what state that content is in. + * + * A record without a draft row still has content to edit - its publication, or + * an empty aggregate - so reading a record is not what turns it into a draft. + * Asking to edit it is, and that is a deliberate act with a row behind it, so + * the record is still a draft when its editor comes back to it later. + * + * Whether the draft says anything new is a separate question from whether one + * is open, because an untouched draft can be discarded but not published. + */ +export async function loadCatalogueEditorState( + recordId: number, + sql?: SyncSql, +): Promise<{ + draft: CatalogueDraft; + /** Whether a draft row exists: the record is open for editing. */ + hasDraft: boolean; + /** Whether that draft differs from what it was opened on. */ + hasChanges: boolean; +}> { + const work = async (client: SyncSql) => { + const [record] = await client` + select records.id, records.kind, records.published_version_id, + codes.code, academic_years.year, listings.title as listing_title + from public.catalogue_records as records + join public.catalogue_codes as codes on codes.id = records.code_id + join public.academic_years on academic_years.id = records.academic_year_id + left join public.catalogue_listings as listings on listings.record_id = records.id + where records.id = ${recordId} + `; + if (!record) + throw new CatalogueDraftError( + "The catalogue record does not exist.", + "NOT_FOUND", + ); + const base = await catalogueDraftBase(client, record); + const [row] = await client` + select * from public.catalogue_drafts where record_id = ${recordId} + `; + if (!row) + return { + draft: unsavedDraft(recordId, base), + hasDraft: false, + hasChanges: false, + }; + const draft = draftFromRow(row); + return { + draft, + hasDraft: true, + hasChanges: draft.contentHash !== base.contentHash, + }; + }; + return sql ? work(sql) : withSyncDatabaseClient(work); +} + +/** + * Opens a draft on a record without changing a word of it. + * + * The editor asks for this when it is opened, so that backing out of it is + * always the same act - discarding a draft - and so that a record someone has + * started work on still reads as theirs after they have navigated away. + */ +export async function beginCatalogueDraft({ recordId, userId, + editingSessionId, sql, }: { recordId: number; userId: string; + editingSessionId: string; sql?: SyncSql; }) { + assertEditingSession(editingSessionId); const work = (client: SyncSql) => client.begin(async (tx) => { const record = await catalogueRecordForUpdate(tx, recordId); @@ -208,25 +318,18 @@ export async function createCatalogueDraft({ "The catalogue record is archived.", "ARCHIVED", ); - const [existing] = await tx` + const [row] = await tx` select * from public.catalogue_drafts where record_id = ${recordId} + for update `; - return existing - ? draftFromRow(existing) - : createDraftInTransaction(tx, record, userId); + const draft = row + ? draftFromRow(row) + : await createDraftInTransaction(tx, record, userId); + return { draft }; }); return sql ? work(sql) : withSyncDatabaseClient(work); } -export async function loadCatalogueDraft(recordId: number) { - return withSyncDatabaseClient(async (sql) => { - const [row] = await sql` - select * from public.catalogue_drafts where record_id = ${recordId} - `; - return row ? draftFromRow(row) : null; - }); -} - /** Saves one semantically changed aggregate and its audit rows atomically. */ export async function saveCatalogueDraft({ recordId, @@ -259,14 +362,24 @@ export async function saveCatalogueDraft({ where record_id = ${recordId} for update `; + const contentHash = contentHashForCatalogueContent(content); + const accepted = { ...content, contentHash } satisfies CatalogueContent; + // A record becomes a draft the moment it differs from what it started + // as, never because its editor autosaved what was already there. Saving + // an untouched record has to leave it exactly as it was found. + const base = draftRow ? null : await catalogueDraftBase(tx, record); + if (base && diffSnapshotWrites(base.content, accepted).length === 0) + return { + draft: unsavedDraft(recordId, base), + unchanged: true as const, + changedPaths: [] as string[], + }; const draft = draftRow ? draftFromRow(draftRow) : await createDraftInTransaction(tx, record, userId); if (draft.revision !== expectedRevision) throw new CatalogueDraftConflictError(draft.revision); - const contentHash = contentHashForCatalogueContent(content); - const accepted = { ...content, contentHash } satisfies CatalogueContent; const changes = diffSnapshotWrites(draft.content, accepted); if (changes.length === 0) return { diff --git a/apps/web/lib/coursemap/admin-catalogue-actions.ts b/apps/web/lib/coursemap/admin-catalogue-actions.ts index 44e1c1b3..fbacdd53 100644 --- a/apps/web/lib/coursemap/admin-catalogue-actions.ts +++ b/apps/web/lib/coursemap/admin-catalogue-actions.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { canWriteCatalogue, getAuthViewer } from "@/lib/auth/viewer"; import type { CatalogueContent } from "@/lib/catalogue/content"; import { + beginCatalogueDraft, CatalogueDraftConflictError, CatalogueDraftError, discardCatalogueDraft, @@ -126,6 +127,36 @@ export async function unpublishAction({ } } +/** + * Opening the editor is what makes a record a draft, so that is an act the + * server hears about rather than a state the browser holds on its own. + */ +export async function beginCatalogueDraftAction({ + recordId, + editingSessionId, + path, +}: { + recordId: number; + editingSessionId: string; + path: string; +}): Promise { + if (!(await canWriteCatalogue())) + return { ok: false, error: "Catalogue write permission is required." }; + const viewer = await getAuthViewer(); + if (!viewer) return { ok: false, error: "Authentication is required." }; + try { + const { draft } = await beginCatalogueDraft({ + recordId, + editingSessionId, + userId: viewer.id, + }); + revalidateRecord(path); + return { ok: true, revision: draft.revision }; + } catch (error) { + return draftFailure(error, "The draft could not be opened."); + } +} + export async function saveCatalogueDraftAction({ recordId, expectedRevision, diff --git a/apps/web/lib/coursemap/admin-catalogue.ts b/apps/web/lib/coursemap/admin-catalogue.ts index beb48241..16f09eb4 100644 --- a/apps/web/lib/coursemap/admin-catalogue.ts +++ b/apps/web/lib/coursemap/admin-catalogue.ts @@ -1,11 +1,15 @@ import "server-only"; import type { PostgrestError } from "@supabase/supabase-js"; +import { emptyCatalogueContent } from "@/lib/catalogue/content"; +import { contentHashForCatalogueContent } from "@/lib/catalogue-import/version-content"; import { createClient } from "@/lib/supabase/server"; import type { CatalogueDirectoryPage, CatalogueDirectoryRecord, CatalogueKind, + CatalogueRecordState, } from "./catalogue-kinds"; +import { catalogueRecordState } from "./catalogue-kinds"; export * from "./catalogue-kinds"; @@ -77,11 +81,14 @@ export async function loadCatalogueDirectoryPage({ kind, academicYear, query = "", + state = null, page = 1, }: { kind: CatalogueKind; academicYear: number; query?: string; + /** Narrows to the state the row's badge reports. Null leaves every row in. */ + state?: CatalogueRecordState | null; page?: number; }): Promise { const supabase = await createClient(); @@ -122,7 +129,7 @@ export async function loadCatalogueDirectoryPage({ readAllRows((from, to) => supabase .from("catalogue_records") - .select("id,code_id,public_id,published_version_id,archived_at") + .select("id,code_id,published_version_id,archived_at") .eq("academic_year_id", year.id) .eq("kind", kind) .order("code_id") @@ -141,7 +148,9 @@ export async function loadCatalogueDirectoryPage({ readAllRows((from, to) => supabase .from("catalogue_drafts") - .select("record_id,catalogue_records!inner(academic_year_id,kind)") + .select( + "record_id,content_hash,revision,catalogue_records!inner(academic_year_id,kind)", + ) .eq("catalogue_records.academic_year_id", year.id) .eq("catalogue_records.kind", kind) .order("record_id") @@ -192,7 +201,31 @@ export async function loadCatalogueDirectoryPage({ const recordByCodeId = new Map( records.data.map((record) => [record.code_id, record]), ); - const draftIds = new Set((drafts.data ?? []).map((draft) => draft.record_id)); + // A draft row is only worth reporting when it says something the record did + // not already say. Comparing hashes keeps a draft that was restored from the + // publication, or edited back to match it, out of the Draft state. + const draftRows = new Map( + (drafts.data ?? []).map((draft) => [draft.record_id, draft]), + ); + const draftedPublishedVersionIds = records.data.flatMap((record) => + draftRows.has(record.id) && record.published_version_id + ? [record.published_version_id] + : [], + ); + // Named by identifier rather than filtered by year, because only records + // that carry a draft reach this list and that is a handful, not thousands. + const { data: publishedVersions } = draftedPublishedVersionIds.length + ? await supabase + .from("catalogue_versions") + .select("id,content_hash") + .in("id", draftedPublishedVersionIds) + : { data: [] as Array<{ id: number; content_hash: string }> }; + const publishedHashes = new Map( + (publishedVersions ?? []).map((version) => [ + version.id, + version.content_hash, + ]), + ); const latestSync = new Map(); for (const sync of syncs.data ?? []) if (!latestSync.has(sync.record_id)) latestSync.set(sync.record_id, sync); @@ -229,7 +262,23 @@ export async function loadCatalogueDirectoryPage({ ? recordByCodeId.get(listing.itemId) : undefined; const sync = record ? latestSync.get(record.id) : undefined; - const hasDraft = record ? draftIds.has(record.id) : false; + const draftRow = record ? draftRows.get(record.id) : undefined; + // What the draft would have started as. An unpublished record starts + // empty, so a draft holding nothing has been opened but says nothing new. + const baseHash = !draftRow + ? null + : record?.published_version_id + ? publishedHashes.get(record.published_version_id) + : contentHashForCatalogueContent( + emptyCatalogueContent({ + kind, + code: listing.code, + academicYear, + title: listing.title, + }), + ); + const hasDraft = draftRow !== undefined; + const hasChanges = hasDraft && draftRow.content_hash !== baseHash; const isPublished = Boolean( record?.published_version_id && !record.archived_at, ); @@ -249,8 +298,10 @@ export async function loadCatalogueDirectoryPage({ code: listing.code, title: listing.title, summary: (listing.summary ?? {}) as Record, - recordPublicId: record?.public_id ?? null, + recordId: record?.id ?? null, hasDraft, + hasChanges, + draftRevision: draftRow?.revision ?? null, isPublished, isListedByAnu: listing.is_current, lastSeenAt: listing.last_seen_at, @@ -275,6 +326,9 @@ export async function loadCatalogueDirectoryPage({ row.code.includes(needle) || (row.title ?? "").toUpperCase().includes(needle), ) + // The whole year is already in memory, so narrowing by state costs a pass + // rather than a query, and it agrees with the badge by construction. + .filter((row) => !state || catalogueRecordState(row) === state) .sort((left, right) => left.code.localeCompare(right.code)); const safePage = Math.max( 1, diff --git a/apps/web/lib/coursemap/catalogue-kinds.ts b/apps/web/lib/coursemap/catalogue-kinds.ts index 40d616c4..c5a591b5 100644 --- a/apps/web/lib/coursemap/catalogue-kinds.ts +++ b/apps/web/lib/coursemap/catalogue-kinds.ts @@ -69,8 +69,14 @@ export type CatalogueDirectoryRecord = { code: string; title: string | null; summary: Record; - recordPublicId: string | null; + /** Null until ANU discovery has created the record a sync would run on. */ + recordId: number | null; + /** True once the record has been opened for editing. */ hasDraft: boolean; + /** True only when that draft says something the publication does not. */ + hasChanges: boolean; + /** The revision a row action has to submit to act on that draft. */ + draftRevision: number | null; isPublished: boolean; isListedByAnu: boolean | null; lastSeenAt: string | null; @@ -90,6 +96,53 @@ export type CatalogueDirectoryRecord = { } | null; }; +/** + * The one state a directory row is in. The badge that prints it and the filter + * that narrows to it both read this, so what an operator can select is exactly + * what they can see, and reordering the cascade moves the two together. + * + * Order is precedence, most urgent first: a broken sync before a delisting, a + * delisting before waiting changes, and only then how far the record has been + * taken. + */ +export type CatalogueRecordState = + | "sync_failed" + | "delisted" + | "syncing" + | "changes_available" + | "draft" + | "published" + | "unpublished"; + +export const CATALOGUE_STATE_LABELS: Record = { + sync_failed: "Sync failed", + delisted: "No longer listed", + syncing: "Syncing", + changes_available: "ANU changes", + draft: "Draft", + published: "Published", + unpublished: "Not published", +}; + +export const CATALOGUE_STATES = Object.keys( + CATALOGUE_STATE_LABELS, +) as CatalogueRecordState[]; + +export function catalogueRecordState( + record: CatalogueDirectoryRecord, +): CatalogueRecordState { + if (record.sourceState === "sync_failed") return "sync_failed"; + if (record.isListedByAnu === false) return "delisted"; + if (record.sourceState === "syncing") return "syncing"; + if (record.sourceState === "changes_available" && record.openChangeCount > 0) + return "changes_available"; + // Unpublished work outranks publication: a published record with a draft is + // the one a person still has to come back to. + if (record.hasDraft) return "draft"; + if (record.isPublished) return "published"; + return "unpublished"; +} + export type CatalogueTableLayout = | "public-courses" | "users" diff --git a/apps/web/lib/coursemap/catalogue-summary.ts b/apps/web/lib/coursemap/catalogue-summary.ts new file mode 100644 index 00000000..b1a8b3aa --- /dev/null +++ b/apps/web/lib/coursemap/catalogue-summary.ts @@ -0,0 +1,46 @@ +import type { CatalogueKind } from "@/lib/coursemap/catalogue-kinds"; + +/** + * The facts ANU publishes alongside a code in its directory listing. They are + * stored verbatim, so every reader has to tolerate a missing or unexpected + * shape rather than trusting the keys to be there. + */ +type ListingSummary = { + career?: unknown; + units?: unknown; + modeOfDelivery?: unknown; + durationYears?: unknown; +}; + +function text(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function count(value: unknown) { + const number = typeof value === "number" ? value : Number(value); + return Number.isFinite(number) && number > 0 ? number : null; +} + +/** + * The one-line description that sits under a record's title: what it is worth, + * who it is for and how it runs. Only the parts ANU actually gave are + * returned, so a sparse listing reads as a short line rather than a row of + * dashes. Session is deliberately left out - ANU joins every offering into one + * slash-separated string that is longer than the title it would sit beneath. + */ +export function catalogueSummaryMeta( + summary: Record, + kind: CatalogueKind, +): string[] { + const listing = summary as ListingSummary; + const parts: string[] = []; + const units = count(listing.units); + if (units) parts.push(`${units} unit${units === 1 ? "" : "s"}`); + const years = kind === "course" ? null : count(listing.durationYears); + if (years) parts.push(`${years} year${years === 1 ? "" : "s"}`); + const career = text(listing.career); + if (career) parts.push(career); + const mode = text(listing.modeOfDelivery); + if (mode) parts.push(mode); + return parts; +} diff --git a/apps/web/package.json b/apps/web/package.json index 5f60fd2f..560128f1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,16 +5,20 @@ "type": "module", "scripts": { "predev": "node scripts/copy-maplibre-worker.mjs", - "dev": "next dev --webpack", + "dev": "node scripts/local/dev-preview.mjs", + "dev:prod": "node scripts/copy-maplibre-worker.mjs && next dev --webpack", "prebuild": "node scripts/copy-maplibre-worker.mjs", - "build": "next build --webpack", - "start": "next start", + "build": "node scripts/local/build-preview.mjs", + "build:next": "next build --webpack", + "build:prod": "node scripts/copy-maplibre-worker.mjs && next build --webpack", + "start": "node scripts/local/start-preview.mjs", + "start:prod": "next start", "lint": "eslint . --ignore-pattern .next", "lint:fix": "pnpm lint --fix", "typecheck": "tsc --noEmit --incremental false", "test:unit": "vitest run --project unit --project component", "test:catalogue-db": "vitest run --project database", - "test:build:auth": "NEXT_PUBLIC_SITE_URL=http://127.0.0.1:4318 NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:9 NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_test pnpm build", + "test:build:auth": "NEXT_PUBLIC_SITE_URL=http://127.0.0.1:4318 NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:9 NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_test pnpm build:prod", "test:auth-access": "COURSEMAP_TEST_PROFILE=access playwright test", "test:e2e:build": "node scripts/local/build-e2e.mjs", "test:e2e": "COURSEMAP_TEST_PROFILE=authenticated playwright test", diff --git a/apps/web/playwright/catalogue-admin.spec.ts b/apps/web/playwright/catalogue-admin.spec.ts index 7338afb5..b2986fef 100644 --- a/apps/web/playwright/catalogue-admin.spec.ts +++ b/apps/web/playwright/catalogue-admin.spec.ts @@ -31,7 +31,9 @@ test("administrators browse year-first catalogue records", async ({ }); }); await page.getByRole("button", { name: "Refresh ANU listing" }).click(); - await expect(page.getByText("ANU listing refreshed.")).toBeVisible(); + // The refresh reports itself in a progress toast, which settles on what the + // listing actually returned rather than on a flat acknowledgement. + await expect(page.getByText("2026 programmes refreshed")).toBeVisible(); await page.goto("/admin/courses/2026?q=COMP1110"); const courseRow = page.getByRole("row", { name: /COMP1110/ }); diff --git a/apps/web/scripts/local/build-preview.mjs b/apps/web/scripts/local/build-preview.mjs new file mode 100644 index 00000000..03280c1c --- /dev/null +++ b/apps/web/scripts/local/build-preview.mjs @@ -0,0 +1,29 @@ +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { appRoot } from "../paths.mjs"; +import { createLocalApplicationEnvironment } from "./supabase-environment.mjs"; + +export function buildLocalProduction({ + environment = createLocalApplicationEnvironment(), + runCommand = spawnSync, +} = {}) { + return runCommand("pnpm", ["run", "build:next"], { + cwd: appRoot, + env: environment, + stdio: "inherit", + }).status; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + let status; + try { + status = buildLocalProduction(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } + process.exit(status ?? 1); +} diff --git a/apps/web/scripts/local/dev-preview.mjs b/apps/web/scripts/local/dev-preview.mjs index 16796241..7cd38fba 100644 --- a/apps/web/scripts/local/dev-preview.mjs +++ b/apps/web/scripts/local/dev-preview.mjs @@ -1,72 +1,53 @@ -import { appRoot, repositoryRoot } from "../paths.mjs"; -import { spawn, spawnSync } from "node:child_process"; - -export function parseSupabaseEnvironment(output) { - const values = new Map(); - for (const line of output.split(/\r?\n/)) { - const match = line.match(/^([A-Z_]+)=(?:"([^"]*)"|(.*))$/); - if (match) values.set(match[1], match[2] ?? match[3] ?? ""); - } - - return { - apiUrl: values.get("API_URL"), - databaseUrl: values.get("DB_URL"), - publishableKey: values.get("PUBLISHABLE_KEY") ?? values.get("ANON_KEY"), - secretKey: values.get("SECRET_KEY") ?? values.get("SERVICE_ROLE_KEY"), - }; +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { appRoot, nextCliPath } from "../paths.mjs"; +import { createLocalApplicationEnvironment } from "./supabase-environment.mjs"; + +export function startLocalDevelopmentPreview({ + environment = createLocalApplicationEnvironment(), + spawnCommand = spawn, +} = {}) { + return spawnCommand( + process.execPath, + [ + nextCliPath, + "dev", + "--webpack", + "--hostname", + "127.0.0.1", + "--port", + "3000", + ], + { + cwd: appRoot, + env: environment, + stdio: "inherit", + }, + ); } -function readSupabaseEnvironment() { - const result = spawnSync("supabase", ["status", "-o", "env"], { - cwd: repositoryRoot, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); - - if (result.status !== 0) { - console.error( - "Local Supabase is unavailable. Run `pnpm db:start` before `pnpm dev:local`.", - ); - process.exit(result.status ?? 1); +function run() { + let child; + try { + child = startLocalDevelopmentPreview(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); } - const { apiUrl, databaseUrl, publishableKey, secretKey } = - parseSupabaseEnvironment(result.stdout); - if (!apiUrl || !publishableKey || !secretKey) { - console.error( - "Supabase did not return its local API URL, public key and server key.", - ); - process.exit(1); + for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => child.kill(signal)); } - return { apiUrl, databaseUrl, publishableKey, secretKey }; + child.on("exit", (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 1); + }); } -const { apiUrl, databaseUrl, publishableKey, secretKey } = - readSupabaseEnvironment(); -const child = spawn( - "pnpm", - ["run", "dev", "--hostname", "127.0.0.1", "--port", "3000"], - { - cwd: appRoot, - env: { - ...process.env, - NEXT_PUBLIC_SITE_URL: "http://127.0.0.1:3000", - // The import pipeline and admin workspace connect to Postgres directly. - COURSEMAP_DATABASE_URL: databaseUrl, - NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: publishableKey, - NEXT_PUBLIC_SUPABASE_URL: apiUrl, - SUPABASE_SECRET_KEY: secretKey, - }, - stdio: "inherit", - }, -); - -for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, () => child.kill(signal)); +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + run(); } - -child.on("exit", (code, signal) => { - if (signal) process.kill(process.pid, signal); - else process.exit(code ?? 1); -}); diff --git a/apps/web/scripts/local/production-preview.mjs b/apps/web/scripts/local/production-preview.mjs new file mode 100644 index 00000000..cd931755 --- /dev/null +++ b/apps/web/scripts/local/production-preview.mjs @@ -0,0 +1,59 @@ +import { spawn, spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { appRoot, nextCliPath } from "../paths.mjs"; +import { createLocalApplicationEnvironment } from "./supabase-environment.mjs"; + +export function startLocalProductionPreview({ + environment = createLocalApplicationEnvironment(), + runBuild = spawnSync, + spawnServer = spawn, +} = {}) { + const build = runBuild("pnpm", ["run", "build"], { + cwd: appRoot, + env: environment, + stdio: "inherit", + }); + if (build.status !== 0) { + return { child: null, exitCode: build.status ?? 1 }; + } + + const child = spawnServer( + process.execPath, + [nextCliPath, "start", "--hostname", "127.0.0.1", "--port", "3000"], + { + cwd: appRoot, + env: environment, + stdio: "inherit", + }, + ); + return { child, exitCode: null }; +} + +function run() { + let result; + try { + result = startLocalProductionPreview(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } + + if (!result.child) process.exit(result.exitCode ?? 1); + const child = result.child; + + for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => child.kill(signal)); + } + + child.on("exit", (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 1); + }); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + run(); +} diff --git a/apps/web/scripts/local/start-preview.mjs b/apps/web/scripts/local/start-preview.mjs new file mode 100644 index 00000000..1c19599a --- /dev/null +++ b/apps/web/scripts/local/start-preview.mjs @@ -0,0 +1,45 @@ +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { appRoot, nextCliPath } from "../paths.mjs"; +import { createLocalApplicationEnvironment } from "./supabase-environment.mjs"; + +export function startBuiltLocalProduction({ + environment = createLocalApplicationEnvironment(), + spawnCommand = spawn, +} = {}) { + return spawnCommand( + process.execPath, + [nextCliPath, "start", "--hostname", "127.0.0.1", "--port", "3000"], + { + cwd: appRoot, + env: environment, + stdio: "inherit", + }, + ); +} + +function run() { + let child; + try { + child = startBuiltLocalProduction(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } + + for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => child.kill(signal)); + } + + child.on("exit", (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 1); + }); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + run(); +} diff --git a/apps/web/scripts/local/supabase-environment.mjs b/apps/web/scripts/local/supabase-environment.mjs new file mode 100644 index 00000000..3765c3bb --- /dev/null +++ b/apps/web/scripts/local/supabase-environment.mjs @@ -0,0 +1,60 @@ +import { spawnSync } from "node:child_process"; +import { repositoryRoot } from "../paths.mjs"; + +export function parseSupabaseEnvironment(output) { + const values = new Map(); + for (const line of output.split(/\r?\n/)) { + const match = line.match(/^([A-Z_]+)=(?:"([^"]*)"|(.*))$/); + if (match) values.set(match[1], match[2] ?? match[3] ?? ""); + } + + return { + apiUrl: values.get("API_URL"), + databaseUrl: values.get("DB_URL"), + publishableKey: values.get("PUBLISHABLE_KEY") ?? values.get("ANON_KEY"), + secretKey: values.get("SECRET_KEY") ?? values.get("SERVICE_ROLE_KEY"), + }; +} + +export function readLocalSupabaseEnvironment({ runCommand = spawnSync } = {}) { + const result = runCommand("supabase", ["status", "-o", "env"], { + cwd: repositoryRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.status !== 0) { + throw new Error( + "Local Supabase is unavailable. Run `pnpm db:start` before starting a local preview.", + ); + } + + const environment = parseSupabaseEnvironment(result.stdout); + if ( + !environment.apiUrl || + !environment.databaseUrl || + !environment.publishableKey || + !environment.secretKey + ) { + throw new Error( + "Supabase did not return its local API URL, database URL, public key and server key.", + ); + } + + return environment; +} + +export function createLocalApplicationEnvironment({ + baseEnvironment = process.env, + supabaseEnvironment = readLocalSupabaseEnvironment(), +} = {}) { + return { + ...baseEnvironment, + NEXT_PUBLIC_SITE_URL: "http://127.0.0.1:3000", + // The import pipeline and admin workspace connect to Postgres directly. + COURSEMAP_DATABASE_URL: supabaseEnvironment.databaseUrl, + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: supabaseEnvironment.publishableKey, + NEXT_PUBLIC_SUPABASE_URL: supabaseEnvironment.apiUrl, + SUPABASE_SECRET_KEY: supabaseEnvironment.secretKey, + }; +} diff --git a/apps/web/scripts/paths.mjs b/apps/web/scripts/paths.mjs index d3dade29..c0a46d32 100644 --- a/apps/web/scripts/paths.mjs +++ b/apps/web/scripts/paths.mjs @@ -1,5 +1,8 @@ import { fileURLToPath } from "node:url"; export const appRoot = fileURLToPath(new URL("../", import.meta.url)); +export const nextCliPath = fileURLToPath( + new URL("../node_modules/next/dist/bin/next", import.meta.url), +); export const repositoryRoot = fileURLToPath( new URL("../../../", import.meta.url), ); diff --git a/apps/web/tests/app-sidebar.test.ts b/apps/web/tests/app-sidebar.test.ts new file mode 100644 index 00000000..6af96954 --- /dev/null +++ b/apps/web/tests/app-sidebar.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; +import { adminCatalogueNavigationYear } from "@/ui/shell/app-sidebar"; + +test("admin catalogue navigation preserves the year being viewed", () => { + expect(adminCatalogueNavigationYear("/admin/courses/2027", 2026)).toBe(2027); + expect( + adminCatalogueNavigationYear("/admin/majors/2025/MATH-MAJ", 2026), + ).toBe(2025); +}); + +test("admin catalogue navigation falls back to the profile catalogue year", () => { + expect(adminCatalogueNavigationYear("/admin/dashboard", 2026)).toBe(2026); + expect( + adminCatalogueNavigationYear("/admin/operations/catalogue", 2026), + ).toBe(2026); +}); diff --git a/apps/web/tests/auth-redirect.test.mjs b/apps/web/tests/auth-redirect.test.mjs index 6b509d92..73671bfa 100644 --- a/apps/web/tests/auth-redirect.test.mjs +++ b/apps/web/tests/auth-redirect.test.mjs @@ -39,6 +39,8 @@ test("rejects external, decoded and handler redirect destinations", () => { "/auth/callback%3Fcode=secret", "/auth/callback%23fragment", "/auth/logout", + "/auth/password", + "/auth/sign-in", ]; unsafe.forEach((candidate) => { diff --git a/apps/web/tests/breadcrumbs.test.tsx b/apps/web/tests/breadcrumbs.test.tsx index b5524975..0d6df723 100644 --- a/apps/web/tests/breadcrumbs.test.tsx +++ b/apps/web/tests/breadcrumbs.test.tsx @@ -2,11 +2,14 @@ import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, expect, test, vi } from "vitest"; import { Breadcrumbs } from "@/ui/shell/breadcrumbs"; +let pathname = "/admin/courses/2026/infs1001"; + vi.mock("next/navigation", () => ({ - usePathname: () => "/admin/courses/2026/infs1001", + usePathname: () => pathname, })); afterEach(() => { + pathname = "/admin/courses/2026/infs1001"; vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -162,3 +165,35 @@ test("does not repeat a catalogue section on its year directory", () => { ); expect(trail).not.toHaveTextContent("2026"); }); + +test.each([ + { + route: "/admin/operations/catalogue", + currentLabel: "Catalogue", + trailingLabel: "Syncs", + }, + { + route: "/admin/operations/catalogue/discovery", + currentLabel: undefined, + trailingLabel: undefined, + }, +])("shows the active catalogue operations section on $route", (props) => { + pathname = props.route; + measureAt(600); + render( + , + ); + + const trail = screen.getByRole("navigation", { name: "Breadcrumb" }); + expect(within(trail).getByRole("link", { name: "Admin" })).toBeVisible(); + expect(within(trail).getByRole("link", { name: "Catalogue" })).toBeVisible(); + expect( + within(trail).getByRole("link", { + name: props.trailingLabel ?? "Discovery", + }), + ).toHaveAttribute("aria-current", "page"); +}); diff --git a/apps/web/tests/catalogue-changelog-database.test.mjs b/apps/web/tests/catalogue-changelog-database.test.mjs index 1ffaf5da..0ac98924 100644 --- a/apps/web/tests/catalogue-changelog-database.test.mjs +++ b/apps/web/tests/catalogue-changelog-database.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { afterAll, beforeAll, test } from "vitest"; import { - createCatalogueDraft, + loadCatalogueEditorState, discardCatalogueDraft, publishCatalogueDraft, restoreCatalogueVersion, @@ -89,7 +89,7 @@ afterAll(async () => { }); test("every operation leaves one attributable audit event behind", async () => { - const draft = await createCatalogueDraft({ recordId, userId: ADMIN_ID, sql }); + const draft = (await loadCatalogueEditorState(recordId, sql)).draft; let revision = draft.revision; let content = draft.content; for (const description of ["First pass.", "Second pass.", "Third pass."]) { @@ -147,7 +147,7 @@ test("every operation leaves one attributable audit event behind", async () => { }); test("restoring a version keeps the draft it replaces", async () => { - const draft = await createCatalogueDraft({ recordId, userId: ADMIN_ID, sql }); + const draft = (await loadCatalogueEditorState(recordId, sql)).draft; const inProgress = structuredClone(draft.content); inProgress.course.details.description = "Work in progress worth keeping."; const saved = await saveCatalogueDraft({ diff --git a/apps/web/tests/catalogue-content-editor.test.tsx b/apps/web/tests/catalogue-content-editor.test.tsx index 5d83e760..4351d035 100644 --- a/apps/web/tests/catalogue-content-editor.test.tsx +++ b/apps/web/tests/catalogue-content-editor.test.tsx @@ -5,12 +5,18 @@ import { screen, waitFor, } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { TooltipProvider } from "@coursemap/ui/primitives/tooltip"; + import { emptyCatalogueContent } from "@/lib/catalogue/content"; +import { CatalogueEditorProvider } from "@/ui/admin/catalogue/catalogue-editor-context"; +import { CatalogueEditorToolbar } from "@/ui/admin/catalogue/catalogue-editor-toolbar"; import { CatalogueContentEditor } from "@/ui/admin/catalogue/content-editor"; const actions = vi.hoisted(() => ({ + begin: vi.fn(), save: vi.fn(), publish: vi.fn(), unpublish: vi.fn(), @@ -18,6 +24,7 @@ const actions = vi.hoisted(() => ({ })); vi.mock("@/lib/coursemap/admin-catalogue-actions", () => ({ + beginCatalogueDraftAction: actions.begin, saveCatalogueDraftAction: actions.save, publishDraftAction: actions.publish, unpublishAction: actions.unpublish, @@ -41,21 +48,29 @@ function initialContent() { }); } -function renderEditor() { +function renderEditor({ hasDraft = true } = {}) { return render( - , + + + + + + , ); } beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); + actions.begin.mockReset(); + actions.begin.mockResolvedValue({ ok: true, revision: 0 }); actions.save.mockReset(); }); @@ -134,3 +149,89 @@ test("a failed autosave preserves the edited value and reports the error", async ); expect(screen.getByLabelText("Description")).toHaveValue("Keep this text"); }); + +test("editing opens the draft actions, with nothing yet to publish", async () => { + actions.save.mockResolvedValue({ ok: true, revision: 1, unchanged: false }); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderEditor({ hasDraft: false }); + await user.click(screen.getByRole("button", { name: "Edit" })); + + expect(screen.getByRole("status")).toHaveTextContent("Draft"); + expect( + screen.getByRole("button", { name: "Discard draft" }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Publish" })).toBeDisabled(); + + fireEvent.change(screen.getByLabelText("Description"), { + target: { value: "Worth keeping" }, + }); + await act(async () => vi.advanceTimersByTime(1_000)); + + await waitFor(() => + expect(screen.getByRole("button", { name: "Publish" })).toBeEnabled(), + ); +}); + +test("discarding a draft leaves the record with nothing to discard", async () => { + actions.discard.mockResolvedValue({ ok: true, message: "Draft discarded." }); + renderEditor(); + + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + await user.click(screen.getByRole("button", { name: "Discard draft" })); + const confirm = await screen.findByRole("button", { + name: "Discard draft", + // The trigger is behind the open dialog, so only the confirmation + // inside it is still reachable. + hidden: false, + }); + await user.click(confirm); + await waitFor(() => expect(actions.discard).toHaveBeenCalled()); + await waitFor(() => + expect( + screen.queryByRole("button", { name: "Discard draft" }), + ).not.toBeInTheDocument(), + ); +}); + +test("a record without a draft is read until editing is asked for", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderEditor({ hasDraft: false }); + + expect(screen.getByRole("status")).toHaveTextContent("Published"); + expect(screen.queryByLabelText("Title")).not.toBeInTheDocument(); + // The values are still there to read, just not to change. + expect(screen.getByText("Test course")).toBeInTheDocument(); + expect(actions.begin).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Edit" })); + expect(screen.getByLabelText("Title")).toHaveValue("Test course"); + // Asking to edit is what opens the draft, so the record is still a draft + // when whoever opened it comes back to the page later. + expect(actions.begin).toHaveBeenCalledWith( + expect.objectContaining({ recordId: 42 }), + ); + expect(actions.save).not.toHaveBeenCalled(); +}); + +test("backing out of an opened draft discards it, keeping no checkpoint", async () => { + actions.discard.mockResolvedValue({ ok: true, message: "Draft discarded." }); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderEditor({ hasDraft: false }); + await user.click(screen.getByRole("button", { name: "Edit" })); + + await user.click(screen.getByRole("button", { name: "Discard draft" })); + expect( + screen.getByText( + "The editor goes back to the published version. Nothing has been changed in it, so nothing is kept.", + ), + ).toBeInTheDocument(); + await user.click( + await screen.findByRole("button", { name: "Discard draft", hidden: false }), + ); + + await waitFor(() => expect(actions.discard).toHaveBeenCalled()); + await waitFor(() => + expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument(), + ); + expect(screen.queryByLabelText("Description")).not.toBeInTheDocument(); +}); diff --git a/apps/web/tests/catalogue-drafts-database.test.mjs b/apps/web/tests/catalogue-drafts-database.test.mjs index ed3f7c34..d5f57cf4 100644 --- a/apps/web/tests/catalogue-drafts-database.test.mjs +++ b/apps/web/tests/catalogue-drafts-database.test.mjs @@ -2,8 +2,9 @@ import assert from "node:assert/strict"; import { afterAll, beforeAll, test } from "vitest"; import { + beginCatalogueDraft, CatalogueDraftConflictError, - createCatalogueDraft, + loadCatalogueEditorState, discardCatalogueDraft, publishCatalogueDraft, restoreCatalogueVersion, @@ -82,11 +83,7 @@ afterAll(async () => { }); test("mutable drafts autosave, audit, publish, discard and restore safely", async () => { - const initial = await createCatalogueDraft({ - recordId, - userId: ADMIN_ID, - sql, - }); + const initial = (await loadCatalogueEditorState(recordId, sql)).draft; assert.equal(initial.revision, 0); assert.equal(initial.baseVersionId, null); assert.equal(initial.content.course.details.title, "Draft Systems"); @@ -195,11 +192,7 @@ test("mutable drafts autosave, audit, publish, discard and restore safely", asyn "A manually authored course.", ); - const fromPublished = await createCatalogueDraft({ - recordId, - userId: ADMIN_ID, - sql, - }); + const fromPublished = (await loadCatalogueEditorState(recordId, sql)).draft; assert.equal(fromPublished.baseVersionId, firstPublish.versionId); assert.equal( fromPublished.contentHash, @@ -243,11 +236,7 @@ test("mutable drafts autosave, audit, publish, discard and restore safely", asyn }); assert.equal(revertedDiscard.meaningful, false); - const nextDraft = await createCatalogueDraft({ - recordId, - userId: ADMIN_ID, - sql, - }); + const nextDraft = (await loadCatalogueEditorState(recordId, sql)).draft; const secondEdit = structuredClone(nextDraft.content); secondEdit.course.details.title = "Draft Systems Advanced"; await saveCatalogueDraft({ @@ -337,11 +326,7 @@ test("mutable drafts autosave, audit, publish, discard and restore safely", asyn 2, ); - const blank = await createCatalogueDraft({ - recordId, - userId: ADMIN_ID, - sql, - }); + const blank = (await loadCatalogueEditorState(recordId, sql)).draft; const discardContent = structuredClone(blank.content); discardContent.course.details.description = "Work worth restoring."; const discardSave = await saveCatalogueDraft({ @@ -432,3 +417,116 @@ test("mutable drafts autosave, audit, publish, discard and restore safely", asyn assert.equal(noOpDiscard.checkpointVersionId, null); assert.ok(secondPublish.versionId > firstPublish.versionId); }); + +async function draftRowCount() { + const [row] = await sql` + select count(*)::integer as count + from public.catalogue_drafts where record_id = ${recordId} + `; + return row.count; +} + +test("reading a record, or saving it unchanged, never makes it a draft", async () => { + await sql`delete from public.catalogue_drafts where record_id = ${recordId}`; + const opened = await loadCatalogueEditorState(recordId, sql); + assert.equal(opened.hasDraft, false); + assert.equal(opened.hasChanges, false); + assert.equal(opened.draft.revision, 0); + assert.equal(await draftRowCount(), 0); + + const untouched = await saveCatalogueDraft({ + recordId, + expectedRevision: 0, + content: opened.draft.content, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + assert.equal(untouched.unchanged, true); + assert.equal(await draftRowCount(), 0); + + const edited = structuredClone(opened.draft.content); + edited.course.details.description = "Now there is something to keep."; + const saved = await saveCatalogueDraft({ + recordId, + expectedRevision: 0, + content: edited, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + assert.equal(saved.draft.revision, 1); + assert.equal(await draftRowCount(), 1); + const editedState = await loadCatalogueEditorState(recordId, sql); + assert.equal(editedState.hasDraft, true); + assert.equal(editedState.hasChanges, true); + + // Undoing the edit by hand leaves the draft open - it is still the record + // someone is working on - but it no longer says anything to publish. + const reverted = await saveCatalogueDraft({ + recordId, + expectedRevision: 1, + content: opened.draft.content, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + assert.equal(reverted.draft.contentHash, opened.draft.contentHash); + const revertedState = await loadCatalogueEditorState(recordId, sql); + assert.equal(revertedState.hasDraft, true); + assert.equal(revertedState.hasChanges, false); + + const discarded = await discardCatalogueDraft({ + recordId, + expectedRevision: reverted.draft.revision, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + assert.equal(discarded.meaningful, false); + assert.equal(await draftRowCount(), 0); +}); + +test("asking to edit a record opens a draft on it, unchanged", async () => { + await sql`delete from public.catalogue_drafts where record_id = ${recordId}`; + const base = await loadCatalogueEditorState(recordId, sql); + + const { draft } = await beginCatalogueDraft({ + recordId, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + assert.equal(draft.revision, 0); + assert.equal(draft.contentHash, base.draft.contentHash); + assert.equal(await draftRowCount(), 1); + + // The record is now a draft, and stays one for whoever opens it next, but + // there is nothing in it that the publication does not already say. + const opened = await loadCatalogueEditorState(recordId, sql); + assert.equal(opened.hasDraft, true); + assert.equal(opened.hasChanges, false); + + // Asking twice is asking once: the draft already open is handed back. + const again = await beginCatalogueDraft({ + recordId, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + assert.equal(again.draft.revision, 0); + assert.equal(await draftRowCount(), 1); + + // Backing out of an untouched draft keeps no checkpoint: there was nothing + // in it to come back to. + const discarded = await discardCatalogueDraft({ + recordId, + expectedRevision: again.draft.revision, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + assert.equal(discarded.meaningful, false); + assert.equal(discarded.checkpointVersionId, null); + assert.equal(await draftRowCount(), 0); +}); diff --git a/apps/web/tests/catalogue-record-state.test.ts b/apps/web/tests/catalogue-record-state.test.ts new file mode 100644 index 00000000..eccf1a50 --- /dev/null +++ b/apps/web/tests/catalogue-record-state.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from "vitest"; +import { + CATALOGUE_STATES, + CATALOGUE_STATE_LABELS, + type CatalogueDirectoryRecord, + catalogueRecordState, +} from "@/lib/coursemap/catalogue-kinds"; + +function record( + overrides: Partial = {}, +): CatalogueDirectoryRecord { + return { + code: "INFS1001", + title: "Introduction", + summary: {}, + recordId: 1, + hasDraft: false, + hasChanges: false, + draftRevision: null, + isPublished: false, + isListedByAnu: true, + lastSeenAt: null, + sourceState: "up_to_date", + openChangeCount: 0, + conflictCount: 0, + latestSync: null, + ...overrides, + }; +} + +test("reports the most urgent fact about a record, in that order", () => { + expect( + catalogueRecordState( + record({ + sourceState: "sync_failed", + isListedByAnu: false, + hasDraft: true, + isPublished: true, + }), + ), + ).toBe("sync_failed"); + expect( + catalogueRecordState( + record({ isListedByAnu: false, hasDraft: true, isPublished: true }), + ), + ).toBe("delisted"); + expect(catalogueRecordState(record({ sourceState: "syncing" }))).toBe( + "syncing", + ); + expect( + catalogueRecordState( + record({ + sourceState: "changes_available", + openChangeCount: 2, + hasDraft: true, + }), + ), + ).toBe("changes_available"); + // Unpublished work outranks publication: that record still needs a person. + expect( + catalogueRecordState(record({ hasDraft: true, isPublished: true })), + ).toBe("draft"); + expect(catalogueRecordState(record({ isPublished: true }))).toBe("published"); + expect(catalogueRecordState(record())).toBe("unpublished"); +}); + +test("treats a sync that found nothing outstanding as settled", () => { + expect( + catalogueRecordState( + record({ + sourceState: "changes_available", + openChangeCount: 0, + isPublished: true, + }), + ), + ).toBe("published"); +}); + +test("offers every state the directory can be narrowed to a name", () => { + expect(CATALOGUE_STATES).toEqual(Object.keys(CATALOGUE_STATE_LABELS)); + for (const state of CATALOGUE_STATES) + expect(CATALOGUE_STATE_LABELS[state]).toBeTruthy(); +}); diff --git a/apps/web/tests/catalogue-student-view-panel.test.tsx b/apps/web/tests/catalogue-student-view-panel.test.tsx index 19d4832d..6411019b 100644 --- a/apps/web/tests/catalogue-student-view-panel.test.tsx +++ b/apps/web/tests/catalogue-student-view-panel.test.tsx @@ -57,7 +57,7 @@ test("the published version is one keyboard-reachable control away", () => { ); }); -test("an unpublished record shows its draft and says students see nothing", () => { +test("an unpublished record shows its draft without a dead control", () => { render( { diff --git a/apps/web/tests/catalogue-sync-button.test.tsx b/apps/web/tests/catalogue-sync-button.test.tsx index 399cd671..3ca963e4 100644 --- a/apps/web/tests/catalogue-sync-button.test.tsx +++ b/apps/web/tests/catalogue-sync-button.test.tsx @@ -2,19 +2,32 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, expect, test, vi } from "vitest"; import { CatalogueSyncButton } from "@/ui/admin/catalogue/sync-button"; -const { refresh, success, failure } = vi.hoisted(() => ({ +const { refresh, progress, success, info, failure } = vi.hoisted(() => ({ refresh: vi.fn(), + progress: vi.fn(), success: vi.fn(), + info: vi.fn(), failure: vi.fn(), })); vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh }) })); -vi.mock("sonner", () => ({ toast: { success, error: failure } })); +// The running toast is a plain toast rather than a loading one, because sonner +// withholds the close button from loading toasts. +vi.mock("sonner", () => ({ + toast: Object.assign(progress, { + loading: progress, + success, + info, + error: failure, + }), +})); beforeEach(() => { vi.restoreAllMocks(); refresh.mockReset(); + progress.mockReset(); success.mockReset(); + info.mockReset(); failure.mockReset(); }); @@ -25,24 +38,107 @@ test("starts one record-level ANU sync", async () => { headers: { "content-type": "application/json" }, }), ); - render(); + render( + , + ); - fireEvent.click(screen.getByRole("button", { name: "Sync from ANU" })); + fireEvent.click(screen.getByRole("button", { name: "Sync" })); await waitFor(() => expect(request).toHaveBeenCalledTimes(1)); expect(JSON.parse(String(request.mock.calls[0]?.[1]?.body))).toEqual({ recordId: 42, kind: "course", }); - expect(await screen.findByRole("button")).toBeDisabled(); - expect(screen.getByRole("button")).toHaveTextContent("Syncing from ANU..."); + // The progress belongs to the toast, so the button keeps its own label + // rather than reflowing the header it sits in. + const button = await screen.findByRole("button"); + expect(button).toBeDisabled(); + expect(button).toHaveTextContent("Sync"); + expect(progress).toHaveBeenCalledWith( + "Syncing COMP1100 from ANU", + expect.objectContaining({ id: "sync:42" }), + ); +}); + +test("reports a sync that could not start in its own toast", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ error: "Sync permission is required." }), { + status: 403, + headers: { "content-type": "application/json" }, + }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Sync" })); + + await waitFor(() => + expect(failure).toHaveBeenCalledWith( + "Syncing COMP1100 from ANU could not start", + expect.objectContaining({ id: "sync:42" }), + ), + ); + // The description is clamped to one line, so the reason it failed is carried + // whole in the tooltip. + const [, options] = failure.mock.calls[0] as [ + string, + { description: { props: { title: string } } }, + ]; + expect(options.description.props.title).toBe("Sync permission is required."); + expect(screen.getByRole("button")).toBeEnabled(); +}); + +test("hands the toast back when the page that was watching it goes", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ syncId: "sync-1", mode: "inline" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const view = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Sync" })); + await waitFor(() => expect(progress).toHaveBeenCalled()); + view.unmount(); + + // Otherwise the toast spins at whatever percentage it had reached, with + // nothing left polling to ever finish it. + await waitFor(() => + expect(info).toHaveBeenCalledWith( + "The ANU sync is still running", + expect.objectContaining({ id: "sync:42" }), + ), + ); }); test("offers a retry after a failed sync", () => { render( ({ + usePathname: () => pathname, +})); + +vi.mock("@/ui/shell", () => ({ + AppShell: ({ children }: { children: ReactNode }) => ( + {children} + ), +})); + test("missing pages have one heading and useful routes home and to the catalogue", () => { render(); expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1); @@ -43,6 +56,21 @@ test("client failures are not labelled as an HTTP server response", () => { expect(screen.queryByText(/500/)).not.toBeInTheDocument(); }); +test("admin failures keep the admin shell and return to its overview", () => { + pathname = "/admin/operations/catalogue"; + try { + render( + {}} />, + ); + expect(screen.getByTestId("admin-shell")).toBeVisible(); + expect( + screen.getByRole("link", { name: "Back to overview" }), + ).toHaveAttribute("href", "/admin/dashboard"); + } finally { + pathname = "/courses"; + } +}); + test("an offline failure shows reconnect guidance and returns to the normal error when online", async () => { const online = vi.spyOn(navigator, "onLine", "get").mockReturnValue(false); const reset = vi.fn(); diff --git a/apps/web/tests/local-preview-seed.test.mjs b/apps/web/tests/local-preview-seed.test.mjs index 2ff68524..422dd2cb 100644 --- a/apps/web/tests/local-preview-seed.test.mjs +++ b/apps/web/tests/local-preview-seed.test.mjs @@ -1,4 +1,4 @@ -import { repositoryRoot } from "../scripts/paths.mjs"; +import { nextCliPath, repositoryRoot } from "../scripts/paths.mjs"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import { readFile } from "node:fs/promises"; @@ -9,11 +9,14 @@ import { resetLocalPreview, } from "../scripts/local/reset-preview.mjs"; import { seedLocalPreview } from "../scripts/local/seed-preview.mjs"; - -const devPreviewSource = new URL( - "../scripts/local/dev-preview.mjs", - import.meta.url, -); +import { buildLocalProduction } from "../scripts/local/build-preview.mjs"; +import { startLocalDevelopmentPreview } from "../scripts/local/dev-preview.mjs"; +import { startLocalProductionPreview } from "../scripts/local/production-preview.mjs"; +import { startBuiltLocalProduction } from "../scripts/local/start-preview.mjs"; +import { + createLocalApplicationEnvironment, + parseSupabaseEnvironment, +} from "../scripts/local/supabase-environment.mjs"; test("keeps predictable preview credentials out of Supabase's default seed", async () => { const defaultSeed = await readFile( @@ -42,12 +45,146 @@ test("the local preview publishes every selectable academic structure kind", asy assert.match(previewSeed, /set published_version_id = snapshots\.id/u); }); -test("passes the local server key to durable import workers", async () => { - const source = await readFile(devPreviewSource, "utf8"); +test("passes the local server key to durable import workers", () => { + const supabaseEnvironment = parseSupabaseEnvironment( + [ + 'API_URL="http://127.0.0.1:54321"', + 'DB_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"', + 'ANON_KEY="public-key"', + 'SERVICE_ROLE_KEY="server-key"', + ].join("\n"), + ); + const environment = createLocalApplicationEnvironment({ + baseEnvironment: { KEEP_ME: "yes" }, + supabaseEnvironment, + }); + + assert.equal(environment.KEEP_ME, "yes"); + assert.equal( + environment.NEXT_PUBLIC_SUPABASE_URL, + supabaseEnvironment.apiUrl, + ); + assert.equal(environment.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, "public-key"); + assert.equal(environment.SUPABASE_SECRET_KEY, "server-key"); + assert.equal( + environment.COURSEMAP_DATABASE_URL, + supabaseEnvironment.databaseUrl, + ); +}); + +test("builds and starts the production preview with the same local environment", () => { + const calls = []; + const child = new EventEmitter(); + const environment = { LOCAL_PREVIEW: "true" }; + + const result = startLocalProductionPreview({ + environment, + runBuild(executable, args, options) { + calls.push({ executable, args, options }); + return { status: 0 }; + }, + spawnServer(executable, args, options) { + calls.push({ executable, args, options }); + return child; + }, + }); + + assert.equal(result.child, child); + assert.equal(result.exitCode, null); + assert.deepEqual(calls, [ + { + executable: "pnpm", + args: ["run", "build"], + options: { + cwd: new URL("../", import.meta.url).pathname, + env: environment, + stdio: "inherit", + }, + }, + { + executable: process.execPath, + args: [nextCliPath, "start", "--hostname", "127.0.0.1", "--port", "3000"], + options: { + cwd: new URL("../", import.meta.url).pathname, + env: environment, + stdio: "inherit", + }, + }, + ]); +}); + +test("the standalone build and start commands also inject the local environment", () => { + const calls = []; + const environment = { LOCAL_PREVIEW: "true" }; + const child = new EventEmitter(); - assert.match(source, /values\.get\("SECRET_KEY"\)/u); - assert.match(source, /values\.get\("SERVICE_ROLE_KEY"\)/u); - assert.match(source, /SUPABASE_SECRET_KEY: secretKey/u); + const buildStatus = buildLocalProduction({ + environment, + runCommand(executable, args, options) { + calls.push({ executable, args, options }); + return { status: 0 }; + }, + }); + const server = startBuiltLocalProduction({ + environment, + spawnCommand(executable, args, options) { + calls.push({ executable, args, options }); + return child; + }, + }); + + assert.equal(buildStatus, 0); + assert.equal(server, child); + assert.deepEqual( + calls.map(({ args, options }) => ({ args, environment: options.env })), + [ + { args: ["run", "build:next"], environment }, + { + args: [ + nextCliPath, + "start", + "--hostname", + "127.0.0.1", + "--port", + "3000", + ], + environment, + }, + ], + ); +}); + +test("development starts Next directly so stopping it cannot orphan a server", () => { + let command; + const child = new EventEmitter(); + const environment = { LOCAL_PREVIEW: "true" }; + + const server = startLocalDevelopmentPreview({ + environment, + spawnCommand(executable, args, options) { + command = { executable, args, options }; + return child; + }, + }); + + assert.equal(server, child); + assert.deepEqual(command, { + executable: process.execPath, + args: [ + nextCliPath, + "dev", + "--webpack", + "--hostname", + "127.0.0.1", + "--port", + "3000", + ], + options: { + cwd: new URL("../", import.meta.url).pathname, + env: environment, + stdio: "inherit", + }, + }); }); test("runs the preview fixture through the verified local database client", async () => { diff --git a/apps/web/tests/operations-error.test.tsx b/apps/web/tests/operations-error.test.tsx new file mode 100644 index 00000000..4559cd24 --- /dev/null +++ b/apps/web/tests/operations-error.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { expect, test, vi } from "vitest"; +import { CatalogueOperationsError } from "@/ui/admin/operations/operations-error"; + +vi.mock("@/ui/shell", () => ({ + AppShell: ({ children }: { children: ReactNode }) => ( + {children} + ), +})); + +test("keeps the admin shell around catalogue operations errors", () => { + const reset = vi.fn(); + render( + , + ); + + expect(screen.getByTestId("admin-shell")).toBeTruthy(); + expect( + screen.getByRole("heading", { + name: "We couldn't load catalogue activity", + }), + ).toBeTruthy(); + expect(screen.getByText("Error reference: reference-123")).toBeTruthy(); + expect( + screen.getByRole("link", { name: "Back to overview" }).getAttribute("href"), + ).toBe("/admin/dashboard"); + + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(reset).toHaveBeenCalledOnce(); +}); diff --git a/apps/web/tests/operations-sync-views.test.tsx b/apps/web/tests/operations-sync-views.test.tsx index f71ea429..e01e2919 100644 --- a/apps/web/tests/operations-sync-views.test.tsx +++ b/apps/web/tests/operations-sync-views.test.tsx @@ -1,5 +1,6 @@ import { render, screen, within } from "@testing-library/react"; -import { expect, test, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, test, vi } from "vitest"; import { TooltipProvider } from "@coursemap/ui/primitives/tooltip"; import type { @@ -9,12 +10,18 @@ import type { } from "@/lib/coursemap/admin-operations"; import { DiscoveryList } from "@/ui/admin/operations/discovery-list"; import { SyncDetailView } from "@/ui/admin/operations/sync-detail"; +import { + SyncDetailTabList, + SyncDetailTabs, +} from "@/ui/admin/operations/sync-detail-tabs"; import { SyncList } from "@/ui/admin/operations/sync-list"; +let searchParams = new URLSearchParams(); + vi.mock("next/navigation", () => ({ usePathname: () => "/admin/operations/catalogue", useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), - useSearchParams: () => new URLSearchParams(), + useSearchParams: () => searchParams, })); vi.mock("@/ui/admin/operations/artefact-viewer", () => ({ @@ -23,6 +30,10 @@ vi.mock("@/ui/admin/operations/artefact-viewer", () => ({ ), })); +afterEach(() => { + searchParams = new URLSearchParams(); +}); + function syncPage( overrides: Partial = {}, ): SyncOperationsPage { @@ -149,6 +160,23 @@ function syncDetail(overrides: Partial = {}): SyncDetail { }; } +/** The detail view reads one tab at a time, so its tab bar comes with it. */ +function renderSyncDetail(sync: SyncDetail) { + return render( + + stage.status === "failed").length + } + /> + + , + ); +} + function renderSyncList(page: SyncOperationsPage) { // FilterBar carries hints through the shared tooltip provider. return render( @@ -177,22 +205,30 @@ test("an empty list says what fills it rather than showing an empty table", () = expect(screen.queryByRole("table")).toBeNull(); }); -test("the sync detail shows the failure, the lease and the attempt that failed", () => { - render(); +test("the sync detail shows the failure, the lease and the attempt that failed", async () => { + const user = userEvent.setup(); + renderSyncDetail(syncDetail()); + // The failure and the lease are true of the sync, so they lead every tab. expect(screen.getByText("OPENROUTER_HTTP_500")).toBeTruthy(); - // The alert and the stage that failed both name it. - expect(screen.getAllByText("OpenRouter returned 500.").length).toBe(2); + expect(screen.getByText("OpenRouter returned 500.")).toBeTruthy(); expect(screen.getByText("99999999-9999-4999-8999-999999999999")).toBeTruthy(); + + await user.click(screen.getByRole("tab", { name: /Stages/ })); const stages = screen.getByText("Model extraction").closest("tr"); expect(within(stages!).getByText("failed")).toBeTruthy(); expect(within(stages!).getByText("3")).toBeTruthy(); + expect(within(stages!).getByText("OpenRouter returned 500.")).toBeTruthy(); + + await user.click(screen.getByRole("tab", { name: /Extractions/ })); expect(screen.getByText("openai/gpt-5-2026")).toBeTruthy(); expect(screen.getByText("2 errors")).toBeTruthy(); + + await user.click(screen.getByRole("tab", { name: /Artefacts/ })); expect(screen.getByTestId("artefacts").textContent).toBe("1"); }); test("the sync detail links back to the record it checked", () => { - render(); + renderSyncDetail(syncDetail()); expect( screen.getByRole("link", { name: /Open the record/ }).getAttribute("href"), ).toBe("/admin/courses/2027/comp2700"); @@ -214,10 +250,59 @@ test("an incomplete listing check says so, because it cannot retire anything", ( errorMessage: null, }, ]; - render(); + render( + + + , + ); + expect( + screen.getByPlaceholderText("Search listing checks"), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Filter" })).toBeInTheDocument(); expect(screen.getByText("Partial")).toBeTruthy(); expect( screen.getByRole("link", { name: "Courses" }).getAttribute("href"), ).toBe("/admin/operations/catalogue/discovery/7"); expect(screen.getByText("120 discovered")).toBeTruthy(); }); + +test("discovery search narrows the loaded listing checks", () => { + searchParams = new URLSearchParams("q=programmes"); + const checks: DiscoveryCheckRow[] = [ + { + id: 7, + kind: "course", + academicYear: 2027, + status: "completed", + isComplete: true, + discoveredCount: 120, + startedAt: "2026-09-21T10:00:00.000Z", + completedAt: "2026-09-21T10:00:20.000Z", + durationMs: 20_000, + errorCode: null, + errorMessage: null, + }, + { + id: 8, + kind: "programme", + academicYear: 2027, + status: "failed", + isComplete: false, + discoveredCount: 0, + startedAt: "2026-09-21T11:00:00.000Z", + completedAt: "2026-09-21T11:00:02.000Z", + durationMs: 2_000, + errorCode: "FETCH_FAILED", + errorMessage: "The listing could not be fetched.", + }, + ]; + + render( + + + , + ); + + expect(screen.getByRole("link", { name: "Programmes" })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Courses" })).toBeNull(); +}); diff --git a/apps/web/tests/sign-in-form.test.tsx b/apps/web/tests/sign-in-form.test.tsx new file mode 100644 index 00000000..deec71cb --- /dev/null +++ b/apps/web/tests/sign-in-form.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { SignInForm } from "@/app/auth/sign-in/sign-in-form"; + +describe("SignInForm", () => { + it("posts credentials when submitted before client hydration", () => { + render(); + + const form = screen + .getByRole("button", { name: "Sign in" }) + .closest("form"); + expect(form).toHaveAttribute("method", "post"); + expect(form).toHaveAttribute("action", "/auth/password"); + }); + + it("shows a server-side fallback error", () => { + render( + , + ); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Email or password is incorrect.", + ); + }); +}); diff --git a/apps/web/ui/admin/catalogue-table/catalogue-empty.tsx b/apps/web/ui/admin/catalogue-table/catalogue-empty.tsx index 5c17f5ec..8ef0f0f2 100644 --- a/apps/web/ui/admin/catalogue-table/catalogue-empty.tsx +++ b/apps/web/ui/admin/catalogue-table/catalogue-empty.tsx @@ -12,6 +12,7 @@ export function CatalogueEmpty({ error = false, clearHref, onSync, + syncing = false, children, }: { title: string; @@ -20,6 +21,7 @@ export function CatalogueEmpty({ error?: boolean; clearHref?: string; onSync?: () => void; + syncing?: boolean; children?: ReactNode; }) { return ( @@ -36,7 +38,13 @@ export function CatalogueEmpty({ Clear filters ) : onSync ? ( - + Run sync now ) : ( diff --git a/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx b/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx index 51508a17..82021b9e 100644 --- a/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx +++ b/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx @@ -128,9 +128,11 @@ export function CatalogueTableLoading({ ) : null} + {/* Search and the filter button beside it. Every table this stands in + for offers both, so the row is held open at its full width. */} - {imports ? : null} + (null); const router = useRouter(); + + // The menu is anchored to a row inside a table that scrolls on its own. It + // keeps tracking that row, so a scroll carries it out of the table and over + // the toolbar above while the row itself is clipped away. The menu belongs + // to a row that is no longer where it was, so it closes rather than chases. + // Scrolling within the menu's own list is not that, and is left alone. + useEffect(() => { + if (!open) return; + function closeOnScrollAway(event: Event) { + const target = event.target; + if (target instanceof Node && content.current?.contains(target)) return; + setOpen(false); + } + // Scroll does not bubble, so the capture phase is the only way to hear a + // scroll from a container this component does not own. + document.addEventListener("scroll", closeOnScrollAway, true); + return () => + document.removeEventListener("scroll", closeOnScrollAway, true); + }, [open]); const items = links.map((link, index) => ({ value: String(index), label: link.label, @@ -74,6 +94,10 @@ export function CatalogueRowActions({ + + Sync failed + + ); + case "delisted": + return ( + + + No longer listed + + ); + case "syncing": + return ( + + + Syncing + + ); + case "changes_available": + return ( + + + {record.conflictCount > 0 + ? `${record.openChangeCount} ANU change${record.openChangeCount === 1 ? "" : "s"}, ${record.conflictCount} conflict${record.conflictCount === 1 ? "" : "s"}` + : `${record.openChangeCount} ANU change${record.openChangeCount === 1 ? "" : "s"}`} + + ); + case "draft": + return Draft; + case "published": + return Published; + default: + return Not published; + } +} diff --git a/apps/web/ui/admin/catalogue-table/catalogue-table.module.css b/apps/web/ui/admin/catalogue-table/catalogue-table.module.css index a9920e90..7ad88c7e 100644 --- a/apps/web/ui/admin/catalogue-table/catalogue-table.module.css +++ b/apps/web/ui/admin/catalogue-table/catalogue-table.module.css @@ -176,14 +176,13 @@ a.title:focus-visible { min-width: 700px; grid-template-columns: minmax(260px, 1fr) 150px 120px 120px 60px; } -/* Catalogue directory: select, identity, details, workflow status, latest - import, actions. The actions column matches the width the sibling layouts - reserve, so the menu button lands in the same place on every admin table. */ +/* Catalogue directory: identity, state, latest sync, actions. The identity + takes every spare pixel because the title and its facts are what is read; + the actions column matches the width the sibling layouts reserve, so the + menu button lands in the same place on every admin table. */ .shell[data-layout="directory"] tr { - min-width: 880px; - grid-template-columns: - 48px minmax(280px, 1.4fr) minmax(160px, 1fr) - 170px 170px 60px; + min-width: 620px; + grid-template-columns: minmax(280px, 1fr) 210px 110px 60px; } /* Operations syncs: record, year, status, trigger, started, duration, model, diff --git a/apps/web/ui/admin/catalogue-table/catalogue-table.tsx b/apps/web/ui/admin/catalogue-table/catalogue-table.tsx index db95d128..010b76c8 100644 --- a/apps/web/ui/admin/catalogue-table/catalogue-table.tsx +++ b/apps/web/ui/admin/catalogue-table/catalogue-table.tsx @@ -70,12 +70,15 @@ export function CatalogueIdentity({ title, kind = "course", href, + meta = [], unavailable = false, }: { code: string; title: string; kind?: string; href?: string; + /** Facts that belong beside the code rather than in a column of their own. */ + meta?: string[]; unavailable?: boolean; }) { const subjectIcons = { @@ -113,8 +116,9 @@ export function CatalogueIdentity({ {title} )} - {code} - {unavailable ? " · No longer listed" : ""} + {[code, ...meta, ...(unavailable ? ["No longer listed"] : [])].join( + " · ", + )} diff --git a/apps/web/ui/admin/catalogue/catalogue-directory.tsx b/apps/web/ui/admin/catalogue/catalogue-directory.tsx index 4f484da6..753bb3ba 100644 --- a/apps/web/ui/admin/catalogue/catalogue-directory.tsx +++ b/apps/web/ui/admin/catalogue/catalogue-directory.tsx @@ -1,19 +1,20 @@ "use client"; -import { Badge } from "@coursemap/ui/components/badge"; import { Button } from "@coursemap/ui/primitives/button"; -import { LoaderCircle, RefreshCw, TriangleAlert } from "lucide-react"; +import { LoaderCircle, RefreshCw } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useState, useTransition } from "react"; -import { toast } from "sonner"; +import { catalogueSummaryMeta } from "@/lib/coursemap/catalogue-summary"; import { CATALOGUE_KIND_LABELS, + CATALOGUE_STATES, + CATALOGUE_STATE_LABELS, type CatalogueDirectoryPage, - type CatalogueDirectoryRecord, adminCatalogueRecordPath, adminCatalogueYearPath, } from "@/lib/coursemap/catalogue-kinds"; import { CatalogueEmpty } from "@/ui/admin/catalogue-table/catalogue-empty"; +import { CatalogueStateBadge } from "@/ui/admin/catalogue-table/catalogue-state-badge"; import { CatalogueIdentity, DataTableShell, @@ -25,55 +26,84 @@ import { TableHeader, TableRow, } from "@/ui/admin/catalogue-table/catalogue-table"; +import { DirectoryRowActions } from "@/ui/admin/catalogue/directory-row-actions"; import { FilterBar } from "@/ui/common/filter-bar"; import { LinkedTableRow } from "@/ui/common/linked-table-row"; import { Pagination } from "@/ui/common/pagination"; +import { startTask } from "@/ui/common/task-toast"; import { YearPicker } from "@/ui/common/year-picker"; import { readImportStream } from "./import-stream"; -function formatDate(value: string | null) { - if (!value) return null; - return new Intl.DateTimeFormat("en-AU", { dateStyle: "medium" }).format( - new Date(value), - ); +/** The column is scanned, so the year is dropped once it is the obvious one. */ +function shortDate(value: string) { + const date = new Date(value); + return new Intl.DateTimeFormat("en-AU", { + day: "numeric", + month: "short", + ...(date.getFullYear() === new Date().getFullYear() + ? {} + : { year: "numeric" }), + }).format(date); } -const SOURCE_STATE_LABELS = { - never_synced: "Never synced", - syncing: "Syncing", - up_to_date: "Up to date", - changes_available: "Changes available", - sync_failed: "Sync failed", -} as const; - -/** Names the work waiting on a record rather than the pipeline state. */ -function sourceStateLabel(record: CatalogueDirectoryRecord) { - if (record.sourceState !== "changes_available") - return SOURCE_STATE_LABELS[record.sourceState]; - const changes = `${record.openChangeCount} ANU change${record.openChangeCount === 1 ? "" : "s"}`; - if (record.conflictCount === 0) return changes; - return `${changes}, ${record.conflictCount} conflict${record.conflictCount === 1 ? "" : "s"}`; +/** The whole timestamp, for the hover that answers "when exactly?". */ +function fullDate(value: string) { + return new Intl.DateTimeFormat("en-AU", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); } -function sourceStateVariant( - state: keyof typeof SOURCE_STATE_LABELS, -): "outline" | "success-light" | "warning-light" | "destructive-light" { - if (state === "up_to_date") return "success-light"; - if (state === "changes_available" || state === "syncing") - return "warning-light"; - if (state === "sync_failed") return "destructive-light"; - return "outline"; +/** + * Each phase of the refresh gets a stretch of the bar: where it starts, which + * is what the work has reported, and where it ends, which the bar drifts + * towards while the phase lasts. ANU answers some phases instantly, so + * without the stretch the bar would be still for a second and then teleport. + */ +const REFRESH_PHASES: Record = { + fetching: { percent: 12, ceiling: 62 }, + saving: { percent: 68, ceiling: 92 }, + done: { percent: 94, ceiling: 99 }, +}; + +type RefreshResult = { + entryCount?: number; + added?: number; + updated?: number; + retired?: number; + isComplete?: boolean; +}; + +/** What the refresh actually did, rather than that it happened. */ +function refreshSummary(result: RefreshResult) { + const entries = `${(result.entryCount ?? 0).toLocaleString("en-AU")} ${ + result.entryCount === 1 ? "entry" : "entries" + }`; + const changes = [ + result.added ? `${result.added} added` : null, + result.updated ? `${result.updated} updated` : null, + result.retired ? `${result.retired} retired` : null, + ].filter(Boolean); + return `${entries} · ${changes.length ? changes.join(", ") : "no changes"}`; } +/** + * The states a row can be in, in the order the badge ranks them, so the menu + * reads down from the rows that need a person to the ones that do not. + */ +const STATE_OPTIONS = CATALOGUE_STATES.map((state) => ({ + value: state, + label: CATALOGUE_STATE_LABELS[state], +})); + export function CatalogueDirectory({ page }: { page: CatalogueDirectoryPage }) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const labels = CATALOGUE_KIND_LABELS[page.kind]; const [refreshing, setRefreshing] = useState(false); - const [refreshMessage, setRefreshMessage] = useState(null); const [, startTransition] = useTransition(); - const filtered = Boolean(searchParams.get("q")); + const filtered = Boolean(searchParams.get("q") || searchParams.get("state")); function changeYear(year: number | "all") { if (year === "all") return; @@ -87,7 +117,12 @@ export function CatalogueDirectory({ page }: { page: CatalogueDirectoryPage }) { async function refreshDirectory() { setRefreshing(true); - setRefreshMessage("Contacting ANU..."); + const task = startTask({ + id: `directory:${page.kind}:${page.academicYear}`, + title: `Refreshing the ANU ${labels.singular.toLowerCase()} listing`, + detail: "Contacting ANU.", + ceiling: 10, + }); try { const response = await fetch("/api/admin/catalogue-directory", { method: "POST", @@ -97,20 +132,43 @@ export function CatalogueDirectory({ page }: { page: CatalogueDirectoryPage }) { academicYear: page.academicYear, }), }); + let result: RefreshResult = {}; await readImportStream(response, (event) => { + if (event.type === "started") { + task.step({ percent: 4, ceiling: 20, detail: "Contacting ANU." }); + } if (event.type === "progress" && typeof event.message === "string") { - setRefreshMessage(event.message); + const phase = REFRESH_PHASES[String(event.phase)] ?? { + percent: 50, + ceiling: 80, + }; + task.step({ ...phase, detail: event.message }); + } + if (event.type === "complete" && event.result) { + result = event.result as RefreshResult; } }); - toast.success("ANU listing refreshed."); + const outcome = { + title: `${page.academicYear} ${labels.plural.toLowerCase()} refreshed`, + detail: refreshSummary(result), + }; + if (result.isComplete === false) { + task.note({ + ...outcome, + detail: `${outcome.detail}. The listing may be incomplete, so nothing was retired.`, + }); + } else { + task.done(outcome); + } router.refresh(); } catch (error) { - toast.error( - error instanceof Error ? error.message : "The refresh failed.", - ); + task.fail({ + title: "The ANU listing refresh failed", + detail: error instanceof Error ? error.message : "The refresh failed.", + retry: refreshDirectory, + }); } finally { setRefreshing(false); - setRefreshMessage(null); } } @@ -127,6 +185,7 @@ export function CatalogueDirectory({ page }: { page: CatalogueDirectoryPage }) { variant="outline" onClick={refreshDirectory} disabled={refreshing} + aria-busy={refreshing} > {refreshing ? ( )} - {refreshMessage ?? "Refresh ANU listing"} + Refresh ANU listing - {page.status.message ? ( - - {page.status.message} - - ) : null} {page.records.length === 0 ? ( ) : ( {labels.singular} - Publication - ANU listing - ANU source + State + Updated + + Actions + @@ -195,6 +260,7 @@ export function CatalogueDirectory({ page }: { page: CatalogueDirectoryPage }) { page.academicYear, record.code, ); + const updated = record.latestSync?.completedAt ?? null; return ( @@ -203,43 +269,30 @@ export function CatalogueDirectory({ page }: { page: CatalogueDirectoryPage }) { title={record.title ?? "Title not available"} kind={page.kind} href={href} + meta={catalogueSummaryMeta(record.summary, page.kind)} /> - - {record.isPublished ? "Published" : "Not published"} - + - - {record.isListedByAnu === false ? ( - - - No longer listed by ANU - {record.lastSeenAt - ? ` · Last seen ${formatDate(record.lastSeenAt)}` - : ""} - - ) : record.isListedByAnu ? ( - Listed by ANU + + {updated ? ( + shortDate(updated) ) : ( - + Never synced )} - - - {sourceStateLabel(record)} - - {record.latestSync?.completedAt ? ( - - {formatDate(record.latestSync.completedAt)} - - ) : null} + + ); diff --git a/apps/web/ui/admin/catalogue/catalogue-editor-context.tsx b/apps/web/ui/admin/catalogue/catalogue-editor-context.tsx new file mode 100644 index 00000000..6f6db115 --- /dev/null +++ b/apps/web/ui/admin/catalogue/catalogue-editor-context.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { + createContext, + useContext, + useEffect, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from "react"; +import { toast } from "sonner"; + +import type { CatalogueContent } from "@/lib/catalogue/content"; +import { + beginCatalogueDraftAction, + discardDraftAction, + publishDraftAction, + saveCatalogueDraftAction, + unpublishAction, +} from "@/lib/coursemap/admin-catalogue-actions"; + +export type CatalogueSaveState = "saved" | "saving" | "error" | "conflict"; + +export type CatalogueEditor = { + write: CatalogueContent; + setWrite: Dispatch>; + /** Whether the fields are being offered for change or merely read. */ + editing: boolean; + beginEditing: () => void; + cancelEditing: () => void; + dirty: boolean; + saveState: CatalogueSaveState; + saveError: string | null; + isPublished: boolean; + hasDraft: boolean; + hasUnpublishedChanges: boolean; + publish: () => Promise; + unpublish: () => Promise; + discard: () => Promise; +}; + +const CatalogueEditorContext = createContext(null); + +export function useCatalogueEditor() { + const editor = useContext(CatalogueEditorContext); + if (!editor) + throw new Error( + "A catalogue editor surface must be rendered inside CatalogueEditorProvider.", + ); + return editor; +} + +/** + * One record's editing session, shared by every surface that acts on it. + * + * The state lives here rather than beside the fields because the toolbar that + * reports and commits it sits above the record's title, outside the tab the + * fields are in. Both read the same session, so what the toolbar says is + * always what the fields hold. + * + * Accepted changes autosave against an expected revision, so another tab can + * never be overwritten silently. + */ +export function CatalogueEditorProvider({ + initial, + recordId, + initialRevision, + initiallyPublished, + initialHasDraft, + initialHasUnpublishedChanges, + path, + children, +}: { + initial: CatalogueContent; + recordId: number; + initialRevision: number; + initiallyPublished: boolean; + /** False until a change worth keeping has been saved against this record. */ + initialHasDraft: boolean; + initialHasUnpublishedChanges: boolean; + path: string; + children: ReactNode; +}) { + const router = useRouter(); + const [write, setWrite] = useState(initial); + const [revision, setRevision] = useState(initialRevision); + const [savedContent, setSavedContent] = useState(() => + JSON.stringify(initial), + ); + const [saveState, setSaveState] = useState("saved"); + const [saveError, setSaveError] = useState(null); + const [failedContent, setFailedContent] = useState(null); + const [isPublished, setIsPublished] = useState(initiallyPublished); + const [hasDraft, setHasDraft] = useState(initialHasDraft); + // A record that already carries a draft is already being worked on, so it + // opens ready to edit. Everything else opens as a reading of the record. + const [editing, setEditing] = useState(initialHasDraft); + const [opening, setOpening] = useState(false); + const [hasUnpublishedChanges, setHasUnpublishedChanges] = useState( + initialHasUnpublishedChanges, + ); + const [editingSessionId, setEditingSessionId] = useState(() => + crypto.randomUUID(), + ); + const currentContent = JSON.stringify(write); + const dirty = currentContent !== savedContent; + // Publication is the only action here that changes a public page, so it is + // also the only one that has to drop the cached public reads. + const publishedRecord = { + kind: initial.kind, + academicYear: initial.academicYear, + code: initial.code, + }; + + useEffect(() => { + const inactivityTimeout = window.setTimeout( + () => setEditingSessionId(crypto.randomUUID()), + 30 * 60 * 1000, + ); + return () => window.clearTimeout(inactivityTimeout); + }, [currentContent]); + + useEffect(() => { + if ( + !dirty || + saveState === "saving" || + saveState === "conflict" || + failedContent === currentContent + ) + return; + const snapshot = write; + const snapshotContent = currentContent; + const timeout = window.setTimeout(async () => { + setSaveState("saving"); + setSaveError(null); + const result = await saveCatalogueDraftAction({ + recordId, + expectedRevision: revision, + content: snapshot, + editingSessionId, + path, + }); + if (result.ok) { + setRevision(result.revision ?? revision); + setSavedContent(snapshotContent); + setFailedContent(null); + setSaveState("saved"); + if (!result.unchanged) { + setHasDraft(true); + setHasUnpublishedChanges(true); + } + return; + } + setSaveError(result.error); + setFailedContent(snapshotContent); + setSaveState(result.code === "STALE_DRAFT" ? "conflict" : "error"); + }, 1000); + return () => window.clearTimeout(timeout); + }, [ + currentContent, + dirty, + editingSessionId, + failedContent, + path, + recordId, + revision, + saveState, + write, + ]); + + async function publish() { + const result = await publishDraftAction({ + recordId, + expectedRevision: revision, + editingSessionId, + path, + record: publishedRecord, + }); + if (!result.ok) throw new Error(result.error); + toast.success(result.message); + setEditingSessionId(crypto.randomUUID()); + setIsPublished(true); + setHasDraft(false); + setHasUnpublishedChanges(false); + setEditing(false); + router.refresh(); + } + + async function unpublish() { + const result = await unpublishAction({ + recordId, + editingSessionId, + path, + record: publishedRecord, + }); + if (!result.ok) throw new Error(result.error); + toast.success(result.message); + setEditingSessionId(crypto.randomUUID()); + setIsPublished(false); + router.refresh(); + } + + async function discard() { + const result = await discardDraftAction({ + recordId, + expectedRevision: revision, + editingSessionId, + path, + }); + if (!result.ok) throw new Error(result.error); + toast.success(result.message); + setEditingSessionId(crypto.randomUUID()); + setHasDraft(false); + setHasUnpublishedChanges(false); + setEditing(false); + router.refresh(); + } + + /** + * The fields are offered straight away and the draft row is opened behind + * them, so asking to edit never waits on a round trip. Should opening fail, + * the editor stays open over a record with no draft row, which is the state + * it was in before the draft was asked for. + */ + async function openDraft() { + if (editing || opening) return; + setEditing(true); + setOpening(true); + const result = await beginCatalogueDraftAction({ + recordId, + editingSessionId, + path, + }); + setOpening(false); + if (!result.ok) { + toast.error(result.error); + return; + } + setRevision(result.revision ?? revision); + setHasDraft(true); + router.refresh(); + } + + return ( + void openDraft(), + // Leaving edit mode is only offered while nothing has been saved, so + // restoring what the record opened with can lose no stored work. + cancelEditing: () => { + setWrite(initial); + setSavedContent(JSON.stringify(initial)); + setSaveState("saved"); + setSaveError(null); + setEditing(false); + }, + dirty, + saveState, + saveError, + isPublished, + hasDraft, + hasUnpublishedChanges, + publish, + unpublish, + discard, + }} + > + {children} + + ); +} diff --git a/apps/web/ui/admin/catalogue/catalogue-editor-toolbar.tsx b/apps/web/ui/admin/catalogue/catalogue-editor-toolbar.tsx new file mode 100644 index 00000000..030721f0 --- /dev/null +++ b/apps/web/ui/admin/catalogue/catalogue-editor-toolbar.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { Button } from "@coursemap/ui/primitives/button"; +import { + Check, + EyeOff, + LoaderCircle, + Pencil, + RefreshCw, + Send, + Trash2, + TriangleAlert, +} from "lucide-react"; + +import { ConfirmDialog } from "@/ui/common/confirm-dialog"; +import { useCatalogueEditor } from "./catalogue-editor-context"; + +/** + * What state this record's content is in, and what can be done about it. + * + * It sits above the record's title rather than above the fields, because what + * it reports - read-only, unsaved, published - is true of the whole record and + * not of one tab. The actions follow the state, so nothing is offered that + * would fail if it were chosen: a record being read offers only Edit, and + * discarding and publishing appear for as long as the editor is open. + */ +export function CatalogueEditorToolbar() { + const { + beginEditing, + cancelEditing, + dirty, + discard, + editing, + hasDraft, + hasUnpublishedChanges, + isPublished, + publish, + saveError, + saveState, + unpublish, + } = useCatalogueEditor(); + const busy = dirty || saveState === "saving"; + // Opening the editor is itself the start of a draft: the record is being + // worked on whether or not a change has been saved against it yet, so the + // state and the actions that follow it do not wait for the first keystroke. + const drafting = hasDraft || editing; + // What the record is right now, in the same shorthand as the header badge + // beside the code. + const resting = drafting + ? { dot: "bg-violet-500", label: "Draft" } + : isPublished + ? { dot: "bg-emerald-500", label: "Published" } + : { dot: "bg-muted-foreground/40", label: "Not published" }; + + return ( + + + + {resting.label} + {editing ? ( + <> + + · + + {saveState === "saving" ? ( + + ) : saveState === "error" || saveState === "conflict" ? ( + + ) : ( + + )} + + {saveState === "saving" + ? "Saving..." + : saveState === "conflict" + ? "This draft changed elsewhere" + : saveState === "error" + ? `Unable to save${saveError ? `: ${saveError}` : ""}` + : "Saved"} + + {saveState === "conflict" ? ( + window.location.reload()} + > + Reload + + ) : null} + > + ) : null} + + + {isPublished ? ( + + Unpublish + + } + /> + ) : null} + {!editing ? ( + + Edit + + ) : null} + {drafting ? ( + <> + {/* + Discarding is the one way back out of the editor. A draft that + has not been opened on the server yet - the moment after Edit, or + after that failed - holds nothing, so backing out of it is only + leaving the editor and asks nothing. + */} + {hasDraft ? ( + + Discard draft + + } + /> + ) : ( + + Discard draft + + )} + + Publish + + } + /> + > + ) : null} + + + ); +} diff --git a/apps/web/ui/admin/catalogue/catalogue-pages.tsx b/apps/web/ui/admin/catalogue/catalogue-pages.tsx index a27dc1bc..2d6e1274 100644 --- a/apps/web/ui/admin/catalogue/catalogue-pages.tsx +++ b/apps/web/ui/admin/catalogue/catalogue-pages.tsx @@ -1,7 +1,9 @@ import { canManageCatalogueOperations } from "@/lib/auth/viewer"; import { CATALOGUE_KIND_LABELS, + CATALOGUE_STATE_LABELS, type CatalogueKind, + type CatalogueRecordState, loadCatalogueDirectoryPage, } from "@/lib/coursemap/admin-catalogue"; import { AppShell } from "@/ui/shell"; @@ -16,6 +18,13 @@ function first(value: string | string[] | undefined) { return Array.isArray(value) ? value[0] : value; } +/** An unknown state in the query string narrows to nothing, so it is dropped. */ +function recordState(value: string | undefined) { + return value && value in CATALOGUE_STATE_LABELS + ? (value as CatalogueRecordState) + : null; +} + /** The directory page for one kind; each route file calls this with its kind. */ export async function CatalogueDirectoryPage({ kind, @@ -33,6 +42,7 @@ export async function CatalogueDirectoryPage({ kind, academicYear, query: first(params.q) ?? "", + state: recordState(first(params.state)), page: Number(first(params.page)) || 1, }); return ( diff --git a/apps/web/ui/admin/catalogue/changelog/changelog-entry.tsx b/apps/web/ui/admin/catalogue/changelog/changelog-entry.tsx index 191b8e62..50e1e30a 100644 --- a/apps/web/ui/admin/catalogue/changelog/changelog-entry.tsx +++ b/apps/web/ui/admin/catalogue/changelog/changelog-entry.tsx @@ -102,15 +102,19 @@ function entryDetail(entry: ChangelogEntryView) { export function ChangelogEntry({ entry, versionHref, + syncsHref, }: { entry: ChangelogEntryView; versionHref: string | null; + /** Where the syncs behind a source entry can be read, for those allowed to. */ + syncsHref: string | null; }) { const detail = entryDetail(entry); + const fromSource = entry.origin === "source"; const actor = entry.kind === "edit" ? null - : (entry.actorName ?? (entry.origin === "source" ? "ANU sync" : null)); + : (entry.actorName ?? (fromSource ? "ANU sync" : null)); return ( @@ -122,7 +126,25 @@ export function ChangelogEntry({ {actor ? ( - {actor} + + {actor} + {/* + An entry ANU produced is only half the story: what it did and + why it did it are in the sync that ran, so the entry says where + that is rather than leaving it to be hunted for. + */} + {fromSource && syncsHref ? ( + <> + {" \u00b7 "} + + Sync diagnostics + + > + ) : null} + ) : null} {detail ? ( {detail} diff --git a/apps/web/ui/admin/catalogue/changelog/changelog-timeline.tsx b/apps/web/ui/admin/catalogue/changelog/changelog-timeline.tsx index d0d1bcab..3040b91c 100644 --- a/apps/web/ui/admin/catalogue/changelog/changelog-timeline.tsx +++ b/apps/web/ui/admin/catalogue/changelog/changelog-timeline.tsx @@ -27,11 +27,14 @@ function dayLabel(value: string, today: Date) { export function ChangelogTimeline({ changelog, path, + syncsHref = null, versionOrdinals, today = new Date(), }: { changelog: CatalogueChangelog; path: string; + /** Null for a reader without the permission to see catalogue operations. */ + syncsHref?: string | null; versionOrdinals: ReadonlyMap; today?: Date; }) { @@ -71,6 +74,7 @@ export function ChangelogTimeline({ 0 || !isPublished; + const showUnpublished = unpublishedCount > 0; return ( + {/* + Everything on this tab is the output of a sync, so the sync that + produced it is named here rather than left to be found in Activity. + */} + {latestSync ? ( + + {latestSync.completedAt + ? `Last checked against ANU on ${new Intl.DateTimeFormat("en-AU", { + dateStyle: "long", + timeStyle: "short", + }).format(new Date(latestSync.completedAt))}. ` + : "A check against ANU is under way. "} + + Sync diagnostics + + + ) : null} {conflicts.length === 0 && incoming.length === 0 ? ( ) : null} @@ -147,7 +173,7 @@ export function CatalogueChangesPanel({ ) : null} {showUnpublished ? ( - + ) : null} diff --git a/apps/web/ui/admin/catalogue/changes/unpublished-changes.tsx b/apps/web/ui/admin/catalogue/changes/unpublished-changes.tsx index cb220d0f..0938242e 100644 --- a/apps/web/ui/admin/catalogue/changes/unpublished-changes.tsx +++ b/apps/web/ui/admin/catalogue/changes/unpublished-changes.tsx @@ -2,22 +2,8 @@ import type { SnapshotChange } from "@/lib/catalogue-import/changes"; import { fieldLabel } from "@/lib/coursemap/catalogue-kinds"; import { FieldChangeList } from "../field-change-list"; -/** Saved draft work that students will not see until the record is published. */ -export function UnpublishedChanges({ - changes, - isPublished, -}: { - changes: SnapshotChange[]; - isPublished: boolean; -}) { - if (!isPublished) { - return ( - - This record has never been published, so nothing in the draft is visible - to students yet. - - ); - } +/** Saved draft work that sits on top of what is currently published. */ +export function UnpublishedChanges({ changes }: { changes: SnapshotChange[] }) { return ( ({ diff --git a/apps/web/ui/admin/catalogue/content-editor.tsx b/apps/web/ui/admin/catalogue/content-editor.tsx index b8d9083f..53ef61ad 100644 --- a/apps/web/ui/admin/catalogue/content-editor.tsx +++ b/apps/web/ui/admin/catalogue/content-editor.tsx @@ -7,19 +7,8 @@ import { CollapsibleTrigger, } from "@coursemap/ui/primitives/collapsible"; import { Input } from "@coursemap/ui/primitives/input"; -import { - Check, - ChevronDown, - EyeOff, - LoaderCircle, - RefreshCw, - Send, - Trash2, - TriangleAlert, -} from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; +import { ChevronDown } from "lucide-react"; +import { useMemo, useState } from "react"; import { requirementWriteWithTree, @@ -30,19 +19,13 @@ import type { CatalogueContent, RequirementRuleKind, } from "@/lib/catalogue/content"; -import { - discardDraftAction, - publishDraftAction, - saveCatalogueDraftAction, - unpublishAction, -} from "@/lib/coursemap/admin-catalogue-actions"; import { FIELD_LABELS } from "@/lib/coursemap/catalogue-kinds"; import { createEmptyTree, type ReviewedRuleTree, } from "@/lib/coursemap/requisite-conditions"; import { RequisiteRuleTree } from "@/ui/admin/requisites/requisite-rule-tree"; -import { ConfirmDialog } from "@/ui/common/confirm-dialog"; +import { useCatalogueEditor } from "./catalogue-editor-context"; import { DetailsEditor, RowsEditor } from "./section-editor"; import { JsonCode } from "@/ui/common/json-code"; @@ -231,104 +214,13 @@ function labelsFor(prefix: string) { } /** - * Edits one mutable catalogue draft. Accepted changes autosave with an - * expected revision so another tab can never be overwritten silently. + * The fields of one catalogue record. The editing session they read and write + * - what is saved, what is published, whether they may be changed at all - + * belongs to the provider above them, so the toolbar reporting that session + * can sit above the record's title instead of above these fields. */ -export function CatalogueContentEditor({ - initial, - recordId, - initialRevision, - initiallyPublished, - initialHasUnpublishedChanges, - path, -}: { - initial: CatalogueContent; - recordId: number; - initialRevision: number; - initiallyPublished: boolean; - initialHasUnpublishedChanges: boolean; - path: string; -}) { - const router = useRouter(); - const [write, setWrite] = useState(initial); - const [revision, setRevision] = useState(initialRevision); - const [savedContent, setSavedContent] = useState(() => - JSON.stringify(initial), - ); - const [saveState, setSaveState] = useState< - "saved" | "saving" | "error" | "conflict" - >("saved"); - const [saveError, setSaveError] = useState(null); - const [failedContent, setFailedContent] = useState(null); - const [isPublished, setIsPublished] = useState(initiallyPublished); - const [hasUnpublishedChanges, setHasUnpublishedChanges] = useState( - initialHasUnpublishedChanges, - ); - const [editingSessionId, setEditingSessionId] = useState(() => - crypto.randomUUID(), - ); - const currentContent = JSON.stringify(write); - const dirty = currentContent !== savedContent; - // Publication is the only action here that changes a public page, so it is - // also the only one that has to drop the cached public reads. - const publishedRecord = { - kind: initial.kind, - academicYear: initial.academicYear, - code: initial.code, - }; - - useEffect(() => { - const inactivityTimeout = window.setTimeout( - () => setEditingSessionId(crypto.randomUUID()), - 30 * 60 * 1000, - ); - return () => window.clearTimeout(inactivityTimeout); - }, [currentContent]); - - useEffect(() => { - if ( - !dirty || - saveState === "saving" || - saveState === "conflict" || - failedContent === currentContent - ) - return; - const snapshot = write; - const snapshotContent = currentContent; - const timeout = window.setTimeout(async () => { - setSaveState("saving"); - setSaveError(null); - const result = await saveCatalogueDraftAction({ - recordId, - expectedRevision: revision, - content: snapshot, - editingSessionId, - path, - }); - if (result.ok) { - setRevision(result.revision ?? revision); - setSavedContent(snapshotContent); - setFailedContent(null); - setSaveState("saved"); - if (!result.unchanged) setHasUnpublishedChanges(true); - return; - } - setSaveError(result.error); - setFailedContent(snapshotContent); - setSaveState(result.code === "STALE_DRAFT" ? "conflict" : "error"); - }, 1000); - return () => window.clearTimeout(timeout); - }, [ - currentContent, - dirty, - editingSessionId, - failedContent, - path, - recordId, - revision, - saveState, - write, - ]); +export function CatalogueContentEditor() { + const { editing, setWrite, write } = useCatalogueEditor(); function updateCourse( patch: Partial>, @@ -364,155 +256,11 @@ export function CatalogueContentEditor({ })); } - async function publish() { - const result = await publishDraftAction({ - recordId, - expectedRevision: revision, - editingSessionId, - path, - record: publishedRecord, - }); - if (!result.ok) throw new Error(result.error); - toast.success(result.message); - setEditingSessionId(crypto.randomUUID()); - setIsPublished(true); - setHasUnpublishedChanges(false); - router.refresh(); - } - - async function unpublish() { - const result = await unpublishAction({ - recordId, - editingSessionId, - path, - record: publishedRecord, - }); - if (!result.ok) throw new Error(result.error); - toast.success(result.message); - setEditingSessionId(crypto.randomUUID()); - setIsPublished(false); - router.refresh(); - } - - async function discard() { - const result = await discardDraftAction({ - recordId, - expectedRevision: revision, - editingSessionId, - path, - }); - if (!result.ok) throw new Error(result.error); - toast.success(result.message); - setEditingSessionId(crypto.randomUUID()); - setHasUnpublishedChanges(false); - router.push(`${path}/student-view`); - router.refresh(); - } - const courseLabels = labelsFor("course.details"); const structureLabels = labelsFor("structure.details"); return ( - - - {saveState === "saving" ? ( - - ) : saveState === "error" || saveState === "conflict" ? ( - - ) : ( - - )} - - {saveState === "saving" - ? "Saving..." - : saveState === "conflict" - ? "This draft changed elsewhere" - : saveState === "error" - ? `Unable to save${saveError ? `: ${saveError}` : ""}` - : "Saved"} - - {saveState === "conflict" ? ( - window.location.reload()} - > - Reload - - ) : null} - - - {isPublished ? ( - - Unpublish - - } - /> - ) : null} - - Discard draft - - } - /> - - Publish - - } - /> - - - {write.course ? ( <> @@ -520,6 +268,7 @@ export function CatalogueContentEditor({ idPrefix="course-details" value={write.course.details as unknown as Row} labels={courseLabels} + readOnly={!editing} readOnlyKeys={["subjectCode", "level"]} onChange={(details) => updateCourse({ @@ -530,43 +279,55 @@ export function CatalogueContentEditor({ } /> - - - updateCourse({ - offering: offering as NonNullable< - CatalogueContent["course"] - >["offering"], - }) - } - /> - - {COURSE_COLLECTIONS.map(({ key, template }) => ( - - updateCourse({ [key]: rows } as never)} + {!editing && + !Object.values(write.course.offering ?? {}).some( + (value) => value !== null && value !== "", + ) ? null : ( + + + updateCourse({ + offering: offering as NonNullable< + CatalogueContent["course"] + >["offering"], + }) + } /> - ))} + )} + {COURSE_COLLECTIONS.map(({ key, template }) => + // A collection nobody filled in is part of the form, not part of + // the record, so reading one leaves it out entirely. + !editing && (write.course![key] as Row[]).length === 0 ? null : ( + + updateCourse({ [key]: rows } as never)} + /> + + ), + )} {COURSE_RULES.map((ruleKey) => ( updateRule(ruleKey, tree, sourceText) @@ -583,6 +344,7 @@ export function CatalogueContentEditor({ idPrefix="structure-details" value={write.structure.details as unknown as Row} labels={structureLabels} + readOnly={!editing} onChange={(details) => updateStructure({ details: details as unknown as NonNullable< @@ -592,23 +354,27 @@ export function CatalogueContentEditor({ } /> - {STRUCTURE_COLLECTIONS.map(({ key, template }) => ( - - updateStructure({ [key]: rows } as never)} - /> - - ))} + {STRUCTURE_COLLECTIONS.map(({ key, template }) => + !editing && (write.structure![key] as Row[]).length === 0 ? null : ( + + updateStructure({ [key]: rows } as never)} + /> + + ), + )} updateRule("structure", tree, sourceText) @@ -624,10 +390,12 @@ function RuleSection({ ruleKey, requirements, onChange, + readOnly = false, }: { ruleKey: RequirementRuleKind; requirements: CatalogueContent["requirements"]; onChange: (tree: ReviewedRuleTree | null, sourceText: string) => void; + readOnly?: boolean; }) { const rule = requirements.rules.find( (candidate) => candidate.key === ruleKey, @@ -641,6 +409,7 @@ function RuleSection({ const conditionCount = requirements.conditions.filter( (condition) => condition.ruleKey === ruleKey, ).length; + if (readOnly && !rule && conditionCount === 0 && !sourceText) return null; return ( { setSourceText(event.target.value); @@ -666,7 +436,7 @@ function RuleSection({ {editable ? ( onChange(next, sourceText)} /> @@ -679,7 +449,7 @@ function RuleSection({ > )} - {rule && tree ? ( + {rule && tree && !readOnly ? ( (null); + const busy = syncing || record.sourceState === "syncing"; + const kindLabel = labels.singular.toLowerCase(); + const recordPath = adminCatalogueRecordPath(kind, academicYear, record.code); + const publishedRecord = { kind, academicYear, code: record.code }; + // A row acts on the draft the list last read. Publishing or discarding a + // revision that has since moved on is refused by the action rather than + // overwriting whoever is editing it in another tab. An opened draft can + // always be discarded; only one holding a change can be published. + const discardable = + record.hasDraft && + record.recordId !== null && + record.draftRevision !== null; + const publishable = discardable && record.hasChanges; + const unpublishable = record.isPublished && record.recordId !== null; + + async function startSync() { + if (record.recordId === null) { + toast.error("Refresh the ANU listing before syncing this record."); + return; + } + setSyncing(true); + try { + const response = await fetch("/api/admin/catalogue-syncs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ recordId: record.recordId, kind }), + }); + const result = (await response.json()) as { error?: string }; + if (!response.ok) { + toast.error(result.error ?? "The ANU sync could not start."); + return; + } + toast.success(`Syncing ${record.code} from ANU...`); + router.refresh(); + } catch { + toast.error("The ANU sync could not start."); + } finally { + setSyncing(false); + } + } + + async function runDraftAction(action: DraftAction) { + const recordId = record.recordId as number; + const editingSessionId = crypto.randomUUID(); + let result: DraftActionResult; + if (action === "publish") + result = await publishDraftAction({ + recordId, + expectedRevision: record.draftRevision as number, + editingSessionId, + path: recordPath, + record: publishedRecord, + }); + else if (action === "discard") + result = await discardDraftAction({ + recordId, + expectedRevision: record.draftRevision as number, + editingSessionId, + path: recordPath, + }); + else + result = await unpublishAction({ + recordId, + editingSessionId, + path: recordPath, + record: publishedRecord, + }); + if (result.ok) toast.success(result.message ?? "Done."); + else toast.error(result.error); + // Even a refusal refreshes: the row is out of date either way, and the + // menu it offers has to match what the record now is. + router.refresh(); + } + + const confirmations: Record< + DraftAction, + { title: string; description: string; confirmLabel: string } + > = { + publish: { + title: `Publish ${record.code}?`, + description: `The saved draft becomes the version students see for ${academicYear}.`, + confirmLabel: "Publish", + }, + discard: { + title: `Discard the draft of ${record.code}?`, + description: record.isPublished + ? `The ${kindLabel} goes back to its published version. A restorable checkpoint of the draft is kept in the changelog.` + : `The ${kindLabel} goes back to being unpublished, with nothing drafted. A restorable checkpoint of the draft is kept in the changelog.`, + confirmLabel: "Discard draft", + }, + unpublish: { + title: `Unpublish ${record.code}?`, + description: `Students will no longer see this ${kindLabel} for ${academicYear}. Versions and draft work are retained.`, + confirmLabel: "Unpublish", + }, + }; + + return ( + <> + {confirming ? ( + { + if (!open) setConfirming(null); + }} + destructive={confirming !== "publish"} + title={confirmations[confirming].title} + description={confirmations[confirming].description} + confirmLabel={confirmations[confirming].confirmLabel} + onConfirm={() => runDraftAction(confirming)} + /> + ) : null} + , + onSelect: () => { + if (!busy) void startSync(); + }, + }, + ...(publishable + ? [ + { + label: "Publish draft", + icon: , + onSelect: () => setConfirming("publish"), + }, + ] + : []), + ...(discardable + ? [ + { + label: "Discard draft", + icon: , + onSelect: () => setConfirming("discard"), + }, + ] + : []), + ...(unpublishable + ? [ + { + label: "Unpublish", + icon: , + onSelect: () => setConfirming("unpublish"), + }, + ] + : []), + ]} + /> + > + ); +} diff --git a/apps/web/ui/admin/catalogue/record-header.tsx b/apps/web/ui/admin/catalogue/record-header.tsx index 92b30637..0b0a8dc0 100644 --- a/apps/web/ui/admin/catalogue/record-header.tsx +++ b/apps/web/ui/admin/catalogue/record-header.tsx @@ -53,43 +53,34 @@ export function RecordHeader({ {publicationLabel} - {record.title} - - {record.isListedByAnu === false ? ( - - - No longer listed by ANU - {record.lastSeenAt - ? `. Last seen in the ANU catalogue on ${formatDate(record.lastSeenAt)}.` - : "."} - - ) : record.isListedByAnu ? ( - Listed by ANU - ) : ( - - No ANU listing information - - )} + {/* + Being listed by ANU is the resting state of every record here, so + saying so on each one said nothing. Only the delisting is worth a + line, and the source link belongs beside the title it is a link to. + */} + + {record.title} View on ANU - {canSync && record.syncs[0] ? ( - - Sync diagnostics - - ) : null} + {record.isListedByAnu === false ? ( + + + No longer listed by ANU + {record.lastSeenAt + ? `. Last seen in the ANU catalogue on ${formatDate(record.lastSeenAt)}.` + : "."} + + ) : null} {openChangeCount > 0 ? ( Checked ANU. No changes found. + {canSync ? ( + <> + {" "} + + Sync diagnostics + + > + ) : null} ) : record.syncs[0]?.status === "failed" ? ( @@ -124,8 +126,10 @@ export function RecordHeader({ {canSync ? ( ) : null} diff --git a/apps/web/ui/admin/catalogue/record-page.tsx b/apps/web/ui/admin/catalogue/record-page.tsx index 7d73dc16..a88762b8 100644 --- a/apps/web/ui/admin/catalogue/record-page.tsx +++ b/apps/web/ui/admin/catalogue/record-page.tsx @@ -3,12 +3,8 @@ import { TabsContent } from "@coursemap/ui/primitives/tabs"; import { canManageCatalogueOperations, canWriteCatalogue, - getAuthViewer, } from "@/lib/auth/viewer"; -import { - createCatalogueDraft, - loadCatalogueDraft, -} from "@/lib/catalogue/drafts"; +import { loadCatalogueEditorState } from "@/lib/catalogue/drafts"; import { diffSnapshotWrites } from "@/lib/catalogue-import/changes"; import { contentHashForCatalogueContent } from "@/lib/catalogue-import/version-content"; import { loadSourceReview } from "@/lib/catalogue/source-review-store"; @@ -21,6 +17,7 @@ import { } from "@/lib/coursemap/admin-catalogue-record"; import { courseDetailsFromWrite } from "@/lib/coursemap/course-version-view"; import { + ADMIN_CATALOGUE_OPERATIONS_PATH, CATALOGUE_KIND_LABELS, type CatalogueKind, adminCatalogueRecordPath, @@ -32,6 +29,8 @@ import { ChangelogTimeline } from "./changelog/changelog-timeline"; import { RecordHeader } from "./record-header"; import { StudentViewPanel } from "./student-view-panel"; import { RecordTabList, RecordTabs, type RecordSection } from "./record-tabs"; +import { CatalogueEditorProvider } from "./catalogue-editor-context"; +import { CatalogueEditorToolbar } from "./catalogue-editor-toolbar"; import { CatalogueContentEditor } from "./content-editor"; function FoundationEmpty({ @@ -81,14 +80,12 @@ export async function CatalogueRecordPage({ const labels = CATALOGUE_KIND_LABELS[kind]; const path = adminCatalogueRecordPath(kind, academicYear, record.code); - const viewer = canWrite ? await getAuthViewer() : null; - const draft = - section === "content" && viewer - ? await createCatalogueDraft({ - recordId: record.recordId, - userId: viewer.id, - }) - : await loadCatalogueDraft(record.recordId); + // Reading a record must never be what makes it a draft, so the editor is + // given the content it would start from - the publication, or an empty + // record - and the draft row is created by asking to edit it. + const { draft, hasDraft, hasChanges } = await loadCatalogueEditorState( + record.recordId, + ); const [studentContent, studentCourse] = await Promise.all([ record.publishedVersionId ? loadVersionWrite(record.publishedVersionId) @@ -98,25 +95,19 @@ export async function CatalogueRecordPage({ : null, ]); const hasUnpublishedChanges = Boolean( - draft && + hasChanges && (!studentContent || draft.contentHash !== contentHashForCatalogueContent(studentContent)), ); - const draftPreview = draft - ? { - course: - kind === "course" ? courseDetailsFromWrite(draft.content) : null, - content: kind === "course" ? null : draft.content, - } - : null; + const draftPreview = { + course: kind === "course" ? courseDetailsFromWrite(draft.content) : null, + content: kind === "course" ? null : draft.content, + }; const publishedPreview = studentContent ? { course: studentCourse, content: studentCourse ? null : studentContent } : null; - const review = await loadSourceReview( - record.recordId, - draft?.content ?? null, - ); - const unpublished = draft + const review = await loadSourceReview(record.recordId, draft.content); + const unpublished = hasChanges ? diffSnapshotWrites(studentContent, draft.content) : []; const changelog = await loadCatalogueChangelog({ @@ -142,60 +133,84 @@ export async function CatalogueRecordPage({ }} tabs={} > - - - - {draft && canWrite ? ( - + + {/* + The toolbar reports the record's state, so it leads the page + rather than the fields. It appears only where it can act: the + other tabs read the record and do not change it. + */} + {canWrite && section === "content" ? ( + + ) : null} + + + {canWrite ? ( + + ) : ( + + )} + + + + + + 0} + isPublished={record.publishedVersionId !== null} + kindLabel={labels.singular.toLowerCase()} + latestSync={ + canManageImports && record.syncs[0] + ? { + id: record.syncs[0].id, + completedAt: record.syncs[0].completedAt, + } + : null + } path={path} + recordId={record.recordId} + review={review} + unpublished={unpublished} /> - ) : ( - + + - )} - - - - - - 0} - isPublished={record.publishedVersionId !== null} - kindLabel={labels.singular.toLowerCase()} - path={path} - recordId={record.recordId} - review={review} - unpublished={unpublished} - /> - - - - - + + + ); diff --git a/apps/web/ui/admin/catalogue/record-tabs.tsx b/apps/web/ui/admin/catalogue/record-tabs.tsx index b19f616a..afdf99a4 100644 --- a/apps/web/ui/admin/catalogue/record-tabs.tsx +++ b/apps/web/ui/admin/catalogue/record-tabs.tsx @@ -4,6 +4,7 @@ import { Tabs } from "@coursemap/ui/primitives/tabs"; import { useRouter } from "next/navigation"; import type { ReactNode } from "react"; import { SectionTabs } from "@/ui/common/section-tabs"; +import { routeIcons } from "@/ui/shell/route-icons"; export type RecordSection = "content" | "student-view" | "changes" | "changelog"; @@ -36,10 +37,19 @@ export function RecordTabList({ changeCount = 0 }: { changeCount?: number }) { ); diff --git a/apps/web/ui/admin/catalogue/section-editor.tsx b/apps/web/ui/admin/catalogue/section-editor.tsx index 0dc98d79..f2264618 100644 --- a/apps/web/ui/admin/catalogue/section-editor.tsx +++ b/apps/web/ui/admin/catalogue/section-editor.tsx @@ -38,6 +38,22 @@ function parseScalar(previous: Scalar, raw: string): Scalar { return raw; } +/** + * A value nobody is being invited to change: the label and what it says. Used + * for fields the record owns rather than the author, and for every field while + * a record is being read rather than edited. + */ +function ReadOnlyField({ label, value }: { label: string; value: Scalar }) { + return ( + + {label} + + {value === null || value === "" ? "\u2014" : String(value)} + + + ); +} + /** One typed input for a scalar value, with null rendered as empty. */ export function ScalarField({ id, @@ -45,13 +61,16 @@ export function ScalarField({ value, onChange, long = false, + readOnly = false, }: { id: string; label: string; value: Scalar; onChange: (value: Scalar) => void; long?: boolean; + readOnly?: boolean; }) { + if (readOnly) return ; if ( typeof value === "boolean" || (value === null && /^(can|is|has|hurdle)/.test(label)) @@ -111,29 +130,31 @@ export function DetailsEditor({ onChange, labels = {}, readOnlyKeys = [], + readOnly = false, }: { idPrefix: string; value: Row; onChange: (value: Row) => void; labels?: Record; readOnlyKeys?: string[]; + /** Reads the whole form rather than offering it for editing. */ + readOnly?: boolean; }) { + // Reading a record should show what it says, not the shape of the form it + // was entered through. A page of labels above em dashes told a reader + // nothing, so a read of the record carries only the fields that were + // filled in. + const entries = Object.entries(value).filter( + ([, fieldValue]) => !readOnly || (fieldValue !== null && fieldValue !== ""), + ); + if (readOnly && entries.length === 0) + return ( + Nothing recorded yet. + ); return ( - - {Object.entries(value).map(([key, fieldValue]) => { + + {entries.map(([key, fieldValue]) => { const long = LONG_TEXT_KEYS.has(key); - if (readOnlyKeys.includes(key)) { - return ( - - - {labels[key] ?? humanise(key)} - - - {fieldValue === null ? "—" : String(fieldValue)} - - - ); - } return ( onChange({ ...value, [key]: next })} /> @@ -161,6 +183,7 @@ export function RowsEditor({ template, hiddenKeys = ["position"], emptyLabel, + readOnly = false, }: { idPrefix: string; rows: Row[]; @@ -168,6 +191,8 @@ export function RowsEditor({ template: Row; hiddenKeys?: string[]; emptyLabel: string; + /** Lists the rows as they stand, without add, remove or entry. */ + readOnly?: boolean; }) { const shape = rows[0] ?? template; const keys = Object.keys(shape).filter((key) => !hiddenKeys.includes(key)); @@ -185,21 +210,23 @@ export function RowsEditor({ Item {index + 1} - - onChange( - renumber( - rows.filter((_, candidate) => candidate !== index), - ), - ) - } - > - - + {readOnly ? null : ( + + onChange( + renumber( + rows.filter((_, candidate) => candidate !== index), + ), + ) + } + > + + + )} {keys.map((key) => { @@ -211,6 +238,7 @@ export function RowsEditor({ label={humanise(key)} value={row[key] ?? null} long={long} + readOnly={readOnly} onChange={(next) => onChange( rows.map((candidate, at) => @@ -228,34 +256,36 @@ export function RowsEditor({ ))} - - onChange( - renumber([ - ...rows, - Object.fromEntries( - Object.entries(shape).map(([key, sample]) => [ - key, - key === "position" - ? rows.length + 1 - : typeof sample === "number" - ? null - : typeof sample === "boolean" + {readOnly ? null : ( + + onChange( + renumber([ + ...rows, + Object.fromEntries( + Object.entries(shape).map(([key, sample]) => [ + key, + key === "position" + ? rows.length + 1 + : typeof sample === "number" ? null - : "", - ]), - ) as Row, - ]), - ) - } - > - - Add item - + : typeof sample === "boolean" + ? null + : "", + ]), + ) as Row, + ]), + ) + } + > + + Add item + + )} ); } diff --git a/apps/web/ui/admin/catalogue/student-view-panel.tsx b/apps/web/ui/admin/catalogue/student-view-panel.tsx index 8730335a..480951a8 100644 --- a/apps/web/ui/admin/catalogue/student-view-panel.tsx +++ b/apps/web/ui/admin/catalogue/student-view-panel.tsx @@ -48,19 +48,7 @@ export function StudentViewPanel({ ); } - if (!draft || !published) { - const only = draft ?? published!; - return ( - - {draft ? ( - - {`This ${kindLabel} hasn't been published yet. Students see nothing until you publish.`} - - ) : null} - - - ); - } + if (!draft || !published) return ; return ( = { + queued: { percent: 12, ceiling: 45, detail: "Waiting for a worker." }, + running: { percent: 50, ceiling: 88, detail: "Reading the ANU page." }, +}; + +const SYNC_OUTCOMES = { + applied: { + title: "ANU changes applied", + detail: "The record is up to date.", + }, + review_required: { + title: "ANU changes need review", + detail: "Open the changes to accept or reject them.", + }, + unchanged: { + title: "No ANU changes", + detail: "ANU has not changed this record since the last sync.", + }, +} as const; export function CatalogueSyncButton({ recordId, + code, kind, latestSync, + hasSynced, }: { recordId: number; + code: string; kind: CatalogueKind; latestSync: CatalogueSync | null; + /** Whether ANU has ever been read for this record, which names the action. */ + hasSynced: boolean; }) { const router = useRouter(); const [isPending, startTransition] = useTransition(); const [startedSyncId, setStartedSyncId] = useState(null); + const task = useRef(null); + const reportedStatus = useRef(null); const awaitingStartedSync = startedSyncId !== null && latestSync?.id !== startedSyncId; const isActive = @@ -33,7 +69,19 @@ export function CatalogueSyncButton({ return () => window.clearInterval(timer); }, [isActive, router]); - function startSync() { + // The retry offered by a failed sync restarts this same handler, so it is + // reached through a ref rather than the handler referring to itself. + const startSyncRef = useRef<() => void>(undefined); + const retrySync = useCallback(() => startSyncRef.current?.(), []); + + const startSync = useCallback(() => { + reportedStatus.current = null; + task.current = startTask({ + id: `sync:${recordId}`, + title: `Syncing ${code} from ANU`, + detail: "Asking ANU for the latest version.", + ceiling: 12, + }); startTransition(async () => { const response = await fetch("/api/admin/catalogue-syncs", { method: "POST", @@ -44,19 +92,68 @@ export function CatalogueSyncButton({ error?: string; syncId?: string; }; - if (!response.ok) { - toast.error(result.error ?? "The ANU sync could not start."); - return; - } - if (!result.syncId) { - toast.error("The ANU sync did not return an identifier."); + if (!response.ok || !result.syncId) { + task.current?.fail({ + title: `Syncing ${code} from ANU could not start`, + detail: result.error ?? "The sync did not return an identifier.", + retry: retrySync, + }); + task.current = null; return; } + task.current?.step(SYNC_PROGRESS.queued); setStartedSyncId(result.syncId); - toast.success("Syncing from ANU..."); router.refresh(); }); - } + }, [code, kind, recordId, retrySync, router]); + + useEffect(() => { + startSyncRef.current = startSync; + }, [startSync]); + + // The sync runs on the server and this button is the only thing watching it. + // Leaving the page stops the poll, so the toast is handed back rather than + // left spinning at whatever percentage it had reached. + useEffect( + () => () => + task.current?.abandon({ + title: "The ANU sync is still running", + detail: "Open the record again to see how it finished.", + }), + [], + ); + + // Only a sync started from this button owns a toast; a scheduled one running + // in the background should not interrupt whoever opened the page. + useEffect(() => { + if (!startedSyncId || latestSync?.id !== startedSyncId) return; + const status = latestSync.status; + if (status === reportedStatus.current) return; + reportedStatus.current = status; + const running = SYNC_PROGRESS[status]; + if (running) { + task.current?.step(running); + return; + } + if (status === "failed") { + task.current?.fail({ + title: `Syncing ${code} from ANU failed`, + detail: latestSync.errorMessage ?? "The sync did not finish.", + retry: retrySync, + }); + } else if (status === "cancelled") { + task.current?.note({ + title: `Syncing ${code} from ANU was cancelled`, + }); + } else { + const outcome = SYNC_OUTCOMES[status as keyof typeof SYNC_OUTCOMES]; + task.current?.done({ + title: outcome?.title ?? `${code} synced from ANU`, + detail: outcome?.detail, + }); + } + task.current = null; + }, [code, latestSync, retrySync, startedSyncId]); return ( - - {isActive - ? "Syncing from ANU..." - : latestSync?.status === "failed" - ? "Retry sync" - : "Sync from ANU"} + {isActive ? ( + + ) : ( + + )} + {latestSync?.status === "failed" && !isActive + ? "Retry sync" + : hasSynced + ? "Resync" + : "Sync"} ); } diff --git a/apps/web/ui/admin/imports/import-model-card.tsx b/apps/web/ui/admin/imports/import-model-card.tsx index 556fbc7d..deaa41b8 100644 --- a/apps/web/ui/admin/imports/import-model-card.tsx +++ b/apps/web/ui/admin/imports/import-model-card.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { ChevronsUpDown, Cpu, Plus, Settings2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@coursemap/ui/primitives/button"; +import { Card } from "@coursemap/ui/primitives/card"; import { DropdownMenu, DropdownMenuContent, @@ -60,20 +61,30 @@ export function ImportModelCard({ }); } return ( - - - - - Default import model - - {updatedAt ? ( - - Updated {dateFormatter.format(new Date(updatedAt))} - - ) : null} + + + + Default import model + + {updatedAt ? ( + + Updated {dateFormatter.format(new Date(updatedAt))} + + ) : null} + + + + @@ -120,7 +131,10 @@ export function ImportModelCard({ "h-14 gap-3 py-2.5", entry.id === model ? "bg-primary/10 text-primary data-highlighted:bg-primary/10 data-highlighted:text-primary" - : "data-highlighted:bg-accent data-highlighted:text-foreground", + : // --accent is mixed against the page, so on the lighter + // popover surface it disappears in dark. Mix from the + // foreground, which reads on either ground. + "data-highlighted:bg-foreground/8 data-highlighted:text-foreground", )} aria-label={`${entry.name}, ${entry.provider}${entry.id === model ? ", selected" : ""}`} > @@ -178,6 +192,6 @@ export function ImportModelCard({ selected={model} /> ) : null} - + ); } diff --git a/apps/web/ui/admin/operations/artefact-data.ts b/apps/web/ui/admin/operations/artefact-data.ts index 1ec2c3cb..01a60ecf 100644 --- a/apps/web/ui/admin/operations/artefact-data.ts +++ b/apps/web/ui/admin/operations/artefact-data.ts @@ -6,6 +6,26 @@ export type SyncArtefactSummary = { mediaType: string; }; +/** + * What each artefact actually is. The names are the pipeline's own vocabulary, + * which means nothing to a reader who has not written the pipeline, so every + * tab carries the one sentence that places it in the run. + */ +export const syncArtefactDescriptions: Record = { + raw_html: "The ANU page exactly as it was fetched, before anything read it.", + normalised_markdown: + "That page reduced to the plain text the extraction works from.", + model_input: "The markdown and instructions assembled for the model to read.", + deterministic_output: + "What rules alone could read off the page, without asking the model.", + model_request: "The request sent to the model, with the settings it ran on.", + model_response: "What the model returned, before anything checked it.", + validated_json: "The model's answer once it passed the schema.", + validation_report: "Every schema and domain check, and which ones failed.", + content_projection: + "The validated answer mapped onto this record's own fields.", +}; + export const syncArtefactLabels: Record = { raw_html: "Raw HTML", normalised_markdown: "Markdown", diff --git a/apps/web/ui/admin/operations/artefact-viewer.tsx b/apps/web/ui/admin/operations/artefact-viewer.tsx index 690a4bf2..2b2fce19 100644 --- a/apps/web/ui/admin/operations/artefact-viewer.tsx +++ b/apps/web/ui/admin/operations/artefact-viewer.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useState } from "react"; -import { LoaderCircle } from "lucide-react"; +import { CircleQuestionMark, LoaderCircle } from "lucide-react"; import { Alert, AlertDescription } from "@coursemap/ui/components/alert"; import { Button } from "@coursemap/ui/primitives/button"; import { @@ -10,11 +10,13 @@ import { TabsList, TabsTrigger, } from "@coursemap/ui/primitives/tabs"; +import { Hint } from "@/ui/common/hint"; import { OptionPicker } from "@/ui/common/option-picker"; import { JsonCode } from "@/ui/common/json-code"; import { ArtefactViewport } from "./artefact-viewport"; import { groupSyncArtefactSummarys, + syncArtefactDescriptions, syncArtefactLabels, parseSyncArtefactSummary, type SyncArtefactSummary, @@ -87,16 +89,40 @@ export function ArtefactViewer({ aria-label="Sync artefacts" className={`${navigationStyles.list} hidden h-auto w-full items-stretch gap-1 bg-transparent p-0 md:flex`} > - {grouped.map((entry) => ( - - {syncArtefactLabels[entry.kind] ?? - entry.kind.replaceAll("_", " ")} - - ))} + {grouped.map((entry) => { + const description = syncArtefactDescriptions[entry.kind]; + const trigger = ( + + {syncArtefactLabels[entry.kind] ?? + entry.kind.replaceAll("_", " ")} + {description ? ( + + ) : null} + + ); + // The tab itself carries the explanation, so the mark beside the + // name stays a mark rather than a second control to reach. + return description ? ( + + {trigger} + + ) : ( + trigger + ); + })} - {check.kind} listing + {CATALOGUE_KIND_LABELS[check.kind]?.plural ?? check.kind} listing {check.academicYear} ) : null} - - {[ - { label: "Discovered", value: String(check.discoveredCount) }, - { label: "Currently listed", value: String(check.listedCount) }, - { label: "No longer listed", value: String(check.retiredCount) }, - { label: "Started", value: formatTimestamp(check.startedAt) }, - { label: "Completed", value: formatTimestamp(check.completedAt) }, - { label: "Duration", value: formatDuration(check.durationMs) }, - ].map((item) => ( - - - {item.label} - - {item.value} - - ))} - + + + + - - - Pages read - - {check.sourcePages.length === 0 ? ( - - This check recorded no source pages. - - ) : ( - - {check.sourcePages.map((page) => ( - - {page.canonicalUrl} - - HTTP {page.httpStatus ?? "—"} ·{" "} - {formatTimestamp(page.fetchedAt)} ·{" "} - {page.contentSha256.slice(0, 16)} - - - ))} - - )} - + + {check.sourcePages.length === 0 ? ( + + This check recorded no source pages. + + ) : ( + + {check.sourcePages.map((page) => ( + + {page.canonicalUrl} + + HTTP {page.httpStatus ?? "—"} ·{" "} + {formatTimestamp(page.fetchedAt)} ·{" "} + {page.contentSha256.slice(0, 16)} + + + ))} + + )} + + ); } diff --git a/apps/web/ui/admin/operations/discovery-list.tsx b/apps/web/ui/admin/operations/discovery-list.tsx index 4837a69b..15935b48 100644 --- a/apps/web/ui/admin/operations/discovery-list.tsx +++ b/apps/web/ui/admin/operations/discovery-list.tsx @@ -1,8 +1,13 @@ +"use client"; + +import { Badge } from "@coursemap/ui/components/badge"; +import { useSearchParams } from "next/navigation"; +import { CATALOGUE_KINDS } from "@/lib/catalogue/content"; import { + ADMIN_CATALOGUE_OPERATIONS_PATH, CATALOGUE_KIND_LABELS, adminCatalogueDiscoveryPath, } from "@/lib/coursemap/catalogue-kinds"; -import { Badge } from "@coursemap/ui/components/badge"; import { CatalogueIdentity, DataTableShell, @@ -16,78 +21,158 @@ import { } from "@/ui/admin/catalogue-table/catalogue-table"; import type { DiscoveryCheckRow } from "@/lib/coursemap/admin-operations"; import { CatalogueEmpty } from "@/ui/admin/catalogue-table/catalogue-empty"; +import { FilterBar, type FilterConfig } from "@/ui/common/filter-bar"; import { LinkedTableRow } from "@/ui/common/linked-table-row"; import { formatDuration, formatTimestamp } from "./operations-format"; +const DISCOVERY_PATH = `${ADMIN_CATALOGUE_OPERATIONS_PATH}/discovery`; + +function discoveryFilters(checks: DiscoveryCheckRow[]): FilterConfig[] { + const years = [...new Set(checks.map((check) => check.academicYear))].sort( + (left, right) => right - left, + ); + return [ + { + key: "kind", + label: "Listing", + options: CATALOGUE_KINDS.map((kind) => ({ + value: kind, + label: CATALOGUE_KIND_LABELS[kind].plural, + })), + }, + { + key: "year", + label: "Year", + options: years.map((year) => ({ + value: String(year), + label: String(year), + })), + }, + { + key: "status", + label: "Status", + options: [ + { value: "running", label: "Running" }, + { value: "completed", label: "Completed" }, + { value: "failed", label: "Failed" }, + ], + }, + { + key: "complete", + label: "Completeness", + options: [ + { value: "complete", label: "Complete" }, + { value: "partial", label: "Partial" }, + ], + }, + ]; +} + /** * ANU listing checks. An incomplete check is why a record can be missing from * the directory without anything having been retired. */ export function DiscoveryList({ checks }: { checks: DiscoveryCheckRow[] }) { - if (checks.length === 0) { + const searchParams = useSearchParams(); + const query = (searchParams.get("q") ?? "").trim().toLocaleLowerCase(); + const kind = searchParams.get("kind") ?? ""; + const year = searchParams.get("year") ?? ""; + const status = searchParams.get("status") ?? ""; + const completeness = searchParams.get("complete") ?? ""; + const filtered = Boolean(query || kind || year || status || completeness); + const visibleChecks = checks.filter((check) => { + const labels = CATALOGUE_KIND_LABELS[check.kind]; + const matchesQuery = + !query || + [ + check.kind, + labels.singular, + labels.plural, + String(check.academicYear), + check.status, + check.isComplete ? "complete" : "partial", + ].some((value) => value.toLocaleLowerCase().includes(query)); return ( - + matchesQuery && + (!kind || check.kind === kind) && + (!year || String(check.academicYear) === year) && + (!status || check.status === status) && + (!completeness || + completeness === (check.isComplete ? "complete" : "partial")) ); - } + }); + return ( - - - ANU listing checks - - - Listing - Year - Status - Complete - Discovered - Started - Duration - - - - {checks.map((check) => ( - - - - - {check.academicYear} - - - {check.status} - - - - {check.isComplete ? ( - "Complete" - ) : ( - - Partial - - )} - - {check.discoveredCount} - {formatTimestamp(check.startedAt)} - {formatDuration(check.durationMs)} - - ))} - - - + + + {visibleChecks.length === 0 ? ( + + ) : ( + + + ANU listing checks + + + Listing + Year + Status + Complete + Discovered + Started + Duration + + + + {visibleChecks.map((check) => ( + + + + + {check.academicYear} + + + {check.status} + + + + {check.isComplete ? ( + "Complete" + ) : ( + + Partial + + )} + + {check.discoveredCount} + {formatTimestamp(check.startedAt)} + {formatDuration(check.durationMs)} + + ))} + + + + )} + ); } diff --git a/apps/web/ui/admin/operations/operations-error.tsx b/apps/web/ui/admin/operations/operations-error.tsx new file mode 100644 index 00000000..0576b244 --- /dev/null +++ b/apps/web/ui/admin/operations/operations-error.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Button } from "@coursemap/ui/primitives/button"; +import Link from "next/link"; +import { ErrorState } from "@/ui/common/error-state"; +import { AppShell } from "@/ui/shell"; + +export function CatalogueOperationsError({ + error, + reset, +}: { + error?: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + + + Try again + + + Back to overview + + + + ); +} diff --git a/apps/web/ui/admin/operations/operations-layout.tsx b/apps/web/ui/admin/operations/operations-layout.tsx new file mode 100644 index 00000000..f0b17ed9 --- /dev/null +++ b/apps/web/ui/admin/operations/operations-layout.tsx @@ -0,0 +1,66 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@coursemap/ui/primitives/card"; +import type { ReactNode } from "react"; + +/** + * The shared furniture of an operations detail page. Syncs and discovery + * checks answer the same kinds of question - what ran, against what, and what + * came back - so they are read in the same shapes rather than each inventing + * its own. + */ + +/** + * Diagnostics are read, not scanned: long fact grids and highlighted source + * become unreadable when a wide screen stretches them edge to edge. Tables and + * artefacts scroll inside this measure rather than widening past it. + */ +export function Measure({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function OperationsSection({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( + + + + {title} + + + {children} + + ); +} + +/** A flat set of recorded values, labelled and wrapped to the page. */ +export function Facts({ + items, +}: { + items: Array<{ label: string; value: string | null }>; +}) { + return ( + + {items.map((item) => ( + + + {item.label} + + {item.value ?? "—"} + + ))} + + ); +} diff --git a/apps/web/ui/admin/operations/operations-pages.tsx b/apps/web/ui/admin/operations/operations-pages.tsx index 81487a5a..e11e1959 100644 --- a/apps/web/ui/admin/operations/operations-pages.tsx +++ b/apps/web/ui/admin/operations/operations-pages.tsx @@ -18,6 +18,7 @@ import { type OperationsSection, } from "./operations-tabs"; import { SyncDetailView } from "./sync-detail"; +import { SyncDetailTabList, SyncDetailTabs } from "./sync-detail-tabs"; import { SyncList } from "./sync-list"; function first(value: string | string[] | undefined) { @@ -52,11 +53,13 @@ export async function CatalogueOperationsPage({ } > - Catalogue operations + Catalogue activity - - + + stage.status === "failed").length + } + /> + } + > + + + ); } @@ -123,7 +138,7 @@ export async function CatalogueDiscoveryDetailPage({ ); diff --git a/apps/web/ui/admin/operations/sync-detail-tabs.tsx b/apps/web/ui/admin/operations/sync-detail-tabs.tsx new file mode 100644 index 00000000..8b9b0928 --- /dev/null +++ b/apps/web/ui/admin/operations/sync-detail-tabs.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Tabs } from "@coursemap/ui/primitives/tabs"; +import { FileCode2, Info, ListChecks, Sparkles } from "lucide-react"; +import { useState, type ReactNode } from "react"; +import { SectionTabs } from "@/ui/common/section-tabs"; + +export type SyncDetailSection = + "overview" | "stages" | "extractions" | "artefacts"; + +/** + * One sync's diagnostics, split by the question being asked of it: what it + * was, where it stopped, what the model cost, and what it captured. The whole + * record used to be a single scroll, so the failing stage sat below several + * screens of contract versions and the artefact viewer never had the page to + * itself. + * + * The section is held here rather than in the URL: it is a place to look + * while reading one sync, not a page worth linking to on its own. + */ +export function SyncDetailTabs({ children }: { children: ReactNode }) { + const [section, setSection] = useState("overview"); + return ( + setSection(next as SyncDetailSection)} + > + {children} + + ); +} + +export function SyncDetailTabList({ + stageCount, + extractionCount, + artefactCount, + failedStageCount, +}: { + stageCount: number; + extractionCount: number; + artefactCount: number; + failedStageCount: number; +}) { + return ( + + ); +} diff --git a/apps/web/ui/admin/operations/sync-detail.tsx b/apps/web/ui/admin/operations/sync-detail.tsx index 16caf4af..607392d0 100644 --- a/apps/web/ui/admin/operations/sync-detail.tsx +++ b/apps/web/ui/admin/operations/sync-detail.tsx @@ -19,10 +19,16 @@ import { TableHeader, TableRow, } from "@coursemap/ui/primitives/table"; +import { TabsContent } from "@coursemap/ui/primitives/tabs"; import { badgeVariantForTone } from "@/lib/ui"; import type { SyncDetail } from "@/lib/coursemap/admin-operations"; import { DataTableShell } from "@/ui/common/data-table"; import { ArtefactViewer } from "./artefact-viewer"; +import { + Facts, + Measure, + OperationsSection as Section, +} from "./operations-layout"; import { formatBytes, formatCost, @@ -45,40 +51,6 @@ const STAGE_LABELS: Record = { source_version_persist: "Source version", }; -function Facts({ - items, -}: { - items: Array<{ label: string; value: string | null }>; -}) { - return ( - - {items.map((item) => ( - - - {item.label} - - {item.value ?? "—"} - - ))} - - ); -} - -function Section({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) { - return ( - - {title} - {children} - - ); -} - /** Everything one sync recorded, for a developer diagnosing or retrying it. */ export function SyncDetailView({ sync }: { sync: SyncDetail }) { const recordPath = adminCatalogueRecordPath( @@ -126,212 +98,247 @@ export function SyncDetailView({ sync }: { sync: SyncDetail }) { ) : null} - - - + + + + + - - - + + + - {sync.sourceDocument ? ( - - - - ) : null} + {sync.sourceDocument ? ( + + + + ) : null} + + - - {sync.stages.length === 0 ? ( - - This sync recorded no stages. - - ) : ( - - - Sync stages - - - Stage - Attempt - Status - Started - Duration - Error - - - - {sync.stages.map((stage) => ( - - - {STAGE_LABELS[stage.stageName] ?? stage.stageName} - - {stage.attemptNumber} - - - {stage.status} - - - {formatTimestamp(stage.startedAt)} - {formatDuration(stage.durationMs)} - - {stage.errorSummary ?? stage.errorCode ?? "—"} - - - ))} - - - - )} - + + + + {sync.stages.length === 0 ? ( + + This sync recorded no stages. + + ) : ( + + + Sync stages + + + Stage + Attempt + Status + Started + Duration + Error + + + + {sync.stages.map((stage) => ( + + + {STAGE_LABELS[stage.stageName] ?? stage.stageName} + + {stage.attemptNumber} + + + {stage.status} + + + + {formatTimestamp(stage.startedAt)} + + + {formatDuration(stage.durationMs)} + + + {stage.errorSummary ?? stage.errorCode ?? "—"} + + + ))} + + + + )} + + + - {sync.extractions.length > 0 ? ( - - - - Model extractions - - - # - Model - Validation - Tokens in - Tokens out - Latency - Cost - - - - {sync.extractions.map((extraction) => ( - - {extraction.extractionNumber} - - {extraction.resolvedModel ?? extraction.requestedModel} - {extraction.reusedFromExtractionId ? ( - - reused - - ) : null} - - - - {extraction.validationStatus} - - {extraction.errorCount > 0 ? ( - - {extraction.errorCount} errors - - ) : extraction.warningCount > 0 ? ( - - {extraction.warningCount} warnings - - ) : null} - - - {extraction.inputTokens} - {extraction.cachedInputTokens > 0 - ? ` (${extraction.cachedInputTokens} cached)` - : ""} - - - {extraction.outputTokens} - {extraction.reasoningTokens > 0 - ? ` (${extraction.reasoningTokens} reasoning)` - : ""} - - - {formatDuration(extraction.latencyMs)} - - {formatCost(extraction.costUsd)} - - ))} - - - - - ) : null} + + + {sync.extractions.length > 0 ? ( + + + + + Model extractions + + + + # + Model + Validation + Tokens in + Tokens out + Latency + Cost + + + + {sync.extractions.map((extraction) => ( + + {extraction.extractionNumber} + + {extraction.resolvedModel ?? + extraction.requestedModel} + {extraction.reusedFromExtractionId ? ( + + reused + + ) : null} + + + + {extraction.validationStatus} + + {extraction.errorCount > 0 ? ( + + {extraction.errorCount} errors + + ) : extraction.warningCount > 0 ? ( + + {extraction.warningCount} warnings + + ) : null} + + + {extraction.inputTokens} + {extraction.cachedInputTokens > 0 + ? ` (${extraction.cachedInputTokens} cached)` + : ""} + + + {extraction.outputTokens} + {extraction.reasoningTokens > 0 + ? ` (${extraction.reasoningTokens} reasoning)` + : ""} + + + {formatDuration(extraction.latencyMs)} + + {formatCost(extraction.costUsd)} + + ))} + + + + + ) : null} + + - - - ({ - id: artefact.id, - kind: artefact.kind, - attemptNumber: artefact.attemptNumber, - mediaType: artefact.mediaType, - }))} - endpoint="/api/admin/catalogue-syncs/artifacts" - /> - - + + + + {/* + An artefact is a whole fetched page or model transcript, so it is + given a window to scroll inside rather than being allowed to set + the length of the page it sits on. + */} + + ({ + id: artefact.id, + kind: artefact.kind, + attemptNumber: artefact.attemptNumber, + mediaType: artefact.mediaType, + }))} + endpoint="/api/admin/catalogue-syncs/artifacts" + /> + + + + ); } diff --git a/apps/web/ui/common/option-menu.tsx b/apps/web/ui/common/option-menu.tsx index 1b0e1c03..fa9613c0 100644 --- a/apps/web/ui/common/option-menu.tsx +++ b/apps/web/ui/common/option-menu.tsx @@ -77,9 +77,12 @@ export function OptionMenu({ aria-pressed={selected} className={cn( "flex h-9 w-full shrink-0 cursor-pointer items-center justify-between gap-2 rounded-md px-2.5 text-left text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring", + // These tones are mixed from the foreground rather than + // taken from --accent, which is mixed against the page and + // so disappears on the lighter popover surface. selected - ? "bg-primary/10 font-medium text-primary" - : "text-foreground/80 hover:bg-accent hover:text-foreground", + ? "bg-primary/15 font-medium text-primary" + : "text-foreground/80 hover:bg-foreground/8 hover:text-foreground", )} key={item.value} onClick={() => onSelect(item.value)} diff --git a/apps/web/ui/common/section-tabs.tsx b/apps/web/ui/common/section-tabs.tsx index 947cb67e..289cc5ef 100644 --- a/apps/web/ui/common/section-tabs.tsx +++ b/apps/web/ui/common/section-tabs.tsx @@ -1,13 +1,19 @@ "use client"; import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; import { Badge } from "@coursemap/ui/components/badge"; import { TabsList, TabsTrigger } from "@coursemap/ui/primitives/tabs"; export type SectionTab = { value: string; label: string; - icon?: ReactNode; + /** + * The section's icon, sized here so a bar of tabs stays even. Take it from + * `routeIcons` wherever the section is also a place in the product, so the + * tab, the breadcrumb and the sidebar all name it the same way. + */ + icon?: LucideIcon; /** Shown beside the label when the section holds outstanding work. */ count?: number; disabled?: boolean; @@ -34,11 +40,14 @@ export function SectionTabs({ {tabs.map((tab) => ( - {tab.icon} + {tab.icon ? ( + + ) : null} {tab.label} {tab.count ? ( diff --git a/apps/web/ui/common/task-toast.tsx b/apps/web/ui/common/task-toast.tsx new file mode 100644 index 00000000..0e6325e4 --- /dev/null +++ b/apps/web/ui/common/task-toast.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { Progress } from "@coursemap/ui/primitives/progress"; +import { LoaderCircle } from "lucide-react"; +import { toast } from "sonner"; + +export type TaskStep = { + /** Where the work has actually reached, 0-100. The bar catches up to it. */ + percent: number; + /** + * Where this phase ends, which is where the next one begins. Once the bar + * has caught up it drifts towards this for as long as the phase lasts, so + * the movement is continuous without ever claiming unreported progress. + */ + ceiling: number; + detail: string; +}; + +export type TaskOutcome = { + title: string; + detail?: string; + retry?: () => void; +}; + +export type TaskHandle = { + step: (step: TaskStep) => void; + done: (outcome: TaskOutcome) => void; + note: (outcome: TaskOutcome) => void; + fail: (outcome: TaskOutcome) => void; + /** Let go of work that outlives whatever was watching it. */ + abandon: (outcome: TaskOutcome) => void; +}; + +const SETTLED_DURATION = 6000; + +/** How often the bar is redrawn while work runs. */ +const TICK_MS = 90; + +/** + * The share of the distance each tick closes while the bar is behind what the + * work has reported. Brisk enough to feel answered, slow enough to read as + * movement rather than a jump. + */ +const CATCH_UP = 0.18; + +/** + * The least the bar moves per tick while catching up. Easing alone only ever + * approaches what was reported, which would strand the bar just short of it + * and never hand over to the drift. + */ +const MIN_CATCH = 0.35; + +/** + * The share closed each tick while the bar is merely waiting out a phase. It + * approaches the end of the phase without arriving, easing off as it goes. + */ +const DRIFT = 0.008; + +/** Below this the bar has settled into its phase and repainting only churns. */ +const STILL = 0.05; + +/** + * How long the running toast is held before it may settle. Phases the server + * answers instantly would otherwise flash past unread. + */ +const MIN_VISIBLE_MS = 800; + +/** + * How long a task may go without a step before it is assumed to have lost + * whatever was driving it. Both catalogue endpoints cap out at a minute, so + * anything past this is a driver that stopped reporting, not slow work. + */ +const STALL_MS = 150_000; + +/** + * The toast body while work runs. It stays the two lines every other toast + * uses, title then detail, with the bar as a rule beneath them; the button + * that started the work keeps its own label rather than reflowing the toolbar + * on every event. + */ +function TaskProgress({ + percent, + detail, +}: { + percent: number; + detail: string; +}) { + return ( + + + {detail} + {Math.round(percent)}% + + {/* The track is drawn against the toast, not the page, so it needs a + ground of its own to show how much of the work is left. */} + + + ); +} + +/** + * Opens one progress toast for a long-running operation and returns the handle + * that drives it. The `id` is the operation rather than the click, so starting + * the same work again replaces its toast instead of stacking another one. + * + * Phases arrive whenever the work reports them, which for a cached or empty + * step is no time at all. The bar therefore catches up to what was reported + * and then drifts through the rest of the phase, so the reported progress + * stays honest while the movement stays readable. + */ +export function startTask({ + id, + title, + detail, + ceiling = 20, +}: { + id: string; + title: string; + detail: string; + ceiling?: number; +}): TaskHandle { + const openedAt = Date.now(); + let shown = 0; + let floor = 0; + let limit = ceiling; + let text = detail; + let steppedAt = Date.now(); + let timer: number | undefined; + let settled = false; + let dismissed = false; + + function paint() { + // Not toast.loading: sonner withholds the close button from a loading + // toast, and work that carries on server-side has to be dismissable. + toast(title, { + id, + icon: , + description: , + duration: Number.POSITIVE_INFINITY, + onDismiss: () => { + dismissed = true; + stopGlide(); + }, + }); + } + + function stopGlide() { + if (timer === undefined) return; + window.clearInterval(timer); + timer = undefined; + } + + function glide() { + if (Date.now() - steppedAt > STALL_MS) { + abandon({ + title, + detail: "This is taking longer than expected. Reload to check on it.", + }); + return; + } + if (shown < floor) { + shown = Math.min( + floor, + shown + Math.max(MIN_CATCH, (floor - shown) * CATCH_UP), + ); + paint(); + return; + } + const next = shown + (limit - shown) * DRIFT; + if (next - shown < STILL) return; + shown = next; + paint(); + } + + function step({ percent, ceiling: end, detail: line }: TaskStep) { + if (settled || dismissed) return; + steppedAt = Date.now(); + floor = Math.max(floor, percent); + limit = Math.max(limit, end); + text = line; + // The wording is what the reader is waiting on, so it lands at once while + // the bar catches up behind it. + paint(); + if (timer === undefined) timer = window.setInterval(glide, TICK_MS); + } + + function settle(kind: "done" | "note" | "fail", outcome: TaskOutcome) { + if (settled) return; + settled = true; + stopGlide(); + // A toast dismissed by hand has been read and put away; only a failure is + // worth bringing back unasked. + if (dismissed && kind !== "fail") return; + const show = () => { + const { title: heading, detail: line, retry } = outcome; + const options = { + id, + // Sonner merges into the toast already on screen, so the spinner this + // task was painted with has to be cleared or it keeps turning under + // the outcome. Undefined hands the icon back to the toast's own type. + icon: undefined, + // A toast is its title and one line under it. A message too long for + // that line is kept whole in the tooltip rather than growing the toast. + description: line ? ( + + {line} + + ) : undefined, + duration: kind === "fail" ? Number.POSITIVE_INFINITY : SETTLED_DURATION, + ...(retry ? { action: { label: "Retry", onClick: retry } } : {}), + }; + if (kind === "done") toast.success(heading, options); + else if (kind === "note") toast.info(heading, options); + else toast.error(heading, options); + }; + const held = Date.now() - openedAt; + if (held >= MIN_VISIBLE_MS) show(); + else window.setTimeout(show, MIN_VISIBLE_MS - held); + } + + function abandon(outcome: TaskOutcome) { + settle("note", outcome); + } + + paint(); + timer = window.setInterval(glide, TICK_MS); + + return { + step, + done: (outcome) => settle("done", outcome), + note: (outcome) => settle("note", outcome), + fail: (outcome) => settle("fail", outcome), + abandon, + }; +} diff --git a/apps/web/ui/common/year-picker.tsx b/apps/web/ui/common/year-picker.tsx index f591a28c..8579de46 100644 --- a/apps/web/ui/common/year-picker.tsx +++ b/apps/web/ui/common/year-picker.tsx @@ -1,21 +1,30 @@ "use client"; +import { Button } from "@coursemap/ui/primitives/button"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@coursemap/ui/primitives/select"; + Popover, + PopoverContent, + PopoverTrigger, +} from "@coursemap/ui/primitives/popover"; + +import { useState } from "react"; +import { CalendarRange, ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/cn"; +import { MenuHint } from "@/ui/common/menu-hint"; +import { OptionMenu } from "@/ui/common/option-menu"; export type YearSelection = number | "all"; +/** Past this many years the list is worth narrowing by typing. */ +const SEARCHABLE_FROM = 8; + /** - * One academic year. This is a native-feeling select rather than the option - * picker: the values are a handful of four-digit years, and a searchable - * popover list drawn below the trigger read as a heavy component for choosing - * a single number. The select opens with the current year aligned over the - * trigger, so a change is one short movement. Newest first, because that is + * One academic year. It is built from the same popover and option list as the + * filter and sort controls beside it, so the toolbar reads as one set of + * controls rather than a native select dropped among them. The menu opens + * below the trigger instead of over it, which keeps the year being left + * behind visible while the next one is chosen. Newest first, because that is * the year being worked on. */ export function YearPicker({ @@ -36,24 +45,57 @@ export function YearPicker({ value: YearSelection; years: number[]; }) { + const [open, setOpen] = useState(false); const ordered = [...new Set(years)].sort((left, right) => right - left); + const items = [ + ...ordered.map((year) => ({ value: String(year), label: String(year) })), + ...(allowAll ? [{ value: "all", label: allLabel }] : []), + ]; + const selected = value === "all" ? allLabel : String(value); + return ( - onChange(next === "all" ? "all" : Number(next))} - > - - - - - {ordered.map((year) => ( - - {year} - - ))} - {allowAll ? {allLabel} : null} - - + + + + + + {selected} + {/* The chevron turns with the menu so the trigger shows its own + state, rather than only the panel below reporting it. */} + + + + + + { + setOpen(false); + onChange(next === "all" ? "all" : Number(next)); + }} + searchPlaceholder={ + items.length > SEARCHABLE_FROM ? "Search years..." : undefined + } + value={String(value)} + /> + + ); } diff --git a/apps/web/ui/shell/app-shell.tsx b/apps/web/ui/shell/app-shell.tsx index cee2dc6e..7313f1c2 100644 --- a/apps/web/ui/shell/app-shell.tsx +++ b/apps/web/ui/shell/app-shell.tsx @@ -13,6 +13,7 @@ import { } from "@coursemap/ui/primitives/sidebar"; import { cn } from "@/lib/cn"; import { AppSidebar } from "@/ui/shell/app-sidebar"; +import type { RouteIconKey } from "@/ui/shell/route-icons"; import { useSidebarDefaultOpen } from "@/ui/shell/sidebar-preference"; import { NotificationsMenu } from "@/ui/shell/notifications-menu"; import { Topbar } from "@/ui/shell/topbar"; @@ -28,6 +29,8 @@ export type AppShellProps = { breadcrumbSegmentLabels?: Record; /** Appends the open section, such as the active tab, to the breadcrumb. */ breadcrumbTrailingLabel?: string; + /** The appended section's icon, named by the route key its tab uses. */ + breadcrumbTrailingIcon?: RouteIconKey; loading?: boolean; admin?: boolean; /** Makes the main region a flex column so one child can claim the rest of the viewport. */ @@ -45,6 +48,7 @@ export function AppShell({ currentBreadcrumbLabel, breadcrumbSegmentLabels, breadcrumbTrailingLabel, + breadcrumbTrailingIcon, loading = false, admin = false, fill = false, @@ -135,6 +139,7 @@ export function AppShell({ breadcrumbSegmentLabels={breadcrumbSegmentLabels} currentBreadcrumbLabel={currentBreadcrumbLabel} breadcrumbTrailingLabel={breadcrumbTrailingLabel} + breadcrumbTrailingIcon={breadcrumbTrailingIcon} /> {tabs && ( ({ + href: `/admin/${segment}/${catalogueYear}`, + activePath: `/admin/${segment}`, + label, + icon, + }); + + return [ + { + label: null, + items: [ + { + href: "/admin/dashboard", + label: "Dashboard", + icon: routeIcons["admin-dashboard"], + }, + ], + }, + { + label: "Catalogue", + items: [ + catalogueItem("courses", "Courses", routeIcons.courses), + catalogueItem("programmes", "Programmes", routeIcons.programmes), + catalogueItem("majors", "Majors", routeIcons.majors), + catalogueItem("minors", "Minors", routeIcons.minors), + catalogueItem( + "specialisations", + "Specialisations", + routeIcons.specialisations, + ), + { + href: "/admin/operations/catalogue", + label: "Activity", + icon: routeIcons.sync, + }, + ], + }, + { + label: "Campus", + items: [ + { + href: "/admin/rooms", + label: "Indoor maps", + icon: routeIcons["admin-rooms"], + }, + ], + }, + { + label: "Access", + items: [ + { href: "/admin/users", label: "Users", icon: routeIcons.users }, + { href: "/admin/roles", label: "Roles", icon: routeIcons.roles }, + ], + }, + ]; +} + +export function adminCatalogueNavigationYear( + pathname: string, + profileCatalogueYear: number, +) { + const match = pathname.match( + /^\/admin\/(?:courses|programmes|majors|minors|specialisations)\/(\d{4})(?:\/|$)/, + ); + return match ? Number(match[1]) : profileCatalogueYear; +} /** Shown to students who hold an admin role. */ const adminEntryNav: NavSection[] = [ @@ -173,10 +188,11 @@ function NavMenuItem({ onNavigate: () => void; }) { const pathname = usePathname(); + const activePath = item.activePath ?? item.href; const isActive = item.href === "/admin/dashboard" ? pathname === item.href || pathname === "/admin" - : pathname === item.href || pathname.startsWith(`${item.href}/`); + : pathname === activePath || pathname.startsWith(`${activePath}/`); const Icon = item.icon; return ( @@ -233,7 +249,12 @@ function NavSections({ } export function AppSidebar({ admin }: { admin: boolean }) { - const { canAccessAdmin } = useCoursemap(); + const { canAccessAdmin, state } = useCoursemap(); + const pathname = usePathname(); + const catalogueYear = adminCatalogueNavigationYear( + pathname, + state.profile.catalogueYear, + ); const { isMobile, setOpenMobile } = useSidebar(); const closeMobileNav = () => { if (isMobile) setOpenMobile(false); @@ -260,7 +281,7 @@ export function AppSidebar({ admin }: { admin: boolean }) { diff --git a/apps/web/ui/shell/breadcrumbs.tsx b/apps/web/ui/shell/breadcrumbs.tsx index 3cf0b40b..84889ed6 100644 --- a/apps/web/ui/shell/breadcrumbs.tsx +++ b/apps/web/ui/shell/breadcrumbs.tsx @@ -13,7 +13,7 @@ import { BreadcrumbSeparator, } from "@coursemap/ui/primitives/breadcrumb"; import { BreadcrumbOverflow } from "@/ui/shell/breadcrumb-overflow"; -import { routeIcons } from "@/ui/shell/route-icons"; +import { routeIcons, type RouteIconKey } from "@/ui/shell/route-icons"; type Crumb = { label: string; href?: string; icon?: LucideIcon }; @@ -146,6 +146,7 @@ export function Breadcrumbs({ currentLabel, segmentLabels, trailingLabel, + trailingIcon, }: { currentLabel?: string; /** Relabels a route segment, or hides it when the value is null. */ @@ -155,6 +156,12 @@ export function Breadcrumbs({ * segment, so it is appended rather than read from the URL. */ trailingLabel?: string; + /** + * The appended section's icon, named by route key rather than passed as a + * component so a server page can ask for it. Give it the key its tab uses, + * and the breadcrumb and the tab bar say the same thing. + */ + trailingIcon?: RouteIconKey; }) { const pathname = usePathname(); const { crumbs } = buildCrumbs(pathname, segmentLabels); @@ -170,7 +177,10 @@ export function Breadcrumbs({ ? { ...crumb, href: pathname } : crumb, ), - { label: trailingLabel }, + { + label: trailingLabel, + icon: trailingIcon && routeIcons[trailingIcon], + }, ] : named; diff --git a/apps/web/ui/shell/notifications-menu.tsx b/apps/web/ui/shell/notifications-menu.tsx index 209f1c9d..b5fd48cc 100644 --- a/apps/web/ui/shell/notifications-menu.tsx +++ b/apps/web/ui/shell/notifications-menu.tsx @@ -154,7 +154,7 @@ function NotificationRow({ > ); const className = - "flex w-full items-start gap-2.5 rounded-md px-2 py-2.5 text-left text-sm transition-colors outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"; + "flex w-full items-start gap-2.5 rounded-md px-2 py-2.5 text-left text-sm transition-colors outline-none hover:bg-foreground/8 focus-visible:ring-2 focus-visible:ring-ring"; // A notification with somewhere to go is a link, so it opens in a new tab and // shows its destination like any other. One without is still readable, and diff --git a/apps/web/ui/shell/route-icons.ts b/apps/web/ui/shell/route-icons.ts index 21f43efa..9d4fef2d 100644 --- a/apps/web/ui/shell/route-icons.ts +++ b/apps/web/ui/shell/route-icons.ts @@ -3,16 +3,21 @@ import { BookOpen, CalendarDays, CalendarRange, + Eye, + FileText, GitCompareArrows, GraduationCap, + History, Import, KeyRound, LayoutDashboard, + Library, LifeBuoy, ListChecks, Map, MapPin, MapPinned, + Radar, RefreshCw, Route, Shield, @@ -51,8 +56,18 @@ export const routeIcons = { roles: KeyRound, imports: Import, sync: RefreshCw, - changes: GitCompareArrows, timetable: CalendarDays, + // Catalogue activity, and the two questions it is asked. + catalogue: Library, + syncs: RefreshCw, + discovery: Radar, + // The sections of one catalogue record. "content" is the record path itself + // rather than a segment of its own, and is named here so its tab and every + // link to it wear the same icon as its siblings. + content: FileText, + "student-view": Eye, + changes: GitCompareArrows, + changelog: History, } satisfies Record; export type RouteIconKey = keyof typeof routeIcons; diff --git a/apps/web/ui/shell/topbar.tsx b/apps/web/ui/shell/topbar.tsx index f40a2494..76a2e15c 100644 --- a/apps/web/ui/shell/topbar.tsx +++ b/apps/web/ui/shell/topbar.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from "react"; import { Separator } from "@coursemap/ui/primitives/separator"; import { SidebarTrigger } from "@coursemap/ui/primitives/sidebar"; import { cn } from "@/lib/cn"; +import type { RouteIconKey } from "@/ui/shell/route-icons"; import { useLoadingProgress } from "./use-loading-progress"; import { Breadcrumbs } from "@/ui/shell/breadcrumbs"; @@ -14,6 +15,7 @@ export function Topbar({ currentBreadcrumbLabel, breadcrumbSegmentLabels, breadcrumbTrailingLabel, + breadcrumbTrailingIcon, }: { loading?: boolean; title?: ReactNode; @@ -21,6 +23,7 @@ export function Topbar({ currentBreadcrumbLabel?: string; breadcrumbSegmentLabels?: Record; breadcrumbTrailingLabel?: string; + breadcrumbTrailingIcon?: RouteIconKey; }) { const header = useLoadingProgress(loading); return ( @@ -43,6 +46,7 @@ export function Topbar({ currentLabel={currentBreadcrumbLabel} segmentLabels={breadcrumbSegmentLabels} trailingLabel={breadcrumbTrailingLabel} + trailingIcon={breadcrumbTrailingIcon} /> )} diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 86b1ec9d..2f7a6c88 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -1,5 +1,6 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", + "buildCommand": "pnpm build:prod", "functions": { "app/api/queues/catalogue-sync/route.ts": { "maxDuration": 300, diff --git a/docs/catalogue-admin-rework.md b/docs/catalogue-admin-rework.md index 5a7dcd96..a71360cd 100644 --- a/docs/catalogue-admin-rework.md +++ b/docs/catalogue-admin-rework.md @@ -189,10 +189,9 @@ Publish. ## Working locally -- Use `pnpm dev:local`. `apps/web/.env.local` points at the hosted project, - which still carries the pre-redesign schema, so `pnpm dev` fails with - `PGRST205` against tables this branch removed. None of the fixes above are - live there; they travel with the cutover after A8. +- Use `pnpm dev`, `pnpm build` and `pnpm start` for the local stack. The + explicit `:prod` variants are the only commands that read hosted credentials + from `apps/web/.env.local`. - `pnpm db:reset` drops the storage buckets and does not recreate them, so imports then fail with an opaque gateway error. Recreate `course-import-artifacts` from `supabase/config.toml`. diff --git a/package.json b/package.json index 52398f36..91943280 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,14 @@ }, "packageManager": "pnpm@12.3.4", "scripts": { - "dev": "turbo run dev", - "dev:local": "node apps/web/scripts/local/dev-preview.mjs", + "dev": "pnpm --filter @coursemap/web dev", + "dev:local": "pnpm --filter @coursemap/web dev", + "dev:prod": "pnpm --filter @coursemap/web dev:prod", + "preview:local": "node apps/web/scripts/local/production-preview.mjs", "build": "turbo run build", + "build:prod": "pnpm --filter @coursemap/web build:prod", + "start": "pnpm --filter @coursemap/web start", + "start:prod": "pnpm --filter @coursemap/web start:prod", "lint": "turbo run lint", "typecheck": "turbo run typecheck", "format": "prettier --write .", diff --git a/packages/ui/style-nova.css b/packages/ui/style-nova.css index 19eace56..877cf85f 100644 --- a/packages/ui/style-nova.css +++ b/packages/ui/style-nova.css @@ -694,6 +694,12 @@ @apply rounded-2xl; } + /* The icon belongs to the title, so it holds the first line however many + lines the description below it runs to. */ + .cn-toast [data-icon] { + @apply mt-0.5 self-start; + } + /* MARK: Table */ .cn-table-container { @apply relative w-full overflow-x-auto; diff --git a/supabase/migrations/20260927100000_remove_untouched_catalogue_drafts.sql b/supabase/migrations/20260927100000_remove_untouched_catalogue_drafts.sql new file mode 100644 index 00000000..7fb9bca2 --- /dev/null +++ b/supabase/migrations/20260927100000_remove_untouched_catalogue_drafts.sql @@ -0,0 +1,13 @@ +-- Opening a catalogue record used to create its draft, so every record anyone +-- had ever looked at reported itself as a draft, and discarding one brought it +-- straight back on the next render. A draft is now created by the first change +-- worth keeping, which leaves the rows that bug produced behind. +-- +-- Those rows are exactly the drafts still at revision 0 that were not restored +-- from a version. A draft at revision 0 has never been saved, so its content is +-- byte-identical to the publication or the empty record it was created from, +-- and restoring is the only other way to sit at revision 0. Deleting them +-- therefore loses no authored work; their provenance rows cascade away with +-- them, and the next edit recreates whatever it needs. +delete from public.catalogue_drafts +where revision = 0 and restored_from_version_id is null;
- {page.status.message} -
{actor}
+ {actor} + {/* + An entry ANU produced is only half the story: what it did and + why it did it are in the sync that ran, so the entry says where + that is rather than leaving it to be hunted for. + */} + {fromSource && syncsHref ? ( + <> + {" \u00b7 "} + + Sync diagnostics + + > + ) : null} +
{detail}
+ {latestSync.completedAt + ? `Last checked against ANU on ${new Intl.DateTimeFormat("en-AU", { + dateStyle: "long", + timeStyle: "short", + }).format(new Date(latestSync.completedAt))}. ` + : "A check against ANU is under way. "} + + Sync diagnostics + +
- This record has never been published, so nothing in the draft is visible - to students yet. -
{record.title}
@@ -124,8 +126,10 @@ export function RecordHeader({ {canSync ? ( ) : null} diff --git a/apps/web/ui/admin/catalogue/record-page.tsx b/apps/web/ui/admin/catalogue/record-page.tsx index 7d73dc16..a88762b8 100644 --- a/apps/web/ui/admin/catalogue/record-page.tsx +++ b/apps/web/ui/admin/catalogue/record-page.tsx @@ -3,12 +3,8 @@ import { TabsContent } from "@coursemap/ui/primitives/tabs"; import { canManageCatalogueOperations, canWriteCatalogue, - getAuthViewer, } from "@/lib/auth/viewer"; -import { - createCatalogueDraft, - loadCatalogueDraft, -} from "@/lib/catalogue/drafts"; +import { loadCatalogueEditorState } from "@/lib/catalogue/drafts"; import { diffSnapshotWrites } from "@/lib/catalogue-import/changes"; import { contentHashForCatalogueContent } from "@/lib/catalogue-import/version-content"; import { loadSourceReview } from "@/lib/catalogue/source-review-store"; @@ -21,6 +17,7 @@ import { } from "@/lib/coursemap/admin-catalogue-record"; import { courseDetailsFromWrite } from "@/lib/coursemap/course-version-view"; import { + ADMIN_CATALOGUE_OPERATIONS_PATH, CATALOGUE_KIND_LABELS, type CatalogueKind, adminCatalogueRecordPath, @@ -32,6 +29,8 @@ import { ChangelogTimeline } from "./changelog/changelog-timeline"; import { RecordHeader } from "./record-header"; import { StudentViewPanel } from "./student-view-panel"; import { RecordTabList, RecordTabs, type RecordSection } from "./record-tabs"; +import { CatalogueEditorProvider } from "./catalogue-editor-context"; +import { CatalogueEditorToolbar } from "./catalogue-editor-toolbar"; import { CatalogueContentEditor } from "./content-editor"; function FoundationEmpty({ @@ -81,14 +80,12 @@ export async function CatalogueRecordPage({ const labels = CATALOGUE_KIND_LABELS[kind]; const path = adminCatalogueRecordPath(kind, academicYear, record.code); - const viewer = canWrite ? await getAuthViewer() : null; - const draft = - section === "content" && viewer - ? await createCatalogueDraft({ - recordId: record.recordId, - userId: viewer.id, - }) - : await loadCatalogueDraft(record.recordId); + // Reading a record must never be what makes it a draft, so the editor is + // given the content it would start from - the publication, or an empty + // record - and the draft row is created by asking to edit it. + const { draft, hasDraft, hasChanges } = await loadCatalogueEditorState( + record.recordId, + ); const [studentContent, studentCourse] = await Promise.all([ record.publishedVersionId ? loadVersionWrite(record.publishedVersionId) @@ -98,25 +95,19 @@ export async function CatalogueRecordPage({ : null, ]); const hasUnpublishedChanges = Boolean( - draft && + hasChanges && (!studentContent || draft.contentHash !== contentHashForCatalogueContent(studentContent)), ); - const draftPreview = draft - ? { - course: - kind === "course" ? courseDetailsFromWrite(draft.content) : null, - content: kind === "course" ? null : draft.content, - } - : null; + const draftPreview = { + course: kind === "course" ? courseDetailsFromWrite(draft.content) : null, + content: kind === "course" ? null : draft.content, + }; const publishedPreview = studentContent ? { course: studentCourse, content: studentCourse ? null : studentContent } : null; - const review = await loadSourceReview( - record.recordId, - draft?.content ?? null, - ); - const unpublished = draft + const review = await loadSourceReview(record.recordId, draft.content); + const unpublished = hasChanges ? diffSnapshotWrites(studentContent, draft.content) : []; const changelog = await loadCatalogueChangelog({ @@ -142,60 +133,84 @@ export async function CatalogueRecordPage({ }} tabs={} > -
Nothing recorded yet.
- {`This ${kindLabel} hasn't been published yet. Students see nothing until you publish.`} -
- Updated {dateFormatter.format(new Date(updatedAt))} -
+ Updated {dateFormatter.format(new Date(updatedAt))} +
- This check recorded no source pages. -
{page.canonicalUrl}
- HTTP {page.httpStatus ?? "—"} ·{" "} - {formatTimestamp(page.fetchedAt)} ·{" "} - {page.contentSha256.slice(0, 16)} -
+ This check recorded no source pages. +
+ HTTP {page.httpStatus ?? "—"} ·{" "} + {formatTimestamp(page.fetchedAt)} ·{" "} + {page.contentSha256.slice(0, 16)} +
- This sync recorded no stages. -
+ This sync recorded no stages. +